seihou-core-0.4.0.0: src/Seihou/Engine/Validate.hs
module Seihou.Engine.Validate
( DiagSeverity (..),
DiagCheck (..),
ValidateReport (..),
buildReport,
renderReportPlain,
reportHasErrors,
)
where
import Data.Map.Strict qualified as Map
import Data.Maybe (isNothing, mapMaybe)
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.Core.Expr (exprRefs, parseExpr)
import Seihou.Core.Module
( checkCommandSafety,
checkDependencyNames,
checkDestVarRefs,
checkExportRefs,
checkFileExistence,
checkNameFormat,
checkPromptRefs,
checkSafeDestinations,
checkUniqueVars,
checkVersionPresent,
extractPlaceholders,
)
import Seihou.Core.Types
import Seihou.Engine.Template (extractIfExprs)
import Seihou.Prelude
import System.Directory (doesFileExist)
-- | Severity of a diagnostic check result.
data DiagSeverity
= DiagError
| DiagWarning
deriving stock (Eq, Show)
-- | A single diagnostic check with its result.
data DiagCheck = DiagCheck
{ diagLabel :: Text,
diagSeverity :: DiagSeverity,
diagDetails :: [Text]
}
deriving stock (Eq, Show)
-- | A complete validation report for a module.
data ValidateReport = ValidateReport
{ reportModule :: Module,
reportPath :: FilePath,
reportDhallOk :: Bool,
reportDhallError :: Maybe Text,
reportChecks :: [DiagCheck]
}
deriving stock (Eq, Show)
-- | Build a structured validation report. When the first argument is True,
-- lint warnings are included after the core checks.
buildReport :: Bool -> FilePath -> Module -> IO ValidateReport
buildReport lint baseDir m = do
fileErrors <- checkFileExistence baseDir m
-- The conditional lint reads template file contents, so it runs in IO and
-- only when --lint is requested.
(undeclaredRefs, typeMismatches) <-
if lint then lintConditionals baseDir m else pure ([], [])
let coreChecks =
[ DiagCheck "Module name format" DiagError (checkNameFormat m),
DiagCheck "Module version declared" DiagError (checkVersionPresent m),
DiagCheck "Unique variable names" DiagError (checkUniqueVars m),
DiagCheck "Prompt references" DiagError (checkPromptRefs m),
DiagCheck "Export references" DiagError (checkExportRefs m),
DiagCheck "Source file existence" DiagError fileErrors,
DiagCheck "Dependency names" DiagError (checkDependencyNames m),
DiagCheck "Safe step destinations" DiagError (checkSafeDestinations m),
DiagCheck "Destination variable references" DiagError (checkDestVarRefs m),
DiagCheck "Command safety" DiagError (checkCommandSafety m)
]
lintChecks =
if lint
then
[ -- Conditional findings are genuine correctness bugs (the class
-- that silently dropped guarded output), so they gate exit
-- status as DiagError, while remaining gated behind --lint.
DiagCheck "Conditional variable references" DiagError undeclaredRefs,
DiagCheck "Conditional comparison types" DiagError typeMismatches,
DiagCheck "Unused variables" DiagWarning (lintUnusedVars m),
DiagCheck "Required variables without prompts" DiagWarning (lintRequiredWithoutPrompt m),
DiagCheck "Duplicate step destinations" DiagWarning (lintDuplicateDestinations m),
DiagCheck "Empty choice lists" DiagWarning (lintEmptyChoices m),
DiagCheck "Missing variable descriptions" DiagWarning (lintMissingDescriptions m)
]
else []
pure
ValidateReport
{ reportModule = m,
reportPath = baseDir,
reportDhallOk = True,
reportDhallError = Nothing,
reportChecks = coreChecks ++ lintChecks
}
-- | Whether the report contains any errors (DiagError with non-empty details).
reportHasErrors :: ValidateReport -> Bool
reportHasErrors report =
not report.reportDhallOk
|| any (\c -> c.diagSeverity == DiagError && not (null c.diagDetails)) report.reportChecks
-- | Render the report as plain text (no ANSI codes).
renderReportPlain :: ValidateReport -> Text
renderReportPlain report =
T.unlines $
[ "Validating module at " <> T.pack report.reportPath <> "...",
""
]
++ dhallLine
++ summaryLines
++ checkLines
++ [""]
++ [resultLine]
where
m = report.reportModule
dhallLine =
if report.reportDhallOk
then [" \x2713 module.dhall evaluates successfully"]
else
[" \x2717 module.dhall failed to evaluate"]
++ case report.reportDhallError of
Just errText -> [" " <> errText]
Nothing -> []
summaryLines =
if report.reportDhallOk
then
[ " \x2713 Module name: " <> m.name.unModuleName,
" \x2713 " <> T.pack (show (length m.vars)) <> " variables declared",
" \x2713 " <> T.pack (show (length m.prompts)) <> " prompts defined",
" \x2713 " <> T.pack (show (length m.steps)) <> " steps defined"
]
else []
checkLines = concatMap renderCheck report.reportChecks
renderCheck c
| null c.diagDetails =
[" \x2713 " <> c.diagLabel]
| c.diagSeverity == DiagWarning =
(" \x26A0 " <> c.diagLabel) : map (\d -> " " <> d) c.diagDetails
| otherwise =
(" \x2717 " <> c.diagLabel) : map (\d -> " " <> d) c.diagDetails
errorCount =
length
[ ()
| c <- report.reportChecks,
c.diagSeverity == DiagError,
not (null c.diagDetails)
]
dhallFailed = not report.reportDhallOk
totalErrors = errorCount + (if dhallFailed then 1 else 0)
resultLine
| totalErrors > 0 =
T.pack (show totalErrors) <> " error(s) found. Module is invalid."
| otherwise =
"Module '" <> m.name.unModuleName <> "' is valid."
-- Lint checks
-- | Variables declared but never referenced in step destinations, exports, or prompts.
lintUnusedVars :: Module -> [Text]
lintUnusedVars m =
let destRefs =
Set.fromList $
concatMap (extractPlaceholders . (.dest)) m.steps
exportRefs =
Set.fromList $
map (.var.unVarName) m.exports
promptRefs =
Set.fromList $
map (.var.unVarName) m.prompts
allRefs = Set.unions [destRefs, exportRefs, promptRefs]
in mapMaybe
( \v ->
let name' = v.name.unVarName
in if Set.member name' allRefs
then Nothing
else Just ("variable '" <> name' <> "' is declared but never referenced")
)
m.vars
-- | Required variables that have no corresponding prompt.
lintRequiredWithoutPrompt :: Module -> [Text]
lintRequiredWithoutPrompt m =
let promptedVars = Set.fromList $ map (.var.unVarName) m.prompts
in mapMaybe
( \v ->
let name' = v.name.unVarName
in if v.required && not (Set.member name' promptedVars)
then Just ("required variable '" <> name' <> "' has no prompt")
else Nothing
)
m.vars
-- | Steps that write to the same destination (excluding patch ops).
lintDuplicateDestinations :: Module -> [Text]
lintDuplicateDestinations m =
let nonPatchDests = [s.dest | s <- m.steps, isNothing s.patch]
dupes = findDuplicates Set.empty Set.empty nonPatchDests
in map (\d -> "multiple steps write to '" <> d <> "'") dupes
findDuplicates :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
findDuplicates _ _ [] = []
findDuplicates seen reported (x : xs)
| Set.member x seen && not (Set.member x reported) =
x : findDuplicates seen (Set.insert x reported) xs
| otherwise = findDuplicates (Set.insert x seen) reported xs
-- | Choice variables with an empty option list.
lintEmptyChoices :: Module -> [Text]
lintEmptyChoices m =
mapMaybe
( \v -> case v.type_ of
VTChoice [] -> Just ("variable '" <> v.name.unVarName <> "' has an empty choice list")
_ -> Nothing
)
m.vars
-- | Variables without a description.
lintMissingDescriptions :: Module -> [Text]
lintMissingDescriptions m =
mapMaybe
( \v ->
if isNothing v.description
then Just ("variable '" <> v.name.unVarName <> "' has no description")
else Nothing
)
m.vars
-- Conditional-expression lint (when clauses + template {{#if}} conditionals)
-- | A finding about a conditional expression, tagged by kind so 'buildReport'
-- can render undeclared-reference and type-mismatch findings under separate
-- check labels.
data CondFinding
= CondUndeclared Text
| CondTypeMismatch Text
-- | Lint every conditional expression in the module: step, command, and prompt
-- @when@ clauses (already parsed to 'Expr') plus @{{#if …}}@ conditionals in
-- text-bearing template files. Returns @(undeclaredReferences, typeMismatches)@.
--
-- Each referenced variable is checked against the module's declarations: a
-- reference to an undeclared variable, or an @Eq@ comparison whose literal type
-- cannot match the variable's declared type (e.g. a @bool@ variable compared
-- against the quoted string @"true"@), becomes a finding.
lintConditionals :: FilePath -> Module -> IO ([Text], [Text])
lintConditionals baseDir m = do
templateExprs <- collectTemplateExprs baseDir m
let declaredTypes = Map.fromList [(d.name, d.type_) | d <- m.vars]
stepExprs =
[("step '" <> s.dest <> "' when clause", c) | s <- m.steps, Just c <- [s.condition]]
commandExprs =
[("command when clause", c) | c0 <- m.commands, Just c <- [c0.condition]]
promptExprs =
[ ("prompt for '" <> p.var.unVarName <> "' when clause", c)
| p <- m.prompts,
Just c <- [p.condition]
]
allExprs = stepExprs ++ commandExprs ++ promptExprs ++ templateExprs
findings = concatMap (uncurry (lintExpr declaredTypes)) allExprs
pure
( [t | CondUndeclared t <- findings],
[t | CondTypeMismatch t <- findings]
)
-- | Read text-bearing template files (@Template@ / @DhallText@ strategies) and
-- extract their @{{#if …}}@ conditionals, parsed to 'Expr'. Files that do not
-- exist are skipped (their absence is already reported by the core
-- source-file-existence check); expressions that fail to parse are skipped here
-- (they surface at render time, outside this lint's scope).
collectTemplateExprs :: FilePath -> Module -> IO [(Text, Expr)]
collectTemplateExprs baseDir m =
concat <$> mapM readStep textBearingSteps
where
textBearingSteps = filter (isTextBearing . (.strategy)) m.steps
isTextBearing Template = True
isTextBearing DhallText = True
isTextBearing _ = False
readStep s = do
let path = baseDir </> "files" </> s.src
exists <- doesFileExist path
if not exists
then pure []
else do
contents <- TIO.readFile path
let label = "template '" <> T.pack s.src <> "' {{#if}} condition"
pure [(label, expr) | raw <- extractIfExprs contents, Right expr <- [parseExpr raw]]
-- | Lint a single expression from the given source against the declared types.
lintExpr :: Map.Map VarName VarType -> Text -> Expr -> [CondFinding]
lintExpr declaredTypes srcLabel expr =
concatMap checkRef (exprRefs expr)
where
checkRef (name, mLit) =
case Map.lookup name declaredTypes of
Nothing ->
[CondUndeclared (srcLabel <> " references undeclared variable: " <> name.unVarName)]
Just ty -> case mLit of
Just lit
| not (literalMatchesType ty lit) ->
[CondTypeMismatch (describeMismatch srcLabel name ty lit)]
_ -> []
-- | Whether an @Eq@ literal's constructor can match a value of the declared
-- type. Bool→'VBool', int→'VInt', text→'VText', choice→'VText' (choice values
-- resolve to text), list→'VList'.
literalMatchesType :: VarType -> VarValue -> Bool
literalMatchesType VTBool (VBool _) = True
literalMatchesType VTInt (VInt _) = True
literalMatchesType VTText (VText _) = True
literalMatchesType (VTChoice _) (VText _) = True
literalMatchesType (VTList _) (VList _) = True
literalMatchesType _ _ = False
-- | Build a human-readable message for a type-inconsistent @Eq@ comparison,
-- with an actionable hint where one applies (the original bug: a @bool@
-- variable compared against the quoted string @"true"@ instead of the
-- bareword @true@).
describeMismatch :: Text -> VarName -> VarType -> VarValue -> Text
describeMismatch srcLabel name ty lit =
srcLabel
<> " compares variable '"
<> name.unVarName
<> "' (declared type "
<> renderVarType ty
<> ") against "
<> describeLiteral lit
<> "; the comparison can never match."
<> mismatchHint ty lit
-- | A short hint steering the author to the correct literal form.
mismatchHint :: VarType -> VarValue -> Text
mismatchHint VTBool (VText t)
| T.toLower (T.strip t) `elem` ["true", "false"] =
" Use the bareword " <> T.toLower (T.strip t) <> " instead of the quoted \"" <> t <> "\"."
mismatchHint VTInt (VText t) = " Use the unquoted number " <> t <> " instead of the quoted \"" <> t <> "\"."
mismatchHint VTText (VBool b) =
" Use the quoted string \"" <> (if b then "true" else "false") <> "\" instead of the bareword."
mismatchHint _ _ = ""
-- | Render an @Eq@ literal for a diagnostic message.
describeLiteral :: VarValue -> Text
describeLiteral (VText t) = "string literal \"" <> t <> "\""
describeLiteral (VBool b) = "bareword " <> (if b then "true" else "false")
describeLiteral (VInt n) = "integer literal " <> T.pack (show n)
describeLiteral (VList _) = "a list literal"
-- | A short rendering of a declared variable type for diagnostic messages.
renderVarType :: VarType -> Text
renderVarType VTText = "text"
renderVarType VTBool = "bool"
renderVarType VTInt = "int"
renderVarType (VTList ty) = "list " <> renderVarType ty
renderVarType (VTChoice _) = "choice"