packages feed

seihou-core 0.11.0.1 → 0.12.0.0

raw patch · 25 files changed

+587/−121 lines, 25 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Seihou.Composition.Resolve: RecipeInputSpec :: [VarDecl] -> [Prompt] -> Maybe (Map VarName Text) -> RecipeInputSpec
+ Seihou.Composition.Resolve: data RecipeInputSpec
+ Seihou.Composition.Resolve: resolveRecipeWithPrompts :: forall (es :: [Effect]). Console :> es => PromptPermission -> Bool -> RecipeInputSpec -> [(ModuleInstance, Module, FilePath)] -> SavedInstanceValues -> Map VarName Text -> Map VarName Text -> Map Text Text -> Text -> Text -> Map VarName Text -> Map VarName Text -> Map VarName Text -> Map VarName Text -> Eff es (Either [VarError] (Map VarName ResolvedVar, Map ModuleInstance (Map VarName ResolvedVar)))
+ Seihou.Composition.Resolve: varValueToText :: VarValue -> Text
+ Seihou.Composition.Resolve: verifyDependencySources :: [FilePath] -> FilePath -> [Dependency] -> IO (Either ModuleLoadError ())
+ Seihou.Core.Blueprint: validateBlueprintForInstall :: FilePath -> Blueprint -> IO (Either ModuleLoadError Blueprint)
+ Seihou.Core.Types: RecipeInputs :: ManifestCapability
+ Seihou.Core.Types: [recipeInputs] :: AppliedComposition -> !Maybe (Map VarName Text)
+ Seihou.Dhall.Eval: normalizeDhallFile :: FilePath -> IO Text
+ Seihou.Dhall.Eval: normalizeInstalledDefinition :: Text -> FilePath -> IO Text
+ Seihou.Interaction.Prompt: runPromptsChecked :: forall (es :: [Effect]). Console :> es => [Prompt] -> [VarDecl] -> Map VarName VarValue -> Eff es (Either [VarError] (Map VarName ResolvedVar))
+ Seihou.Manifest.Upgrade: AddUnknownRecipeInputs :: UpgradeStepAction
- Seihou.Core.Types: AppliedComposition :: !ApplicationId -> !AppliedTarget -> !ArtifactOrigin -> !Maybe Text -> ![ModuleName] -> !Maybe Text -> !Maybe Text -> ![AppliedInstanceState] -> !Map CommandFingerprint CommandReceipt -> ![CheckoutCommand] -> !UTCTime -> AppliedComposition
+ Seihou.Core.Types: AppliedComposition :: !ApplicationId -> !AppliedTarget -> !ArtifactOrigin -> !Maybe Text -> ![ModuleName] -> !Maybe Text -> !Maybe Text -> ![AppliedInstanceState] -> !Map CommandFingerprint CommandReceipt -> ![CheckoutCommand] -> !Maybe (Map VarName Text) -> !UTCTime -> AppliedComposition
- Seihou.Core.Types: Dependency :: !ModuleName -> !Map VarName Text -> Dependency
+ Seihou.Core.Types: Dependency :: !ModuleName -> !Map VarName Text -> !Maybe Text -> Dependency

Files

seihou-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-core-version: 0.11.0.1+version: 0.12.0.0 synopsis: Core library for Seihou project scaffolding description:   Core library for Seihou, a composable project scaffolding system.
src/Seihou/Composition/Recipe.hs view
@@ -11,11 +11,11 @@ import Seihou.Core.Recipe (validateRecipe) import Seihou.Core.Types -type ExpandedRecipe = (ModuleName, [ModuleName], Map VarName Text, [VarDecl], [Prompt])+type ExpandedRecipe = (ModuleName, [ModuleName], Map VarName Text, [VarDecl], [Prompt], [Dependency])  -- | Expand a 'Recipe' into the inputs that the existing composition pipeline -- expects: a primary module name, additional module names, variable overrides,--- recipe-level variable declarations, and recipe-level prompts.+-- recipe-level variable declarations, prompts, and intact source requirements. -- -- The first module in the recipe's list becomes the primary module (used for -- config namespace derivation). All remaining modules become additional modules.@@ -29,4 +29,4 @@       let primaryName = (primary ^. #module_)           additionalNames = map (^. #module_) additional           overrides = Map.unions (map (^. #vars) (validated ^. #modules))-       in Right (primaryName, additionalNames, overrides, validated ^. #vars, validated ^. #prompts)+       in Right (primaryName, additionalNames, overrides, validated ^. #vars, validated ^. #prompts, validated ^. #modules)
src/Seihou/Composition/Resolve.hs view
@@ -1,29 +1,39 @@ module Seihou.Composition.Resolve   ( loadComposition,+    verifyDependencySources,     SavedInstanceValues,     PromptPermission (..),     resolveComposedVariables,     resolveComposedVariablesWithSaved,     resolveWithPrompts,     resolveWithPromptPermission,+    RecipeInputSpec (..),+    resolveRecipeWithPrompts,+    varValueToText,     exportedVars,     collectParentVars,   ) where +import Control.Monad (unless)+import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Except (ExceptT (..), runExceptT) import Data.Generics.Labels ()+import Data.List (find) import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe) import Data.Set qualified as Set import Data.Text qualified as T import Seihou.Composition.Graph (buildGraph, topoSort) import Seihou.Composition.Instance (ModuleInstance (..), mkInstance, primaryInstance)+import Seihou.Core.ArtifactIdentity (normalizeOriginUrl)+import Seihou.Core.ArtifactOriginDetect (OriginInfo (..), readOriginInfo) import Seihou.Core.Module (discoverModule, validateModule) import Seihou.Core.Types-import Seihou.Core.Variable (resolveVariablesWithSaved)+import Seihou.Core.Variable (coerceValue, resolveVariablesWithSaved) import Seihou.Dhall.Eval (evalModuleFromFile) import Seihou.Effect.Console (Console, isInteractive, putText)-import Seihou.Interaction.Prompt (runPrompts)+import Seihou.Interaction.Prompt (promptForVar, runPrompts, runPromptsChecked) import Seihou.Prelude  -- | Values captured by a previously accepted composition, kept distinct per@@ -55,6 +65,7 @@       loaded = Map.singleton primaryInst (effectivePrimary, primaryDir)       seeds = [(mkInstance (dep ^. #module_) (parentVarsFromDep dep)) | dep <- effectivePrimary ^. #dependencies]   allInstances <- ExceptT $ loadTransitive searchPaths loaded seeds+  mapM_ (\(inst, (m, dir)) -> ExceptT (verifyDependencySources searchPaths dir ((if inst == primaryInst then primaryMod else m) ^. #dependencies))) (Map.toList allInstances)   let entries = [(inst, m) | (inst, (m, _)) <- Map.toList allInstances]       graph = buildGraph entries   order <- ExceptT . pure $ topoSort graph@@ -279,6 +290,100 @@                             (Map.insert inst fullResolved perModule)                             (Map.insert inst myExports allExports) +-- | Recipe declarations are resolved before any consuming module defaults.+-- Nothing denotes historical inputs that have not yet been adopted; Just empty+-- records an accepted omission of all optional inputs.+data RecipeInputSpec = RecipeInputSpec [VarDecl] [Prompt] (Maybe (Map VarName Text))++resolveRecipeWithPrompts ::+  (Console :> es) =>+  PromptPermission ->+  Bool ->+  RecipeInputSpec ->+  [(ModuleInstance, Module, FilePath)] ->+  SavedInstanceValues ->+  Map VarName Text ->+  Map VarName Text ->+  Map Text Text ->+  Text ->+  Text ->+  Map VarName Text ->+  Map VarName Text ->+  Map VarName Text ->+  Map VarName Text ->+  Eff es (Either [VarError] (Map VarName ResolvedVar, Map ModuleInstance (Map VarName ResolvedVar)))+resolveRecipeWithPrompts permission confirmRecipe (RecipeInputSpec decls prompts recorded) modules saved cli literals env ns ctx local nsCfg ctxCfg global = do+  interactive <- case permission of+    PromptsForbidden -> pure False+    PromptsAllowed -> isInteractive+  let explicit = Map.union cli literals+      historical d =+        nubOrdBy+          id+          [ either (const raw) varValueToText (coerceValue (d ^. #name) (d ^. #type_) raw)+          | (inst, m, _) <- modules,+            any ((== d ^. #name) . (^. #name)) (m ^. #vars),+            Just raw <- [Map.lookup inst saved >>= Map.lookup (d ^. #name)]+          ]+      conflicts =+        [ ValidationFailed (d ^. #name) ("conflicting saved recipe inputs; supply --var " <> d ^. #name . #unVarName <> "=VALUE or use --reconfigure")+        | d <- decls,+          recorded == Nothing,+          not (Map.member (d ^. #name) explicit),+          length (historical d) > 1+        ]+      replay = fromMaybe (Map.fromList [(d ^. #name, raw) | d <- decls, [raw] <- [historical d]]) recorded+      results = [resolveVariablesWithSaved [d] explicit replay env ns ctx local nsCfg ctxCfg global Map.empty | d <- decls]+      values = Map.unions [v | Right v <- results]+      errors = concat [e | Left e <- results]+      (_, fatal) = partitionErrors errors+  if not (null (conflicts ++ fatal))+    then pure (Left (conflicts ++ fatal))+    else do+      confirmed <- if interactive && confirmRecipe then confirmValues (Map.toList values) values else pure (Right values)+      case confirmed of+        Left errs -> pure (Left errs)+        Right initial -> do+          let missing = [d | d <- decls, not (Map.member (d ^. #name) initial)]+              eligible = case recorded of+                Just _ -> filter (^. #required) missing+                Nothing -> missing+              required = filter (^. #required) eligible+              optional = filter (not . (^. #required)) eligible+          firstAnswers <- if interactive then runPromptsChecked prompts required (Map.map (^. #value) initial) else pure (Right Map.empty)+          case firstAnswers of+            Left errs -> pure (Left errs)+            Right answers -> do+              let current = Map.union answers initial+              secondAnswers <- if interactive then runPromptsChecked prompts optional (Map.map (^. #value) current) else pure (Right Map.empty)+              case secondAnswers of+                Left errs -> pure (Left errs)+                Right extras -> do+                  let recipeValues = Map.union extras current+                      omissions = [MissingRequiredVar (d ^. #name) | d <- decls, d ^. #required, not (Map.member (d ^. #name) recipeValues)]+                      broadcast = Map.map (varValueToText . (^. #value)) recipeValues+                  if not (null omissions)+                    then pure (Left omissions)+                    else do+                      let owned = Set.fromList (map (^. #name) decls)+                          consumers = [(i, m & #prompts %~ filter (\p -> not (Set.member (p ^. #var) owned)), dir) | (i, m, dir) <- modules]+                      resolved <- resolveWithPromptPermission permission consumers saved (Map.union explicit broadcast) env ns ctx local nsCfg ctxCfg global+                      pure $ fmap (\perModule -> (recipeValues, Map.map (Map.mapWithKey (restoreSource recipeValues)) perModule)) resolved+  where+    restoreSource recipeValues name rv = case Map.lookup name recipeValues of+      Just recipeValue | not (Map.member name cli || Map.member name literals) -> rv & #source .~ recipeValue ^. #source+      _ -> rv+    confirmValues [] acc = pure (Right acc)+    confirmValues ((name, rv) : rest) acc+      | rv ^. #source /= FromDefault = confirmValues rest acc+      | otherwise = do+          let decl = rv ^. #decl+              prompt = fromMaybe (Prompt name (name ^. #unVarName) Nothing Nothing) (find ((== name) . (^. #var)) prompts)+          answer <- promptForVar prompt decl (Map.map (^. #value) acc)+          case answer of+            Left err -> pure (Left [err])+            Right accepted -> confirmValues rest (Map.insert name accepted acc)+ -- | Collect the exports visible along a module's dependency edges. -- -- Each dependency edge is resolved to the exact child instance@@ -440,3 +545,30 @@     go seen (x : xs)       | Set.member (f x) seen = go seen xs       | otherwise = x : go (Set.insert (f x) seen) xs++-- | Verify producer requirements locally, including on the first generation.+-- A local shadow without provenance cannot satisfy an explicit source.+verifyDependencySources :: [FilePath] -> FilePath -> [Dependency] -> IO (Either ModuleLoadError ())+verifyDependencySources paths _declaringDir deps = runExceptT $ do+  mapM_ check deps+  where+    check dep = case dep ^. #source of+      Nothing -> pure ()+      Just required -> do+        let name = dep ^. #module_+        dir <- ExceptT (discoverModule paths name)+        actual <- liftIO (readOriginInfo dir)+        unless (maybe False (\info -> normalizeOriginUrl (info ^. #sourceUrl) == normalizeOriginUrl required) actual) $+          ExceptT $+            pure $+              Left $+                ValidationError+                  name+                  [ "Dependency '"+                      <> name ^. #unModuleName+                      <> "' requires "+                      <> required+                      <> "; resolved copy at "+                      <> T.pack dir+                      <> " has different or unknown provenance. Reinstall the declaring artifact and remove or correct the local shadow."+                  ]
src/Seihou/Core/Application.hs view
@@ -66,6 +66,7 @@       instances = map buildInstance modulesInOrder,       commandReceipts = Map.empty,       checkoutCommands = [],+      recipeInputs = Nothing,       appliedAt = now     }   where
src/Seihou/Core/Blueprint.hs view
@@ -1,6 +1,7 @@ module Seihou.Core.Blueprint   ( validateBlueprint,     validateBlueprintWith,+    validateBlueprintForInstall,     checkBlueprintNameFormat,     checkBlueprintVersionPresent,     checkBlueprintPromptNonEmpty,@@ -77,8 +78,18 @@   Blueprint ->   IO (Either ModuleLoadError Blueprint) validateBlueprintWith searchPaths baseDir b = do-  fileErrs <- checkBlueprintFiles baseDir b   baseErrs <- checkBlueprintBaseModulesWith searchPaths b+  validateBlueprintDefinition baseDir b baseErrs++-- | Acquisition validates references against fetched registries, not the user's+-- local cache. All declaration and payload checks still apply before install.+validateBlueprintForInstall :: FilePath -> Blueprint -> IO (Either ModuleLoadError Blueprint)+validateBlueprintForInstall baseDir b =+  validateBlueprintDefinition baseDir b (concatMap baseDeclarationErrors (b ^. #baseModules))++validateBlueprintDefinition :: FilePath -> Blueprint -> [Text] -> IO (Either ModuleLoadError Blueprint)+validateBlueprintDefinition baseDir b baseErrs = do+  fileErrs <- checkBlueprintFiles baseDir b   let pureErrs =         checkBlueprintNameFormat b           <> checkBlueprintVersionPresent b@@ -158,15 +169,6 @@     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 []@@ -184,16 +186,7 @@                 ["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+      pure (baseDeclarationErrors dep <> resolveErrs)  -- Rule 7: every @files@ entry's source must exist on disk relative to -- @baseDir/files/@@@ -377,3 +370,18 @@   | Just probe <- [b ^. #versionProbe],     T.null (T.strip probe)   ]++baseDeclarationErrors :: Dependency -> [Text]+baseDeclarationErrors dep =+  let n = dep ^. #module_ . #unModuleName+   in ["invalid baseModule name: " <> n | not (isValidModuleName n)]+        <> ["baseModule '" <> n <> "' has invalid var binding name: " <> vn | VarName vn <- Map.keys (dep ^. #vars), not (isValidVarBindingName vn)]+  where+    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
src/Seihou/Core/Recipe.hs view
@@ -9,6 +9,7 @@ import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as T+import Seihou.Core.Expr (exprRefs) import Seihou.Core.Module (isValidModuleName) import Seihou.Core.Types @@ -21,6 +22,8 @@ --   2. At least one module listed --   3. No duplicate module names in the list --   4. All variable binding names in module entries match @[a-z][a-z0-9.-]*@+--   5. Recipe variable declarations are unique+--   6. Prompt targets and condition references name declared recipe variables validateRecipe :: Recipe -> Either [Text] Recipe validateRecipe recipe =   let errs =@@ -28,6 +31,7 @@           <> checkNonEmptyModules recipe           <> checkNoDuplicateModules recipe           <> checkVarBindingNames recipe+          <> checkRecipeVarsAndPrompts recipe    in if null errs         then Right recipe         else Left errs@@ -43,14 +47,14 @@ -- Rule 2: At least one module must be listed checkNonEmptyModules :: Recipe -> [Text] checkNonEmptyModules recipe-  | null (recipe ^. #modules) = ["recipe must list at least one module"]+  | null (recipe ^. #modules) = ["modules: recipe must list at least one module"]   | otherwise = []  -- Rule 3: No duplicate module names checkNoDuplicateModules :: Recipe -> [Text] checkNoDuplicateModules recipe =   let names = map (^. #module_ . #unModuleName) (recipe ^. #modules)-   in map (\n -> "duplicate module in recipe: " <> n) (findDupes Set.empty Set.empty names)+   in map (\n -> "modules: duplicate module in recipe: " <> n) (findDupes Set.empty Set.empty names)  findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text] findDupes _ _ [] = []@@ -68,7 +72,7 @@         ( \(VarName vn) ->             if isValidVarBindingName vn               then []-              else ["invalid var binding name '" <> vn <> "' in module '" <> dep ^. #module_ . #unModuleName <> "'"]+              else ["modules.vars: invalid var binding name '" <> vn <> "' in module '" <> dep ^. #module_ . #unModuleName <> "'"]         )         (Map.keys (dep ^. #vars)) @@ -78,3 +82,20 @@       Just (c, rest) ->         (c >= 'a' && c <= 'z')           && T.all (\ch -> (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.') rest++-- Descriptor-local checks deliberately require no values or member schemas.+checkRecipeVarsAndPrompts :: Recipe -> [Text]+checkRecipeVarsAndPrompts recipe =+  let names = map (^. #name . #unVarName) (recipe ^. #vars)+      declared = Set.fromList names+      undeclared (VarName name) = Set.notMember name declared+      promptErrors prompt =+        let target = prompt ^. #var . #unVarName+            refs = maybe [] exprRefs (prompt ^. #condition)+         in ["prompts: target '" <> target <> "' is undeclared; add it to vars" | undeclared (prompt ^. #var)]+              <> [ "prompts: '" <> target <> "' when references undeclared variable '" <> ref <> "'; add it to vars"+                 | (name@(VarName ref), _) <- refs,+                   undeclared name+                 ]+   in map (\name -> "vars: duplicate variable declaration '" <> name <> "'") (findDupes Set.empty Set.empty names)+        <> concatMap promptErrors (recipe ^. #prompts)
src/Seihou/Core/Types.hs view
@@ -249,13 +249,14 @@ -- defaults in the precedence chain. data Dependency = Dependency   { module_ :: !ModuleName,-    vars :: !(Map VarName Text)+    vars :: !(Map VarName Text),+    source :: !(Maybe Text)   }   deriving stock (Eq, Show, Generic)  -- | Create a bare dependency with no variable bindings. simpleDep :: ModuleName -> Dependency-simpleDep name = Dependency {module_ = name, vars = mempty}+simpleDep name = Dependency {module_ = name, vars = mempty, source = Nothing}  -- | Extract module names from a list of dependencies. depModuleNames :: [Dependency] -> [ModuleName]@@ -597,6 +598,7 @@     -- explicit shared-write evidence introduced by schema 7.     TargetedAdditiveSharedPathUpdate   | SeedReceipts+  | RecipeInputs   | SeedOwnerClaims   | CheckoutLocalOutputs   | CheckoutLocalCommands@@ -751,6 +753,7 @@     instances :: ![AppliedInstanceState],     commandReceipts :: !(Map CommandFingerprint CommandReceipt),     checkoutCommands :: ![CheckoutCommand],+    recipeInputs :: !(Maybe (Map VarName Text)),     appliedAt :: !UTCTime   }   deriving stock (Eq, Show, Generic)
src/Seihou/Dhall/Eval.hs view
@@ -1,5 +1,7 @@ module Seihou.Dhall.Eval-  ( evalDhallExpr,+  ( normalizeDhallFile,+    normalizeInstalledDefinition,+    evalDhallExpr,     evalModuleFromFile,     evalRecipeFromFile,     evalBlueprintFromFile,@@ -42,10 +44,11 @@ import Data.Text.IO qualified as TIO import Data.Void (Void) import Dhall (defaultInputSettings, input, inputExprWithSettings, inputFile, list, record, rootDirectory, sourceName, strictText)-import Dhall.Core (Chunks (..), makeRecordField)+import Dhall.Core (Chunks (..), makeRecordField, recordFieldValue) import Dhall.Core qualified as Dhall (Expr (..)) import Dhall.Map qualified as DhallMap-import Dhall.Marshal.Decode (Decoder (..), Extractor, bool, constructor, field, maybe, natural, string, union)+import Dhall.Marshal.Decode (Decoder (..), Extractor, bool, constructor, extractError, field, maybe, natural, string, union)+import Dhall.Pretty (prettyExpr) import Dhall.Src (Src) import Seihou.Core.Expr (parseExpr) import Seihou.Core.Migration (BlueprintMigration (..), EntailedEdge (..), Migration (..), MigrationOp (..))@@ -96,6 +99,7 @@     expr <- inputExprWithSettings settings text     case extract moduleDecoder expr of       Success m -> do+        mapM_ evaluate (m ^. #dependencies)         -- Force lazy decoder thunks that may contain 'error' calls         mapM_ (\v -> evaluate (v ^. #type_)) (m ^. #vars)         mapM_ (\s -> evaluate (s ^. #lifecycle) >> evaluate (s ^. #strategy) >> evaluate (s ^. #condition) >> mapM_ evaluate (s ^. #patch)) (m ^. #steps)@@ -125,8 +129,9 @@     expr <- inputExprWithSettings settings text     case extract recipeDecoder expr of       Success r -> do+        mapM_ evaluate (r ^. #modules)         -- Force lazy decoder thunks that may contain 'error' calls-        mapM_ (\v -> evaluate (v ^. #type_)) (r ^. #vars)+        mapM_ (\v -> evaluate (forceType (v ^. #type_))) (r ^. #vars)         mapM_ (\p -> evaluate (p ^. #condition)) (r ^. #prompts)         pure r       Failure e -> throwIO e@@ -136,6 +141,14 @@        in pure $ Left (DhallEvalError name (T.pack (show e)))     Right r -> pure (Right r) +-- Evaluate nested type constructors within the recipe loader's exception boundary.+forceType :: VarType -> ()+forceType (VTList element) = forceType element+forceType VTText = ()+forceType VTBool = ()+forceType VTInt = ()+forceType (VTChoice _) = ()+ -- | Guess a module name from its file path by taking the parent directory name. guessModuleName :: FilePath -> ModuleName guessModuleName path =@@ -270,17 +283,35 @@  -- | Decoder for the top-level Recipe type from Dhall. recipeDecoder :: Decoder Recipe-recipeDecoder =-  record-    ( Recipe-        <$> field "name" recipeNameDecoder-        <*> field "version" (maybe strictText)-        <*> field "description" (maybe strictText)-        <*> field "modules" (list dependencyDecoder)-        <*> field "vars" (list varDeclDecoder)-        <*> field "prompts" (list promptDecoder)-    )+recipeDecoder = Decoder decodeRecipe (expected fieldsDecoder)+  where+    fieldsDecoder =+      record+        ( Recipe+            <$> field "name" (decodeContext "name: expected a recipe name as Text" recipeNameDecoder)+            <*> field "version" (decodeContext "version: expected Optional Text" (maybe strictText))+            <*> field "description" (decodeContext "description: expected Optional Text" (maybe strictText))+            <*> field "modules" (decodeContext "modules: expected a list of module names or records with module and vars bindings" (list dependencyDecoder))+            <*> field "vars" (decodeContext "vars: expected variable declarations with name, type, default, description, required and validation fields" (list varDeclDecoder))+            <*> field "prompts" (decodeContext "prompts: expected prompt records with var, text, when and choices fields" (list promptDecoder))+        ) +    decodeRecipe expr@(Dhall.RecordLit fields) =+      case filter (\key -> DhallMap.lookup key fields == Nothing) ["name", "version", "description", "modules", "vars", "prompts"] of+        [] -> extract fieldsDecoder expr+        missing -> extractError ("recipe.dhall: missing fields: " <> T.intercalate ", " missing)+    decodeRecipe _ = extractError "recipe.dhall: expected a recipe record"++-- Dhall extraction bypasses a single expected type to support both dependency+-- forms. Give extraction failures author-facing field context rather than an+-- internal InvalidDecoder report (which cannot name the enclosing field).+decodeContext :: Text -> Decoder a -> Decoder a+decodeContext context (Decoder decode expectedType) = Decoder decodeWithContext expectedType+  where+    decodeWithContext expr = case decode expr of+      Success value -> Success value+      Failure _ -> extractError context+ recipeNameDecoder :: Decoder RecipeName recipeNameDecoder = RecipeName <$> strictText @@ -339,6 +370,7 @@     expr <- inputExprWithSettings settings text     case extract blueprintDecoder expr of       Success b -> do+        mapM_ evaluate (b ^. #baseModules)         mapM_ (\v -> evaluate (v ^. #type_)) (b ^. #vars)         mapM_ (\p -> evaluate (p ^. #condition)) (b ^. #prompts)         pure b@@ -508,11 +540,13 @@      paramDepDecoder :: Decoder Dependency     paramDepDecoder =-      record-        ( mkDep-            <$> field "module" moduleNameDecoder-            <*> field "vars" (list varBindingDecoder)-        )+      withDefaults [("source", noneText)] $+        record+          ( mkDep+              <$> field "module" moduleNameDecoder+              <*> field "vars" (list varBindingDecoder)+              <*> field "source" (maybe strictText)+          )      varBindingDecoder :: Decoder (VarName, Text)     varBindingDecoder =@@ -522,8 +556,10 @@             <*> field "value" strictText         ) -    mkDep :: ModuleName -> [(VarName, Text)] -> Dependency-    mkDep name bindings = Dependency {module_ = name, vars = Map.fromList bindings}+    mkDep :: ModuleName -> [(VarName, Text)] -> Maybe Text -> Dependency+    mkDep name bindings src+      | Just value <- src, T.null (T.strip value) || "${" `T.isInfixOf` value = error "dependency source must be a nonempty literal Git source"+      | otherwise = Dependency {module_ = name, vars = Map.fromList bindings, source = src}  -- | Decoder for VarType from a Dhall Text string. -- Dhall does not support recursive types, so VarType is represented as a@@ -653,7 +689,11 @@       Prompt         { var = v,           text = t,-          condition = parseWhen whenText,+          condition = case whenText of+            Nothing -> Nothing+            Just source -> case parseExpr source of+              Right expr -> Just expr+              Left err -> errorWithoutStackTrace ("prompts: '" <> T.unpack (v ^. #unVarName) <> "' when: " <> T.unpack err <> " (" <> T.unpack source <> ")"),           choices = choices         } @@ -813,3 +853,39 @@     Left (e :: SomeException) ->       pure $ Left (RegistryEvalError (T.pack path) (T.pack (show e)))     Right r -> pure (Right r)++-- | Resolve imports while the source checkout is still available.+normalizeDhallFile :: FilePath -> IO Text+normalizeDhallFile path = do+  contents <- TIO.readFile path+  expr <- inputExprWithSettings (set rootDirectory (takeDirectory path) defaultInputSettings) contents+  pure (T.pack (show (prettyExpr expr)))++-- | Persist same-registry requirements at install time. Older installed files+-- keep legacy behavior until reinstall, rather than inferring new constraints.+normalizeInstalledDefinition :: Text -> FilePath -> IO Text+normalizeInstalledDefinition origin path = do+  contents <- TIO.readFile path+  expr <- inputExprWithSettings (set rootDirectory (takeDirectory path) defaultInputSettings) contents+  pure (T.pack (show (prettyExpr (rewrite expr))))+  where+    textLiteral value = Dhall.TextLit (Chunks [] value)+    sourceField = makeRecordField (Dhall.Some (textLiteral origin))+    bindingType = Dhall.Record (DhallMap.fromList [("name", makeRecordField Dhall.Text), ("value", makeRecordField Dhall.Text)])+    edge (Dhall.TextLit (Chunks [] name)) =+      Dhall.RecordLit+        ( DhallMap.fromList+            [("module", makeRecordField (textLiteral name)), ("vars", makeRecordField (Dhall.ListLit (Just (Dhall.App Dhall.List bindingType)) mempty)), ("source", sourceField)]+        )+    edge (Dhall.RecordLit fields) =+      case recordFieldValue <$> DhallMap.lookup "source" fields of+        Just (Dhall.Some _) -> Dhall.RecordLit fields+        _ -> Dhall.RecordLit (DhallMap.insert "source" sourceField fields)+    edge other = other+    rewrite (Dhall.RecordLit fields) = Dhall.RecordLit (foldl' update fields ["dependencies", "modules", "baseModules"])+    rewrite other = other+    update fields key = case recordFieldValue <$> DhallMap.lookup key fields of+      Just (Dhall.ListLit annotation values)+        | not (null values) ->+            DhallMap.insert key (makeRecordField (Dhall.ListLit annotation (fmap edge values))) fields+      _ -> fields
src/Seihou/Interaction/Prompt.hs view
@@ -1,5 +1,6 @@ module Seihou.Interaction.Prompt   ( runPrompts,+    runPromptsChecked,     promptForVar,   ) where@@ -9,7 +10,7 @@ import Data.Text qualified as T import Seihou.Core.Expr (evalExpr) import Seihou.Core.Types-import Seihou.Core.Variable (coerceValue, validateVarValue)+import Seihou.Core.Variable (coerceDefault, coerceValue, validateVarValue) import Seihou.Effect.Console (Console, getLine, putText) import Seihou.Prelude import Prelude hiding (getLine)@@ -31,31 +32,43 @@   Map VarName VarValue ->   Eff es (Map VarName ResolvedVar) runPrompts prompts unresolvedDecls currentBindings =-  go prompts Map.empty+  either (const Map.empty) id <$> runPromptsInternal False prompts unresolvedDecls currentBindings++-- | Preserve exhausted invalid answers; an empty optional answer is a skip.+runPromptsChecked ::+  (Console :> es) =>+  [Prompt] ->+  [VarDecl] ->+  Map VarName VarValue ->+  Eff es (Either [VarError] (Map VarName ResolvedVar))+runPromptsChecked = runPromptsInternal True++runPromptsInternal ::+  (Console :> es) =>+  Bool ->+  [Prompt] ->+  [VarDecl] ->+  Map VarName VarValue ->+  Eff es (Either [VarError] (Map VarName ResolvedVar))+runPromptsInternal checked prompts unresolvedDecls currentBindings = go prompts Map.empty   where     declMap = Map.fromList [(d ^. #name, d) | d <- unresolvedDecls]--    go :: (Console :> es) => [Prompt] -> Map VarName ResolvedVar -> Eff es (Map VarName ResolvedVar)-    go [] acc = pure acc-    go (p : ps) acc = do-      let vn = (p ^. #var)-      -- Skip if variable is not in the unresolved set-      case Map.lookup vn declMap of-        Nothing -> go ps acc-        Just decl -> do-          -- Skip if already resolved by an earlier prompt-          if Map.member vn acc-            then go ps acc-            else do-              -- Evaluate when condition-              let allBindings = Map.union (Map.map (^. #value) acc) currentBindings-              if shouldPrompt p allBindings-                then do-                  result <- promptForVar p decl allBindings-                  case result of-                    Left _err -> go ps acc-                    Right rv -> go ps (Map.insert vn rv acc)-                else go ps acc+    go [] acc = pure (Right acc)+    go (p : ps) acc = case Map.lookup (p ^. #var) declMap of+      Nothing -> go ps acc+      Just decl+        | Map.member (p ^. #var) acc -> go ps acc+        | otherwise -> do+            let bindings = Map.union (Map.map (^. #value) acc) currentBindings+            if not (shouldPrompt p bindings)+              then go ps acc+              else do+                result <- promptForVar p decl bindings+                case result of+                  Left (MissingRequiredVar _) | not (decl ^. #required) -> go ps acc+                  Left err | checked -> pure (Left [err])+                  Left _ -> go ps acc+                  Right rv -> go ps (Map.insert (p ^. #var) rv acc)  -- | Check if a prompt should be displayed based on its @when@ condition. shouldPrompt :: Prompt -> Map VarName VarValue -> Bool@@ -94,16 +107,10 @@   raw <- getLine   if T.null (T.strip raw)     then case decl ^. #default_ of-      Just defVal ->-        -- Accept the default value-        pure-          ( Right-              ResolvedVar-                { value = defVal,-                  source = FromPrompt,-                  decl = decl-                }-          )+      Just defVal -> pure $ do+        val <- coerceDefault (decl ^. #name) (decl ^. #type_) defVal+        validateVarValue decl val+        Right ResolvedVar {value = val, source = FromPrompt, decl = decl}       Nothing         | not (decl ^. #required) ->             -- Optional variable with no default — skip@@ -152,7 +159,7 @@                in case coerceAndValidate decl chosen of                     Left err -> pure (Left err)                     Right rv -> pure (Right rv)-        _ -> pure (Left (MissingRequiredVar (decl ^. #name)))+        _ -> pure (Left (ValidationFailed (decl ^. #name) "invalid selection"))  -- | Coerce raw text to the variable's type and validate. coerceAndValidate :: VarDecl -> Text -> Either VarError ResolvedVar
src/Seihou/Manifest/Types.hs view
@@ -25,7 +25,7 @@ import Data.ByteString.Lazy qualified as LBS import Data.Generics.Labels () import Data.Map.Strict qualified as Map-import Data.Maybe (fromMaybe)+import Data.Maybe (fromMaybe, isJust) import Data.Set qualified as Set import Data.Text qualified as T import Data.Time (UTCTime)@@ -80,7 +80,7 @@ -- change advances this number is -- docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md. currentManifestVersion :: ManifestSchemaVersion-currentManifestVersion = ManifestSchemaVersion 11+currentManifestVersion = ManifestSchemaVersion 12  -- | The oldest schema the ordinary decoder reads. Anything older records -- machine-specific absolute paths and must go through the explicit@@ -94,6 +94,7 @@ minimumManifestVersion :: ManifestCapability -> ManifestSchemaVersion minimumManifestVersion TargetedAdditiveSharedPathUpdate = ManifestSchemaVersion 7 minimumManifestVersion SeedReceipts = ManifestSchemaVersion 8+minimumManifestVersion RecipeInputs = ManifestSchemaVersion 12 minimumManifestVersion SeedOwnerClaims = ManifestSchemaVersion 11 minimumManifestVersion CheckoutLocalOutputs = ManifestSchemaVersion 9 minimumManifestVersion CheckoutLocalCommands = ManifestSchemaVersion 10@@ -397,6 +398,7 @@       ++ maybe [] (\v -> ["namespace" .= v]) (composition ^. #namespace)       ++ maybe [] (\v -> ["context" .= v]) (composition ^. #context)       ++ ["checkoutCommands" .= map checkoutCommandToJSON (composition ^. #checkoutCommands) | schema >= ManifestSchemaVersion 10]+      ++ ["recipeInputs" .= fmap varsToJSON (composition ^. #recipeInputs) | schema >= ManifestSchemaVersion 12]       ++ commandReceiptsField (composition ^. #commandReceipts)   where     commandReceiptsField receipts@@ -408,6 +410,9 @@  parseApplication :: ManifestSchemaVersion -> Aeson.Value -> Aeson.Parser AppliedComposition parseApplication schema = Aeson.withObject "AppliedComposition" $ \o -> do+  when (schema < minimumManifestVersion RecipeInputs) $ do+    legacyInputs <- o Aeson..:? "recipeInputs" :: Aeson.Parser (Maybe Aeson.Value)+    unless (legacyInputs == Nothing) (fail "recipe inputs require manifest schema 12")   commands <-     if schema >= ManifestSchemaVersion 10       then o .: "checkoutCommands" >>= mapM parseCheckoutCommand@@ -426,6 +431,7 @@     <*> o Aeson..:? "instances" Aeson..!= []     <*> (commandReceiptsFromJSON =<< o Aeson..:? "commandReceipts" Aeson..!= Aeson.object [])     <*> pure commands+    <*> (if schema >= ManifestSchemaVersion 12 then traverse varsFromJSON =<< o .: "recipeInputs" else pure Nothing)     <*> o .: "appliedAt"  instance ToJSON CommandReceipt where@@ -843,6 +849,7 @@ manifestEncodingLoss :: Manifest -> [Text] manifestEncodingLoss manifest =   either (pure . renderManifestInvariantError) (const []) (validateManifestInvariants manifest)+    <> ["recipe inputs require manifest schema 12" | not (manifestSupports RecipeInputs manifest), any (isJust . (^. #recipeInputs)) (manifest ^. #applications)]     <> ["seed owner claims require manifest schema 11" | not (manifestSupports SeedOwnerClaims manifest), any (\r -> case r ^. #ownership of KnownSeedOwners _ -> True; _ -> False) (Map.elems (manifest ^. #seeds))]     <> either (pure . T.pack) (const []) (Aeson.parseEither (const (validateCheckoutCommands manifest)) Aeson.Null)     <> ["removal commands cannot declare checkout setup" | applied <- manifest ^. #modules, removal <- maybe [] pure (applied ^. #removal), any ((== CheckoutCommandLifecycle) . (^. #lifecycle)) (removal ^. #commands)]
src/Seihou/Manifest/Upgrade.hs view
@@ -38,6 +38,7 @@ import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap import Data.Generics.Labels ()+import Data.Maybe (fromMaybe) import Data.Text qualified as T import Seihou.Core.Types import Seihou.Manifest.Types (currentManifestVersion, sharedWriteModeToText)@@ -58,6 +59,7 @@     MakeSharedWriteEvidenceExplicit   | AddEmptySeedReceipts   | AddEmptyCheckoutOutputs+  | AddUnknownRecipeInputs   | MakeSeedOwnershipExplicit   | AddEmptyCheckoutCommands   deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)@@ -121,13 +123,15 @@     step 7 AddEmptySeedReceipts "seed receipts",     step 8 AddEmptyCheckoutOutputs "checkout outputs",     step 9 AddEmptyCheckoutCommands "repeatable checkout commands",-    step 10 MakeSeedOwnershipExplicit "explicit seed instance ownership"+    step 10 MakeSeedOwnershipExplicit "explicit seed instance ownership",+    step 11 AddUnknownRecipeInputs "recipe inputs"   ]   where     step from stepAction =       ManifestUpgradeStep (ManifestSchemaVersion from) (ManifestSchemaVersion (from + 1)) stepAction  upgradeStepKind :: UpgradeStepAction -> UpgradeStepKind+upgradeStepKind AddUnknownRecipeInputs = LosslessUpgrade upgradeStepKind StampVersion = LosslessUpgrade upgradeStepKind ConvertMachineLocalPaths = InferenceBearingUpgrade upgradeStepKind MakeSharedWriteEvidenceExplicit = LosslessUpgrade@@ -189,6 +193,9 @@   unless (actual == upgradeStep ^. #fromVersion) $     Left (StepVersionMismatch upgradeStep actual)   transformed <- case upgradeStep ^. #action of+    AddUnknownRecipeInputs -> case document of+      Aeson.Object fields -> Right (Aeson.Object (KeyMap.insert "applications" (addInputs (fromMaybe (Aeson.Array mempty) (KeyMap.lookup "applications" fields))) fields))+      _ -> Right document     StampVersion -> Right document     MakeSeedOwnershipExplicit -> first (MalformedDocument actual) (makeSeedOwnershipExplicit document)     AddEmptyCheckoutCommands -> case document of@@ -319,3 +326,11 @@       pure (Aeson.Object (KeyMap.insert "ownership" ownership (KeyMap.delete "applications" receipt)))     convert _ _ = Left "seed receipt must be an object" makeSeedOwnershipExplicit _ = Left "manifest must be an object"++-- Historical applications have no known recipe-resolution record.+addInputs :: Aeson.Value -> Aeson.Value+addInputs (Aeson.Array apps) = Aeson.Array (fmap add apps)+  where+    add (Aeson.Object fields) = Aeson.Object (KeyMap.insert "recipeInputs" Aeson.Null fields)+    add other = other+addInputs other = other
test/Seihou/Composition/GraphSpec.hs view
@@ -141,8 +141,8 @@                 steps = [],                 commands = [],                 dependencies =-                  [ Dependency "helper" (Map.singleton "skill.name" "exec-plan"),-                    Dependency "helper" (Map.singleton "skill.name" "master-plan")+                  [ Dependency "helper" (Map.singleton "skill.name" "exec-plan") Nothing,+                    Dependency "helper" (Map.singleton "skill.name" "master-plan") Nothing                   ],                 removal = Nothing,                 migrations = []@@ -176,8 +176,8 @@                 steps = [],                 commands = [],                 dependencies =-                  [ Dependency "child" (Map.singleton "x" "1"),-                    Dependency "child" (Map.singleton "x" "1")+                  [ Dependency "child" (Map.singleton "x" "1") Nothing,+                    Dependency "child" (Map.singleton "x" "1") Nothing                   ],                 removal = Nothing,                 migrations = []
test/Seihou/Composition/RecipeSpec.hs view
@@ -3,6 +3,7 @@ import Data.Map.Strict qualified as Map import Data.Text (Text) import Seihou.Composition.Recipe (expandRecipe)+import Seihou.Core.Recipe (validateRecipe) import Seihou.Core.Types import Test.Hspec import Test.Tasty@@ -11,7 +12,7 @@ tests :: IO TestTree tests = testSpec "Seihou.Composition.Recipe" spec -expectExpanded :: Recipe -> (ModuleName, [ModuleName], Map.Map VarName Text, [VarDecl], [Prompt])+expectExpanded :: Recipe -> (ModuleName, [ModuleName], Map.Map VarName Text, [VarDecl], [Prompt], [Dependency]) expectExpanded recipe =   case expandRecipe recipe of     Right expanded -> expanded@@ -28,15 +29,16 @@                 description = Nothing,                 modules =                   [ simpleDep "mod-a",-                    simpleDep "mod-b",+                    Dependency "mod-b" mempty (Just "https://example.com/registry"),                     simpleDep "mod-c"                   ],                 vars = [],                 prompts = []               }-          (primary, additional, _, _, _) = expectExpanded recipe+          (primary, additional, _, _, _, requirements) = expectExpanded recipe       primary `shouldBe` ModuleName "mod-a"       additional `shouldBe` [ModuleName "mod-b", ModuleName "mod-c"]+      requirements `shouldBe` [simpleDep "mod-a", Dependency "mod-b" mempty (Just "https://example.com/registry"), simpleDep "mod-c"]      it "collects variable overrides from module entries" $ do       let recipe =@@ -45,13 +47,13 @@                 version = Nothing,                 description = Nothing,                 modules =-                  [ Dependency (ModuleName "base") (Map.singleton "project.name" "my-app"),-                    Dependency (ModuleName "nix") (Map.singleton "nix.system" "aarch64-darwin")+                  [ Dependency (ModuleName "base") (Map.singleton "project.name" "my-app") Nothing,+                    Dependency (ModuleName "nix") (Map.singleton "nix.system" "aarch64-darwin") Nothing                   ],                 vars = [],                 prompts = []               }-          (_, _, overrides, _, _) = expectExpanded recipe+          (_, _, overrides, _, _, _) = expectExpanded recipe       Map.lookup (VarName "project.name") overrides `shouldBe` Just "my-app"       Map.lookup (VarName "nix.system") overrides `shouldBe` Just "aarch64-darwin" @@ -81,7 +83,7 @@                 vars = [recipeVar],                 prompts = [recipePrompt]               }-          (_, _, _, vars, prompts) = expectExpanded recipe+          (_, _, _, vars, prompts, _) = expectExpanded recipe       vars `shouldBe` [recipeVar]       prompts `shouldBe` [recipePrompt] @@ -95,7 +97,7 @@                 vars = [],                 prompts = []               }-          (primary, additional, overrides, _, _) = expectExpanded recipe+          (primary, additional, overrides, _, _, _) = expectExpanded recipe       primary `shouldBe` ModuleName "the-module"       additional `shouldBe` []       Map.null overrides `shouldBe` True@@ -110,4 +112,19 @@                 vars = [],                 prompts = []               }-      expandRecipe recipe `shouldBe` Left ["recipe must list at least one module"]+      expandRecipe recipe `shouldBe` Left ["modules: recipe must list at least one module"]++    it "shares rejection of malformed prompt targets and conditions with descriptor validation" $ do+      let recipe =+            Recipe+              "bad-prompts"+              Nothing+              Nothing+              [simpleDep "unavailable"]+              []+              [Prompt "missing-target" "Question" (Just (ExprNot (ExprIsSet "missing-condition"))) Nothing]+      case validateRecipe recipe of+        Left errors -> do+          length errors `shouldBe` 2+          expandRecipe recipe `shouldBe` Left errors+        Right _ -> expectationFailure "Expected malformed prompt references to fail"
test/Seihou/Composition/ResolveSpec.hs view
@@ -410,7 +410,7 @@   describe "resolveComposedVariables (parameterized dependencies)" $ do     it "parent-supplied var resolves in dependency" $ do       let child = mkModule "child" [] [mkTextVar "skill.name" Nothing True] []-          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "exec-plan")] [] []+          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "exec-plan") Nothing] [] []           childInst = mkInstance "child" (ParentVars (Map.singleton "skill.name" "exec-plan"))           parentInst = primaryInstance "parent"           modules = [(childInst, child, "/fake/child"), (parentInst, parent', "/fake/parent")]@@ -423,7 +423,7 @@      it "parent-supplied var overrides dependency's default" $ do       let child = mkModule "child" [] [mkTextVar "skill.name" (Just (VText "old")) False] []-          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "new")] [] []+          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "new") Nothing] [] []           childInst = mkInstance "child" (ParentVars (Map.singleton "skill.name" "new"))           parentInst = primaryInstance "parent"           modules = [(childInst, child, "/fake/child"), (parentInst, parent', "/fake/parent")]@@ -436,7 +436,7 @@      it "CLI override beats parent-supplied var" $ do       let child = mkModule "child" [] [mkTextVar "skill.name" Nothing True] []-          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "from-parent")] [] []+          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "from-parent") Nothing] [] []           childInst = mkInstance "child" (ParentVars (Map.singleton "skill.name" "from-parent"))           parentInst = primaryInstance "parent"           modules = [(childInst, child, "/fake/child"), (parentInst, parent', "/fake/parent")]@@ -450,7 +450,7 @@      it "config beats parent-supplied var" $ do       let child = mkModule "child" [] [mkTextVar "skill.name" (Just (VText "default")) False] []-          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "parent-val")] [] []+          parent' = mkModuleWithDeps "parent" [Dependency "child" (Map.singleton "skill.name" "parent-val") Nothing] [] []           childInst = mkInstance "child" (ParentVars (Map.singleton "skill.name" "parent-val"))           parentInst = primaryInstance "parent"           modules = [(childInst, child, "/fake/child"), (parentInst, parent', "/fake/parent")]@@ -475,13 +475,13 @@           parentA =             mkModuleWithDeps               "parent-a"-              [Dependency "helper" (Map.singleton "skill.name" "exec-plan")]+              [Dependency "helper" (Map.singleton "skill.name" "exec-plan") Nothing]               []               []           parentB =             mkModuleWithDeps               "parent-b"-              [Dependency "helper" (Map.singleton "skill.name" "master-plan")]+              [Dependency "helper" (Map.singleton "skill.name" "master-plan") Nothing]               []               []           instA = mkInstance "helper" (ParentVars (Map.singleton "skill.name" "exec-plan"))
test/Seihou/Core/ApplicationSpec.hs view
@@ -70,6 +70,7 @@       instances = [],       commandReceipts = Map.empty,       checkoutCommands = [],+      recipeInputs = Nothing,       appliedAt = fixedTime     } 
test/Seihou/Core/BlueprintSpec.hs view
@@ -374,7 +374,7 @@       withSystemTempDirectory "seihou-test" $ \tmpDir -> do         let bad =               withBlueprintBaseModules-                [Dependency {module_ = "nope-not-here", vars = Map.empty}]+                [Dependency {module_ = "nope-not-here", vars = Map.empty, source = Nothing}]                 goodBlueprint         result <- validateBlueprintWith [tmpDir] tmpDir bad         case result of@@ -389,7 +389,7 @@         writeFile (nestedDir </> "blueprint.dhall") (sampleBlueprintDhall "nested-bp")         let bad =               withBlueprintBaseModules-                [Dependency {module_ = "nested-bp", vars = Map.empty}]+                [Dependency {module_ = "nested-bp", vars = Map.empty, source = Nothing}]                 goodBlueprint         result <- validateBlueprintWith [tmpDir] tmpDir bad         case result of
test/Seihou/Core/RecipeSpec.hs view
@@ -78,7 +78,7 @@                 version = Nothing,                 description = Nothing,                 modules =-                  [ Dependency (ModuleName "mod-a") (Map.singleton (VarName "INVALID") "val")+                  [ Dependency (ModuleName "mod-a") (Map.singleton (VarName "INVALID") "val") Nothing                   ],                 vars = [],                 prompts = []@@ -94,7 +94,7 @@                 version = Nothing,                 description = Nothing,                 modules =-                  [ Dependency (ModuleName "mod-a") (Map.singleton (VarName "nix.system") "aarch64-darwin")+                  [ Dependency (ModuleName "mod-a") (Map.singleton (VarName "nix.system") "aarch64-darwin") Nothing                   ],                 vars = [],                 prompts = []@@ -114,3 +114,36 @@       case validateRecipe recipe of         Left errs -> length errs `shouldSatisfy` (>= 2)         Right _ -> expectationFailure "Expected validation failure"++    it "accepts unanswered required declarations and nested conditions" $ do+      validateRecipe interactiveRecipe `shouldBe` Right interactiveRecipe++    it "reports duplicate declarations, prompt targets and all nested references together" $ do+      let recipe =+            Recipe+              "interactive"+              Nothing+              Nothing+              [simpleDep "unavailable"]+              [requiredVar "enabled", requiredVar "enabled"]+              [Prompt "missing-target" "Question" (Just (ExprAnd (ExprIsSet "unknown-a") (ExprNot (ExprOr (ExprLit True) (ExprEq "unknown-b" (VBool False)))))) Nothing]+      case validateRecipe recipe of+        Left errs -> do+          length errs `shouldBe` 4+          mapM_+            (\needle -> hasError needle errs `shouldBe` True)+            ["vars: duplicate", "missing-target", "unknown-a", "unknown-b"]+        Right _ -> expectationFailure "Expected all four descriptor errors"++requiredVar :: VarName -> VarDecl+requiredVar name = VarDecl name VTBool Nothing Nothing True Nothing++interactiveRecipe :: Recipe+interactiveRecipe =+  Recipe+    "interactive"+    Nothing+    Nothing+    [Dependency "unavailable" (Map.singleton "member.only" "literal ${unchanged}") Nothing]+    [requiredVar "enabled", requiredVar "owner"]+    [Prompt "owner" "Owner?" (Just (ExprAnd (ExprEq "enabled" (VBool True)) (ExprNot (ExprOr (ExprIsSet "owner") (ExprLit False))))) Nothing]
test/Seihou/Core/SeedSpec.hs view
@@ -42,6 +42,7 @@           [AppliedInstanceState name emptyParentVars (LocalOrigin (name ^. #unModuleName)) Nothing Map.empty | name <- ["demo", "second"]]           Map.empty           []+          Nothing           now       exact = first & #ownership .~ claims a ["demo", "second"]       known = emptyManifest now & #applications .~ map application [a, b] & #seeds .~ Map.singleton "CHANGELOG.md" exact
test/Seihou/Dhall/EvalSpec.hs view
@@ -7,7 +7,7 @@ import Data.Text qualified as T import Data.Text.IO qualified as TIO import Seihou.Core.Types-import Seihou.Dhall.Eval (evalDhallExpr, evalModuleFromFile, evalRecipeFromFile)+import Seihou.Dhall.Eval (evalDhallExpr, evalModuleFromFile, evalRecipeFromFile, normalizeInstalledDefinition) import System.Directory (createDirectoryIfMissing) import System.FilePath ((</>)) import System.IO.Temp (withSystemTempDirectory)@@ -301,6 +301,23 @@                \, removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }\n\                \}" +    it "preserves a declared source and its variable bindings" $+      withSystemTempDirectory "seihou-source" $ \dir -> do+        writeFile (dir </> "module.dhall") (emptyModuleWithDeps "[{ module = \"base\", source = Some \"https://example.com/registry.git\", vars = [{ name = \"x\", value = \"y\" }] }]")+        result <- evalModuleFromFile (dir </> "module.dhall")+        case result of+          Right m -> m ^. #dependencies `shouldBe` [Dependency "base" (Map.singleton "x" "y") (Just "https://example.com/registry.git")]+          Left err -> expectationFailure (show err)++    it "rejects a malformed source instead of dropping it" $+      withSystemTempDirectory "seihou-source" $ \dir -> do+        forM_ ["42", "Some \"\""] $ \sourceExpr -> do+          writeFile (dir </> "module.dhall") (emptyModuleWithDeps ("[{ module = \"base\", source = " <> sourceExpr <> ", vars = [] : List { name : Text, value : Text } }]"))+          result <- evalModuleFromFile (dir </> "module.dhall")+          case result of+            Left _ -> pure ()+            Right _ -> expectationFailure "malformed source was accepted"+     it "decodes a bare string dependency" $ do       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do         writeFile (tmpDir </> "module.dhall") (emptyModuleWithDeps "[\"base\"]")@@ -313,6 +330,16 @@             (dep ^. #module_) `shouldBe` ModuleName "base"             Map.null (dep ^. #vars) `shouldBe` True +    it "roundtrips installed legacy names as typed dependencies with sources" $+      withSystemTempDirectory "seihou-source" $ \dir -> do+        let path = dir </> "module.dhall"+        writeFile path (emptyModuleWithDeps "[\"base\"]")+        normalizeInstalledDefinition "https://example.com/registry" path >>= TIO.writeFile path+        result <- evalModuleFromFile path+        case result of+          Right m -> m ^. #dependencies `shouldBe` [Dependency "base" mempty (Just "https://example.com/registry")]+          Left err -> expectationFailure (show err)+     it "decodes a parameterized record dependency" $ do       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do         writeFile (tmpDir </> "module.dhall") (emptyModuleWithDeps "[{ module = \"base\", vars = [{ name = \"x\", value = \"y\" }] }]")@@ -350,6 +377,23 @@             Map.lookup (VarName "skill.name") (dep ^. #vars) `shouldBe` Just "exec-plan"    describe "evalRecipeFromFile" $ do+    it "returns contextual load errors for malformed recipe conditions, types and defaults" $ do+      withSystemTempDirectory "recipe-decoding" $ \tmpDir -> do+        let descriptor ty def condition =+              "{ name = \"probe\", version = None Text, description = None Text, modules = [\"unavailable\"], vars = [{ name = \"enabled\", type = \"" <> ty <> "\", default = " <> def <> ", description = None Text, required = True, validation = None Text }], prompts = [{ var = \"enabled\", text = \"Enable?\", when = " <> condition <> ", choices = None (List Text) }] }"+        forM_+          [ ("mystery", "None Text", "None Text", "type"),+            ("list mystery", "None Text", "None Text", "type"),+            ("bool", "Some \"invalid\"", "None Text", "enabled"),+            ("bool", "None Text", "Some \"Eq enabled\"", "prompts: 'enabled' when")+          ]+          $ \(ty, def, cond, context) -> do+            writeFile (tmpDir </> "recipe.dhall") (descriptor ty def cond)+            result <- evalRecipeFromFile (tmpDir </> "recipe.dhall")+            case result of+              Left (DhallEvalError _ message) -> message `shouldSatisfy` T.isInfixOf context+              other -> expectationFailure ("Expected controlled decode failure, got: " <> show other)+     it "decodes the haskell-with-nix-recipe fixture" $ do       result <- evalRecipeFromFile (fixtureDir </> "haskell-with-nix-recipe" </> "recipe.dhall")       case result of
test/Seihou/Engine/CheckoutSpec.hs view
@@ -37,7 +37,7 @@ fixtureManifest = emptyManifest now & #applications .~ [app] & #checkoutOutputs .~ Map.fromList [(dest, record source) | (dest, source) <- [(".local/config", "config/editor"), (".tool/assets", "assets/shared")]]   where     now = UTCTime (fromGregorian 2026 10 15) 0-    app = AppliedComposition (ApplicationId "a") (AppliedModuleTarget "demo") (LocalOrigin "demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (LocalOrigin "demo") (Just "1") Map.empty] Map.empty [] now+    app = AppliedComposition (ApplicationId "a") (AppliedModuleTarget "demo") (LocalOrigin "demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (LocalOrigin "demo") (Just "1") Map.empty] Map.empty [] Nothing now     record source = CheckoutOutputRecord (DirectorySymlink source) (Map.singleton (ApplicationId "a") (Set.singleton "demo"))  withFixture :: (FilePath -> IO a) -> IO a
test/Seihou/Engine/RemoveSpec.hs view
@@ -81,7 +81,7 @@ spec = do   describe "seed preservation and exact selection" $ do     let appId = ApplicationId "a"-        app = AppliedComposition appId (AppliedModuleTarget modName) (LocalOrigin "test-module") Nothing [] Nothing Nothing [AppliedInstanceState name emptyParentVars (LocalOrigin text) Nothing Map.empty | (name, text) <- [(modName, "test-module"), (otherMod, "other-module")]] Map.empty [] fixedTime+        app = AppliedComposition appId (AppliedModuleTarget modName) (LocalOrigin "test-module") Nothing [] Nothing Nothing [AppliedInstanceState name emptyParentVars (LocalOrigin text) Nothing Map.empty | (name, text) <- [(modName, "test-module"), (otherMod, "other-module")]] Map.empty [] Nothing fixedTime         receipt = SeedRecord modName (KnownSeedOwners (Map.singleton appId (Set.fromList [modName, otherMod]))) SeedCreated fixedTime         manifest = mkManifest False [] & #applications .~ [app] & #seeds .~ Map.singleton "seed.txt" receipt     it "retires a no-spec instance without losing a co-seeder or original supplier" $ do
test/Seihou/Engine/SeedTransactionSpec.hs view
@@ -29,7 +29,7 @@ tests = testSpec "seed update transactions" $ do   let now = UTCTime (fromGregorian 2026 10 26) 0       appId = ApplicationId "app"-      app = AppliedComposition appId (AppliedModuleTarget "seeder") (LocalOrigin "seeder") Nothing [] Nothing Nothing [AppliedInstanceState "seeder" emptyParentVars (LocalOrigin "seeder") Nothing Map.empty] Map.empty [] now+      app = AppliedComposition appId (AppliedModuleTarget "seeder") (LocalOrigin "seeder") Nothing [] Nothing Nothing [AppliedInstanceState "seeder" emptyParentVars (LocalOrigin "seeder") Nothing Map.empty] Map.empty [] Nothing now       manifest = emptyManifest now & #applications .~ [app]       claims = Map.singleton appId (Set.singleton "seeder")       createAction = SeedAction "seed.txt" "seeder" claims SeedWrite (Just "seed bytes\n")
test/Seihou/Interaction/PromptSpec.hs view
@@ -1,12 +1,12 @@ module Seihou.Interaction.PromptSpec (tests) where -import Control.Lens ((^.))+import Control.Lens ((&), (.~), (^.)) import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Data.Text (Text) import Effectful import Seihou.Composition.Instance (primaryInstance)-import Seihou.Composition.Resolve (PromptPermission (..), resolveWithPromptPermission, resolveWithPrompts)+import Seihou.Composition.Resolve (PromptPermission (..), RecipeInputSpec (..), resolveRecipeWithPrompts, resolveWithPromptPermission, resolveWithPrompts) import Seihou.Core.Types import Seihou.Effect.ConsolePure import Seihou.Interaction.Prompt (promptForVar, runPrompts)@@ -94,6 +94,85 @@  spec :: Spec spec = do+  describe "recipe input resolution" $ do+    let feature = mkBoolVar "feature" Nothing True+        consumer = mkModule "consumer" [] [mkBoolVar "feature" (Just (VBool False)) False] [] []+        modules = [(primaryInstance "consumer", consumer, "/fixture")]+        resolve permission spec saved cli literals local =+          resolveRecipeWithPrompts permission False spec modules saved cli literals Map.empty "" "" local Map.empty Map.empty Map.empty+        recipe = RecipeInputSpec [feature] [mkPrompt "feature" "Feature?"] Nothing+    it "requires the recipe choice before considering a consumer default" $ do+      let (result, _) = runPureEff $ runConsolePureNonInteractive $ resolve PromptsForbidden recipe Map.empty Map.empty Map.empty Map.empty+      result `shouldBe` Left [MissingRequiredVar "feature"]+    it "broadcasts explicit false and retains prompt source" $ do+      let (result, console) = runPureEff $ runConsolePure ["false"] $ resolve PromptsAllowed recipe Map.empty Map.empty Map.empty Map.empty+      case result of+        Left errs -> expectationFailure (show errs)+        Right (inputs, values) -> do+          (inputs Map.! "feature") ^. #value `shouldBe` VBool False+          (values Map.! primaryInstance "consumer" Map.! "feature") ^. #source `shouldBe` FromPrompt+      console ^. #outputs `shouldBe` ["Feature?"]+    it "keeps CLI above literals, and literals above config" $ do+      let check cli literal local = fst $ runPureEff $ runConsolePureNonInteractive $ resolve PromptsForbidden recipe Map.empty cli literal local+      case check (Map.singleton "feature" "false") (Map.singleton "feature" "true") Map.empty of+        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False+        Left errs -> expectationFailure (show errs)+      case check Map.empty (Map.singleton "feature" "false") (Map.singleton "feature" "true") of+        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False+        Left errs -> expectationFailure (show errs)+    it "lets optional conditions see resolved siblings despite another missing input" $ do+      let details = mkTextVar "details" Nothing False+          spec =+            RecipeInputSpec+              [feature, mkBoolVar "other" Nothing True, details]+              [mkPrompt "other" "Other?", mkConditionalPrompt "details" "Details?" (ExprEq "feature" (VBool True))]+              Nothing+          (result, console) = runPureEff $ runConsolePure ["false", "owner"] $ resolve PromptsAllowed spec Map.empty Map.empty Map.empty (Map.singleton "feature" "true")+      case result of+        Right (inputs, _) -> (inputs Map.! "details") ^. #value `shouldBe` VText "owner"+        Left errs -> expectationFailure (show errs)+      console ^. #outputs `shouldBe` ["Other?", "Details? [skip]:"]+    it "rejects exhausted invalid optional answers" $ do+      let optional = feature & #required .~ False+          spec = RecipeInputSpec [optional] [mkPrompt "feature" "Feature?"] Nothing+          (result, _) = runPureEff $ runConsolePure ["bad", "bad", "bad"] $ resolve PromptsAllowed spec Map.empty Map.empty Map.empty Map.empty+      result `shouldBe` Left [CoercionFailed "feature" VTBool "bad"]+    it "adopts historical false through recipe coercion and rejects disagreeing consumers" $ do+      let second = primaryInstance "second"+          both = modules <> [(second, consumer, "/second")]+          check saved cli =+            fst $+              runPureEff $+                runConsolePureNonInteractive $+                  resolveRecipeWithPrompts PromptsForbidden False recipe both saved cli Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty+          saved a b = Map.fromList [(primaryInstance "consumer", Map.singleton "feature" a), (second, Map.singleton "feature" b)]+      case check (saved "false" "no") Map.empty of+        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False+        Left errs -> expectationFailure (show errs)+      case check (saved "false" "true") Map.empty of+        Left [ValidationFailed "feature" _] -> pure ()+        other -> expectationFailure (show other)+      case check (saved "false" "true") (Map.singleton "feature" "true") of+        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool True+        Left errs -> expectationFailure (show errs)+    it "validates the broadcast against each consumer declaration" $ do+      let invalid = consumer & #vars .~ [mkTextVar "feature" Nothing True & #validation .~ Just (ValMinLength 10)]+          (result, _) =+            runPureEff $+              runConsolePureNonInteractive $+                resolveRecipeWithPrompts PromptsForbidden False recipe [(primaryInstance "consumer", invalid, "/fixture")] Map.empty (Map.singleton "feature" "true") Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty+      case result of+        Left [ValidationFailed "feature" _] -> pure ()+        other -> expectationFailure (show other)+    it "replays recorded choices before config and skips accepted optional omissions" $ do+      let optional = mkTextVar "details" Nothing False+          spec = RecipeInputSpec [feature, optional] [mkPrompt "details" "Details?"] (Just (Map.singleton "feature" "false"))+          (result, console) = runPureEff $ runConsolePure [] $ resolve PromptsAllowed spec Map.empty Map.empty Map.empty (Map.singleton "feature" "true")+      case result of+        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False+        Left errs -> expectationFailure (show errs)+      console ^. #outputs `shouldBe` []+   describe "runPrompts" $ do     it "fills a required text variable with FromPrompt source" $ do       let decl = mkTextVar "project.name" Nothing True
test/Seihou/Manifest/TypesSpec.hs view
@@ -108,6 +108,7 @@                  ],                commandReceipts = Map.empty,                checkoutCommands = [],+               recipeInputs = Nothing,                appliedAt = fixedTime              }          ]@@ -195,6 +196,7 @@       instances = composition ^. #instances,       commandReceipts = receipts,       checkoutCommands = [],+      recipeInputs = composition ^. #recipeInputs,       appliedAt = composition ^. #appliedAt     } @@ -230,6 +232,15 @@  spec :: Spec spec = do+  describe "recipe inputs" $ do+    forM_ [Nothing, Just Map.empty, Just (Map.singleton "feature" "false")] $ \inputs ->+      it ("roundtrips unknown, empty and false recipe state: " <> show inputs) $ do+        let manifest = manifestWithEveryOriginPosition & #applications %~ map (\app -> app & #recipeInputs .~ inputs)+        manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest+    it "refuses to lose recipe input state through an older encoding" $ do+      let manifest = manifestWithEveryOriginPosition & #version .~ ManifestSchemaVersion 11 & #applications %~ map (\app -> app & #recipeInputs .~ Just Map.empty)+      manifestEncodingLoss manifest `shouldSatisfy` (not . null)+   -- The manifest is checked into version control and read on other machines,   -- so no origin it records may name a location that only exists on the   -- machine that wrote it. See@@ -267,7 +278,7 @@    describe "checkout command codec" $ do     let command = CheckoutCommand "demo" 0 "mkdir -p .local/setup" Nothing-        application commands = AppliedComposition (ApplicationId "setup") (AppliedModuleTarget "demo") (ProjectOrigin "modules/demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (ProjectOrigin "modules/demo") (Just "1") Map.empty] Map.empty commands fixedTime+        application commands = AppliedComposition (ApplicationId "setup") (AppliedModuleTarget "demo") (ProjectOrigin "modules/demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (ProjectOrigin "modules/demo") (Just "1") Map.empty] Map.empty commands Nothing fixedTime         manifest commands = emptyManifest fixedTime & #applications .~ [application commands]         mutateApp transform value = case value of           Aeson.Object fields -> case KeyMap.lookup "applications" fields of@@ -518,6 +529,7 @@                   ],                 commandReceipts = Map.singleton fingerprint receipt,                 checkoutCommands = [],+                recipeInputs = Nothing,                 appliedAt = fixedTime               }           application2 =@@ -532,6 +544,7 @@                 instances = [],                 commandReceipts = Map.empty,                 checkoutCommands = [],+                recipeInputs = Nothing,                 appliedAt = fixedTime2               }           fileRecord =@@ -702,6 +715,7 @@                 instances = [],                 commandReceipts = Map.empty,                 checkoutCommands = [],+                recipeInputs = Nothing,                 appliedAt = fixedTime               }           fileRecord = FileRecord (SHA256 "hash") "base" Template fixedTime Nothing mempty SharedWriteUnknown
test/Seihou/Manifest/UpgradeSpec.hs view
@@ -63,6 +63,13 @@  spec :: Spec spec = do+  it "upgrades schema 11 with unknown recipe inputs without manufacturing answers" $ do+    let document = Aeson.object ["version" Aeson..= (11 :: Int), "applications" Aeson..= [Aeson.object ["producerOwned" Aeson..= True]]]+    case upgradeDocumentLosslessly (v 12) document of+      Left err -> expectationFailure (show err)+      Right (_, Aeson.Object fields) -> KeyMap.lookup "applications" fields `shouldBe` Just (Aeson.toJSON [Aeson.object ["producerOwned" Aeson..= True, "recipeInputs" Aeson..= Aeson.Null]])+      Right _ -> expectationFailure "expected manifest object"+   describe "the step registry" $ do     it "plans a contiguous path from every supported version to the current one" $       -- ADR 0014's mechanical gap check: a schema bump without its adjacent