packages feed

keiro-dsl-0.10.0.0: src/Keiro/Dsl/ConformancePackage.hs

-- | Planning and safe filesystem execution for the one runnable conformance
-- package generated per checked service.
module Keiro.Dsl.ConformancePackage
  ( ConformanceServiceKey (..),
    ConformanceFile (..),
    ConformancePackagePlan (..),
    ConformancePackageFailure (..),
    ConformancePackageRecord (..),
    ConformanceFactSide (..),
    DuplicateFactKey (..),
    ConformanceFactResult (..),
    ConformanceWriteDisposition (..),
    ConformanceStaleFile (..),
    PreparedConformancePackage,
    ConformancePackageReport (..),
    cabaliseConformanceService,
    conformancePackageDirectory,
    conformanceRecordFileName,
    planConformancePackage,
    parseConformancePackageRecord,
    renderConformancePackageRecord,
    preflightConformancePackage,
    executePreparedConformancePackage,
    compareConformanceFacts,
    renderConformancePackageFailure,
    renderConformancePackageReport,
  )
where

import Control.Monad (forM)
import Data.Char (isAlphaNum, isAscii, ord)
import Data.List (groupBy, sortOn)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Keiro.Dsl.RuntimePackage (RuntimePackageName (..), isCabalPackageName, mkRuntimePackageName)
import Keiro.Dsl.Scaffold (ModuleKind (..), generatedBannerFor, isGeneratedBannerLine)
import Keiro.Dsl.SemanticContract (CheckedService (..))
import Keiro.Dsl.ServiceHarness (serviceConformanceFactValues)
import Numeric (showHex)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath (isAbsolute, splitDirectories, takeDirectory, (</>))

data ConformanceServiceKey
  = WorkspaceConformanceService !Text
  | StandaloneConformanceService !Text
  deriving stock (Eq, Ord, Show)

data ConformanceFile = ConformanceFile
  { conformanceFilePath :: !FilePath,
    conformanceFileText :: !Text,
    conformanceFileKind :: !ModuleKind
  }
  deriving stock (Eq, Show)

data ConformancePackagePlan = ConformancePackagePlan
  { cppServiceKey :: !ConformanceServiceKey,
    cppDirectory :: !FilePath,
    cppPackageName :: !Text,
    cppRuntimePackage :: !RuntimePackageName,
    cppFacadeModule :: !Text,
    cppFiles :: ![ConformanceFile]
  }
  deriving stock (Eq, Show)

data ConformanceFactSide = ExpectedFact | ActualFact
  deriving stock (Eq, Ord, Show)

data DuplicateFactKey = DuplicateFactKey
  { duplicateFactSide :: !ConformanceFactSide,
    duplicateFactKey :: !String
  }
  deriving stock (Eq, Ord, Show)

data ConformanceFactResult
  = ConformanceFactMatch !String !String
  | ConformanceFactMismatch !String !String !String
  | ConformanceFactMissing !String !String
  | ConformanceFactUnexpected !String !String
  deriving stock (Eq, Ord, Show)

data ConformancePackageFailure
  = UnsafeConformanceServiceKey !Text
  | UnsafeConformancePath !FilePath
  | ConformancePathCollision ![FilePath]
  | PackageDuplicateFactKeys ![DuplicateFactKey]
  | ConformanceGeneratedBannerMissing ![FilePath]
  | InvalidConformancePackageRecord !FilePath
  | ConformancePackageRecordMismatch !FilePath
  deriving stock (Eq, Show)

data ConformancePackageRecord = ConformancePackageRecord
  { cprSchema :: !Int,
    cprServiceKey :: !ConformanceServiceKey,
    cprRuntimePackage :: !RuntimePackageName,
    cprFacadeModule :: !Text,
    cprFiles :: ![(ModuleKind, FilePath)]
  }
  deriving stock (Eq, Show)

data ConformanceWriteDisposition = ConformanceCreated | ConformanceOverwritten | ConformanceSkipped | ConformanceUnchanged
  deriving stock (Eq, Show)

data ConformanceStaleFile = ConformanceStaleFile
  { conformanceStaleKind :: !ModuleKind,
    conformanceStalePath :: !FilePath,
    conformanceStaleBannerPresent :: !(Maybe Bool)
  }
  deriving stock (Eq, Show)

data PreparedConformancePackage = PreparedConformancePackage
  { preparedRoot :: !FilePath,
    preparedPlan :: !ConformancePackagePlan,
    preparedStale :: ![ConformanceStaleFile]
  }
  deriving stock (Eq, Show)

data ConformancePackageReport = ConformancePackageReport
  { conformanceReportRoot :: !FilePath,
    conformanceReportPlan :: !ConformancePackagePlan,
    conformanceReportDispositions :: ![(ConformanceFile, ConformanceWriteDisposition)],
    conformanceReportStale :: ![ConformanceStaleFile]
  }
  deriving stock (Eq, Show)

conformanceRecordFileName :: FilePath
conformanceRecordFileName = "keiro-dsl-conformance-record.txt"

conformancePackageDirectory :: ConformanceServiceKey -> FilePath
conformancePackageDirectory = \case
  WorkspaceConformanceService service -> "keiro-dsl-conformance.workspace." <> T.unpack service
  StandaloneConformanceService context -> "keiro-dsl-conformance." <> T.unpack context

-- | Produce a collision-safe Cabal fragment. Ordinary lowercase Cabal names,
-- including hyphenated names, stay readable; every other spelling is encoded
-- character by character so @_@ and @-@ (and case) can never alias.
cabaliseConformanceService :: Text -> Text
cabaliseConformanceService service
  | isCabalPackageName service,
    T.toLower service == service =
      service
  | otherwise = "x-" <> T.intercalate "-" (map encodeCharacter (T.unpack service))
  where
    encodeCharacter character = "c" <> T.pack (showHex (ord character) "")

planConformancePackage :: ConformanceServiceKey -> RuntimePackageName -> Text -> CheckedService -> Either [ConformancePackageFailure] ConformancePackagePlan
planConformancePackage serviceKey runtimePackage facadeModule service
  | not (safeServiceKey serviceKey) = Left [UnsafeConformanceServiceKey (serviceKeyText serviceKey)]
  | not (null unsafePaths) = Left (map UnsafeConformancePath unsafePaths)
  | not (null collisions) = Left (map ConformancePathCollision collisions)
  | not (null duplicateFacts) = Left [PackageDuplicateFactKeys duplicateFacts]
  | otherwise = Right plan
  where
    serviceName = serviceKeyText serviceKey
    packageName = "keiro-" <> cabaliseConformanceService serviceName <> "-conformance"
    packageDirectory = conformancePackageDirectory serviceKey
    banner = generatedBannerFor (checkedLanguageContract service) ("conformance package " <> renderServiceKey serviceKey)
    factValues = sortOn fst (serviceConformanceFactValues service)
    duplicateFacts = duplicateKeys ActualFact [(T.unpack key, T.unpack value) | (key, value) <- factValues]
    cabalPath = T.unpack packageName <> ".cabal"
    baseFiles =
      [ ConformanceFile cabalPath (renderCabal banner packageName runtimePackage serviceName) Generated,
        ConformanceFile "src/Main.hs" (renderMain banner facadeModule) Generated,
        ConformanceFile "src/KeiroConformance/Expectations.hs" (renderExpectations factValues) HoleStub
      ]
    recordRows = [(conformanceFileKind file, conformanceFilePath file) | file <- baseFiles] <> [(Generated, conformanceRecordFileName)]
    record =
      ConformancePackageRecord
        { cprSchema = 1,
          cprServiceKey = serviceKey,
          cprRuntimePackage = runtimePackage,
          cprFacadeModule = facadeModule,
          cprFiles = recordRows
        }
    recordFile = ConformanceFile conformanceRecordFileName (banner <> "\n" <> renderConformancePackageRecord record) Generated
    files = baseFiles <> [recordFile]
    paths = packageDirectory : map conformanceFilePath files
    unsafePaths = filter (not . safeRelativePath) paths
    collisions =
      [ entries
      | entries <- Map.elems (Map.fromListWith (<>) [(T.toCaseFold (T.pack path), [path]) | path <- map conformanceFilePath files]),
        length entries > 1
      ]
    plan =
      ConformancePackagePlan
        { cppServiceKey = serviceKey,
          cppDirectory = packageDirectory,
          cppPackageName = packageName,
          cppRuntimePackage = runtimePackage,
          cppFacadeModule = facadeModule,
          cppFiles = files
        }

renderCabal :: Text -> Text -> RuntimePackageName -> Text -> Text
renderCabal banner packageName runtimePackage serviceName =
  T.unlines
    [ "cabal-version: 3.0",
      banner,
      "name: " <> packageName,
      "version: 0.0.0.0",
      "synopsis: Generated conformance runner for Keiro service " <> serviceName,
      "license: BSD-3-Clause",
      "build-type: Simple",
      "",
      "test-suite conformance",
      "  type: exitcode-stdio-1.0",
      "  hs-source-dirs: src",
      "  main-is: Main.hs",
      "  other-modules: KeiroConformance.Expectations",
      "  build-depends:",
      "      base >=4.18 && <5",
      "    , " <> unRuntimePackageName runtimePackage,
      "  default-language: GHC2024",
      "  default-extensions: OverloadedStrings"
    ]

renderMain :: Text -> Text -> Text
renderMain banner facadeModule =
  T.unlines
    [ banner,
      "module Main (main) where",
      "",
      "import Control.Monad (when)",
      "import Data.List (group, groupBy, sort, sortOn)",
      "import " <> facadeModule <> " (runServiceConformanceChecks, serviceConformanceFacts)",
      "import KeiroConformance.Expectations (expectedServiceConformanceFacts)",
      "import System.Exit (exitFailure)",
      "",
      "data FactResult",
      "  = FactMatch String String",
      "  | FactMismatch String String String",
      "  | FactMissing String String",
      "  | FactUnexpected String String",
      "",
      "newtype UniqueFactMap = UniqueFactMap [(String, String)]",
      "",
      "main :: IO ()",
      "main = do",
      "  checks <- runServiceConformanceChecks",
      "  mapM_ renderCheck checks",
      "  case compareFacts expectedServiceConformanceFacts serviceConformanceFacts of",
      "    Left duplicates -> do",
      "      mapM_ (putStrLn . (\"FAIL  duplicate conformance fact key: \" <>)) duplicates",
      "      exitFailure",
      "    Right facts -> do",
      "      mapM_ renderFact facts",
      "      when (any (not . snd) checks || any factFailed facts) exitFailure",
      "",
      "renderCheck :: (String, Bool) -> IO ()",
      "renderCheck (key, passed) = putStrLn ((if passed then \"PASS  \" else \"FAIL  \") <> key)",
      "",
      "renderFact :: FactResult -> IO ()",
      "renderFact result = putStrLn (case result of",
      "  FactMatch key _ -> \"PASS  \" <> key",
      "  FactMismatch key expected actual -> \"FAIL  \" <> key <> \" expected=\" <> show expected <> \" actual=\" <> show actual",
      "  FactMissing key expected -> \"FAIL  \" <> key <> \" expected=\" <> show expected <> \" actual=<missing>\"",
      "  FactUnexpected key actual -> \"FAIL  \" <> key <> \" expected=<missing> actual=\" <> show actual)",
      "",
      "factFailed :: FactResult -> Bool",
      "factFailed FactMatch {} = False",
      "factFailed _ = True",
      "",
      "compareFacts :: [(String, String)] -> [(String, String)] -> Either [String] [FactResult]",
      "compareFacts expected actual = do",
      "  expectedMap <- uniqueFactMap \"expected\" expected",
      "  actualMap <- uniqueFactMap \"actual\" actual",
      "  pure [compareKey key expectedMap actualMap | key <- factKeys expectedMap actualMap]",
      "",
      "uniqueFactMap :: String -> [(String, String)] -> Either [String] UniqueFactMap",
      "uniqueFactMap side facts =",
      "  case [side <> \"/\" <> fst first | entries@(first : _) <- groupBy sameKey (sortOn fst facts), length entries > 1] of",
      "    [] -> Right (UniqueFactMap (sortOn fst facts))",
      "    duplicates -> Left duplicates",
      "  where",
      "    sameKey left right = fst left == fst right",
      "",
      "factKeys :: UniqueFactMap -> UniqueFactMap -> [String]",
      "factKeys (UniqueFactMap expected) (UniqueFactMap actual) = [key | key : _ <- group (sort (map fst expected <> map fst actual))]",
      "",
      "compareKey :: String -> UniqueFactMap -> UniqueFactMap -> FactResult",
      "compareKey key (UniqueFactMap expected) (UniqueFactMap actual) =",
      "  case (lookup key expected, lookup key actual) of",
      "    (Just expectedValue, Just actualValue)",
      "      | expectedValue == actualValue -> FactMatch key actualValue",
      "      | otherwise -> FactMismatch key expectedValue actualValue",
      "    (Just expectedValue, Nothing) -> FactMissing key expectedValue",
      "    (Nothing, Just actualValue) -> FactUnexpected key actualValue",
      "    (Nothing, Nothing) -> error \"factKeys returned a key absent from both validated maps\""
    ]

renderExpectations :: [(Text, Text)] -> Text
renderExpectations facts =
  T.unlines $
    [ "-- Created once by keiro-dsl. This module is application-owned; review and edit it to accept conformance changes.",
      "module KeiroConformance.Expectations (expectedServiceConformanceFacts) where",
      "",
      "expectedServiceConformanceFacts :: [(String, String)]"
    ]
      <> case facts of
        [] -> ["expectedServiceConformanceFacts = []"]
        _ ->
          ["expectedServiceConformanceFacts ="]
            <> [ (if index == (0 :: Int) then "  [ " else "  , ") <> "(" <> haskellString key <> ", " <> haskellString value <> ")"
               | (index, (key, value)) <- zip [0 ..] facts
               ]
            <> ["  ]"]
  where
    haskellString = T.pack . show . T.unpack

renderConformancePackageRecord :: ConformancePackageRecord -> Text
renderConformancePackageRecord record =
  T.unlines $
    [ "schema " <> tshow (cprSchema record),
      "service-key " <> renderServiceKey (cprServiceKey record),
      "runtime-package " <> unRuntimePackageName (cprRuntimePackage record),
      "facade-module " <> cprFacadeModule record
    ]
      <> ["file " <> kindLabel fileKind <> " " <> T.pack path | (fileKind, path) <- cprFiles record]
  where
    kindLabel Generated = "generated"
    kindLabel HoleStub = "create-once"

parseConformancePackageRecord :: Text -> Maybe ConformancePackageRecord
parseConformancePackageRecord input = do
  schema <- exactlyOne [value | ["schema", raw] <- rows, Just value <- [readInt raw]]
  serviceKey <- exactlyOne [value | "service-key" : rest <- rows, Just value <- [parseServiceKey rest]]
  runtimePackage <- exactlyOne [value | ["runtime-package", raw] <- rows, Right value <- [mkRuntimePackageName raw]]
  facadeModule <- exactlyOne [value | ["facade-module", value] <- rows]
  files <- traverse parseFile [row | row@(keyword : _) <- rows, keyword == "file"]
  let knownRows = 4 + length files
  if schema == 1 && knownRows == length rows && safeServiceKey serviceKey && safeFiles files
    then
      Just
        ConformancePackageRecord
          { cprSchema = schema,
            cprServiceKey = serviceKey,
            cprRuntimePackage = runtimePackage,
            cprFacadeModule = facadeModule,
            cprFiles = files
          }
    else Nothing
  where
    rows = [T.words line | line <- T.lines input, let stripped = T.strip line, not (T.null stripped), not (isGeneratedBannerLine stripped)]
    parseServiceKey ["workspace", value] = Just (WorkspaceConformanceService value)
    parseServiceKey ["standalone", value] = Just (StandaloneConformanceService value)
    parseServiceKey _ = Nothing
    parseFile ["file", "generated", path] = Just (Generated, T.unpack path)
    parseFile ["file", "create-once", path] = Just (HoleStub, T.unpack path)
    parseFile _ = Nothing
    safeFiles files =
      all (safeRelativePath . snd) files
        && length files == Set.size (Set.fromList (map (T.toCaseFold . T.pack . snd) files))
    readInt raw = case reads (T.unpack raw) of
      [(value, "")] -> Just value
      _ -> Nothing

preflightConformancePackage :: FilePath -> Bool -> ConformancePackagePlan -> IO (Either [ConformancePackageFailure] PreparedConformancePackage)
preflightConformancePackage out forceGeneratedOverwrite plan = do
  bannerless <- if forceGeneratedOverwrite then pure [] else missingPackageBanners root (cppFiles plan)
  previousResult <- readPreviousRecord root forceGeneratedOverwrite bannerless plan
  case [ConformanceGeneratedBannerMissing (map (cppDirectory plan </>) bannerless) | not (null bannerless)] <> either id (const []) previousResult of
    failures@(_ : _) -> pure (Left failures)
    [] -> do
      let previous = either (const Nothing) id previousResult
      stale <- maybe (pure []) (stalePackageFiles root (map conformanceFilePath (cppFiles plan))) previous
      pure (Right PreparedConformancePackage {preparedRoot = root, preparedPlan = plan, preparedStale = stale})
  where
    root = out </> cppDirectory plan

readPreviousRecord :: FilePath -> Bool -> [FilePath] -> ConformancePackagePlan -> IO (Either [ConformancePackageFailure] (Maybe ConformancePackageRecord))
readPreviousRecord root forceGeneratedOverwrite bannerless plan = do
  let path = root </> conformanceRecordFileName
  exists <- doesFileExist path
  if not exists || conformanceRecordFileName `elem` bannerless || forceGeneratedOverwrite
    then pure (Right Nothing)
    else do
      parsed <- parseConformancePackageRecord <$> TIO.readFile path
      pure $ case parsed of
        Nothing -> Left [InvalidConformancePackageRecord (cppDirectory plan </> conformanceRecordFileName)]
        Just record
          | cprServiceKey record == cppServiceKey plan -> Right (Just record)
          | otherwise -> Left [ConformancePackageRecordMismatch (cppDirectory plan </> conformanceRecordFileName)]

missingPackageBanners :: FilePath -> [ConformanceFile] -> IO [FilePath]
missingPackageBanners root files = fmap concat . forM generated $ \file -> do
  let path = root </> conformanceFilePath file
  exists <- doesFileExist path
  if not exists
    then pure []
    else do
      contents <- TIO.readFile path
      pure [conformanceFilePath file | not (any isGeneratedBannerLine (T.lines contents))]
  where
    generated = [file | file <- files, conformanceFileKind file == Generated]

stalePackageFiles :: FilePath -> [FilePath] -> ConformancePackageRecord -> IO [ConformanceStaleFile]
stalePackageFiles root current record = fmap concat . forM removed $ \(fileKind, path) -> do
  exists <- doesFileExist (root </> path)
  if not exists
    then pure []
    else do
      evidence <- case fileKind of
        HoleStub -> pure Nothing
        Generated -> do
          contents <- TIO.readFile (root </> path)
          pure (Just (any isGeneratedBannerLine (T.lines contents)))
      pure [ConformanceStaleFile fileKind path evidence]
  where
    currentSet = Set.fromList current
    removed = [(fileKind, path) | (fileKind, path) <- cprFiles record, path `Set.notMember` currentSet]

executePreparedConformancePackage :: PreparedConformancePackage -> IO ConformancePackageReport
executePreparedConformancePackage prepared = do
  dispositions <- traverse (writeConformanceFile (preparedRoot prepared)) (cppFiles plan)
  pure
    ConformancePackageReport
      { conformanceReportRoot = preparedRoot prepared,
        conformanceReportPlan = plan,
        conformanceReportDispositions = dispositions,
        conformanceReportStale = preparedStale prepared
      }
  where
    plan = preparedPlan prepared

writeConformanceFile :: FilePath -> ConformanceFile -> IO (ConformanceFile, ConformanceWriteDisposition)
writeConformanceFile root file = do
  let path = root </> conformanceFilePath file
  exists <- doesFileExist path
  case conformanceFileKind file of
    HoleStub
      | exists -> pure (file, ConformanceSkipped)
      | otherwise -> write path ConformanceCreated
    Generated
      | exists -> do
          existing <- TIO.readFile path
          if existing == conformanceFileText file
            then pure (file, ConformanceUnchanged)
            else write path ConformanceOverwritten
      | otherwise -> write path ConformanceCreated
  where
    write path disposition = do
      createDirectoryIfMissing True (takeDirectory path)
      TIO.writeFile path (conformanceFileText file)
      pure (file, disposition)

compareConformanceFacts :: [(String, String)] -> [(String, String)] -> Either [DuplicateFactKey] [ConformanceFactResult]
compareConformanceFacts expected actual =
  case duplicateKeys ExpectedFact expected <> duplicateKeys ActualFact actual of
    duplicates@(_ : _) -> Left duplicates
    [] -> Right (map compareKey allKeys)
  where
    expectedMap = Map.fromList expected
    actualMap = Map.fromList actual
    allKeys = Set.toAscList (Map.keysSet expectedMap <> Map.keysSet actualMap)
    compareKey key = case (Map.lookup key expectedMap, Map.lookup key actualMap) of
      (Just expectedValue, Just actualValue)
        | expectedValue == actualValue -> ConformanceFactMatch key actualValue
        | otherwise -> ConformanceFactMismatch key expectedValue actualValue
      (Just expectedValue, Nothing) -> ConformanceFactMissing key expectedValue
      (Nothing, Just actualValue) -> ConformanceFactUnexpected key actualValue
      (Nothing, Nothing) -> error "compareConformanceFacts union key missing from both maps"

duplicateKeys :: ConformanceFactSide -> [(String, String)] -> [DuplicateFactKey]
duplicateKeys side facts =
  [ DuplicateFactKey side key
  | entries@((key, _) : _) <- groupBy (\left right -> fst left == fst right) (sortOn fst facts),
    length entries > 1
  ]

renderConformancePackageFailure :: ConformancePackageFailure -> [Text]
renderConformancePackageFailure = \case
  UnsafeConformanceServiceKey key -> ["error: unsafe conformance service key '" <> key <> "' -- refusing to scaffold; nothing was written"]
  UnsafeConformancePath path -> ["error: unsafe conformance package path -- refusing to scaffold; nothing was written", "  " <> T.pack path]
  ConformancePathCollision paths -> ["error: conformance package path collision -- refusing to scaffold; nothing was written"] <> map ("  " <>) (map T.pack paths)
  PackageDuplicateFactKeys duplicates ->
    ["error: duplicate conformance fact keys -- refusing to scaffold; nothing was written"]
      <> ["  " <> sideLabel (duplicateFactSide duplicate) <> "/" <> T.pack (duplicateFactKey duplicate) | duplicate <- duplicates]
  ConformanceGeneratedBannerMissing paths ->
    ["error: refusing to overwrite generated conformance package files without a recognized '-- @generated' banner"]
      <> map ("  " <>) (map T.pack paths)
      <> ["nothing was written"]
  InvalidConformancePackageRecord path -> ["error: invalid generated conformance package record -- refusing to scaffold; nothing was written", "  " <> T.pack path]
  ConformancePackageRecordMismatch path -> ["error: conformance package record belongs to a different service -- refusing to scaffold; nothing was written", "  " <> T.pack path]
  where
    sideLabel ExpectedFact = "expected"
    sideLabel ActualFact = "actual"

renderConformancePackageReport :: ConformancePackageReport -> [Text]
renderConformancePackageReport report =
  [ "conformance-package: " <> T.pack cabalPath,
    "conformance-target: cabal test " <> cppPackageName plan
  ]
    <> [ "conformance-file: " <> T.pack (root </> conformanceFilePath file) <> " " <> dispositionTag disposition
       | (file, disposition) <- dispositions,
         conformanceFileKind file == Generated,
         conformanceFilePath file /= conformanceRecordFileName
       ]
    <> ["expectations: " <> T.pack (root </> conformanceFilePath file) <> " " <> dispositionTag disposition | (file, disposition) <- dispositions, conformanceFileKind file == HoleStub]
    <> ["conformance-record: " <> T.pack (root </> conformanceRecordFileName) <> " " <> dispositionTag disposition | (file, disposition) <- dispositions, conformanceFilePath file == conformanceRecordFileName]
    <> staleLines
  where
    plan = conformanceReportPlan report
    root = conformanceReportRoot report
    dispositions = conformanceReportDispositions report
    cabalPath = root </> T.unpack (cppPackageName plan) <> ".cabal"
    dispositionTag ConformanceCreated = "(created)"
    dispositionTag ConformanceOverwritten = "(overwritten)"
    dispositionTag ConformanceSkipped = "(skipped: already present)"
    dispositionTag ConformanceUnchanged = "(unchanged)"
    staleLines = case conformanceReportStale report of
      [] -> []
      stale ->
        ["conformance-stale: " <> tshow (length stale) <> " file(s) are no longer produced; keiro-dsl never deletes files."]
          <> ["  " <> staleKindLabel (conformanceStaleKind file) <> " " <> T.pack (root </> conformanceStalePath file) <> staleEvidence file | file <- stale]
    staleKindLabel Generated = "generated"
    staleKindLabel HoleStub = "create-once"
    staleEvidence file = case conformanceStaleBannerPresent file of
      Nothing -> " (hand-owned; preserve and review)"
      Just True -> " (recognized generated banner present; review before deleting)"
      Just False -> " (generated banner missing; preserve and review)"

safeServiceKey :: ConformanceServiceKey -> Bool
safeServiceKey key = case T.uncons (serviceKeyText key) of
  Nothing -> False
  Just (first, rest) -> asciiAlphaNum first && T.all wireCharacter rest
  where
    asciiAlphaNum character = isAscii character && isAlphaNum character
    wireCharacter character = asciiAlphaNum character || character == '_' || character == '-'

safeRelativePath :: FilePath -> Bool
safeRelativePath path =
  not (null path)
    && not (isAbsolute path)
    && all (\component -> component /= ".." && component /= "." && not (null component)) (splitDirectories path)

serviceKeyText :: ConformanceServiceKey -> Text
serviceKeyText (WorkspaceConformanceService service) = service
serviceKeyText (StandaloneConformanceService context) = context

renderServiceKey :: ConformanceServiceKey -> Text
renderServiceKey (WorkspaceConformanceService service) = "workspace " <> service
renderServiceKey (StandaloneConformanceService context) = "standalone " <> context

exactlyOne :: [a] -> Maybe a
exactlyOne [value] = Just value
exactlyOne _ = Nothing

tshow :: (Show a) => a -> Text
tshow = T.pack . show