seihou-core-0.7.0.0: src/Seihou/Core/Blueprint.hs
module Seihou.Core.Blueprint
( validateBlueprint,
validateBlueprintWith,
checkBlueprintNameFormat,
checkBlueprintVersionPresent,
checkBlueprintPromptNonEmpty,
checkBlueprintUniqueVars,
checkBlueprintPromptRefs,
checkBlueprintBaseModules,
checkBlueprintBaseModulesWith,
checkBlueprintFiles,
checkBlueprintTags,
checkBlueprintAllowedTools,
checkBlueprintMigrations,
checkBlueprintLaunch,
checkBlueprintVersionProbe,
)
where
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.Core.Migration (BlueprintMigration (..), EntailedEdge (..))
import Seihou.Core.Module (defaultSearchPaths, discoverRunnable, isValidModuleName)
import Seihou.Core.Types
import Seihou.Core.Version (parseVersion)
import Seihou.Prelude
import System.Directory (doesFileExist)
-- | Validate a decoded 'Blueprint' against the documented rules.
-- The 'FilePath' is the blueprint's base directory (containing
-- @blueprint.dhall@).
--
-- Validation rules:
--
-- 1. Name format: matches @[a-z][a-z0-9-]*@.
-- 2. Version, when given, is non-empty (blueprints may legitimately
-- omit a version during early authoring; we only reject @Just ""@).
-- 3. Prompt body is non-empty after trimming.
-- 4. Variable names declared in @vars@ are unique.
-- 5. Every interactive prompt references a declared variable.
-- 6. Each @baseModules@ entry is well-formed (name format and var
-- binding names) and resolves to a module or recipe — not another
-- blueprint and not nothing.
-- 7. Every @files@ entry exists at @baseDir/files/SRC@.
-- 8. Every tag is non-empty.
-- 9. Every @allowedTools@ entry, when set, is non-empty.
-- 10. Every migration is a forward dotted-numeric edge with a non-empty
-- prompt, and each starting version occurs at most once. Every entailed
-- edge names a well-formed blueprint other than this one, with a forward
-- dotted-numeric window, and no edge entails the same edge twice.
-- Whether the named blueprint exists and declares that exact edge cannot
-- be checked here — this function is pure and existence is a filesystem
-- question — so it is checked when @seihou agent migrate@ resolves the
-- cohort.
-- 11. Every field the @launch@ record does set is non-blank. The values
-- themselves are parsed by the CLI, which owns the provider and effort
-- vocabularies.
-- 12. @versionProbe@, when set, is non-blank. What the command /does/ is
-- deliberately not checked: validation must not execute anything, and
-- seihou cannot know whether the author's @jq@ or @nix@ is installed on
-- the consumer's machine. A probe that fails at run time degrades to
-- requiring @--to@ rather than failing the blueprint.
validateBlueprint :: FilePath -> Blueprint -> IO (Either ModuleLoadError Blueprint)
validateBlueprint baseDir b = do
searchPaths <- defaultSearchPaths
validateBlueprintWith searchPaths baseDir b
-- | Same as 'validateBlueprint' but takes the search paths used for
-- resolving base-module references explicitly. Useful for tests that
-- need to pin the lookup roots; production code should call
-- 'validateBlueprint' which pulls them from 'defaultSearchPaths'.
validateBlueprintWith ::
[FilePath] ->
FilePath ->
Blueprint ->
IO (Either ModuleLoadError Blueprint)
validateBlueprintWith searchPaths baseDir b = do
fileErrs <- checkBlueprintFiles baseDir b
baseErrs <- checkBlueprintBaseModulesWith searchPaths b
let pureErrs =
checkBlueprintNameFormat b
<> checkBlueprintVersionPresent b
<> checkBlueprintPromptNonEmpty b
<> checkBlueprintUniqueVars b
<> checkBlueprintPromptRefs b
<> checkBlueprintTags b
<> checkBlueprintAllowedTools b
<> checkBlueprintMigrations b
<> checkBlueprintLaunch b
<> checkBlueprintVersionProbe b
allErrs = pureErrs <> fileErrs <> baseErrs
pure $
if null allErrs
then Right b
else Left (ValidationError (b ^. #name) allErrs)
-- Rule 1: blueprint name must match [a-z][a-z0-9-]*
checkBlueprintNameFormat :: Blueprint -> [Text]
checkBlueprintNameFormat b =
let n = (b ^. #name . #unModuleName)
in if T.null n || not (isValidModuleName n)
then ["blueprint name must match [a-z][a-z0-9-]*, got: " <> n]
else []
-- Rule 2: if a version is given it must not be empty
checkBlueprintVersionPresent :: Blueprint -> [Text]
checkBlueprintVersionPresent b = case b ^. #version of
Nothing -> []
Just v
| T.null (T.strip v) -> ["blueprint version, if specified, must not be empty"]
| otherwise -> []
-- Rule 3: prompt body must not be empty after trimming
checkBlueprintPromptNonEmpty :: Blueprint -> [Text]
checkBlueprintPromptNonEmpty b
| T.null (T.strip (b ^. #prompt)) = ["blueprint prompt must not be empty"]
| otherwise = []
-- Rule 4: declared variable names must be unique
checkBlueprintUniqueVars :: Blueprint -> [Text]
checkBlueprintUniqueVars b =
let names = map (\d -> d ^. #name . #unVarName) (b ^. #vars)
in map (\n -> "duplicate variable name: " <> n) (findDupes Set.empty Set.empty names)
findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
findDupes _ _ [] = []
findDupes seen reported (x : xs)
| Set.member x seen && not (Set.member x reported) = x : findDupes seen (Set.insert x reported) xs
| otherwise = findDupes (Set.insert x seen) reported xs
-- Rule 5: every prompt references a declared variable
checkBlueprintPromptRefs :: Blueprint -> [Text]
checkBlueprintPromptRefs b =
let varNames = Set.fromList (map (^. #name) (b ^. #vars))
in concatMap
( \p ->
if Set.member (p ^. #var) varNames
then []
else ["prompt references undeclared variable: " <> p ^. #var . #unVarName]
)
(b ^. #prompts)
-- Rule 6: base modules must be well-formed and resolve to a module or
-- recipe (not another blueprint). The check uses the same default
-- search paths as @seihou run@; tests can pass custom roots via
-- 'checkBlueprintBaseModulesWith'.
checkBlueprintBaseModules :: Blueprint -> IO [Text]
checkBlueprintBaseModules b = do
searchPaths <- defaultSearchPaths
checkBlueprintBaseModulesWith searchPaths b
checkBlueprintBaseModulesWith :: [FilePath] -> Blueprint -> IO [Text]
checkBlueprintBaseModulesWith searchPaths b =
concat <$> mapM (checkOne searchPaths) (b ^. #baseModules)
where
checkOne :: [FilePath] -> Dependency -> IO [Text]
checkOne paths dep = do
let n = (dep ^. #module_ . #unModuleName)
nameErrs =
[ "invalid baseModule name: " <> n
| not (isValidModuleName n)
]
bindingErrs =
[ "baseModule '" <> n <> "' has invalid var binding name: " <> vn
| (VarName vn) <- Map.keys (dep ^. #vars),
not (isValidVarBindingName vn)
]
resolveErrs <-
if not (isValidModuleName n)
then pure []
else do
result <- discoverRunnable paths (dep ^. #module_)
pure $ case result of
Right (RunnableModule _ _) -> []
Right (RunnableRecipe _ _) -> []
Right (RunnableBlueprint _ _) ->
[ "baseModule '"
<> n
<> "' resolves to a blueprint; baseModules must be modules or recipes"
]
Left (ModuleNotFound _ _) ->
["baseModule '" <> n <> "' not found in any search path"]
Left _ ->
["baseModule '" <> n <> "' failed to load"]
pure (nameErrs <> bindingErrs <> resolveErrs)
isValidVarBindingName :: Text -> Bool
isValidVarBindingName t = case T.uncons t of
Nothing -> False
Just (c, rest) ->
(c >= 'a' && c <= 'z')
&& T.all
(\ch -> (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.')
rest
-- Rule 7: every @files@ entry's source must exist on disk relative to
-- @baseDir/files/@
checkBlueprintFiles :: FilePath -> Blueprint -> IO [Text]
checkBlueprintFiles baseDir b =
concat
<$> mapM
( \bf -> do
let p = baseDir </> "files" </> (bf ^. #src)
exists <- doesFileExist p
pure $
if exists
then []
else ["blueprint file not found: " <> T.pack (bf ^. #src)]
)
(b ^. #files)
-- Rule 8: tags must not be empty strings
checkBlueprintTags :: Blueprint -> [Text]
checkBlueprintTags b =
[ "tag must not be empty"
| t <- b ^. #tags,
T.null (T.strip t)
]
-- Rule 9: @allowedTools@, when set, must contain only non-empty entries
checkBlueprintAllowedTools :: Blueprint -> [Text]
checkBlueprintAllowedTools b = case b ^. #allowedTools of
Nothing -> []
Just xs ->
[ "allowedTools entry must not be empty"
| t <- xs,
T.null (T.strip t)
]
-- Rule 10: every migration is a forward dotted-numeric version edge with a
-- non-empty prompt, and each starting version occurs at most once.
checkBlueprintMigrations :: Blueprint -> [Text]
checkBlueprintMigrations b =
concatMap checkOne (b ^. #migrations) <> duplicateErrors
where
checkOne :: BlueprintMigration -> [Text]
checkOne migration =
promptErrors migration
<> versionErrors "from" (migration ^. #from)
<> versionErrors "to" (migration ^. #to)
<> orderErrors migration
<> concatMap (entailErrors migration) (migration ^. #entails)
<> duplicateEntailErrors migration
-- An entailed edge names another blueprint's exact edge. Everything
-- checkable without touching the filesystem is checked here; existence of
-- the named blueprint and of the exact edge is resolved by
-- @seihou agent migrate@, which is the only caller that has search paths.
entailErrors :: BlueprintMigration -> EntailedEdge -> [Text]
entailErrors migration entailed =
nameErrors
<> entailVersionErrors "from" (entailed ^. #from)
<> entailVersionErrors "to" (entailed ^. #to)
<> entailOrderErrors
<> selfErrors
where
prefix =
"blueprint migration "
<> migration ^. #from
<> " -> "
<> migration ^. #to
<> " entails "
nameErrors
| T.null target || not (isValidModuleName target) =
[prefix <> "a blueprint whose name must match [a-z][a-z0-9-]*, got: " <> target]
| otherwise = []
entailVersionErrors label versionText = case parseVersion versionText of
Nothing ->
[ prefix
<> "'"
<> target
<> "' with a "
<> label
<> " version that is not dotted numeric: "
<> versionText
]
Just _ -> []
entailOrderErrors =
case (parseVersion (entailed ^. #from), parseVersion (entailed ^. #to)) of
(Just fromVersion, Just toVersion)
| fromVersion >= toVersion ->
[ prefix
<> "'"
<> target
<> "' with an edge that does not advance versions: "
<> entailed ^. #from
<> " -> "
<> entailed ^. #to
]
_ -> []
-- Entailment crosses blueprints. An edge naming its own blueprint is
-- either a typo or an attempt to express ordering within one
-- migrations list, which the version window already decides.
selfErrors
| target == b ^. #name . #unModuleName =
[prefix <> "an edge of its own blueprint '" <> target <> "'"]
| otherwise = []
target = entailed ^. #blueprint
duplicateEntailErrors :: BlueprintMigration -> [Text]
duplicateEntailErrors migration =
map
( \key ->
"blueprint migration "
<> migration ^. #from
<> " -> "
<> migration ^. #to
<> " entails the same edge twice: "
<> key
)
(findDupes Set.empty Set.empty (map renderEntailed (migration ^. #entails)))
renderEntailed entailed =
entailed ^. #blueprint <> " " <> entailed ^. #from <> " -> " <> entailed ^. #to
promptErrors :: BlueprintMigration -> [Text]
promptErrors migration =
[ "blueprint migration "
<> migration ^. #from
<> " -> "
<> migration ^. #to
<> " prompt must not be empty"
| T.null (T.strip (migration ^. #prompt))
]
versionErrors label versionText = case parseVersion versionText of
Nothing -> ["blueprint migration " <> label <> " version is not dotted numeric: " <> versionText]
Just _ -> []
orderErrors :: BlueprintMigration -> [Text]
orderErrors migration = case (parseVersion (migration ^. #from), parseVersion (migration ^. #to)) of
(Just fromVersion, Just toVersion)
| fromVersion >= toVersion ->
[ "blueprint migration must advance versions: "
<> migration ^. #from
<> " -> "
<> migration ^. #to
]
_ -> []
duplicateErrors =
map
("duplicate blueprint migration from version: " <>)
(findDupes Set.empty Set.empty (map (^. #from) (b ^. #migrations)))
-- Rule 11: every field the @launch@ record does set must be non-blank. The
-- declared values themselves (which provider, which effort) are parsed by the
-- CLI layer, which owns those vocabularies; core only rejects blanks.
checkBlueprintLaunch :: Blueprint -> [Text]
checkBlueprintLaunch b = case b ^. #launch of
Nothing -> []
Just l ->
blankErr "provider" (l ^. #provider)
<> blankErr "model" (l ^. #model)
<> blankErr "effort" (l ^. #effort)
<> blankErr "mode" (l ^. #mode)
where
blankErr key value =
[ "launch." <> key <> ", if specified, must not be empty"
| Just v <- [value],
T.null (T.strip v)
]
-- Rule 12: @versionProbe@, when set, must not be blank. Nothing more is
-- checkable here: the command is a shell string for the consumer's machine,
-- and validation runs on the author's.
checkBlueprintVersionProbe :: Blueprint -> [Text]
checkBlueprintVersionProbe b =
[ "versionProbe, if specified, must not be empty"
| Just probe <- [b ^. #versionProbe],
T.null (T.strip probe)
]