packages feed

seihou-core 0.6.0.0 → 0.7.0.0

raw patch · 12 files changed

+946/−75 lines, 12 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Seihou.Core.ArtifactIdentity: normalizeOriginUrl :: Text -> Text
+ Seihou.Core.ArtifactIdentity: normalizeProjectPath :: FilePath -> FilePath
+ Seihou.Core.ArtifactIdentity: sameArtifactIdentity :: ArtifactOrigin -> ArtifactOrigin -> Bool
+ Seihou.Core.Blueprint: checkBlueprintVersionProbe :: Blueprint -> [Text]
+ Seihou.Core.Migration: BlueprintMigrationStep :: !Text -> !BlueprintMigration -> !Maybe EntailmentSite -> BlueprintMigrationStep
+ Seihou.Core.Migration: EntailedBlueprintNotFound :: !EntailmentSite -> !Text -> EntailmentError
+ Seihou.Core.Migration: EntailedEdge :: !Text -> !Text -> !Text -> EntailedEdge
+ Seihou.Core.Migration: EntailedEdgeNotDeclared :: !EntailmentSite -> !Text -> !Text -> !Text -> EntailmentError
+ Seihou.Core.Migration: EntailmentCycle :: ![Text] -> EntailmentError
+ Seihou.Core.Migration: EntailmentSite :: !Text -> !Text -> !Text -> EntailmentSite
+ Seihou.Core.Migration: [blueprint] :: EntailmentSite -> !Text
+ Seihou.Core.Migration: [edge] :: BlueprintMigrationStep -> !BlueprintMigration
+ Seihou.Core.Migration: [entailedBy] :: BlueprintMigrationStep -> !Maybe EntailmentSite
+ Seihou.Core.Migration: [entails] :: BlueprintMigration -> ![EntailedEdge]
+ Seihou.Core.Migration: [owner] :: BlueprintMigrationStep -> !Text
+ Seihou.Core.Migration: data BlueprintMigrationStep
+ Seihou.Core.Migration: data EntailedEdge
+ Seihou.Core.Migration: data EntailmentError
+ Seihou.Core.Migration: data EntailmentSite
+ Seihou.Core.Migration: expandEntailedEdges :: (Text -> Maybe [BlueprintMigration]) -> [BlueprintMigrationStep] -> Either EntailmentError [BlueprintMigrationStep]
+ Seihou.Core.Migration: instance GHC.Classes.Eq Seihou.Core.Migration.BlueprintMigrationStep
+ Seihou.Core.Migration: instance GHC.Classes.Eq Seihou.Core.Migration.EntailedEdge
+ Seihou.Core.Migration: instance GHC.Classes.Eq Seihou.Core.Migration.EntailmentError
+ Seihou.Core.Migration: instance GHC.Classes.Eq Seihou.Core.Migration.EntailmentSite
+ Seihou.Core.Migration: instance GHC.Internal.Generics.Generic Seihou.Core.Migration.BlueprintMigrationStep
+ Seihou.Core.Migration: instance GHC.Internal.Generics.Generic Seihou.Core.Migration.EntailedEdge
+ Seihou.Core.Migration: instance GHC.Internal.Generics.Generic Seihou.Core.Migration.EntailmentError
+ Seihou.Core.Migration: instance GHC.Internal.Generics.Generic Seihou.Core.Migration.EntailmentSite
+ Seihou.Core.Migration: instance GHC.Internal.Show.Show Seihou.Core.Migration.BlueprintMigrationStep
+ Seihou.Core.Migration: instance GHC.Internal.Show.Show Seihou.Core.Migration.EntailedEdge
+ Seihou.Core.Migration: instance GHC.Internal.Show.Show Seihou.Core.Migration.EntailmentError
+ Seihou.Core.Migration: instance GHC.Internal.Show.Show Seihou.Core.Migration.EntailmentSite
+ Seihou.Core.Types: MigrationApplied :: MigrationOutcome
+ Seihou.Core.Types: MigrationNotApplicable :: !Text -> MigrationOutcome
+ Seihou.Core.Types: [outcome] :: AppliedBlueprintMigration -> !MigrationOutcome
+ Seihou.Core.Types: [versionProbe] :: Blueprint -> !Maybe Text
+ Seihou.Core.Types: data MigrationOutcome
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.MigrationOutcome
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.MigrationOutcome
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.MigrationOutcome
+ Seihou.Manifest.Types: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.Types.MigrationOutcome
+ Seihou.Manifest.Types: instance Data.Aeson.Types.ToJSON.ToJSON Seihou.Core.Types.MigrationOutcome
- Seihou.Core.Migration: BlueprintMigration :: !Text -> !Text -> !Text -> BlueprintMigration
+ Seihou.Core.Migration: BlueprintMigration :: !Text -> !Text -> !Text -> ![EntailedEdge] -> BlueprintMigration
- Seihou.Core.Migration: BlueprintMigrationPlan :: !Text -> !Version -> !Version -> ![BlueprintMigration] -> BlueprintMigrationPlan
+ Seihou.Core.Migration: BlueprintMigrationPlan :: !Text -> !Version -> !Version -> ![BlueprintMigrationStep] -> BlueprintMigrationPlan
- Seihou.Core.Migration: [from] :: BlueprintMigrationPlan -> !Version
+ Seihou.Core.Migration: [from] :: EntailmentSite -> !Text
- Seihou.Core.Migration: [steps] :: BlueprintMigrationPlan -> ![BlueprintMigration]
+ Seihou.Core.Migration: [steps] :: BlueprintMigrationPlan -> ![BlueprintMigrationStep]
- Seihou.Core.Migration: [to] :: BlueprintMigrationPlan -> !Version
+ Seihou.Core.Migration: [to] :: EntailmentSite -> !Text
- Seihou.Core.Types: AppliedBlueprint :: !ModuleName -> !Maybe Text -> !UTCTime -> ![ModuleName] -> !Bool -> !Maybe Text -> !Maybe Text -> AppliedBlueprint
+ Seihou.Core.Types: AppliedBlueprint :: !ModuleName -> !ArtifactOrigin -> !Maybe Text -> !UTCTime -> ![ModuleName] -> !Bool -> !Maybe Text -> !Maybe Text -> AppliedBlueprint
- Seihou.Core.Types: AppliedBlueprintMigration :: !ModuleName -> !Maybe Text -> !Text -> !Text -> !UTCTime -> !Maybe Text -> AppliedBlueprintMigration
+ Seihou.Core.Types: AppliedBlueprintMigration :: !ModuleName -> !ArtifactOrigin -> !Maybe Text -> !Text -> !Text -> !MigrationOutcome -> !UTCTime -> !Maybe Text -> AppliedBlueprintMigration
- Seihou.Core.Types: AppliedRecipe :: !RecipeName -> !Maybe Text -> !UTCTime -> AppliedRecipe
+ Seihou.Core.Types: AppliedRecipe :: !RecipeName -> !ArtifactOrigin -> !Maybe Text -> !UTCTime -> AppliedRecipe
- Seihou.Core.Types: Blueprint :: !ModuleName -> !Maybe Text -> !Maybe Text -> !Text -> ![VarDecl] -> ![Prompt] -> ![Dependency] -> ![BlueprintFile] -> !Maybe [Text] -> ![Text] -> ![BlueprintMigration] -> !Maybe AgentLaunch -> Blueprint
+ Seihou.Core.Types: Blueprint :: !ModuleName -> !Maybe Text -> !Maybe Text -> !Text -> ![VarDecl] -> ![Prompt] -> ![Dependency] -> ![BlueprintFile] -> !Maybe [Text] -> ![Text] -> ![BlueprintMigration] -> !Maybe AgentLaunch -> !Maybe Text -> Blueprint
- Seihou.Core.Types: [origin] :: AppliedModule -> !ArtifactOrigin
+ Seihou.Core.Types: [origin] :: AppliedBlueprintMigration -> !ArtifactOrigin
- Seihou.Manifest.Types: hasAppliedBlueprintMigration :: ModuleName -> Text -> Text -> Manifest -> Bool
+ Seihou.Manifest.Types: hasAppliedBlueprintMigration :: ArtifactOrigin -> ModuleName -> Text -> Text -> Manifest -> Bool

Files

seihou-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-core-version: 0.6.0.0+version: 0.7.0.0 synopsis: Core library for Seihou project scaffolding description:   Core library for Seihou, a composable project scaffolding system.@@ -40,6 +40,7 @@     Seihou.Composition.Resolve     Seihou.Core.AgentPrompt     Seihou.Core.Application+    Seihou.Core.ArtifactIdentity     Seihou.Core.ArtifactOriginDetect     Seihou.Core.ArtifactRef     Seihou.Core.Blueprint@@ -157,6 +158,7 @@     Seihou.Core.CommandFingerprintSpec     Seihou.Core.CommandVarSpec     Seihou.Core.ContextSpec+    Seihou.Core.EntailmentSpec     Seihou.Core.ExprSpec     Seihou.Core.InstallSpec     Seihou.Core.ListSpec
+ src/Seihou/Core/ArtifactIdentity.hs view
@@ -0,0 +1,77 @@+-- | When two recorded artifact origins name the same artifact.+--+-- The manifest identifies an artifact by its origin plus its name (see+-- docs\/adr\/0002-artifact-identity-is-origin-url-plus-name.md), and several+-- places have to ask whether two such identities are the same one: the+-- blueprint-migration receipt ledger in 'Seihou.Manifest.Types' when it+-- upserts a receipt, the pending-edge filter in+-- 'Seihou.CLI.BlueprintMigration' when it decides what still has to run, and+-- the pre-generation guard in 'Seihou.CLI.ManifestGuard' when it compares+-- what the manifest records against what is installed here. They must agree,+-- or a receipt could be written as a new entry while being read as a+-- duplicate, so the comparison lives in one place.+--+-- This module answers a plain yes-or-no question. The richer three-way+-- judgement that distinguishes "different artifact" from "cannot be proved+-- either way" belongs to 'Seihou.CLI.ManifestGuard.judgeArtifact' and is not+-- appropriate here: a receipt either records this exact identity or it does+-- not, with no unverifiable middle ground.+module Seihou.Core.ArtifactIdentity+  ( sameArtifactIdentity,+    normalizeOriginUrl,+    normalizeProjectPath,+  )+where++import Data.Maybe (fromMaybe)+import Data.Text qualified as T+import Seihou.Core.Types (ArtifactOrigin (..))+import Seihou.Prelude++-- | Whether two recorded origins name the same artifact.+--+-- Two origins of different kinds are never the same artifact. Within a kind+-- the comparison is structural, after normalising away spellings that differ+-- without meaning anything: a trailing @.git@ on a git URL, and a @.\/@+-- prefix or trailing slash on a project-relative path.+--+-- A 'LocalOrigin' carries no provenance at all, so two of them compare equal+-- exactly when they carry the same name. That is deliberately weak — it is+-- also the strongest statement available about an artifact seihou can only+-- identify by name — and it is what makes two receipts written before origins+-- were recorded still match each other.+sameArtifactIdentity :: ArtifactOrigin -> ArtifactOrigin -> Bool+sameArtifactIdentity left right = case (left, right) of+  (RemoteOrigin leftUrl leftName _, RemoteOrigin rightUrl rightName _) ->+    normalizeOriginUrl leftUrl == normalizeOriginUrl rightUrl+      && leftName == rightName+  (ProjectOrigin leftPath, ProjectOrigin rightPath) ->+    normalizeProjectPath leftPath == normalizeProjectPath rightPath+  (LocalOrigin leftName, LocalOrigin rightName) -> leftName == rightName+  _ -> False++-- | Reduce a git URL to a form two spellings of the same repository share.+--+-- @https:\/\/host\/repo@, @https:\/\/host\/repo.git@ and+-- @https:\/\/host\/repo\/@ all name the same repository, and a manifest+-- written by a developer who typed one of them must not read as a different+-- artifact to a developer who typed another.+normalizeOriginUrl :: Text -> Text+normalizeOriginUrl =+  dropTrailingSlashes . dropGitSuffix . dropTrailingSlashes . T.strip+  where+    dropTrailingSlashes = T.dropWhileEnd (== '/')+    dropGitSuffix url = fromMaybe url (T.stripSuffix ".git" url)++-- | Reduce a project-relative path to a comparable form. The manifest stores+-- these with forward slashes; @.\/@ prefixes and trailing slashes are noise.+normalizeProjectPath :: FilePath -> FilePath+normalizeProjectPath =+  dropWhileEnd' (== '/') . dropDotPrefix . dropWhileEnd' (== '/')+  where+    dropDotPrefix path = fromMaybe path (stripPrefix' "./" path)+    stripPrefix' prefix path =+      if take (length prefix) path == prefix+        then Just (drop (length prefix) path)+        else Nothing+    dropWhileEnd' p = reverse . dropWhile p . reverse
src/Seihou/Core/Blueprint.hs view
@@ -13,6 +13,7 @@     checkBlueprintAllowedTools,     checkBlueprintMigrations,     checkBlueprintLaunch,+    checkBlueprintVersionProbe,   ) where @@ -20,7 +21,7 @@ import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Data.Text qualified as T-import Seihou.Core.Migration (BlueprintMigration (..))+import Seihou.Core.Migration (BlueprintMigration (..), EntailedEdge (..)) import Seihou.Core.Module (defaultSearchPaths, discoverRunnable, isValidModuleName) import Seihou.Core.Types import Seihou.Core.Version (parseVersion)@@ -46,10 +47,21 @@ --   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.+--      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@@ -77,6 +89,7 @@           <> checkBlueprintAllowedTools b           <> checkBlueprintMigrations b           <> checkBlueprintLaunch b+          <> checkBlueprintVersionProbe b       allErrs = pureErrs <> fileErrs <> baseErrs   pure $     if null allErrs@@ -228,7 +241,85 @@         <> 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 "@@ -276,3 +367,13 @@       | 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)+  ]
src/Seihou/Core/Migration.hs view
@@ -3,18 +3,27 @@     Migration (..),     MigrationOp (..),     BlueprintMigration (..),+    EntailedEdge (..),      -- * Migration planning     MigrationPlan (..),     BlueprintMigrationPlan (..),+    BlueprintMigrationStep (..),     MigrationPlanError (..),     planMigrationChain,     planBlueprintMigrationChain,++    -- * Entailment expansion+    EntailmentSite (..),+    EntailmentError (..),+    expandEntailedEdges,   ) where +import Control.Monad (foldM) import Data.Generics.Labels () import Data.List (sortOn)+import Data.Set qualified as Set import Seihou.Core.Version (Version, parseVersion) import Seihou.Prelude @@ -50,13 +59,35 @@   }   deriving stock (Eq, Show, Generic) +-- | A reference from one blueprint's migration edge to an exact edge of+-- another blueprint. Resolution is by name through the same search paths+-- @seihou agent migrate@ uses; the referenced edge must exist verbatim.+--+-- This is how a breaking change that reaches consumers through an+-- intermediary library travels. A blueprint for @keiro@ — which absorbed a+-- breaking change from @kiroku@ — declares that crossing its own+-- @2.4.0 -> 3.0.0@ edge entails crossing kiroku's @1.9.0 -> 2.0.0@ edge. A+-- project that depends on keiro and has never heard of kiroku still gets+-- kiroku's upgrade guidance, in kiroku's own version space.+data EntailedEdge = EntailedEdge+  { blueprint :: !Text,+    from :: !Text,+    to :: !Text+  }+  deriving stock (Eq, Show, Generic)+ -- | One agent-guided source migration declared by a blueprint. The -- version strings use the same dotted-numeric format as module migrations, -- while 'prompt' describes only the changes needed for this edge.+--+-- 'entails' names exact edges of other blueprints that crossing this edge+-- requires. They are expanded recursively and run before this edge; see+-- 'expandEntailedEdges'. data BlueprintMigration = BlueprintMigration   { from :: !Text,     to :: !Text,-    prompt :: !Text+    prompt :: !Text,+    entails :: ![EntailedEdge]   }   deriving stock (Eq, Show, Generic) @@ -102,14 +133,52 @@   }   deriving stock (Eq, Show, Generic) +-- | Where an entailment declaration was written: the blueprint that owns the+-- declaring edge, and that edge's own version window.+--+-- Both 'EntailmentError' variants carry one because both are authoring+-- mistakes in that exact edge, and an error message that cannot say which+-- edge to fix is useless to the author who has to fix it.+data EntailmentSite = EntailmentSite+  { blueprint :: !Text,+    from :: !Text,+    to :: !Text+  }+  deriving stock (Eq, Show, Generic)++-- | One edge to run, together with the blueprint that declares it.+--+-- @owner@ is the name of the blueprint whose @migrations@ list contains+-- @edge@ — not the blueprint the user named on the command line. Receipts+-- are written under the owner, which is what makes a shared cohort edge the+-- same edge from either entry point.+--+-- @entailedBy@ names the edge that pulled this one in, when this step was+-- reached through entailment rather than selected directly by the version+-- window. It exists so output can say @(entailed by keiro-upgrade 2.4.0 ->+-- 3.0.0)@. It is display-only and must never enter an identity comparison:+-- the same cohort edge reached from two different declaring edges is one+-- edge, and treating the two as distinct would cross it twice.+data BlueprintMigrationStep = BlueprintMigrationStep+  { owner :: !Text,+    edge :: !BlueprintMigration,+    entailedBy :: !(Maybe EntailmentSite)+  }+  deriving stock (Eq, Show, Generic)+ -- | The ordered blueprint migrations selected for a requested version -- window. A non-trivial window may have no selected steps when the author -- declared no agent intervention for that range.+--+-- @name@ and the version window belong to the blueprint the user invoked.+-- After 'expandEntailedEdges' has run, individual steps may be owned by other+-- blueprints and carry versions from those blueprints' version spaces; each+-- step says which blueprint it belongs to. data BlueprintMigrationPlan = BlueprintMigrationPlan   { name :: !Text,     from :: !Version,     to :: !Version,-    steps :: ![BlueprintMigration]+    steps :: ![BlueprintMigrationStep]   }   deriving stock (Eq, Show, Generic) @@ -179,6 +248,10 @@  -- | Compute the ordered agent-guided migrations for a blueprint and version -- window. Selection and errors deliberately match 'planMigrationChain'.+--+-- Every selected edge is labelled with @blueprintName@, because at this point+-- every edge in the plan came out of that blueprint's own @migrations@ list.+-- Steps owned by other blueprints appear only after 'expandEntailedEdges'. planBlueprintMigrationChain ::   Text ->   [BlueprintMigration] ->@@ -193,11 +266,128 @@               { name = blueprintName,                 from = current,                 to = target,-                steps = steps+                steps = map ownedBy steps               }         )     )     (planMigrationWindow (^. #from) (^. #to) migrations current target)+  where+    ownedBy selected =+      BlueprintMigrationStep+        { owner = blueprintName,+          edge = selected,+          entailedBy = Nothing+        }++-- | All the ways entailment expansion can fail. Every variant is an authoring+-- mistake in a published blueprint rather than anything the consumer running+-- the migration did, so each carries enough to name the blueprint whose author+-- has to fix it.+data EntailmentError+  = -- | An entailed blueprint could not be resolved on this machine. Carries+    -- the declaring edge and the name that did not resolve. This is a+    -- consumer-fixable situation — the blueprint is simply not installed —+    -- but seihou refuses rather than skipping, because the consumer does not+    -- know the cohort and a silently omitted member leaves a half-migrated+    -- project with no signal.+    EntailedBlueprintNotFound !EntailmentSite !Text+  | -- | The named blueprint resolved but declares no edge with that exact+    -- window. Carries the declaring edge, then the entailed blueprint's name,+    -- @from@, and @to@. Entailment names one exact edge; falling back to+    -- window planning inside the entailed blueprint would let a release+    -- silently change which upstream work it implies.+    EntailedEdgeNotDeclared !EntailmentSite !Text !Text !Text+  | -- | Entailment forms a cycle. Carries the chain in order, each element+    -- rendered as @blueprint from -> to@, beginning and ending with the edge+    -- that closed it.+    EntailmentCycle ![Text]+  deriving stock (Eq, Show, Generic)++-- | Expand each selected edge into its entailed edges followed by itself,+-- recursively, in declaration order.+--+-- @lookupMigrations@ answers "what edges does this blueprint declare?" and+-- returns 'Nothing' for a blueprint that could not be resolved. Keeping it a+-- parameter is what lets this function stay pure: discovery is the CLI's job.+--+-- Ordering: an entailed edge runs /before/ the edge that declares it, and+-- several entailed edges run in declaration order. The entailed edge is the+-- deeper change — kiroku's API — and the declaring edge's own guidance may+-- assume it has already been applied.+--+-- Deduplication: an edge already emitted is not emitted again, no matter how+-- many selected edges entail it. Identity is the triple @(owner, from, to)@,+-- which deliberately ignores @entailedBy@: the same cohort edge reached from+-- two declaring edges is one piece of work. This is expansion-time+-- deduplication only; dropping edges this project has already recorded+-- receipts for happens afterwards and separately.+--+-- Cycles are a hard error rather than a silently broken chain, because a+-- cycle means two blueprints each claim the other's edge must run first and+-- there is no order that satisfies both.+expandEntailedEdges ::+  (Text -> Maybe [BlueprintMigration]) ->+  [BlueprintMigrationStep] ->+  Either EntailmentError [BlueprintMigrationStep]+expandEntailedEdges lookupMigrations topSteps = do+  (expanded, _visited) <- foldM (expandStep []) ([], Set.empty) topSteps+  Right expanded+  where+    -- @path@ is the chain of edges currently being expanded, oldest first.+    -- @emitted@ is the output so far, in final order. @visited@ is every+    -- edge already emitted, so a second reference to it is dropped.+    expandStep path (emitted, visited) step+      | stepKey `Set.member` visited = Right (emitted, visited)+      | stepKey `elem` path = Left (EntailmentCycle (renderCycle path stepKey))+      | otherwise = do+          entailedSteps <- traverse (resolveEntailed step) (step ^. #edge . #entails)+          (emitted', visited') <-+            foldM (expandStep (path <> [stepKey])) (emitted, visited) entailedSteps+          Right (emitted' <> [step], Set.insert stepKey visited')+      where+        stepKey = edgeKey step++    resolveEntailed declaringStep entailed =+      case lookupMigrations (entailed ^. #blueprint) of+        Nothing -> Left (EntailedBlueprintNotFound site (entailed ^. #blueprint))+        Just declared ->+          case [ candidate+               | candidate <- declared,+                 candidate ^. #from == entailed ^. #from,+                 candidate ^. #to == entailed ^. #to+               ] of+            (matched : _) ->+              Right+                BlueprintMigrationStep+                  { owner = entailed ^. #blueprint,+                    edge = matched,+                    entailedBy = Just site+                  }+            [] ->+              Left+                ( EntailedEdgeNotDeclared+                    site+                    (entailed ^. #blueprint)+                    (entailed ^. #from)+                    (entailed ^. #to)+                )+      where+        site =+          EntailmentSite+            { blueprint = declaringStep ^. #owner,+              from = declaringStep ^. #edge . #from,+              to = declaringStep ^. #edge . #to+            }++    edgeKey step = (step ^. #owner, step ^. #edge . #from, step ^. #edge . #to)++    -- The cycle a reader wants to see starts where the repeat began, not at+    -- whichever top-level edge happened to lead there.+    renderCycle path repeated =+      map renderKey (dropWhile (/= repeated) path <> [repeated])++    renderKey (owner, fromVersion, toVersion) =+      owner <> " " <> fromVersion <> " -> " <> toVersion  -- | Shared gap-tolerant version-window planner. Keeping parsing, duplicate -- detection, ordering, overlap handling, and overshoot handling here prevents
src/Seihou/Core/Types.hs view
@@ -46,6 +46,7 @@     AppliedModule (..),     AppliedRecipe (..),     AppliedBlueprint (..),+    MigrationOutcome (..),     AppliedBlueprintMigration (..),     FileRecord (..),     SHA256 (..),@@ -314,7 +315,14 @@     allowedTools :: !(Maybe [Text]),     tags :: ![Text],     migrations :: ![BlueprintMigration],-    launch :: !(Maybe AgentLaunch)+    launch :: !(Maybe AgentLaunch),+    -- | A shell command that prints which version of this blueprint's+    -- library the project currently declares. It supplies the default+    -- @--to@ for @seihou agent migrate@; only the blueprint's author+    -- knows where the version lives in their ecosystem, which is how+    -- seihou infers a target without reading any package-manager format+    -- itself.+    versionProbe :: !(Maybe Text)   }   deriving stock (Eq, Show, Generic) @@ -582,8 +590,16 @@   deriving stock (Eq, Show, Generic)  -- | Recipe provenance recorded in the manifest when a recipe is used.+--+-- @origin@ is the recipe's portable identity. Turning it back into a+-- directory on the current machine is+-- 'Seihou.Core.ArtifactRef.resolveArtifactOrigin'; no path is ever recorded+-- here. Manifests written before the field existed decode with a+-- 'LocalOrigin' carrying the recorded name, which honestly says "this+-- recipe's provenance cannot be verified". data AppliedRecipe = AppliedRecipe   { name :: !RecipeName,+    origin :: !ArtifactOrigin,     recipeVersion :: !(Maybe Text),     appliedAt :: !UTCTime   }@@ -602,8 +618,16 @@ -- @agentSessionId@ is reserved for the deferred resume feature recorded -- in @docs/masterplans/3-agent-driven-blueprints.md@; in v1 it is always -- 'Nothing' and the encoder omits the JSON key in that case.+--+-- @origin@ is the blueprint's portable identity. Turning it back into a+-- directory on the current machine is+-- 'Seihou.Core.ArtifactRef.resolveArtifactOrigin'; no path is ever recorded+-- here. Manifests written before the field existed decode with a+-- 'LocalOrigin' carrying the recorded name, which honestly says "this+-- blueprint's provenance cannot be verified". data AppliedBlueprint = AppliedBlueprint   { name :: !ModuleName,+    origin :: !ArtifactOrigin,     blueprintVersion :: !(Maybe Text),     appliedAt :: !UTCTime,     baselineModules :: ![ModuleName],@@ -613,14 +637,49 @@   }   deriving stock (Eq, Show, Generic) --- | A durable receipt for one successfully completed agent-guided blueprint--- migration edge. Exact-edge identity is the blueprint 'name' together with--- 'fromVersion' and 'toVersion'; the remaining fields are audit metadata.+-- | What actually happened when seihou ran one blueprint migration edge.+--+-- 'MigrationApplied' means the provider interaction returned. As+-- @docs\/user\/blueprint-migrations.md@ states, that is bookkeeping and not+-- proof that the build passes.+--+-- 'MigrationNotApplicable' means the edge reported that its precondition is+-- unmet in this project and it deliberately changed nothing. The attempt is+-- recorded so the audit trail is complete, but it does not suppress a later+-- run: the precondition may be met by then.+--+-- The reason is carried inside the constructor rather than in a sibling+-- @Maybe Text@ field, so the type cannot express a reason for an applied edge+-- or a skipped edge with no reason.+data MigrationOutcome+  = MigrationApplied+  | MigrationNotApplicable !Text+  deriving stock (Eq, Show, Generic)++-- | A durable receipt for one attempted agent-guided blueprint migration+-- edge. Exact-edge identity is the 'origin' and 'name' of the blueprint that+-- owns the edge together with 'fromVersion' and 'toVersion'; the remaining+-- fields, 'outcome' included, are audit metadata.+--+-- @outcome@ is deliberately not part of the identity: re-running an edge that+-- was previously not applicable replaces its receipt rather than appending a+-- second one for the same edge.+--+-- @origin@ is the blueprint's portable identity. Turning it back into a+-- directory on the current machine is+-- 'Seihou.Core.ArtifactRef.resolveArtifactOrigin'; no path is ever recorded+-- here. It is part of the identity, not merely audit metadata, because two+-- blueprints published by different repositories under the same name are not+-- the same blueprint and their identically-numbered edges are not the same+-- edge. Manifests written before the field existed decode with a+-- 'LocalOrigin' carrying the recorded name. data AppliedBlueprintMigration = AppliedBlueprintMigration   { name :: !ModuleName,+    origin :: !ArtifactOrigin,     blueprintVersion :: !(Maybe Text),     fromVersion :: !Text,     toVersion :: !Text,+    outcome :: !MigrationOutcome,     appliedAt :: !UTCTime,     agentSessionId :: !(Maybe Text)   }
src/Seihou/Dhall/Eval.hs view
@@ -48,7 +48,7 @@ import Dhall.Marshal.Decode (Decoder (..), Extractor, bool, constructor, field, maybe, natural, string, union) import Dhall.Src (Src) import Seihou.Core.Expr (parseExpr)-import Seihou.Core.Migration (BlueprintMigration (..), Migration (..), MigrationOp (..))+import Seihou.Core.Migration (BlueprintMigration (..), EntailedEdge (..), Migration (..), MigrationOp (..)) import Seihou.Core.Registry (Registry (..), RegistryEntry (..)) import Seihou.Core.Types import Seihou.Core.Variable (coerceDefault)@@ -176,7 +176,7 @@ moduleDecoder =   withDefaults     [ ("removal", noneText),-      ("migrations", emptyMigrationList)+      ("migrations", emptyRecordList)     ]     $ record       ( Module@@ -193,12 +193,14 @@           <*> field "migrations" (list migrationDecoder)       ) --- | A Dhall expression representing an empty list of Migration records.+-- | A Dhall expression representing an empty list of records, used as the+-- default for a list-typed field an older artifact omits entirely. -- The list element type annotation is unused by the list extractor (which -- ignores the annotation and reads element values), so we use a placeholder--- type to keep the synthesized expression compact.-emptyMigrationList :: Dhall.Expr Src Void-emptyMigrationList = Dhall.ListLit (Just Dhall.Text) mempty+-- type to keep the synthesized expression compact and reuse one constant for+-- every such field.+emptyRecordList :: Dhall.Expr Src Void+emptyRecordList = Dhall.ListLit (Just Dhall.Text) mempty  -- | Decoder for a single 'Migration' record. migrationDecoder :: Decoder Migration@@ -211,13 +213,30 @@     )  -- | Decoder for one agent-guided blueprint migration edge.+-- Uses 'withDefaults' to handle blueprints published before the @entails@+-- field existed. The default has to be attached here rather than on+-- 'blueprintDecoder', because the missing key is inside each element of the+-- @migrations@ list rather than on the blueprint record itself. blueprintMigrationDecoder :: Decoder BlueprintMigration blueprintMigrationDecoder =+  withDefaults [("entails", emptyRecordList)] $+    record+      ( BlueprintMigration+          <$> field "from" strictText+          <*> field "to" strictText+          <*> field "prompt" strictText+          <*> field "entails" (list entailedEdgeDecoder)+      )++-- | Decoder for one entailed-edge reference: the blueprint that owns the+-- entailed edge, and that edge's exact version window.+entailedEdgeDecoder :: Decoder EntailedEdge+entailedEdgeDecoder =   record-    ( BlueprintMigration-        <$> field "from" strictText+    ( EntailedEdge+        <$> field "blueprint" strictText+        <*> field "from" strictText         <*> field "to" strictText-        <*> field "prompt" strictText     )  -- | Decoder for a 'MigrationOp' from a Dhall union value.@@ -277,12 +296,16 @@     )  -- | Decoder for the top-level Blueprint type from Dhall.--- Uses 'withDefaults' to handle blueprints that predate the @migrations@ and--- @launch@ fields.+-- Uses 'withDefaults' to handle blueprints that predate the @migrations@,+-- @launch@, and @versionProbe@ fields. blueprintDecoder :: Decoder Blueprint blueprintDecoder =-  withDefaults [("migrations", emptyMigrationList), ("launch", noneText)] $-    record+  withDefaults+    [ ("migrations", emptyRecordList),+      ("launch", noneText),+      ("versionProbe", noneText)+    ]+    $ record       ( Blueprint           <$> field "name" moduleNameDecoder           <*> field "version" (maybe strictText)@@ -296,6 +319,7 @@           <*> field "tags" (list strictText)           <*> field "migrations" (list blueprintMigrationDecoder)           <*> field "launch" (maybe agentLaunchDecoder)+          <*> field "versionProbe" (maybe strictText)       )  -- | Evaluate a @blueprint.dhall@ file and decode it into a 'Blueprint'.
src/Seihou/Manifest/Types.hs view
@@ -16,9 +16,11 @@ import Data.ByteString.Lazy qualified as LBS import Data.Generics.Labels () import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe) import Data.Set qualified as Set import Data.Text qualified as T import Data.Time (UTCTime)+import Seihou.Core.ArtifactIdentity (sameArtifactIdentity) import Seihou.Core.Types import Seihou.Manifest.Hash (baselineRefFromText) import Seihou.Prelude hiding ((.=))@@ -89,6 +91,11 @@ -- | Insert or replace one exact blueprint migration receipt. Replacement is -- performed in place, while adding a v5-only receipt upgrades the manifest -- version and preserves every unrelated field.+--+-- The receipt's 'outcome' is deliberately excluded from the edge comparison,+-- which is the one place it is left out of one. An edge that reported itself+-- not applicable and later runs for real must replace its earlier receipt, not+-- accumulate a second one for the same edge. writeAppliedBlueprintMigration :: AppliedBlueprintMigration -> Manifest -> Manifest writeAppliedBlueprintMigration receipt manifest =   Manifest@@ -104,7 +111,8 @@     }   where     sameEdge existing =-      existing ^. #name == receipt ^. #name+      sameArtifactIdentity (existing ^. #origin) (receipt ^. #origin)+        && existing ^. #name == receipt ^. #name         && existing ^. #fromVersion == receipt ^. #fromVersion         && existing ^. #toVersion == (receipt ^. #toVersion) @@ -112,12 +120,26 @@       | any sameEdge receipts = map (\existing -> if sameEdge existing then receipt else existing) receipts       | otherwise = receipts <> [receipt] --- | Whether one exact blueprint migration edge already has a receipt.-hasAppliedBlueprintMigration :: ModuleName -> Text -> Text -> Manifest -> Bool-hasAppliedBlueprintMigration blueprintName fromVersion toVersion manifest =+-- | Whether one exact blueprint migration edge has been applied.+--+-- The edge is identified by the origin and name of the blueprint that owns it+-- together with its @from@ and @to@ versions, and only a receipt whose outcome+-- is 'MigrationApplied' counts — matching the completion key+-- 'Seihou.CLI.BlueprintMigration.pendingBlueprintMigrations' applies when it+-- decides what is still pending. The two must agree, or an edge could be+-- reported here as done while the planner still schedules it.+--+-- This is deliberately a different comparison from+-- 'writeAppliedBlueprintMigration'’s: that one identifies the edge in order to+-- upsert its receipt and so ignores the outcome, while this one answers+-- whether the work happened.+hasAppliedBlueprintMigration :: ArtifactOrigin -> ModuleName -> Text -> Text -> Manifest -> Bool+hasAppliedBlueprintMigration blueprintOrigin blueprintName fromVersion toVersion manifest =   any     ( \receipt ->-        receipt ^. #name == blueprintName+        receipt ^. #outcome == MigrationApplied+          && sameArtifactIdentity (receipt ^. #origin) blueprintOrigin+          && receipt ^. #name == blueprintName           && receipt ^. #fromVersion == fromVersion           && receipt ^. #toVersion == toVersion     )@@ -242,6 +264,22 @@ artifactOriginName (LocalOrigin artifact) = artifact artifactOriginName (ProjectOrigin path) = T.pack (takeFileName path) +-- | The origin to use for a record written before 'ArtifactOrigin' reached the+-- agent-applied records — the blueprint, blueprint-migration and recipe+-- entries, all of which carried a bare name until+-- docs\/plans\/81-record-artifact-origin-for-agent-applied-artifacts.md.+--+-- Where the artifact actually came from is genuinely unrecoverable: nothing on+-- disk says which repository a receipt written last month was resolved from.+-- 'LocalOrigin' is the constructor that already means "provenance seihou+-- cannot verify", so decoding to it is honest rather than a fabrication, and+-- it keeps every older manifest readable without an explicit conversion pass+-- (see docs\/adr\/0005-legacy-manifests-convert-through-an-explicit-command.md,+-- whose explicit-command rule exists for conversions that lose or relocate+-- information; this one loses nothing).+legacyLocalOrigin :: Text -> Maybe ArtifactOrigin -> ArtifactOrigin+legacyLocalOrigin recordedName = fromMaybe (LocalOrigin recordedName)+ instance ToJSON AppliedInstanceState where   toJSON state =     Aeson.object $@@ -325,21 +363,24 @@   toJSON ar =     Aeson.object $       [ "name" .= (ar ^. #name . #unRecipeName),+        "origin" .= (ar ^. #origin),         "appliedAt" .= (ar ^. #appliedAt)       ]         ++ maybe [] (\v -> ["version" .= v]) (ar ^. #recipeVersion)  instance FromJSON AppliedRecipe where-  parseJSON = Aeson.withObject "AppliedRecipe" $ \o ->-    AppliedRecipe-      <$> (RecipeName <$> o .: "name")-      <*> o Aeson..:? "version"+  parseJSON = Aeson.withObject "AppliedRecipe" $ \o -> do+    name <- RecipeName <$> o .: "name"+    origin <- legacyLocalOrigin (name ^. #unRecipeName) <$> o Aeson..:? "origin"+    AppliedRecipe name origin+      <$> o Aeson..:? "version"       <*> o .: "appliedAt"  instance ToJSON AppliedBlueprint where   toJSON ab =     Aeson.object $       [ "name" .= (ab ^. #name . #unModuleName),+        "origin" .= (ab ^. #origin),         "appliedAt" .= (ab ^. #appliedAt),         "baselineModules" .= map (^. #unModuleName) (ab ^. #baselineModules),         "noBaseline" .= (ab ^. #noBaseline)@@ -349,34 +390,68 @@         ++ maybe [] (\s -> ["agentSessionId" .= s]) (ab ^. #agentSessionId)  instance FromJSON AppliedBlueprint where-  parseJSON = Aeson.withObject "AppliedBlueprint" $ \o ->-    AppliedBlueprint-      <$> (ModuleName <$> o .: "name")-      <*> o Aeson..:? "version"+  parseJSON = Aeson.withObject "AppliedBlueprint" $ \o -> do+    name <- ModuleName <$> o .: "name"+    origin <- legacyLocalOrigin (name ^. #unModuleName) <$> o Aeson..:? "origin"+    AppliedBlueprint name origin+      <$> o Aeson..:? "version"       <*> o .: "appliedAt"       <*> (map ModuleName <$> o Aeson..:? "baselineModules" Aeson..!= [])       <*> o Aeson..:? "noBaseline" Aeson..!= False       <*> o Aeson..:? "userPrompt"       <*> o Aeson..:? "agentSessionId" +-- | A nested object with a discriminator, matching 'ArtifactOrigin', because+-- the not-applicable case carries a reason and a bare string would have+-- nowhere to put it.+instance ToJSON MigrationOutcome where+  toJSON MigrationApplied =+    Aeson.object ["status" .= ("applied" :: Text)]+  toJSON (MigrationNotApplicable reason) =+    Aeson.object+      [ "status" .= ("not-applicable" :: Text),+        "reason" .= reason+      ]++instance FromJSON MigrationOutcome where+  parseJSON = Aeson.withObject "MigrationOutcome" $ \o -> do+    status <- o .: "status" :: Aeson.Parser Text+    case status of+      "applied" -> pure MigrationApplied+      "not-applicable" -> MigrationNotApplicable <$> o Aeson..:? "reason" Aeson..!= unstatedReason+      other -> fail ("unknown blueprint migration outcome: " <> T.unpack other)++-- | Stand-in for a not-applicable outcome whose reason is missing. An edge+-- that reports itself skipped always supplies one, so this only covers a+-- hand-edited manifest.+unstatedReason :: Text+unstatedReason = "(no reason recorded)"+ instance ToJSON AppliedBlueprintMigration where   toJSON receipt =     Aeson.object $       [ "name" .= (receipt ^. #name . #unModuleName),+        "origin" .= (receipt ^. #origin),         "from" .= (receipt ^. #fromVersion),         "to" .= (receipt ^. #toVersion),+        "outcome" .= (receipt ^. #outcome),         "appliedAt" .= (receipt ^. #appliedAt)       ]         ++ maybe [] (\version -> ["version" .= version]) (receipt ^. #blueprintVersion)         ++ maybe [] (\sessionId -> ["agentSessionId" .= sessionId]) (receipt ^. #agentSessionId)  instance FromJSON AppliedBlueprintMigration where-  parseJSON = Aeson.withObject "AppliedBlueprintMigration" $ \o ->-    AppliedBlueprintMigration-      <$> (ModuleName <$> o .: "name")-      <*> o Aeson..:? "version"+  parseJSON = Aeson.withObject "AppliedBlueprintMigration" $ \o -> do+    name <- ModuleName <$> o .: "name"+    origin <- legacyLocalOrigin (name ^. #unModuleName) <$> o Aeson..:? "origin"+    AppliedBlueprintMigration name origin+      <$> o Aeson..:? "version"       <*> o .: "from"       <*> o .: "to"+      -- A receipt written before the field existed records an edge whose+      -- session returned, which is exactly what 'MigrationApplied' means, so+      -- reading it that way preserves its meaning rather than inventing one.+      <*> o Aeson..:? "outcome" Aeson..!= MigrationApplied       <*> o .: "appliedAt"       <*> o Aeson..:? "agentSessionId" 
test/Main.hs view
@@ -13,6 +13,7 @@ import Seihou.Core.CommandFingerprintSpec qualified as CommandFingerprintSpec import Seihou.Core.CommandVarSpec qualified as CommandVarSpec import Seihou.Core.ContextSpec qualified as ContextSpec+import Seihou.Core.EntailmentSpec qualified as EntailmentSpec import Seihou.Core.ExprSpec qualified as ExprSpec import Seihou.Core.InstallSpec qualified as InstallSpec import Seihou.Core.ListSpec qualified as ListSpec@@ -80,6 +81,7 @@   commandVarTests <- CommandVarSpec.tests   typesTests <- TypesSpec.tests   contextTests <- ContextSpec.tests+  entailmentTests <- EntailmentSpec.tests   exprTests <- ExprSpec.tests   installTests <- InstallSpec.tests   listTests <- ListSpec.tests@@ -128,4 +130,4 @@   manifestTypesTests <- ManifestTypesSpec.tests   promptTests <- PromptSpec.tests   confirmTests <- ConfirmSpec.tests-  defaultMain (testGroup "seihou-core" [graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, schemaUpgradeTests, statusTests, variableTests, versionTests, templateTests, threeWayMergeTests, updateTransactionTests, planTests, previewTests, reconcileTests, sectionTests, validateTests, splitFlakeTests, dhallTextFlakeTests, typedDhallTextTests, conditionalTemplateTests, configTests, dhallEvalTests, migrationDecoderTests, configReaderTests, configWriterTests, baselineStoreTests, filesystemTests, loggerTests, manifestStoreTests, conflictTests, baselineTests, diffTests, executeTests, engineMigrateTests, removeTests, compositionTests, executionTests, integrationTests, generationTests, manifestTypesTests, promptTests, confirmTests])+  defaultMain (testGroup "seihou-core" [graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, entailmentTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, schemaUpgradeTests, statusTests, variableTests, versionTests, templateTests, threeWayMergeTests, updateTransactionTests, planTests, previewTests, reconcileTests, sectionTests, validateTests, splitFlakeTests, dhallTextFlakeTests, typedDhallTextTests, conditionalTemplateTests, configTests, dhallEvalTests, migrationDecoderTests, configReaderTests, configWriterTests, baselineStoreTests, filesystemTests, loggerTests, manifestStoreTests, conflictTests, baselineTests, diffTests, executeTests, engineMigrateTests, removeTests, compositionTests, executionTests, integrationTests, generationTests, manifestTypesTests, promptTests, confirmTests])
test/Seihou/Core/BlueprintSpec.hs view
@@ -4,7 +4,12 @@ import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Data.Text qualified as T-import Seihou.Core.Blueprint (checkBlueprintLaunch, checkBlueprintMigrations, validateBlueprintWith)+import Seihou.Core.Blueprint+  ( checkBlueprintLaunch,+    checkBlueprintMigrations,+    checkBlueprintVersionProbe,+    validateBlueprintWith,+  ) import Seihou.Core.Migration (BlueprintMigration (..)) import Seihou.Core.Module (discoverRunnable) import Seihou.Core.Types@@ -49,6 +54,7 @@     []     []     Nothing+    Nothing  -- | Helpers to update individual 'Blueprint' fields without ambiguous -- record updates. Several @Blueprint@ fields collide by name with@@ -56,48 +62,52 @@ -- the ambiguity once and for all. withBlueprintName :: ModuleName -> Blueprint -> Blueprint withBlueprintName n b =-  Blueprint n (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint n (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintVersion :: Maybe T.Text -> Blueprint -> Blueprint withBlueprintVersion v b =-  Blueprint (b ^. #name) v (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) v (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintPrompt :: T.Text -> Blueprint -> Blueprint withBlueprintPrompt p b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) p (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) p (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintVars :: [VarDecl] -> Blueprint -> Blueprint withBlueprintVars vs b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) vs (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) vs (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintPrompts :: [Prompt] -> Blueprint -> Blueprint withBlueprintPrompts ps b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) ps (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) ps (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintBaseModules :: [Dependency] -> Blueprint -> Blueprint withBlueprintBaseModules ds b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) ds (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) ds (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintFiles :: [BlueprintFile] -> Blueprint -> Blueprint withBlueprintFiles fs b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) fs (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) fs (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintAllowedTools :: Maybe [T.Text] -> Blueprint -> Blueprint withBlueprintAllowedTools at b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) at (b ^. #tags) (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) at (b ^. #tags) (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintTags :: [T.Text] -> Blueprint -> Blueprint withBlueprintTags ts b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) ts (b ^. #migrations) (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) ts (b ^. #migrations) (b ^. #launch) (b ^. #versionProbe)  withBlueprintMigrations :: [BlueprintMigration] -> Blueprint -> Blueprint withBlueprintMigrations migrations b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) migrations (b ^. #launch)+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) migrations (b ^. #launch) (b ^. #versionProbe)  withBlueprintLaunch :: Maybe AgentLaunch -> Blueprint -> Blueprint withBlueprintLaunch launch b =-  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) launch+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) launch (b ^. #versionProbe) +withBlueprintVersionProbe :: Maybe T.Text -> Blueprint -> Blueprint+withBlueprintVersionProbe probe b =+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch) probe+ spec :: Spec spec = do   describe "evalBlueprintFromFile (sample fixture)" $ do@@ -115,8 +125,8 @@           (b ^. #baseModules) `shouldBe` []           length (b ^. #files) `shouldBe` 1           (b ^. #migrations)-            `shouldBe` [ BlueprintMigration "1.0.0" "2.0.0" "Update {{project.name}} for the first library release.",-                         BlueprintMigration "2.5.0" "3.0.0" "Update {{project.name}} for the second library release."+            `shouldBe` [ BlueprintMigration "1.0.0" "2.0.0" "Update {{project.name}} for the first library release." [],+                         BlueprintMigration "2.5.0" "3.0.0" "Update {{project.name}} for the second library release." []                        ]      it "decodes declared blueprint migrations in declaration order" $ do@@ -127,8 +137,8 @@         case result of           Right b ->             (b ^. #migrations)-              `shouldBe` [ BlueprintMigration "1.0.0" "2.0.0" "first edge",-                           BlueprintMigration "2.5.0" "3.0.0" "second edge"+              `shouldBe` [ BlueprintMigration "1.0.0" "2.0.0" "first edge" [],+                           BlueprintMigration "2.5.0" "3.0.0" "second edge" []                          ]           Left err -> expectationFailure ("Expected migrations to decode, got: " <> show err) @@ -180,6 +190,42 @@                   }           Left err -> expectationFailure ("Expected legacy launch to decode, got: " <> show err) +    it "decodes a declared version probe" $ do+      withSystemTempDirectory "seihou-blueprint-probe-decode" $ \tmpDir -> do+        let path = tmpDir </> "blueprint.dhall"+        writeFile path (sampleBlueprintWithVersionProbeDhall "probe-bp")+        result <- evalBlueprintFromFile path+        case result of+          Right b -> (b ^. #versionProbe) `shouldBe` Just "jq -r .dependencies.payments package.json"+          Left err -> expectationFailure ("Expected version probe to decode, got: " <> show err)++    -- Regression: blueprints authored against a schema pin that predates+    -- @versionProbe@ must keep decoding. 'sampleBlueprintDhall' writes no+    -- such key, so this exercises the decoder's 'withDefaults'.+    it "decodes a blueprint with no version probe as Nothing" $ do+      withSystemTempDirectory "seihou-blueprint-noprobe-decode" $ \tmpDir -> do+        let path = tmpDir </> "blueprint.dhall"+        writeFile path (sampleBlueprintDhall "no-probe-bp")+        result <- evalBlueprintFromFile path+        case result of+          Right b -> (b ^. #versionProbe) `shouldBe` Nothing+          Left err -> expectationFailure ("Expected blueprint to decode, got: " <> show err)++  describe "checkBlueprintVersionProbe" $ do+    it "rejects a blank probe command" $+      checkBlueprintVersionProbe (withBlueprintVersionProbe (Just "   ") goodBlueprint)+        `shouldBe` ["versionProbe, if specified, must not be empty"]++    -- Validation runs on the author's machine and must execute nothing, so+    -- anything non-blank is accepted; a probe that cannot run degrades to+    -- requiring --to at migrate time.+    it "accepts any non-blank command without running it" $+      checkBlueprintVersionProbe (withBlueprintVersionProbe (Just "definitely-not-installed --version") goodBlueprint)+        `shouldBe` []++    it "accepts a blueprint that declares no probe" $+      checkBlueprintVersionProbe goodBlueprint `shouldBe` []+   describe "validateBlueprintWith (sample fixture)" $ do     it "accepts the sample-blueprint fixture" $ do       cwd <- getCurrentDirectory@@ -275,26 +321,26 @@           other -> expectationFailure ("Expected ValidationError, got: " <> show other)      it "rejects an empty migration prompt" $ do-      let bad = withBlueprintMigrations [BlueprintMigration "1.0.0" "2.0.0" "  "] goodBlueprint+      let bad = withBlueprintMigrations [BlueprintMigration "1.0.0" "2.0.0" "  " []] goodBlueprint       checkBlueprintMigrations bad `shouldSatisfy` hasError "prompt must not be empty"      it "rejects malformed migration versions" $ do-      let bad = withBlueprintMigrations [BlueprintMigration "release-1" "next" "change"] goodBlueprint+      let bad = withBlueprintMigrations [BlueprintMigration "release-1" "next" "change" []] goodBlueprint           errors = checkBlueprintMigrations bad       errors `shouldSatisfy` hasError "from version is not dotted numeric"       errors `shouldSatisfy` hasError "to version is not dotted numeric"      it "rejects migration edges that do not advance" $ do-      let equalEdge = withBlueprintMigrations [BlueprintMigration "2.0.0" "2.0.0" "change"] goodBlueprint-          reverseEdge = withBlueprintMigrations [BlueprintMigration "3.0.0" "2.0.0" "change"] goodBlueprint+      let equalEdge = withBlueprintMigrations [BlueprintMigration "2.0.0" "2.0.0" "change" []] goodBlueprint+          reverseEdge = withBlueprintMigrations [BlueprintMigration "3.0.0" "2.0.0" "change" []] goodBlueprint       checkBlueprintMigrations equalEdge `shouldSatisfy` hasError "must advance versions"       checkBlueprintMigrations reverseEdge `shouldSatisfy` hasError "must advance versions"      it "rejects duplicate migration starts" $ do       let bad =             withBlueprintMigrations-              [ BlueprintMigration "1.0.0" "2.0.0" "first",-                BlueprintMigration "1.0.0" "3.0.0" "second"+              [ BlueprintMigration "1.0.0" "2.0.0" "first" [],+                BlueprintMigration "1.0.0" "3.0.0" "second" []               ]               goodBlueprint       checkBlueprintMigrations bad `shouldSatisfy` hasError "duplicate blueprint migration from version"@@ -507,6 +553,16 @@            "    , effort = Some \"max\"",            "    , mode = Some \"reserved\"",            "    }",+           "}"+         ]++-- | A blueprint declaring the shell command that reads its library's version+-- out of the consuming project.+sampleBlueprintWithVersionProbeDhall :: T.Text -> String+sampleBlueprintWithVersionProbeDhall n =+  unlines $+    init (lines (sampleBlueprintDhall n))+      <> [ ", versionProbe = Some \"jq -r .dependencies.payments package.json\"",            "}"          ] 
+ test/Seihou/Core/EntailmentSpec.hs view
@@ -0,0 +1,217 @@+-- | Tests for 'expandEntailedEdges', the pure heart of blueprint migration+-- fan-out. It turns the edges a version window selected into the flat, ordered+-- list of steps a run actually performs, following each edge's declared+-- entailments into other blueprints.+--+-- Most of the risk in the feature lives here: a subtle bug produces a+-- plausible-looking plan that runs the wrong work, in the wrong order, or+-- twice.+module Seihou.Core.EntailmentSpec (tests) where++import Control.Lens ((^.))+import Data.Generics.Labels ()+import Data.Text (Text)+import Seihou.Core.Migration+  ( BlueprintMigration (..),+    BlueprintMigrationStep (..),+    EntailedEdge (..),+    EntailmentError (..),+    EntailmentSite (..),+    expandEntailedEdges,+  )+import Test.Hspec+import Test.Tasty (TestTree)+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.Core.Migration entailment" spec++spec :: Spec+spec = describe "expandEntailedEdges" $ do+  it "leaves a step with no entailed edges alone" $ do+    let step = ownedStep "keiro-upgrade" (edge "2.4.0" "3.0.0" [])+    expandEntailedEdges (const Nothing) [step] `shouldBe` Right [step]++  -- The ordering rule the whole design rests on: the entailed edge is the+  -- deeper change, and the declaring edge's guidance may assume it landed.+  it "runs one entailed edge before the edge that declares it" $ do+    let kirokuEdge = edge "1.9.0" "2.0.0" []+        keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        declared = library [("kiroku-upgrade", [kirokuEdge])]+    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]+      `shouldBe` Right+        [ entailedStep "kiroku-upgrade" kirokuEdge (EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0"),+          ownedStep "keiro-upgrade" keiroEdge+        ]++  it "runs several entailed edges in declaration order, all before the declaring edge" $ do+    let firstEdge = edge "1.0.0" "1.1.0" []+        secondEdge = edge "5.0.0" "6.0.0" []+        declaring =+          edge+            "2.4.0"+            "3.0.0"+            [ EntailedEdge "alpha" "1.0.0" "1.1.0",+              EntailedEdge "beta" "5.0.0" "6.0.0"+            ]+        declared = library [("alpha", [firstEdge]), ("beta", [secondEdge])]+        site = EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0"+    fmap (map label) (expandEntailedEdges declared [ownedStep "keiro-upgrade" declaring])+      `shouldBe` Right+        [ "alpha 1.0.0 -> 1.1.0",+          "beta 5.0.0 -> 6.0.0",+          "keiro-upgrade 2.4.0 -> 3.0.0"+        ]+    -- and the middle step remembers what pulled it in+    fmap (map (^. #entailedBy)) (expandEntailedEdges declared [ownedStep "keiro-upgrade" declaring])+      `shouldBe` Right [Just site, Just site, Nothing]++  -- Recursion is what lets a three-deep cohort work without every blueprint+  -- knowing the whole graph.+  it "expands transitive entailment depth first" $ do+    let deepest = edge "0.1.0" "0.2.0" []+        middle = edge "1.9.0" "2.0.0" [EntailedEdge "shibuya" "0.1.0" "0.2.0"]+        top = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        declared = library [("kiroku-upgrade", [middle]), ("shibuya", [deepest])]+    fmap (map label) (expandEntailedEdges declared [ownedStep "keiro-upgrade" top])+      `shouldBe` Right+        [ "shibuya 0.1.0 -> 0.2.0",+          "kiroku-upgrade 1.9.0 -> 2.0.0",+          "keiro-upgrade 2.4.0 -> 3.0.0"+        ]++  -- Two selected edges of one blueprint can both depend on the same upstream+  -- edge. It is one piece of work and must run once.+  it "emits a shared entailed edge only once" $ do+    let shared = edge "1.9.0" "2.0.0" []+        earlier = edge "2.0.0" "2.4.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        later = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        declared = library [("kiroku-upgrade", [shared])]+    fmap+      (map label)+      ( expandEntailedEdges+          declared+          [ownedStep "keiro-upgrade" earlier, ownedStep "keiro-upgrade" later]+      )+      `shouldBe` Right+        [ "kiroku-upgrade 1.9.0 -> 2.0.0",+          "keiro-upgrade 2.0.0 -> 2.4.0",+          "keiro-upgrade 2.4.0 -> 3.0.0"+        ]++  -- The over-eager cycle check this test exists to catch keys on blueprint+  -- name. Two blueprints may legitimately entail each other at *different*+  -- edges, which is a chain, not a cycle.+  it "does not mistake mutual entailment at different edges for a cycle" $ do+    let kirokuEarly = edge "1.0.0" "1.5.0" []+        kirokuLate = edge "1.9.0" "2.0.0" [EntailedEdge "keiro-upgrade" "1.0.0" "2.0.0"]+        keiroEarly = edge "1.0.0" "2.0.0" [EntailedEdge "kiroku-upgrade" "1.0.0" "1.5.0"]+        keiroLate = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        declared =+          library+            [ ("kiroku-upgrade", [kirokuEarly, kirokuLate]),+              ("keiro-upgrade", [keiroEarly, keiroLate])+            ]+    fmap (map label) (expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroLate])+      `shouldBe` Right+        [ "kiroku-upgrade 1.0.0 -> 1.5.0",+          "keiro-upgrade 1.0.0 -> 2.0.0",+          "kiroku-upgrade 1.9.0 -> 2.0.0",+          "keiro-upgrade 2.4.0 -> 3.0.0"+        ]++  it "reports a cycle with its chain rather than looping" $ do+    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        kirokuEdge = edge "1.9.0" "2.0.0" [EntailedEdge "keiro-upgrade" "2.4.0" "3.0.0"]+        declared =+          library [("keiro-upgrade", [keiroEdge]), ("kiroku-upgrade", [kirokuEdge])]+    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]+      `shouldBe` Left+        ( EntailmentCycle+            [ "keiro-upgrade 2.4.0 -> 3.0.0",+              "kiroku-upgrade 1.9.0 -> 2.0.0",+              "keiro-upgrade 2.4.0 -> 3.0.0"+            ]+        )++  -- A cycle that does not include the edge the run started from. The reported+  -- chain should begin where the repetition begins, not at the entry point.+  it "reports a cycle deeper than the entry point from where it closes" $ do+    let top = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        kirokuEdge = edge "1.9.0" "2.0.0" [EntailedEdge "shibuya" "0.1.0" "0.2.0"]+        shibuyaEdge = edge "0.1.0" "0.2.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        declared =+          library [("kiroku-upgrade", [kirokuEdge]), ("shibuya", [shibuyaEdge])]+    expandEntailedEdges declared [ownedStep "keiro-upgrade" top]+      `shouldBe` Left+        ( EntailmentCycle+            [ "kiroku-upgrade 1.9.0 -> 2.0.0",+              "shibuya 0.1.0 -> 0.2.0",+              "kiroku-upgrade 1.9.0 -> 2.0.0"+            ]+        )++  -- Skipping an unresolvable member silently would leave a half-migrated+  -- project with no signal, because the consumer does not know the cohort.+  it "refuses when an entailed blueprint cannot be resolved" $ do+    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+    expandEntailedEdges (const Nothing) [ownedStep "keiro-upgrade" keiroEdge]+      `shouldBe` Left+        ( EntailedBlueprintNotFound+            (EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0")+            "kiroku-upgrade"+        )++  -- Entailment names one exact edge. Falling back to window planning inside+  -- the entailed blueprint would let a release silently change which upstream+  -- work it implies.+  it "refuses when the entailed blueprint declares no such edge" $ do+    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        declared =+          library [("kiroku-upgrade", [edge "1.0.0" "1.5.0" [], edge "1.5.0" "2.0.0" []])]+    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]+      `shouldBe` Left+        ( EntailedEdgeNotDeclared+            (EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0")+            "kiroku-upgrade"+            "1.9.0"+            "2.0.0"+        )++  it "matches an entailed edge on both ends of its window, not just its start" $ do+    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]+        declared = library [("kiroku-upgrade", [edge "1.9.0" "1.9.5" []])]+    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]+      `shouldSatisfy` \result -> case result of+        Left (EntailedEdgeNotDeclared _ _ _ _) -> True+        _ -> False++-- ---------------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------------++edge :: Text -> Text -> [EntailedEdge] -> BlueprintMigration+edge fromVersion toVersion entailed =+  BlueprintMigration+    { from = fromVersion,+      to = toVersion,+      prompt = "migrate " <> fromVersion <> " -> " <> toVersion,+      entails = entailed+    }++ownedStep :: Text -> BlueprintMigration -> BlueprintMigrationStep+ownedStep owner declared =+  BlueprintMigrationStep {owner = owner, edge = declared, entailedBy = Nothing}++entailedStep :: Text -> BlueprintMigration -> EntailmentSite -> BlueprintMigrationStep+entailedStep owner declared site =+  BlueprintMigrationStep {owner = owner, edge = declared, entailedBy = Just site}++-- | A stand-in for the blueprints a run has loaded off disk.+library :: [(Text, [BlueprintMigration])] -> Text -> Maybe [BlueprintMigration]+library table name = lookup name table++-- | The shape a failure is easiest to read in: owner and window per step.+label :: BlueprintMigrationStep -> Text+label step =+  step ^. #owner <> " " <> step ^. #edge . #from <> " -> " <> step ^. #edge . #to
test/Seihou/Core/MigrationSpec.hs view
@@ -6,6 +6,7 @@ import Seihou.Core.Migration   ( BlueprintMigration (..),     BlueprintMigrationPlan (..),+    BlueprintMigrationStep (..),     Migration (..),     MigrationOp (..),     MigrationPlan (..),@@ -162,15 +163,18 @@    describe "planBlueprintMigrationChain" $ do     it "orders in-window migrations while allowing intentional gaps" $ do-      let early = BlueprintMigration "1.0.0" "2.0.0" "first"-          late = BlueprintMigration "2.5.0" "3.0.0" "second"+      let early = BlueprintMigration "1.0.0" "2.0.0" "first" []+          late = BlueprintMigration "2.5.0" "3.0.0" "second" []           result = planBlueprintMigrationChain "demo" [late, early] (mkV "1.0.0") (mkV "3.0.0")       case result of         Right (Just plan) -> do           (plan ^. #name) `shouldBe` "demo"           (plan ^. #from) `shouldBe` mkV "1.0.0"           (plan ^. #to) `shouldBe` mkV "3.0.0"-          (plan ^. #steps) `shouldBe` [early, late]+          -- Every window-selected edge is owned by the blueprint that was+          -- planned; owners other than that one appear only after entailment+          -- expansion.+          (plan ^. #steps) `shouldBe` [ownedBy "demo" early, ownedBy "demo" late]         other -> expectationFailure ("Expected ordered blueprint plan, got: " <> show other)      it "returns Nothing for an equal version window" $ do@@ -182,20 +186,20 @@         `shouldBe` Left (MigrationDowngradeNotSupported (mkV "3.0.0") (mkV "2.0.0"))      it "rejects an unparseable declared version" $ do-      let migration = BlueprintMigration "release-1" "2.0.0" "change"+      let migration = BlueprintMigration "release-1" "2.0.0" "change" []       planBlueprintMigrationChain "demo" [migration] (mkV "1.0.0") (mkV "2.0.0")         `shouldBe` Left (MigrationVersionUnparseable "release-1")      it "rejects duplicate starts" $ do-      let first = BlueprintMigration "1.0.0" "2.0.0" "first"-          second = BlueprintMigration "1.0.0" "1.5.0" "second"+      let first = BlueprintMigration "1.0.0" "2.0.0" "first" []+          second = BlueprintMigration "1.0.0" "1.5.0" "second" []           result = planBlueprintMigrationChain "demo" [first, second] (mkV "1.0.0") (mkV "2.0.0")       case result of         Left (MigrationDuplicateEdge fromVersion _) -> fromVersion `shouldBe` mkV "1.0.0"         other -> expectationFailure ("Expected duplicate blueprint edge error, got: " <> show other)      it "skips an edge that overshoots the target" $ do-      let migration = BlueprintMigration "1.0.0" "3.0.0" "too far"+      let migration = BlueprintMigration "1.0.0" "3.0.0" "too far" []           result = planBlueprintMigrationChain "demo" [migration] (mkV "1.0.0") (mkV "2.0.0")       case result of         Right (Just plan) -> (plan ^. #steps) `shouldBe` []@@ -209,3 +213,9 @@ mkV t = case parseVersion t of   Just ver -> ver   Nothing -> error ("MigrationSpec.mkV: bad version literal " <> show t)++-- | A directly selected step: owned by the planned blueprint, entailed by+-- nothing.+ownedBy :: Text -> BlueprintMigration -> BlueprintMigrationStep+ownedBy owner edge =+  BlueprintMigrationStep {owner = owner, edge = edge, entailedBy = Nothing}
test/Seihou/Manifest/TypesSpec.hs view
@@ -34,13 +34,19 @@ mkBlueprintMigrationReceipt blueprintName fromVersion toVersion appliedAt =   AppliedBlueprintMigration     { name = ModuleName blueprintName,+      origin = RemoteOrigin ("https://github.com/acme/" <> blueprintName) blueprintName Nothing,       blueprintVersion = Just "0.4.0",       fromVersion = fromVersion,       toVersion = toVersion,+      outcome = MigrationApplied,       appliedAt = appliedAt,       agentSessionId = Nothing     } +-- | The identity the @payments@ blueprint carries in the receipt cases below.+paymentsOrigin :: ArtifactOrigin+paymentsOrigin = RemoteOrigin "https://github.com/acme/payments" "payments" Nothing+ -- | Helper to set modules on a Manifest without ambiguous record update. withManifestModules :: [AppliedModule] -> Manifest -> Manifest withManifestModules mods m =@@ -145,11 +151,19 @@         )     & #applications       %~ map (withCommandReceipts (Map.singleton receiptFingerprint receipt))-    & #recipe .~ Just (AppliedRecipe (RecipeName "haskell-service") (Just "3.1.0") fixedTime)+    & #recipe+      .~ Just+        ( AppliedRecipe+            (RecipeName "haskell-service")+            (RemoteOrigin "https://github.com/acme/haskell-service" "haskell-service" Nothing)+            (Just "3.1.0")+            fixedTime+        )     & #blueprint       .~ Just         ( AppliedBlueprint             { name = ModuleName "service-blueprint",+              origin = RemoteOrigin "https://github.com/acme/service-blueprint" "service-blueprint" Nothing,               blueprintVersion = Just "2.0.0",               appliedAt = fixedTime,               baselineModules = [ModuleName "haskell-base"],@@ -508,6 +522,7 @@       let ab =             AppliedBlueprint               { name = ModuleName "payments-service",+                origin = RemoteOrigin "https://github.com/acme/payments-service" "payments-service" Nothing,                 blueprintVersion = Just "0.3.1",                 appliedAt = fixedTime,                 baselineModules = [ModuleName "nix-flake", ModuleName "haskell-base"],@@ -521,6 +536,7 @@       let ab =             AppliedBlueprint               { name = ModuleName "lone-blueprint",+                origin = LocalOrigin "lone-blueprint",                 blueprintVersion = Nothing,                 appliedAt = fixedTime,                 baselineModules = [],@@ -535,6 +551,7 @@           ab1 =             AppliedBlueprint               (ModuleName "first")+              (LocalOrigin "first")               Nothing               fixedTime               []@@ -544,6 +561,7 @@           ab2 =             AppliedBlueprint               (ModuleName "second")+              (LocalOrigin "second")               (Just "1.0.0")               fixedTime2               [ModuleName "x"]@@ -560,13 +578,28 @@       let receipt =             AppliedBlueprintMigration               (ModuleName "payments")+              (RemoteOrigin "https://github.com/acme/payments" "payments" Nothing)               (Just "0.4.0")               "1.0.0"               "2.0.0"+              MigrationApplied               fixedTime               (Just "session-123")       Aeson.eitherDecode (Aeson.encode receipt) `shouldBe` Right receipt +    it "round-trips a not-applicable receipt with its reason" $ do+      let receipt =+            AppliedBlueprintMigration+              (ModuleName "payments")+              (RemoteOrigin "https://github.com/acme/payments" "payments" Nothing)+              (Just "0.4.0")+              "1.0.0"+              "2.0.0"+              (MigrationNotApplicable "the project has not adopted the bundle")+              fixedTime+              Nothing+      Aeson.eitherDecode (Aeson.encode receipt) `shouldBe` Right receipt+     it "round-trips a version-5 manifest containing a receipt" $ do       let receipt = mkBlueprintMigrationReceipt "payments" "1.0.0" "2.0.0" fixedTime           manifest = ((emptyManifest fixedTime) & #blueprintMigrations .~ [receipt])@@ -578,17 +611,42 @@           replacement =             AppliedBlueprintMigration               (ModuleName "payments")+              (RemoteOrigin "https://github.com/acme/payments" "payments" Nothing)               (Just "0.5.0")               "1.0.0"               "2.0.0"+              MigrationApplied               fixedTime2               (Just "rerun")           manifest1 = writeAppliedBlueprintMigration unrelated (writeAppliedBlueprintMigration first (emptyManifest fixedTime))           manifest2 = writeAppliedBlueprintMigration replacement manifest1       (manifest2 ^. #blueprintMigrations) `shouldBe` [replacement, unrelated]-      hasAppliedBlueprintMigration "payments" "1.0.0" "2.0.0" manifest2 `shouldBe` True-      hasAppliedBlueprintMigration "payments" "2.0.0" "3.0.0" manifest2 `shouldBe` False+      hasAppliedBlueprintMigration paymentsOrigin "payments" "1.0.0" "2.0.0" manifest2 `shouldBe` True+      hasAppliedBlueprintMigration paymentsOrigin "payments" "2.0.0" "3.0.0" manifest2 `shouldBe` False+      -- A blueprint of the same name from another repository has its own+      -- receipts, so the identical edge is not recorded for it.+      hasAppliedBlueprintMigration+        (RemoteOrigin "https://github.com/other/payments" "payments" Nothing)+        "payments"+        "1.0.0"+        "2.0.0"+        manifest2+        `shouldBe` False +    -- The upsert key deliberately ignores the outcome, so a re-run replaces+    -- the earlier record. The read side does not ignore it: an edge that+    -- reported itself inapplicable has not been applied.+    it "upserts across a change of outcome and reports the edge unapplied while it is skipped" $ do+      let edge = mkBlueprintMigrationReceipt "payments" "1.0.0" "2.0.0" fixedTime+          skipped = edge & #outcome .~ MigrationNotApplicable "no adr bundle"+          applied = edge & #appliedAt .~ fixedTime2+          afterSkip = writeAppliedBlueprintMigration skipped (emptyManifest fixedTime)+          afterApply = writeAppliedBlueprintMigration applied afterSkip+      (afterSkip ^. #blueprintMigrations) `shouldBe` [skipped]+      hasAppliedBlueprintMigration paymentsOrigin "payments" "1.0.0" "2.0.0" afterSkip `shouldBe` False+      (afterApply ^. #blueprintMigrations) `shouldBe` [applied]+      hasAppliedBlueprintMigration paymentsOrigin "payments" "1.0.0" "2.0.0" afterApply `shouldBe` True+     it "preserves modules, applications, files, recipe, and normal blueprint provenance" $ do       let appliedModule = AppliedModule "base" emptyParentVars (LocalOrigin "base") (Just "1.0.0") fixedTime Nothing           application =@@ -605,8 +663,8 @@                 appliedAt = fixedTime               }           fileRecord = FileRecord (SHA256 "hash") "base" Template fixedTime Nothing mempty-          recipe = AppliedRecipe "recipe" (Just "1.0.0") fixedTime-          normalBlueprint = AppliedBlueprint "payments" (Just "0.4.0") fixedTime [] False Nothing Nothing+          recipe = AppliedRecipe "recipe" (LocalOrigin "recipe") (Just "1.0.0") fixedTime+          normalBlueprint = AppliedBlueprint "payments" (LocalOrigin "payments") (Just "0.4.0") fixedTime [] False Nothing Nothing           seed =             ( (emptyManifest fixedTime)                 & #modules .~ [appliedModule]