packages feed

keiro-dsl 0.4.0.1 → 0.5.0.0

raw patch · 14 files changed

+7462/−2847 lines, 14 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Keiro.Dsl.Diff: advisoryAt :: ChangeContext -> Name -> Text -> Text -> DiagnosticCode -> Text -> Change
+ Keiro.Dsl.DiffReport: OwnedSite :: !FilePath -> !Int -> OwnedSite
+ Keiro.Dsl.DiffReport: RemedyRescaffoldWorkspace :: Remedy
+ Keiro.Dsl.DiffReport: WorkspaceChange :: !Change -> !Maybe OwnedSite -> ![(Text, Maybe OwnedSite)] -> WorkspaceChange
+ Keiro.Dsl.DiffReport: WorkspaceMeta :: !Text -> !FilePath -> !Text -> ![FilePath] -> ![FilePath] -> !Bool -> WorkspaceMeta
+ Keiro.Dsl.DiffReport: [osFile] :: OwnedSite -> !FilePath
+ Keiro.Dsl.DiffReport: [osLine] :: OwnedSite -> !Int
+ Keiro.Dsl.DiffReport: [wcChange] :: WorkspaceChange -> !Change
+ Keiro.Dsl.DiffReport: [wcDeclarationSite] :: WorkspaceChange -> !Maybe OwnedSite
+ Keiro.Dsl.DiffReport: [wcUseSites] :: WorkspaceChange -> ![(Text, Maybe OwnedSite)]
+ Keiro.Dsl.DiffReport: [wmAdoptionBaseline] :: WorkspaceMeta -> !Bool
+ Keiro.Dsl.DiffReport: [wmIdentity] :: WorkspaceMeta -> !Text
+ Keiro.Dsl.DiffReport: [wmManifest] :: WorkspaceMeta -> !FilePath
+ Keiro.Dsl.DiffReport: [wmMembersNew] :: WorkspaceMeta -> ![FilePath]
+ Keiro.Dsl.DiffReport: [wmMembersOld] :: WorkspaceMeta -> ![FilePath]
+ Keiro.Dsl.DiffReport: [wmSince] :: WorkspaceMeta -> !Text
+ Keiro.Dsl.DiffReport: data OwnedSite
+ Keiro.Dsl.DiffReport: data WorkspaceChange
+ Keiro.Dsl.DiffReport: data WorkspaceDiffReport
+ Keiro.Dsl.DiffReport: data WorkspaceMeta
+ Keiro.Dsl.DiffReport: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Dsl.DiffReport.WorkspaceDiffReport
+ Keiro.Dsl.DiffReport: instance GHC.Classes.Eq Keiro.Dsl.DiffReport.OwnedSite
+ Keiro.Dsl.DiffReport: instance GHC.Classes.Eq Keiro.Dsl.DiffReport.WorkspaceChange
+ Keiro.Dsl.DiffReport: instance GHC.Classes.Eq Keiro.Dsl.DiffReport.WorkspaceDiffReport
+ Keiro.Dsl.DiffReport: instance GHC.Classes.Eq Keiro.Dsl.DiffReport.WorkspaceMeta
+ Keiro.Dsl.DiffReport: instance GHC.Internal.Show.Show Keiro.Dsl.DiffReport.OwnedSite
+ Keiro.Dsl.DiffReport: instance GHC.Internal.Show.Show Keiro.Dsl.DiffReport.WorkspaceChange
+ Keiro.Dsl.DiffReport: instance GHC.Internal.Show.Show Keiro.Dsl.DiffReport.WorkspaceDiffReport
+ Keiro.Dsl.DiffReport: instance GHC.Internal.Show.Show Keiro.Dsl.DiffReport.WorkspaceMeta
+ Keiro.Dsl.DiffReport: workspaceDiffReport :: WorkspaceMeta -> Set CompatibilitySurface -> [WorkspaceChange] -> WorkspaceDiffReport
+ Keiro.Dsl.Scaffold: bindingSkeletonOwners :: Context -> Spec -> TypeGraph -> [(ScaffoldModule, [Name])]
+ Keiro.Dsl.Scaffold: scaffoldStructuralOwners :: Context -> Spec -> [(ScaffoldModule, [Name])]
+ Keiro.Dsl.ScaffoldRun: GoldenRootDivergence :: !FilePath -> ![FilePath] -> Refusal
+ Keiro.Dsl.ScaffoldRun: Unchanged :: WriteDisposition
+ Keiro.Dsl.ScaffoldRun: constraintPlan :: Spec -> ConsumerPlan -> [Text]
+ Keiro.Dsl.ScaffoldRun: mappingDrift :: [MappingIdentity] -> [MappingIdentity] -> [MappingDrift]
+ Keiro.Dsl.ScaffoldRun: missingGeneratedBanners :: FilePath -> [ScaffoldModule] -> IO [FilePath]
+ Keiro.Dsl.ScaffoldRun: newBindingObligations :: [BindingHole] -> [BindingHole] -> [BindingHole]
+ Keiro.Dsl.ScaffoldRun: obligationKindLabel :: BindingObligationKind -> Text
+ Keiro.Dsl.ScaffoldRun: pureRefusals :: Context -> Spec -> [ScaffoldModule] -> [Refusal]
+ Keiro.Dsl.ScaffoldRun: renderMappingIdentity :: MappingIdentity -> Text
+ Keiro.Dsl.ScaffoldRun: staleAgainst :: FilePath -> [FilePath] -> [(ModuleKind, FilePath)] -> IO [StaleModule]
+ Keiro.Dsl.Validate: OwnershipMoved :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceAuthorityChanged :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceAuthorityConflict :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceContextMismatch :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceDuplicateDeclaration :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceDuplicateNodeName :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceMemberParseFailed :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceMemberUnreadable :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspacePathCollision :: DiagnosticCode
+ Keiro.Dsl.Validate: nodeIdentity :: Node -> (Text, Name, Loc)
+ Keiro.Dsl.Workspace: ContentSource :: (FilePath -> IO (Either Text Text)) -> ContentSource
+ Keiro.Dsl.Workspace: LineMap :: [(Int, Int, FilePath)] -> LineMap
+ Keiro.Dsl.Workspace: OwnershipIndex :: !Map (Text, Name) (FilePath, Loc) -> !Map (Text, Name) (FilePath, Loc) -> OwnershipIndex
+ Keiro.Dsl.Workspace: WorkspaceDiagnostic :: !NonEmpty WorkspaceLocation -> !Severity -> !DiagnosticCode -> !Text -> WorkspaceDiagnostic
+ Keiro.Dsl.Workspace: WorkspaceLocation :: !WorkspaceFile -> !Int -> !Text -> WorkspaceLocation
+ Keiro.Dsl.Workspace: WorkspaceManifest :: !Text -> !Loc -> !Maybe Text -> !Loc -> !Maybe Placement -> !Loc -> !NonEmpty WorkspaceMemberRef -> WorkspaceManifest
+ Keiro.Dsl.Workspace: WorkspaceManifestFile :: WorkspaceFile
+ Keiro.Dsl.Workspace: WorkspaceManifestUnparseable :: !ParseError -> WorkspaceFailure
+ Keiro.Dsl.Workspace: WorkspaceManifestUnreadable :: !Text -> WorkspaceFailure
+ Keiro.Dsl.Workspace: WorkspaceMember :: !FilePath -> !Spec -> !Int -> !Int -> WorkspaceMember
+ Keiro.Dsl.Workspace: WorkspaceMemberFile :: !FilePath -> WorkspaceFile
+ Keiro.Dsl.Workspace: WorkspaceMemberRef :: !FilePath -> !Loc -> WorkspaceMemberRef
+ Keiro.Dsl.Workspace: WorkspaceRefused :: !NonEmpty WorkspaceDiagnostic -> WorkspaceFailure
+ Keiro.Dsl.Workspace: WorkspaceSpec :: !Text -> !FilePath -> !Name -> !Maybe Text -> !Maybe Placement -> ![WorkspaceMember] -> !Spec -> !LineMap -> !OwnershipIndex -> WorkspaceSpec
+ Keiro.Dsl.Workspace: [csRead] :: ContentSource -> FilePath -> IO (Either Text Text)
+ Keiro.Dsl.Workspace: [lmRanges] :: LineMap -> [(Int, Int, FilePath)]
+ Keiro.Dsl.Workspace: [oiDeclarations] :: OwnershipIndex -> !Map (Text, Name) (FilePath, Loc)
+ Keiro.Dsl.Workspace: [oiNodes] :: OwnershipIndex -> !Map (Text, Name) (FilePath, Loc)
+ Keiro.Dsl.Workspace: [wdCode] :: WorkspaceDiagnostic -> !DiagnosticCode
+ Keiro.Dsl.Workspace: [wdLocations] :: WorkspaceDiagnostic -> !NonEmpty WorkspaceLocation
+ Keiro.Dsl.Workspace: [wdMessage] :: WorkspaceDiagnostic -> !Text
+ Keiro.Dsl.Workspace: [wdSeverity] :: WorkspaceDiagnostic -> !Severity
+ Keiro.Dsl.Workspace: [wlFile] :: WorkspaceLocation -> !WorkspaceFile
+ Keiro.Dsl.Workspace: [wlLine] :: WorkspaceLocation -> !Int
+ Keiro.Dsl.Workspace: [wlRole] :: WorkspaceLocation -> !Text
+ Keiro.Dsl.Workspace: [wmLineBase] :: WorkspaceMember -> !Int
+ Keiro.Dsl.Workspace: [wmLineCount] :: WorkspaceMember -> !Int
+ Keiro.Dsl.Workspace: [wmPath] :: WorkspaceMember -> !FilePath
+ Keiro.Dsl.Workspace: [wmSpec] :: WorkspaceMember -> !Spec
+ Keiro.Dsl.Workspace: [wmfLayoutLoc] :: WorkspaceManifest -> !Loc
+ Keiro.Dsl.Workspace: [wmfLayout] :: WorkspaceManifest -> !Maybe Placement
+ Keiro.Dsl.Workspace: [wmfMembers] :: WorkspaceManifest -> !NonEmpty WorkspaceMemberRef
+ Keiro.Dsl.Workspace: [wmfModuleRootLoc] :: WorkspaceManifest -> !Loc
+ Keiro.Dsl.Workspace: [wmfModuleRoot] :: WorkspaceManifest -> !Maybe Text
+ Keiro.Dsl.Workspace: [wmfServiceLoc] :: WorkspaceManifest -> !Loc
+ Keiro.Dsl.Workspace: [wmfService] :: WorkspaceManifest -> !Text
+ Keiro.Dsl.Workspace: [wmrLoc] :: WorkspaceMemberRef -> !Loc
+ Keiro.Dsl.Workspace: [wmrPath] :: WorkspaceMemberRef -> !FilePath
+ Keiro.Dsl.Workspace: [wsContext] :: WorkspaceSpec -> !Name
+ Keiro.Dsl.Workspace: [wsLayout] :: WorkspaceSpec -> !Maybe Placement
+ Keiro.Dsl.Workspace: [wsLineMap] :: WorkspaceSpec -> !LineMap
+ Keiro.Dsl.Workspace: [wsManifestPath] :: WorkspaceSpec -> !FilePath
+ Keiro.Dsl.Workspace: [wsMembers] :: WorkspaceSpec -> ![WorkspaceMember]
+ Keiro.Dsl.Workspace: [wsMergedSpec] :: WorkspaceSpec -> !Spec
+ Keiro.Dsl.Workspace: [wsModuleRoot] :: WorkspaceSpec -> !Maybe Text
+ Keiro.Dsl.Workspace: [wsOwnership] :: WorkspaceSpec -> !OwnershipIndex
+ Keiro.Dsl.Workspace: [wsService] :: WorkspaceSpec -> !Text
+ Keiro.Dsl.Workspace: checkWorkspace :: WorkspaceSpec -> [WorkspaceDiagnostic]
+ Keiro.Dsl.Workspace: collectLocs :: Spec -> [Int]
+ Keiro.Dsl.Workspace: composeWorkspace :: FilePath -> WorkspaceManifest -> [(FilePath, Text, Spec)] -> Either (NonEmpty WorkspaceDiagnostic) WorkspaceSpec
+ Keiro.Dsl.Workspace: data OwnershipIndex
+ Keiro.Dsl.Workspace: data WorkspaceDiagnostic
+ Keiro.Dsl.Workspace: data WorkspaceFailure
+ Keiro.Dsl.Workspace: data WorkspaceFile
+ Keiro.Dsl.Workspace: data WorkspaceLocation
+ Keiro.Dsl.Workspace: data WorkspaceManifest
+ Keiro.Dsl.Workspace: data WorkspaceMember
+ Keiro.Dsl.Workspace: data WorkspaceMemberRef
+ Keiro.Dsl.Workspace: data WorkspaceSpec
+ Keiro.Dsl.Workspace: declarationOwner :: OwnershipIndex -> Text -> Name -> Maybe (FilePath, Loc)
+ Keiro.Dsl.Workspace: fileContentSource :: FilePath -> ContentSource
+ Keiro.Dsl.Workspace: instance (Keiro.Dsl.Workspace.HasLocs a, Keiro.Dsl.Workspace.HasLocs b) => Keiro.Dsl.Workspace.HasLocs (GHC.Internal.Data.Either.Either a b)
+ Keiro.Dsl.Workspace: instance (Keiro.Dsl.Workspace.HasLocs a, Keiro.Dsl.Workspace.HasLocs b) => Keiro.Dsl.Workspace.HasLocs (a, b)
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.LineMap
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.OwnershipIndex
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceDiagnostic
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceFailure
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceFile
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceLocation
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceManifest
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceMember
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceMemberRef
+ Keiro.Dsl.Workspace: instance GHC.Classes.Eq Keiro.Dsl.Workspace.WorkspaceSpec
+ Keiro.Dsl.Workspace: instance GHC.Classes.Ord Keiro.Dsl.Workspace.WorkspaceFile
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.LineMap
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.OwnershipIndex
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceDiagnostic
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceFailure
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceFile
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceLocation
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceManifest
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceMember
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceMemberRef
+ Keiro.Dsl.Workspace: instance GHC.Internal.Show.Show Keiro.Dsl.Workspace.WorkspaceSpec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.GHasLocs GHC.Internal.Generics.U1
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.GHasLocs GHC.Internal.Generics.V1
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Data.Text.Internal.Text
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs GHC.Num.Integer.Integer
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs GHC.Types.Bool
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs GHC.Types.Char
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs GHC.Types.Double
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs GHC.Types.Int
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.AdvanceNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Aggregate
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Atom
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.BackoffSpec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.BindRow
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.CmpOp
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Command
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Consistency
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ContractEvent
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ContractField
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ContractNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ContractType
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.CorrelateDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.DecodeSpec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.DerivStrategy
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Derivation
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.DeriveSpec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Disp
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.DispAction
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.DispatchDisposition
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.DispatchNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Disposition
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.DispositionRow
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.EmitMapRow
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.EmitNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.EnumDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.EnvelopeBinding
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.EnvelopeLayer
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Event
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.EventBody
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Expr
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Field
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.FieldBinding
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.FireAtExpr
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.FireDisposition
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.FireNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.FireOutcome
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.HandleNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.HaskellSource
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Hole
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.IdDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.IdExpr
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.IdStrategy
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.InboxAction
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.InkPersist
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.InputDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.IntakeNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Loc
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.MappedDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.MappedShape
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Mapping
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Node
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.OnMissing
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.OperationNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.OperationShape
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.PgmqDispatchNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Placement
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.PolicyChoice
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Presence
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ProcessNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ProjectionSpec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.PublisherNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ReadModelNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RegDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RegInitial
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ResolveDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ResolveSource
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RmColumn
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RmFeed
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RmScope
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RouterDispatchNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RouterNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.RuleDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.SagaRef
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.SnapPolicy
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.SnapshotSpec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Spec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.StateDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.TimerNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.Transition
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.TransitionMode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.TypeExpr
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.UnionEncoding
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.UnknownFields
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WfBodyItem
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WireArm
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WireEnum
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WireField
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WireSource
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WireSpec
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WorkflowNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WorkqueueNode
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WqDispRow
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WqField
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WqGroupKey
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WqOrdering
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.WqProvision
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs a => Keiro.Dsl.Workspace.HasLocs (GHC.Internal.Maybe.Maybe a)
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs a => Keiro.Dsl.Workspace.HasLocs [a]
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs c => Keiro.Dsl.Workspace.GHasLocs (GHC.Internal.Generics.K1 i c)
+ Keiro.Dsl.Workspace: instance forall k (a :: k -> *) (b :: k -> *). (Keiro.Dsl.Workspace.GHasLocs a, Keiro.Dsl.Workspace.GHasLocs b) => Keiro.Dsl.Workspace.GHasLocs (a GHC.Internal.Generics.:*: b)
+ Keiro.Dsl.Workspace: instance forall k (a :: k -> *) (b :: k -> *). (Keiro.Dsl.Workspace.GHasLocs a, Keiro.Dsl.Workspace.GHasLocs b) => Keiro.Dsl.Workspace.GHasLocs (a GHC.Internal.Generics.:+: b)
+ Keiro.Dsl.Workspace: instance forall k (a :: k -> *) i (c :: GHC.Internal.Generics.Meta). Keiro.Dsl.Workspace.GHasLocs a => Keiro.Dsl.Workspace.GHasLocs (GHC.Internal.Generics.M1 i c a)
+ Keiro.Dsl.Workspace: isWorkspacePath :: FilePath -> Bool
+ Keiro.Dsl.Workspace: loadWorkspace :: ContentSource -> FilePath -> IO (Either WorkspaceFailure WorkspaceSpec)
+ Keiro.Dsl.Workspace: newtype ContentSource
+ Keiro.Dsl.Workspace: newtype LineMap
+ Keiro.Dsl.Workspace: nodeOwner :: OwnershipIndex -> Text -> Name -> Maybe (FilePath, Loc)
+ Keiro.Dsl.Workspace: normalizeMemberPath :: Text -> Either Text FilePath
+ Keiro.Dsl.Workspace: oneMemberWorkspace :: FilePath -> Spec -> WorkspaceSpec
+ Keiro.Dsl.Workspace: parseWorkspaceManifest :: FilePath -> Text -> Either ParseError WorkspaceManifest
+ Keiro.Dsl.Workspace: relocateLocs :: (Int -> Int) -> Spec -> Spec
+ Keiro.Dsl.Workspace: renderWorkspaceDiagnostic :: FilePath -> WorkspaceDiagnostic -> Text
+ Keiro.Dsl.Workspace: renderWorkspaceFailure :: FilePath -> WorkspaceFailure -> [Text]
+ Keiro.Dsl.Workspace: renderWorkspaceManifest :: WorkspaceManifest -> Text
+ Keiro.Dsl.Workspace: resolveWorkspaceLine :: WorkspaceSpec -> Int -> Maybe (FilePath, Int)
+ Keiro.Dsl.Workspace: workspaceDisplayPath :: FilePath -> WorkspaceFile -> FilePath
+ Keiro.Dsl.Workspace: workspaceExtension :: String
+ Keiro.Dsl.WorkspaceAdoption: ClaimedFile :: !FilePath -> !ClaimEvidence -> !Maybe Text -> !Maybe Text -> ClaimedFile
+ Keiro.Dsl.WorkspaceAdoption: ClaimedFromBanner :: ClaimEvidence
+ Keiro.Dsl.WorkspaceAdoption: ClaimedFromRecord :: ClaimEvidence
+ Keiro.Dsl.WorkspaceAdoption: MigrationReport :: !Text -> !Maybe (FilePath, Text) -> ![ClaimedFile] -> ![StaleModule] -> ![FilePath] -> MigrationReport
+ Keiro.Dsl.WorkspaceAdoption: [cfEvidence] :: ClaimedFile -> !ClaimEvidence
+ Keiro.Dsl.WorkspaceAdoption: [cfPath] :: ClaimedFile -> !FilePath
+ Keiro.Dsl.WorkspaceAdoption: [cfSource] :: ClaimedFile -> !Maybe Text
+ Keiro.Dsl.WorkspaceAdoption: [cfSpec] :: ClaimedFile -> !Maybe Text
+ Keiro.Dsl.WorkspaceAdoption: [mrClaimed] :: MigrationReport -> ![ClaimedFile]
+ Keiro.Dsl.WorkspaceAdoption: [mrLegacyRecord] :: MigrationReport -> !Maybe (FilePath, Text)
+ Keiro.Dsl.WorkspaceAdoption: [mrLikelyStale] :: MigrationReport -> ![StaleModule]
+ Keiro.Dsl.WorkspaceAdoption: [mrService] :: MigrationReport -> !Text
+ Keiro.Dsl.WorkspaceAdoption: [mrUnclaimed] :: MigrationReport -> ![FilePath]
+ Keiro.Dsl.WorkspaceAdoption: adoptedRows :: MigrationReport -> [AdoptedRow]
+ Keiro.Dsl.WorkspaceAdoption: adoptionReport :: FilePath -> Text -> Text -> [ScaffoldModule] -> IO (Maybe MigrationReport)
+ Keiro.Dsl.WorkspaceAdoption: data ClaimEvidence
+ Keiro.Dsl.WorkspaceAdoption: data ClaimedFile
+ Keiro.Dsl.WorkspaceAdoption: data MigrationReport
+ Keiro.Dsl.WorkspaceAdoption: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceAdoption.ClaimEvidence
+ Keiro.Dsl.WorkspaceAdoption: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceAdoption.ClaimedFile
+ Keiro.Dsl.WorkspaceAdoption: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceAdoption.MigrationReport
+ Keiro.Dsl.WorkspaceAdoption: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceAdoption.ClaimEvidence
+ Keiro.Dsl.WorkspaceAdoption: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceAdoption.ClaimedFile
+ Keiro.Dsl.WorkspaceAdoption: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceAdoption.MigrationReport
+ Keiro.Dsl.WorkspaceAdoption: markLegacyRecordSuperseded :: FilePath -> Text -> Text -> IO ()
+ Keiro.Dsl.WorkspaceAdoption: outputTreeFiles :: FilePath -> IO [FilePath]
+ Keiro.Dsl.WorkspaceAdoption: renderMigrationReport :: MigrationReport -> [Text]
+ Keiro.Dsl.WorkspaceDiff: OwnedSite :: !FilePath -> !Int -> OwnedSite
+ Keiro.Dsl.WorkspaceDiff: WorkspaceChange :: !Change -> !Maybe OwnedSite -> ![(Text, Maybe OwnedSite)] -> WorkspaceChange
+ Keiro.Dsl.WorkspaceDiff: WorkspaceMeta :: !Text -> !FilePath -> !Text -> ![FilePath] -> ![FilePath] -> !Bool -> WorkspaceMeta
+ Keiro.Dsl.WorkspaceDiff: [osFile] :: OwnedSite -> !FilePath
+ Keiro.Dsl.WorkspaceDiff: [osLine] :: OwnedSite -> !Int
+ Keiro.Dsl.WorkspaceDiff: [wcChange] :: WorkspaceChange -> !Change
+ Keiro.Dsl.WorkspaceDiff: [wcDeclarationSite] :: WorkspaceChange -> !Maybe OwnedSite
+ Keiro.Dsl.WorkspaceDiff: [wcUseSites] :: WorkspaceChange -> ![(Text, Maybe OwnedSite)]
+ Keiro.Dsl.WorkspaceDiff: [wmAdoptionBaseline] :: WorkspaceMeta -> !Bool
+ Keiro.Dsl.WorkspaceDiff: [wmIdentity] :: WorkspaceMeta -> !Text
+ Keiro.Dsl.WorkspaceDiff: [wmManifest] :: WorkspaceMeta -> !FilePath
+ Keiro.Dsl.WorkspaceDiff: [wmMembersNew] :: WorkspaceMeta -> ![FilePath]
+ Keiro.Dsl.WorkspaceDiff: [wmMembersOld] :: WorkspaceMeta -> ![FilePath]
+ Keiro.Dsl.WorkspaceDiff: [wmSince] :: WorkspaceMeta -> !Text
+ Keiro.Dsl.WorkspaceDiff: data OwnedSite
+ Keiro.Dsl.WorkspaceDiff: data WorkspaceChange
+ Keiro.Dsl.WorkspaceDiff: data WorkspaceDiffReport
+ Keiro.Dsl.WorkspaceDiff: data WorkspaceMeta
+ Keiro.Dsl.WorkspaceDiff: diffWorkspaces :: WorkspaceSpec -> WorkspaceSpec -> [WorkspaceChange]
+ Keiro.Dsl.WorkspaceDiff: renderWorkspaceFinding :: WorkspaceChange -> Text
+ Keiro.Dsl.WorkspaceDiff: workspaceDiffReport :: WorkspaceMeta -> Set CompatibilitySurface -> [WorkspaceChange] -> WorkspaceDiffReport
+ Keiro.Dsl.WorkspaceRecord: AdoptedRow :: !FilePath -> !Text -> !Maybe Text -> !Maybe Text -> AdoptedRow
+ Keiro.Dsl.WorkspaceRecord: WorkspaceModuleRow :: !ModuleKind -> !FilePath -> !Maybe FilePath -> WorkspaceModuleRow
+ Keiro.Dsl.WorkspaceRecord: WorkspaceRecord :: !Text -> !Text -> !Text -> !Text -> !Text -> ![FilePath] -> ![WorkspaceModuleRow] -> ![MappingIdentity] -> ![BindingHole] -> ![AdoptedRow] -> WorkspaceRecord
+ Keiro.Dsl.WorkspaceRecord: [adEvidence] :: AdoptedRow -> !Text
+ Keiro.Dsl.WorkspaceRecord: [adPath] :: AdoptedRow -> !FilePath
+ Keiro.Dsl.WorkspaceRecord: [adSource] :: AdoptedRow -> !Maybe Text
+ Keiro.Dsl.WorkspaceRecord: [adSpec] :: AdoptedRow -> !Maybe Text
+ Keiro.Dsl.WorkspaceRecord: [wrAdopted] :: WorkspaceRecord -> ![AdoptedRow]
+ Keiro.Dsl.WorkspaceRecord: [wrBindingObligations] :: WorkspaceRecord -> ![BindingHole]
+ Keiro.Dsl.WorkspaceRecord: [wrContext] :: WorkspaceRecord -> !Text
+ Keiro.Dsl.WorkspaceRecord: [wrLayout] :: WorkspaceRecord -> !Text
+ Keiro.Dsl.WorkspaceRecord: [wrManifest] :: WorkspaceRecord -> !Text
+ Keiro.Dsl.WorkspaceRecord: [wrMappings] :: WorkspaceRecord -> ![MappingIdentity]
+ Keiro.Dsl.WorkspaceRecord: [wrMembers] :: WorkspaceRecord -> ![FilePath]
+ Keiro.Dsl.WorkspaceRecord: [wrModuleRoot] :: WorkspaceRecord -> !Text
+ Keiro.Dsl.WorkspaceRecord: [wrModules] :: WorkspaceRecord -> ![WorkspaceModuleRow]
+ Keiro.Dsl.WorkspaceRecord: [wrService] :: WorkspaceRecord -> !Text
+ Keiro.Dsl.WorkspaceRecord: [wrmKind] :: WorkspaceModuleRow -> !ModuleKind
+ Keiro.Dsl.WorkspaceRecord: [wrmOwner] :: WorkspaceModuleRow -> !Maybe FilePath
+ Keiro.Dsl.WorkspaceRecord: [wrmPath] :: WorkspaceModuleRow -> !FilePath
+ Keiro.Dsl.WorkspaceRecord: data AdoptedRow
+ Keiro.Dsl.WorkspaceRecord: data WorkspaceModuleRow
+ Keiro.Dsl.WorkspaceRecord: data WorkspaceRecord
+ Keiro.Dsl.WorkspaceRecord: instance Data.Aeson.Types.FromJSON.FromJSON Keiro.Dsl.WorkspaceRecord.AdoptedRow
+ Keiro.Dsl.WorkspaceRecord: instance Data.Aeson.Types.FromJSON.FromJSON Keiro.Dsl.WorkspaceRecord.WorkspaceModuleRow
+ Keiro.Dsl.WorkspaceRecord: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Dsl.WorkspaceRecord.AdoptedRow
+ Keiro.Dsl.WorkspaceRecord: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Dsl.WorkspaceRecord.WorkspaceModuleRow
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceRecord.AdoptedRow
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceRecord.WorkspaceModuleRow
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceRecord.WorkspaceRecord
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceRecord.AdoptedRow
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceRecord.WorkspaceModuleRow
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceRecord.WorkspaceRecord
+ Keiro.Dsl.WorkspaceRecord: parseWorkspaceRecord :: Text -> Maybe WorkspaceRecord
+ Keiro.Dsl.WorkspaceRecord: renderWorkspaceRecord :: WorkspaceRecord -> Text
+ Keiro.Dsl.WorkspaceRecord: supersededByLine :: Text -> Text
+ Keiro.Dsl.WorkspaceRecord: workspaceManifestFileName :: Text -> FilePath
+ Keiro.Dsl.WorkspaceRecord: workspaceMigrationReportFileName :: Text -> FilePath
+ Keiro.Dsl.WorkspaceRecord: workspaceRecordFileName :: Text -> FilePath
+ Keiro.Dsl.WorkspaceScaffold: ContextLevel :: ModuleProvenance
+ Keiro.Dsl.WorkspaceScaffold: MemberOwned :: !FilePath -> ModuleProvenance
+ Keiro.Dsl.WorkspaceScaffold: OwnershipMove :: !FilePath -> !Maybe FilePath -> !Maybe FilePath -> OwnershipMove
+ Keiro.Dsl.WorkspaceScaffold: WorkspacePlan :: !WorkspaceSpec -> !Context -> !FilePath -> ![(ScaffoldModule, ModuleProvenance)] -> WorkspacePlan
+ Keiro.Dsl.WorkspaceScaffold: WorkspaceScaffoldReport :: !FilePath -> !FilePath -> !Text -> !Context -> ![FilePath] -> ![(ScaffoldModule, ModuleProvenance, WriteDisposition)] -> !FilePath -> !FilePath -> !Maybe Text -> ![StaleModule] -> ![OwnershipMove] -> !ConsumerPlan -> ![Text] -> ![MappingDrift] -> ![BindingHole] -> !Maybe MigrationReport -> WorkspaceScaffoldReport
+ Keiro.Dsl.WorkspaceScaffold: [omCurrent] :: OwnershipMove -> !Maybe FilePath
+ Keiro.Dsl.WorkspaceScaffold: [omPath] :: OwnershipMove -> !FilePath
+ Keiro.Dsl.WorkspaceScaffold: [omPrevious] :: OwnershipMove -> !Maybe FilePath
+ Keiro.Dsl.WorkspaceScaffold: [wpContext] :: WorkspacePlan -> !Context
+ Keiro.Dsl.WorkspaceScaffold: [wpGoldenRoot] :: WorkspacePlan -> !FilePath
+ Keiro.Dsl.WorkspaceScaffold: [wpModules] :: WorkspacePlan -> ![(ScaffoldModule, ModuleProvenance)]
+ Keiro.Dsl.WorkspaceScaffold: [wpWorkspace] :: WorkspacePlan -> !WorkspaceSpec
+ Keiro.Dsl.WorkspaceScaffold: [wsrBuildManifestPath] :: WorkspaceScaffoldReport -> !FilePath
+ Keiro.Dsl.WorkspaceScaffold: [wsrConstraintPlan] :: WorkspaceScaffoldReport -> ![Text]
+ Keiro.Dsl.WorkspaceScaffold: [wsrConsumerPlan] :: WorkspaceScaffoldReport -> !ConsumerPlan
+ Keiro.Dsl.WorkspaceScaffold: [wsrContext] :: WorkspaceScaffoldReport -> !Context
+ Keiro.Dsl.WorkspaceScaffold: [wsrDispositions] :: WorkspaceScaffoldReport -> ![(ScaffoldModule, ModuleProvenance, WriteDisposition)]
+ Keiro.Dsl.WorkspaceScaffold: [wsrManifestPath] :: WorkspaceScaffoldReport -> !FilePath
+ Keiro.Dsl.WorkspaceScaffold: [wsrMappingDrift] :: WorkspaceScaffoldReport -> ![MappingDrift]
+ Keiro.Dsl.WorkspaceScaffold: [wsrMembers] :: WorkspaceScaffoldReport -> ![FilePath]
+ Keiro.Dsl.WorkspaceScaffold: [wsrMigration] :: WorkspaceScaffoldReport -> !Maybe MigrationReport
+ Keiro.Dsl.WorkspaceScaffold: [wsrNewHoles] :: WorkspaceScaffoldReport -> ![BindingHole]
+ Keiro.Dsl.WorkspaceScaffold: [wsrOutDir] :: WorkspaceScaffoldReport -> !FilePath
+ Keiro.Dsl.WorkspaceScaffold: [wsrOwnershipMoves] :: WorkspaceScaffoldReport -> ![OwnershipMove]
+ Keiro.Dsl.WorkspaceScaffold: [wsrPreviousManifest] :: WorkspaceScaffoldReport -> !Maybe Text
+ Keiro.Dsl.WorkspaceScaffold: [wsrRecordPath] :: WorkspaceScaffoldReport -> !FilePath
+ Keiro.Dsl.WorkspaceScaffold: [wsrService] :: WorkspaceScaffoldReport -> !Text
+ Keiro.Dsl.WorkspaceScaffold: [wsrStale] :: WorkspaceScaffoldReport -> ![StaleModule]
+ Keiro.Dsl.WorkspaceScaffold: data ModuleProvenance
+ Keiro.Dsl.WorkspaceScaffold: data OwnershipMove
+ Keiro.Dsl.WorkspaceScaffold: data WorkspacePlan
+ Keiro.Dsl.WorkspaceScaffold: data WorkspaceScaffoldReport
+ Keiro.Dsl.WorkspaceScaffold: executeWorkspaceScaffold :: FilePath -> Bool -> WorkspacePlan -> IO (Either [Refusal] WorkspaceScaffoldReport)
+ Keiro.Dsl.WorkspaceScaffold: goldenRootDivergence :: FilePath -> WorkspaceSpec -> IO [Refusal]
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceScaffold.ModuleProvenance
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceScaffold.OwnershipMove
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceScaffold.WorkspacePlan
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceScaffold.WorkspaceScaffoldReport
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Classes.Ord Keiro.Dsl.WorkspaceScaffold.ModuleProvenance
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceScaffold.ModuleProvenance
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceScaffold.OwnershipMove
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceScaffold.WorkspacePlan
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceScaffold.WorkspaceScaffoldReport
+ Keiro.Dsl.WorkspaceScaffold: planWorkspaceScaffold :: FilePath -> Context -> WorkspaceSpec -> Either [Refusal] WorkspacePlan
+ Keiro.Dsl.WorkspaceScaffold: planWorkspaceScaffoldWithGoldens :: [GoldenPayload] -> FilePath -> Context -> WorkspaceSpec -> Either [Refusal] WorkspacePlan
+ Keiro.Dsl.WorkspaceScaffold: provenanceOwner :: ModuleProvenance -> Maybe FilePath
+ Keiro.Dsl.WorkspaceScaffold: renderWorkspaceScaffoldReport :: WorkspaceScaffoldReport -> [Text]

Files

CHANGELOG.md view
@@ -6,6 +6,121 @@  ## [Unreleased] +## 0.5.0.0 — 2026-07-31++### Breaking Changes++- `DiagnosticCode` gains nine constructors:+  `WorkspaceMemberUnreadable`, `WorkspaceMemberParseFailed`,+  `WorkspaceContextMismatch`, `WorkspaceAuthorityConflict`,+  `WorkspaceDuplicateDeclaration`, `WorkspaceDuplicateNodeName`,+  `WorkspacePathCollision`, `OwnershipMoved`, and `WorkspaceAuthorityChanged`.+  Additions are append-only, but exhaustive matches over the type must be+  extended.+- `Keiro.Dsl.ScaffoldRun.Refusal` gains `GoldenRootDivergence`, raised only by+  the workspace path when a golden payload fixture sits beside a member that+  the one workspace golden root lacks.+- `Keiro.Dsl.ScaffoldRun.WriteDisposition` gains `Unchanged`, produced only by+  the workspace write path when a Generated module's bytes already match.+- `Keiro.Dsl.DiffReport.Remedy` gains `RemedyRescaffoldWorkspace`.++No behaviour of the single-file path changed: it keeps its context-keyed+record and manifest names, its report bytes, and its `Overwritten`+disposition, and no existing generated bytes moved.++### New Features++- Adds **service workspaces**: a `.keiro-workspace` manifest names a service and+  lists its member `.keiro` files, and `keiro-dsl check <manifest>` validates+  them as one service contract. Shared ids, enums, rules, and mapped structural+  types resolve once across all members, so an aggregate in one file may use a+  declaration or feed a read model owned by another. A single `.keiro` file is+  unchanged and behaves as a one-member workspace.++  Membership is a set: member paths are normalized and canonically sorted, so+  listing order changes neither the parsed manifest nor any output. Composition+  refuses, before producing a graph, when members declare different contexts,+  when a member's `module`/`layout` clause contradicts the manifest authority,+  when a shared declaration or node is owned by two members (identical+  duplicates never silently merge), or when two members claim generated module+  paths that collide under case folding. One diagnostic can cite several files:+  the primary location keeps the established+  `<file>:<line>: error[<Code>]: <message>` shape and each further location+  follows as an indented `note:` line.++  `check --emit`, `--explain-bindings`, `--coverage-report`, and+  `--fail-on-opaque` all work against the merged whole-service graph, and+  `keiro-dsl parse <manifest>` round-trips the manifest canonically.+  Dispatch is by file extension, so every single-file branch is unchanged.++  New `Keiro.Dsl.Workspace` module; `Keiro.Dsl.Validate.nodeIdentity` is now+  exported. Recorded as ADR-14.++- **Whole-workspace scaffolding.** `keiro-dsl scaffold <manifest> --out DIR`+  emits the complete generated module set for every member in one invocation,+  with unchanged flags. The set is emitted once from the merged spec, so the+  structural projection facade and the replay-audit assembly are produced+  exactly once from the complete graph, and every existing refusal gate runs+  over the whole set. Both preflights — golden fixtures stranded beside a+  member, and Generated paths lacking the `@generated` banner — are evaluated+  across the whole workspace before the output directory is created, so a+  failure in any member leaves the tree, the record, and the build manifest+  byte-for-byte untouched.++  History is workspace-keyed (`workspace.<service>`, a name a context can never+  collide with), and each record row carries its producing member, so an+  aggregate moved between member files is reported as an ownership move with+  zero stale churn and zero content change. A Generated module whose bytes+  already match is reported `(unchanged)` rather than rewritten, making+  idempotence observable. Ownership is attributed structurally through the new+  `scaffoldStructuralOwners` and `bindingSkeletonOwners` seams in+  `Keiro.Dsl.Scaffold`, never by parsing the human-readable `origin` string.++  New `Keiro.Dsl.WorkspaceRecord` and `Keiro.Dsl.WorkspaceScaffold` modules;+  `Keiro.Dsl.ScaffoldRun` now exports the gates and helpers the workspace path+  reuses (`pureRefusals`, `missingGeneratedBanners`, `staleAgainst`,+  `constraintPlan`, `mappingDrift`, `newBindingObligations`,+  `obligationKindLabel`, `renderMappingIdentity`) so the two paths cannot+  diverge on what counts as a refusal.++- **Adoption of pre-workspace output.** The first whole-workspace scaffold into+  a directory that already holds per-context output imports what is+  attributable, reports everything, and claims nothing silently. A file is+  claimed only with evidence — `record` when a legacy per-context record for+  this workspace's effective context lists it, or `banner` when it sits at a+  planned Generated path carrying the `@generated` banner while no surviving+  record lists it. Hole paths are never claimed; everything else is listed as+  unclaimed and left untouched, and a bannerless file at a planned Generated+  path still refuses the run. Nothing is deleted or renamed: the superseded+  legacy record gains one appended `superseded-by:` line that its own v1 parser+  ignores. The migration report is persisted once as+  `keiro-dsl-migration-report.workspace.<service>.txt`. New+  `Keiro.Dsl.WorkspaceAdoption` module; recorded as ADR-15.++- **Whole-workspace diffing.** `keiro-dsl diff <manifest> --since <rev>`+  composes the historical workspace from the manifest and member blobs at that+  revision through the same loader and diffs it against the working tree as one+  service. Membership deltas are covered, and when the manifest itself does not+  exist at the old revision an adoption baseline is composed from the current+  membership and flagged in the report.++  Findings from the merged graph are annotated with the member file and line+  that own the changed declaration and each use site, so a shared declaration's+  blast radius is visible across files. The report carries workspace metadata+  (service identity, manifest, `since`, old and new membership, adoption-baseline+  flag), and replay-impact and coverage output are merged across members. Owner-map+  and manifest-authority changes are reported as the non-blocking consumer-build+  advisories `OwnershipMoved` and `WorkspaceAuthorityChanged`, classified+  independently of wire evolution so they never mask a real wire finding.++  The `keiro-dsl/diff-report/1` JSON schema is preserved: single-file reports+  keep their original bytes, and workspace inputs add a top-level `workspace`+  object plus optional per-finding `declaration` and `useSites` keys, which+  version-1 readers ignore. New `Keiro.Dsl.WorkspaceDiff` module; new+  `Keiro.Dsl.DiffReport` exports `OwnedSite`, `WorkspaceChange`,+  `WorkspaceMeta`, `WorkspaceDiffReport`, and `workspaceDiffReport`. ADR-4 is+  amended with the composed-workspace diff boundary.+ ## 0.4.0.1 — 2026-07-28  ### Other Changes
app/Main.hs view
@@ -6,6 +6,7 @@  import Control.Monad (when) import Data.Aeson qualified as Aeson+import Data.List.NonEmpty qualified as NE import Data.Maybe (fromMaybe) import Data.Text qualified as T import Data.Text.IO qualified as TIO@@ -22,10 +23,13 @@ import Keiro.Dsl.ScaffoldRun (executeScaffold, planScaffoldWithGoldens, renderRefusals, renderScaffoldReport) import Keiro.Dsl.Skeleton (skeletonFor) import Keiro.Dsl.Validate (Diagnostic (..), Severity (..), renderDiagnostic, validateSpec)+import Keiro.Dsl.Workspace (ContentSource (..), LineMap (..), OwnershipIndex (..), WorkspaceDiagnostic (..), WorkspaceFailure, WorkspaceManifest (..), WorkspaceMember (..), WorkspaceMemberRef (..), WorkspaceSpec (..), checkWorkspace, fileContentSource, isWorkspacePath, loadWorkspace, parseWorkspaceManifest, renderWorkspaceDiagnostic, renderWorkspaceFailure, renderWorkspaceManifest)+import Keiro.Dsl.WorkspaceDiff (WorkspaceChange (..), WorkspaceMeta (..), diffWorkspaces, renderWorkspaceFinding, workspaceDiffReport)+import Keiro.Dsl.WorkspaceScaffold (executeWorkspaceScaffold, planWorkspaceScaffoldWithGoldens, renderWorkspaceScaffoldReport) import Options.Applicative import System.Directory (canonicalizePath, createDirectoryIfMissing, doesFileExist) import System.Exit (ExitCode (..), exitFailure)-import System.FilePath (makeRelative, normalise, takeDirectory, (</>))+import System.FilePath (isAbsolute, makeRelative, normalise, takeDirectory, takeFileName, (</>)) import System.IO (hPutStrLn, stderr) import System.Process (readProcessWithExitCode) @@ -141,12 +145,21 @@ sinceOpt = strOption (long "since" <> metavar "GIT-REF" <> help "Git ref to diff the spec against (e.g. HEAD, a tag, a branch)")  fileArg :: Parser FilePath-fileArg = argument str (metavar "FILE" <> help "Path to a .keiro spec (use /dev/stdin for stdin)")+fileArg = argument str (metavar "FILE" <> help "Path to a .keiro spec or .keiro-workspace manifest (use /dev/stdin for stdin)")  kindArg :: Parser String kindArg = argument str (metavar "KIND" <> help "Node kind to scaffold a starter spec for")  run :: Command -> IO ()+-- Workspace dispatch. A @FILE@ ending in @.keiro-workspace@ is a workspace+-- manifest; everything else takes the untouched single-file path below.+run (Parse fp) | isWorkspacePath fp = runWorkspaceParse fp+run (Check fp emit explainBindings coverageOptions)+    | isWorkspacePath fp = runWorkspaceCheck fp emit explainBindings coverageOptions+run (Scaffold fp out cliRoot cliCollocate forceGeneratedOverwrite cliGoldens comparisonRequest)+    | isWorkspacePath fp = runWorkspaceScaffold fp out cliRoot cliCollocate forceGeneratedOverwrite cliGoldens comparisonRequest+run (Diff fp ref emitGoldensRoot replayImpactOut gatedSurfaces explain reportOut coverageOptions)+    | isWorkspacePath fp = runWorkspaceDiff fp ref emitGoldensRoot replayImpactOut gatedSurfaces explain reportOut coverageOptions run (Parse fp) = do     input <- TIO.readFile fp     case parseSpec fp input of@@ -245,6 +258,295 @@                             mapM_ (\path -> Aeson.encodeFile path (diffReport effectiveGate changes)) reportOut                             coverageOk <- runDiffCoverage fp (T.pack ref) oldSpec newSpec coverageOptions                             if any (gatedBreaking effectiveGate) changes || not coverageOk then exitFailure else pure ()++{- | @parse@ on a workspace manifest: read it, parse it, and print it back in+canonical form (clauses in order, members codepoint-sorted).+-}+runWorkspaceParse :: FilePath -> IO ()+runWorkspaceParse fp = do+    input <- TIO.readFile fp+    case parseWorkspaceManifest fp input of+        Left err -> do+            hPutStrLn stderr (T.unpack err)+            exitFailure+        Right manifest -> TIO.putStrLn (renderWorkspaceManifest manifest)++{- | @check@ on a workspace manifest: compose the whole service from its member+@.keiro@ files and validate it as one contract. Diagnostics are rendered+against the member file and line that produced them, and a single diagnostic+may cite several files at once.++The success options work against the merged graph, which is an ordinary 'Spec':+@--emit@ prints the canonical whole-service view, @--explain-bindings@ lists the+service's binding obligations, and the coverage options report on the merged+mapped-type graph with the manifest as the report's subject.+-}+runWorkspaceCheck :: FilePath -> Bool -> Bool -> Maybe CheckCoverageOptions -> IO ()+runWorkspaceCheck fp emit explainBindings coverageOptions = do+    loaded <- loadWorkspace (fileContentSource (takeDirectory fp)) fp+    case loaded of+        Left failure -> do+            mapM_ (TIO.hPutStrLn stderr) (renderWorkspaceFailure fp failure)+            exitFailure+        Right workspace -> do+            let diags = checkWorkspace workspace+                spec = wsMergedSpec workspace+            mapM_ (TIO.hPutStrLn stderr . renderWorkspaceDiagnostic fp) diags+            if any ((== Error) . wdSeverity) diags+                then exitFailure+                else do+                    when emit (TIO.putStrLn (renderSpec spec))+                    if explainBindings+                        then case bindingObligations spec of+                            Left graphErrors -> do+                                hPutStrLn stderr ("validated workspace did not resolve its mapped type graph: " <> show graphErrors)+                                exitFailure+                            Right obligations -> TIO.putStrLn (renderBindingObligations (wsContext workspace) obligations)+                        else pure ()+                    coverageOk <- runCheckCoverage fp spec coverageOptions+                    when (coverageOk && not emit && not explainBindings) (putStrLn "OK")+                    when (not coverageOk) exitFailure++{- | @scaffold@ on a workspace manifest: compose the whole service, then plan+and emit the complete module set for every member in one invocation.++Every refusal — a member that will not parse, a cross-member conflict, a+validation error anywhere in the merged graph, a module-path collision, a golden+fixture stranded beside a member, a Generated target without the banner — is+raised before the first output byte changes, exactly as on the single-file path.++The context is folded with the same precedence the single-file path uses: a CLI+flag beats the workspace authority, which (per EP-153) beats a member clause.+The single-file branch below is not touched, so existing users' bytes are+unchanged by construction.+-}+runWorkspaceScaffold ::+    FilePath ->+    FilePath ->+    Maybe String ->+    Bool ->+    Bool ->+    Maybe FilePath ->+    Maybe (String, FilePath) ->+    IO ()+runWorkspaceScaffold fp out cliRoot cliCollocate forceGeneratedOverwrite cliGoldens comparisonRequest = do+    loaded <- loadWorkspace (fileContentSource (takeDirectory fp)) fp+    case loaded of+        Left failure -> do+            mapM_ (TIO.hPutStrLn stderr) (renderWorkspaceFailure fp failure)+            exitFailure+        Right workspace -> do+            -- Validation gate: never scaffold an invalid service. Abort on any+            -- error-severity diagnostic before writing a single module.+            let diags = checkWorkspace workspace+            mapM_ (TIO.hPutStrLn stderr . renderWorkspaceDiagnostic fp) diags+            when (any ((== Error) . wdSeverity) diags) exitFailure+            let spec = wsMergedSpec workspace+                ctx = workspaceContext cliRoot cliCollocate workspace+                goldenRoot = fromMaybe (takeDirectory fp </> "golden-payloads") cliGoldens+            goldens <- loadGoldenPayloads goldenRoot spec+            case ( planWorkspaceScaffoldWithGoldens goldens goldenRoot ctx workspace+                 , traverse (\(name, _) -> codecComparisonModule ctx spec (T.pack name)) comparisonRequest+                 ) of+                (Left refusals, _) -> do+                    mapM_ (TIO.hPutStrLn stderr) (renderRefusals refusals)+                    exitFailure+                (_, Left comparisonError) -> TIO.hPutStrLn stderr comparisonError >> exitFailure+                (Right plan, Right comparisonModule) -> do+                    comparisonReady <- preflightComparison out comparisonRequest comparisonModule+                    case comparisonReady of+                        Left comparisonError -> TIO.hPutStrLn stderr comparisonError >> exitFailure+                        Right () -> do+                            result <- executeWorkspaceScaffold out forceGeneratedOverwrite plan+                            case result of+                                Left refusals -> do+                                    mapM_ (TIO.hPutStrLn stderr) (renderRefusals refusals)+                                    exitFailure+                                Right report -> do+                                    mapM_ (TIO.hPutStrLn stderr) (renderWorkspaceScaffoldReport report)+                                    writeComparison comparisonRequest comparisonModule++{- | @diff@ on a workspace manifest: compose the working-tree service and the+service described by the manifest and member blobs at @--since@, then feed both+merged specs through the existing differ, replay-impact analysis, coverage+report, golden emission, and gates.++The historical side is read exclusively through 'ContentSource'.  In+particular, member paths are joined textually beneath the manifest's+repository-relative directory; they are never canonicalized because an old+member may no longer exist in the working tree.+-}+runWorkspaceDiff ::+    FilePath ->+    String ->+    Maybe FilePath ->+    Maybe FilePath ->+    [CompatibilitySurface] ->+    Bool ->+    Maybe FilePath ->+    Maybe DiffCoverageOptions ->+    IO ()+runWorkspaceDiff fp ref emitGoldensRoot replayImpactOut gatedSurfaces explain reportOut coverageOptions = do+    let dir = takeDirectory fp+    rootRes <- git dir ["rev-parse", "--show-toplevel"]+    case rootRes of+        Left err -> hPutStrLn stderr err >> exitFailure+        Right rootRaw -> do+            let repoRoot = trim rootRaw+            absFp <- canonicalizePath fp+            let relManifestPath = makeRelative repoRoot absFp+                relManifestDir = takeDirectory relManifestPath+                oldSource = gitContentSource repoRoot ref relManifestDir+            refRes <- git repoRoot ["cat-file", "-e", ref <> "^{commit}"]+            case refRes of+                Left err -> hPutStrLn stderr err >> exitFailure+                Right _ -> do+                    newLoaded <- loadWorkspace (fileContentSource dir) fp+                    case newLoaded of+                        Left failure -> printWorkspaceFailure fp failure+                        Right newWorkspace -> do+                            currentManifestText <- TIO.readFile fp+                            case parseWorkspaceManifest fp currentManifestText of+                                Left err -> hPutStrLn stderr (T.unpack err) >> exitFailure+                                Right currentManifest -> do+                                    oldManifestRes <- git repoRoot ["show", ref <> ":" <> relManifestPath]+                                    (adoptionBaseline, oldLoaded) <- case oldManifestRes of+                                        Right _ -> do+                                            loaded <- loadWorkspace oldSource fp+                                            pure (False, loaded)+                                        Left _ -> do+                                            loaded <- loadAdoptionBaseline oldSource fp currentManifest newWorkspace+                                            pure (True, loaded)+                                    case oldLoaded of+                                        Left failure -> do+                                            printWorkspaceFailureLines fp failure+                                            when adoptionBaseline $+                                                hPutStrLn stderr "workspace adoption baseline could not be composed; commit the workspace manifest before diffing across it, or fix the member files at the old revision"+                                            exitFailure+                                        Right oldWorkspace -> do+                                            when adoptionBaseline $+                                                putStrLn+                                                    ( "workspace adoption baseline: "+                                                        <> fp+                                                        <> " does not exist at "+                                                        <> ref+                                                        <> "; composing the old service from the current members' blobs at "+                                                        <> ref+                                                    )+                                            let oldSpec = wsMergedSpec oldWorkspace+                                                newSpec = wsMergedSpec newWorkspace+                                                goldenRoot = fmap (workspaceGoldenRoot fp) emitGoldensRoot+                                            written <- maybe (pure []) (\root -> emitGoldenPayloads root oldSpec newSpec) goldenRoot+                                            mapM_ (putStrLn . ("golden: wrote synthesized weak stand-in " <>)) written+                                            let workspaceChanges = diffWorkspaces oldWorkspace newWorkspace+                                                changes = map wcChange workspaceChanges+                                                impact = replayImpact oldSpec newSpec+                                                effectiveGate = gateWith gatedSurfaces+                                                reportMeta =+                                                    WorkspaceMeta+                                                        { wmIdentity = wsService newWorkspace+                                                        , wmManifest = fp+                                                        , wmSince = T.pack ref+                                                        , wmMembersOld = map wmPath (wsMembers oldWorkspace)+                                                        , wmMembersNew = map wmPath (wsMembers newWorkspace)+                                                        , wmAdoptionBaseline = adoptionBaseline+                                                        }+                                            mapM_ (TIO.putStrLn . renderWorkspaceFinding) workspaceChanges+                                            when explain $+                                                mapM_ (TIO.putStrLn . renderExplainBlock) (filter shouldExplain changes)+                                            TIO.putStrLn (renderReplayImpact impact)+                                            mapM_ (`Aeson.encodeFile` impact) replayImpactOut+                                            mapM_ (\path -> Aeson.encodeFile path (workspaceDiffReport reportMeta effectiveGate workspaceChanges)) reportOut+                                            coverageOk <- runDiffCoverage fp (T.pack ref) oldSpec newSpec coverageOptions+                                            if any (gatedBreaking effectiveGate) changes || not coverageOk then exitFailure else pure ()++-- | A @git show@ backed source rooted at a workspace manifest directory.+gitContentSource :: FilePath -> String -> FilePath -> ContentSource+gitContentSource repoRoot ref relManifestDir =+    ContentSource+        { csRead = \relative -> do+            let relPath = normalise (relManifestDir </> relative)+            result <- git repoRoot ["show", ref <> ":" <> relPath]+            pure $ case result of+                Left err -> Left (T.pack ("git show " <> ref <> ":" <> relPath <> " failed: " <> trim err))+                Right contents -> Right (T.pack contents)+        }++{- | Build the historical side for the commit that introduces a workspace+manifest.  Current members absent from the old revision contribute no nodes;+members that do exist are still parsed and composed by the ordinary workspace+loader, so malformed or mutually inconsistent old specs remain hard refusals.+-}+loadAdoptionBaseline ::+    ContentSource ->+    FilePath ->+    WorkspaceManifest ->+    WorkspaceSpec ->+    IO (Either WorkspaceFailure WorkspaceSpec)+loadAdoptionBaseline oldSource manifestPath currentManifest newWorkspace = do+    present <- traverse presentAtRevision (NE.toList (wmfMembers currentManifest))+    case NE.nonEmpty [member | (member, True) <- present] of+        Nothing -> pure (Right (emptyWorkspaceBaseline newWorkspace))+        Just members ->+            let oldManifest = currentManifest{wmfMembers = members}+                manifestName = takeFileName manifestPath+                baselineSource =+                    ContentSource+                        { csRead = \relative ->+                            if relative == manifestName+                                then pure (Right (renderWorkspaceManifest oldManifest))+                                else csRead oldSource relative+                        }+             in loadWorkspace baselineSource manifestPath+  where+    presentAtRevision member = do+        result <- csRead oldSource (wmrPath member)+        pure (member, either (const False) (const True) result)++-- | The sound old side when every current member is new at the adoption ref.+emptyWorkspaceBaseline :: WorkspaceSpec -> WorkspaceSpec+emptyWorkspaceBaseline workspace =+    workspace+        { wsMembers = []+        , wsMergedSpec =+            (wsMergedSpec workspace)+                { specIds = []+                , specEnums = []+                , specRules = []+                , specMapped = []+                , specNodes = []+                }+        , wsLineMap = LineMap []+        , wsOwnership = OwnershipIndex mempty mempty+        }++workspaceGoldenRoot :: FilePath -> FilePath -> FilePath+workspaceGoldenRoot manifestPath requested+    | isAbsolute requested = requested+    | otherwise = normalise (takeDirectory manifestPath </> requested)++printWorkspaceFailure :: FilePath -> WorkspaceFailure -> IO a+printWorkspaceFailure fp failure = printWorkspaceFailureLines fp failure >> exitFailure++printWorkspaceFailureLines :: FilePath -> WorkspaceFailure -> IO ()+printWorkspaceFailureLines fp = mapM_ (TIO.hPutStrLn stderr) . renderWorkspaceFailure fp++{- | Fold the workspace's module-root and layout authority with the CLI+overrides to a 'Context'. Precedence is CLI flag > workspace authority >+built-in default; EP-153 already resolved the manifest-versus-member question+into 'wsModuleRoot' and 'wsLayout', so this mirrors 'mkContext' exactly one+level up.+-}+workspaceContext :: Maybe String -> Bool -> WorkspaceSpec -> Context+workspaceContext cliRoot cliCollocate workspace =+    Context+        { contextName = wsContext workspace+        , moduleRoot = maybe (fromMaybe "" (wsModuleRoot workspace)) T.pack cliRoot+        , placement =+            if cliCollocate+                then CollocatedLeaf+                else fromMaybe GeneratedPrefix (wsLayout workspace)+        }  shouldExplain :: Change -> Bool shouldExplain Additive{} = False
keiro-dsl.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            keiro-dsl-version:         0.4.0.1+version:         0.5.0.0 synopsis:        Typed specification toolchain for keiro services description:   keiro-dsl is the toolchain over a typed `.keiro` specification of a keiro@@ -53,6 +53,11 @@     Keiro.Dsl.Skeleton     Keiro.Dsl.TypeGraph     Keiro.Dsl.Validate+    Keiro.Dsl.Workspace+    Keiro.Dsl.WorkspaceAdoption+    Keiro.Dsl.WorkspaceDiff+    Keiro.Dsl.WorkspaceRecord+    Keiro.Dsl.WorkspaceScaffold    build-depends:     , aeson               >=2.2.1 && <2.3
src/Keiro/Dsl/Diff.hs view
@@ -28,6 +28,7 @@     publicContractContext,     persistedIdentityContext,     consumerBuildContext,+    advisoryAt,     changeContextRoot,     changeContextPaths,     classifyCompatibility,@@ -247,6 +248,7 @@ -} classifyCompatibility :: ChangeContext -> DiagnosticCode -> CompatibilityVector classifyCompatibility context code+    | code `elem` [OwnershipMoved, WorkspaceAuthorityChanged] = mappedBuildVector     | code == MappedFieldAddedWithDefault = mappedFieldAdditionVector context     | code `elem` [MappedArmAdded, MappedEnumValueAdded] = mappedDirectionalAdditionVector context     | code `elem` mappedWireBreakingCodes = mappedWireBreakingVector context@@ -1926,6 +1928,7 @@             | code `elem` publicCodes -> publicContractContext root paths             | code `elem` queueCodes -> queueContext root paths             | code `elem` identityCodes -> persistedIdentityContext root paths+            | code `elem` [OwnershipMoved, WorkspaceAuthorityChanged] -> consumerBuildContext root paths             | code == AggFoldSurfaceChanged -> snapshotContext root paths             | code == EnumCtorAdded -> ChangeContext root paths ContextGeneral label             | code `elem` privateCodes -> privateEventContext root paths
src/Keiro/Dsl/DiffReport.hs view
@@ -3,12 +3,19 @@ The JSON schema identifier is @keiro-dsl/diff-report/1@.  Consumers must ignore unknown object keys.  Vector keys and entries in the @paths@ array are append-only so later nested type-expression work can refine findings without-invalidating version-1 readers.+invalidating version-1 readers. Workspace inputs add a top-level @workspace@+object and optional per-finding @declaration@ and @useSites@ keys; single-file+reports keep their original bytes. -} module Keiro.Dsl.DiffReport (     Remedy (..),     DiffReport,     diffReport,+    OwnedSite (..),+    WorkspaceChange (..),+    WorkspaceMeta (..),+    WorkspaceDiffReport,+    workspaceDiffReport,     remediationFor,     renderRemedy,     renderFinding,@@ -21,6 +28,7 @@ ) where  import Data.Aeson (ToJSON (..), Value, object, (.=))+import Data.Aeson.Types (Pair) import Data.List.NonEmpty (NonEmpty (..)) import Data.List.NonEmpty qualified as NonEmpty import Data.Set (Set)@@ -38,6 +46,7 @@     | RemedyReplayOnlyEdge     | RemedyStateCodecBump     | RemedyRecompileConsumers+    | RemedyRescaffoldWorkspace     | RemedyRunConformance     | RemedyDoNotDeploy Text     deriving stock (Eq, Show)@@ -51,6 +60,42 @@ diffReport :: Set CompatibilitySurface -> [Change] -> DiffReport diffReport = DiffReport +-- | One source location from a composed workspace's ownership index.+data OwnedSite = OwnedSite+    { osFile :: !FilePath+    , osLine :: !Int+    }+    deriving stock (Eq, Show)++-- | A merged-graph finding enriched with declaration and use-site ownership.+data WorkspaceChange = WorkspaceChange+    { wcChange :: !Change+    , wcDeclarationSite :: !(Maybe OwnedSite)+    , wcUseSites :: ![(Text, Maybe OwnedSite)]+    }+    deriving stock (Eq, Show)++-- | Provenance for the two workspace graphs compared by one command.+data WorkspaceMeta = WorkspaceMeta+    { wmIdentity :: !Text+    , wmManifest :: !FilePath+    , wmSince :: !Text+    , wmMembersOld :: ![FilePath]+    , wmMembersNew :: ![FilePath]+    , wmAdoptionBaseline :: !Bool+    }+    deriving stock (Eq, Show)++data WorkspaceDiffReport = WorkspaceDiffReport+    { workspaceReportMeta :: !WorkspaceMeta+    , workspaceReportGate :: !(Set CompatibilitySurface)+    , workspaceReportFindings :: ![WorkspaceChange]+    }+    deriving stock (Eq, Show)++workspaceDiffReport :: WorkspaceMeta -> Set CompatibilitySurface -> [WorkspaceChange] -> WorkspaceDiffReport+workspaceDiffReport = WorkspaceDiffReport+ instance ToJSON DiffReport where     toJSON report =         object@@ -60,22 +105,63 @@             , "findings" .= map (findingValue (reportGate report)) (reportFindings report)             ] +instance ToJSON WorkspaceDiffReport where+    toJSON report =+        object+            [ "schema" .= ("keiro-dsl/diff-report/1" :: Text)+            , "gate" .= map surfaceName (Set.toAscList (workspaceReportGate report))+            , "breaking" .= any (gatedBreaking (workspaceReportGate report) . wcChange) (workspaceReportFindings report)+            , "findings" .= map (workspaceFindingValue (workspaceReportGate report)) (workspaceReportFindings report)+            , "workspace" .= workspaceMetaValue (workspaceReportMeta report)+            ]+ findingValue :: Set CompatibilitySurface -> Change -> Value-findingValue gate change =+findingValue gate change = object (findingPairs gate change)++workspaceFindingValue :: Set CompatibilitySurface -> WorkspaceChange -> Value+workspaceFindingValue gate workspaceChange =     object-        [ "label" .= labelName (deriveLabel gate (ckVector kind))-        , "node" .= ckNode kind-        , "facet" .= ckFacet kind-        , "subject" .= ckSubject kind-        , "code" .= T.pack (show (ckCode kind))-        , "paths" .= ckPaths kind-        , "vector" .= vectorValue (ckVector kind)-        , "detail" .= ckDetail kind-        , "remedies" .= map renderRemedy (NonEmpty.toList (remediationFor (ckContext kind) (ckCode kind)))-        ]+        ( findingPairs gate (wcChange workspaceChange)+            <> maybe [] (\site -> ["declaration" .= ownedSiteValue site]) (wcDeclarationSite workspaceChange)+            <> ["useSites" .= map useSiteValue (wcUseSites workspaceChange) | not (null (wcUseSites workspaceChange))]+        )++findingPairs :: Set CompatibilitySurface -> Change -> [Pair]+findingPairs gate change =+    [ "label" .= labelName (deriveLabel gate (ckVector kind))+    , "node" .= ckNode kind+    , "facet" .= ckFacet kind+    , "subject" .= ckSubject kind+    , "code" .= T.pack (show (ckCode kind))+    , "paths" .= ckPaths kind+    , "vector" .= vectorValue (ckVector kind)+    , "detail" .= ckDetail kind+    , "remedies" .= map renderRemedy (NonEmpty.toList (remediationFor (ckContext kind) (ckCode kind)))+    ]   where     kind = changeKind change +ownedSiteValue :: OwnedSite -> Value+ownedSiteValue site = object ["file" .= osFile site, "line" .= osLine site]++useSiteValue :: (Text, Maybe OwnedSite) -> Value+useSiteValue (path, site) =+    object+        ( ["path" .= path]+            <> maybe [] (\owned -> ["file" .= osFile owned, "line" .= osLine owned]) site+        )++workspaceMetaValue :: WorkspaceMeta -> Value+workspaceMetaValue meta =+    object+        [ "identity" .= wmIdentity meta+        , "manifest" .= wmManifest meta+        , "since" .= wmSince meta+        , "membersOld" .= wmMembersOld meta+        , "membersNew" .= wmMembersNew meta+        , "adoptionBaseline" .= wmAdoptionBaseline meta+        ]+ vectorValue :: CompatibilityVector -> Value vectorValue vector =     object@@ -90,6 +176,8 @@  remediationFor :: ChangeContext -> DiagnosticCode -> NonEmpty Remedy remediationFor context code+    | code == OwnershipMoved = RemedyRescaffoldWorkspace :| []+    | code == WorkspaceAuthorityChanged = RemedyRescaffoldWorkspace :| [RemedyRecompileConsumers]     | code == AggGuardTightened = RemedyReplayOnlyEdge :| [RemedyRunConformance]     | code == AggFoldSurfaceChanged = RemedyStateCodecBump :| [RemedyRunConformance]     | code `elem` mappedWireCodes = mappedWireRemedy@@ -208,6 +296,7 @@     RemedyReplayOnlyEdge -> "add the computed replay-only edge described by docs/adr/0002-replay-only-edges-are-the-sanctioned-remedy-for-guard-tightening.md"     RemedyStateCodecBump -> "invalidate and rebuild snapshots by bumping state-codec version when automatic fingerprinting cannot see the change"     RemedyRecompileConsumers -> "recompile every affected consumer against the generated interface"+    RemedyRescaffoldWorkspace -> "re-run the whole-workspace scaffold so the record's ownership and golden roots follow the change"     RemedyRunConformance -> "run the generated conformance and historical fixture suites"     RemedyDoNotDeploy detail -> detail 
src/Keiro/Dsl/Scaffold.hs view
@@ -27,9 +27,11 @@     holePrefixFor,     scaffoldReplayAudit,     scaffoldStructural,+    scaffoldStructuralOwners,     codecComparisonModule,     codecComparisonBanner,     bindingSkeletonModules,+    bindingSkeletonOwners,     scaffoldAggregate,     scaffoldProcess,     scaffoldRouter,@@ -396,21 +398,40 @@ schema-derived Keiki field witnesses; neither layer owns consumer behavior. -} scaffoldStructural :: Context -> Spec -> [ScaffoldModule]-scaffoldStructural ctx spec = case resolveTypeGraph spec of+scaffoldStructural ctx spec = map fst (scaffoldStructuralOwners ctx spec)++{- | 'scaffoldStructural' paired with the mapped declarations each module was+emitted for. A shape module names exactly one declaration; a binding skeleton+names every declaration whose obligations it carries (several declarations may+share one leaf binding module); the projection facade names __none__, because it+is emitted once for the whole context from the complete resolved graph.++This is the attribution seam whole-workspace scaffolding needs: a workspace+emits from one merged spec, and this list says which declaration — and therefore+which member file — produced each structural module, without parsing the+human-readable 'origin' string.+-}+scaffoldStructuralOwners :: Context -> Spec -> [(ScaffoldModule, [Name])]+scaffoldStructuralOwners ctx spec = case resolveTypeGraph spec of     Left _ -> []-    Right graph -> map (shapeModule ctx graph) structural <> projectionModules <> bindingSkeletonModules ctx spec graph+    Right graph ->+        [(shapeModule ctx graph entry, [sdName (fst entry)]) | entry <- structural]+            <> projectionModules+            <> bindingSkeletonOwners ctx spec graph       where         structural =             [ (declaration, shape)             | ResolvedStructural declaration shape <- Map.elems (tgDeclarations graph)             ]         projectionModules =-            [ ScaffoldModule-                { modulePath = T.unpack (T.replace "." "/" (structuralProjectionModule ctx) <> ".hs")-                , moduleText = emitStructuralProjections ctx graph-                , kind = Generated-                , origin = "context " <> specContext spec <> " mapped structural facade"-                }+            [ ( ScaffoldModule+                    { modulePath = T.unpack (T.replace "." "/" (structuralProjectionModule ctx) <> ".hs")+                    , moduleText = emitStructuralProjections ctx graph+                    , kind = Generated+                    , origin = "context " <> specContext spec <> " mapped structural facade"+                    }+              , []+              )             | not (null (projectionSpecs graph))             ] @@ -637,10 +658,19 @@ module, so grouping happens by module rather than by declaration. -} bindingSkeletonModules :: Context -> Spec -> TypeGraph -> [ScaffoldModule]-bindingSkeletonModules ctx spec graph = case bindingObligations spec of+bindingSkeletonModules ctx spec graph = map fst (bindingSkeletonOwners ctx spec graph)++{- | 'bindingSkeletonModules' paired with the mapped declarations whose+obligations each skeleton carries, in first-appearance order. A skeleton shared+by declarations from different member files therefore names all of them, which+is what lets whole-workspace scaffolding treat it as context-level rather than+attributing it to an arbitrary member.+-}+bindingSkeletonOwners :: Context -> Spec -> TypeGraph -> [(ScaffoldModule, [Name])]+bindingSkeletonOwners ctx spec graph = case bindingObligations spec of     Left _ -> []     Right obligations ->-        [ emitBindingSkeleton ctx graph owner entries+        [ (emitBindingSkeleton ctx graph owner entries, nub (map obligationMappedName entries))         | (owner, entries) <- Map.toAscList (Map.fromListWith (<>) [(obligationModule obligation, [obligation]) | obligation <- obligations])         ] 
src/Keiro/Dsl/ScaffoldRun.hs view
@@ -14,6 +14,19 @@     executeScaffold,     renderRefusals,     renderScaffoldReport,++    -- * Shared with whole-workspace scaffolding ("Keiro.Dsl.WorkspaceScaffold")++    --+    -- $shared+    pureRefusals,+    missingGeneratedBanners,+    staleAgainst,+    constraintPlan,+    mappingDrift,+    newBindingObligations,+    obligationKindLabel,+    renderMappingIdentity, ) where  import Data.List (sortOn)@@ -34,15 +47,33 @@ import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath (takeDirectory, (</>)) +{- $shared+These are the pieces whole-workspace scaffolding reuses verbatim rather than+reimplementing, so a workspace and a single spec can never disagree about what+counts as a refusal, what counts as stale, or how an identity renders.+"Keiro.Dsl.WorkspaceScaffold" cannot live in this module because+"Keiro.Dsl.Workspace" already imports it (its cross-member collision check asks+the planner), so the seam is exports rather than shared privates.+-}+ data Refusal     = PathCollision !FilePath ![Text]     | FirewallBreach ![(FilePath, Text, Int)]     | LoweringRefusal ![Text]     | MissingGeneratedBanner ![FilePath]     | ImportCycle ![Text]+    | {- | Golden payload fixtures found beside a workspace member that the one+      workspace golden root does not have. Raised only by the workspace path.+      -}+      GoldenRootDivergence !FilePath ![FilePath]     deriving stock (Eq, Show) -data WriteDisposition = Overwritten | Created | Skipped+{- | What one module write did. 'Unchanged' is produced only by the workspace+write path, which compares bytes before overwriting a Generated module so that+an idempotent re-run is observable in the report; the single-spec 'writeModule'+never produces it.+-}+data WriteDisposition = Overwritten | Created | Skipped | Unchanged     deriving stock (Eq, Show)  data StaleModule = StaleModule@@ -110,14 +141,25 @@ planScaffoldWithGoldens :: [GoldenPayload] -> Context -> Spec -> Either [Refusal] [ScaffoldModule] planScaffoldWithGoldens goldens ctx spec =     let modules = scaffoldModulesWithGoldens goldens ctx spec-        breaches = firewallBreaches modules-        refusals =-            collisionRefusals modules-                <> dependencyRefusals ctx spec modules-                <> [FirewallBreach breaches | not (null breaches)]-                <> [LoweringRefusal lowering | let lowering = scaffoldRefusals spec, not (null lowering)]-     in if null refusals then Right modules else Left refusals+     in case pureRefusals ctx spec modules of+            [] -> Right modules+            refusals -> Left refusals +{- | Every pure refusal gate, over an already-built module set: case-folded path+collisions, generated\/consumer collisions and import cycles, firewall breaches,+and lowering refusals. Whole-workspace planning builds its module set from the+merged spec and then runs exactly this, so no gate can apply to one input shape+and not the other.+-}+pureRefusals :: Context -> Spec -> [ScaffoldModule] -> [Refusal]+pureRefusals ctx spec modules =+    collisionRefusals modules+        <> dependencyRefusals ctx spec modules+        <> [FirewallBreach breaches | not (null breaches)]+        <> [LoweringRefusal lowering | let lowering = scaffoldRefusals spec, not (null lowering)]+  where+    breaches = firewallBreaches modules+ dependencyRefusals :: Context -> Spec -> [ScaffoldModule] -> [Refusal] dependencyRefusals ctx spec modules = collisionWithConsumers <> namespaceCycles   where@@ -264,10 +306,17 @@     if exists then parseRecord <$> TIO.readFile path else pure Nothing  existingStale :: FilePath -> [ScaffoldModule] -> ScaffoldRecord -> IO [StaleModule]-existingStale out modules record = fmap concat $ mapM stillExists removed+existingStale out modules record = staleAgainst out (map modulePath modules) (recFiles record)++{- | The files a previous run recorded that the current plan no longer produces+and that are still on disk. keiro-dsl never deletes; this is what the report+lists for a human to review.+-}+staleAgainst :: FilePath -> [FilePath] -> [(ModuleKind, FilePath)] -> IO [StaleModule]+staleAgainst out currentPathList previous = fmap concat $ mapM stillExists removed   where-    currentPaths = Set.fromList (map modulePath modules)-    removed = [(fileKind, path) | (fileKind, path) <- recFiles record, path `Set.notMember` currentPaths]+    currentPaths = Set.fromList currentPathList+    removed = [(fileKind, path) | (fileKind, path) <- previous, path `Set.notMember` currentPaths]     stillExists (fileKind, path) = do         exists <- doesFileExist (out </> path)         pure [StaleModule fileKind path | exists]@@ -336,6 +385,14 @@         , "  " <> T.intercalate " -> " path         , "  keep bindings in a leaf module that imports only Structural.Shape.* and Keiro.Codec.Structural"         ]+    render (GoldenRootDivergence root paths) =+        [ "error: golden payload fixtures live beside a workspace member instead of under the workspace golden root -- refusing to scaffold"+        ]+            <> ["  " <> T.pack path | path <- paths]+            <> [ "  move these files under " <> T.pack root <> "; keiro-dsl reads one golden root per workspace"+               , "  (a fixture the root lacks would be silently replaced by a synthesized stand-in)"+               , "nothing was written"+               ]  renderScaffoldReport :: ScaffoldReport -> [Text] renderScaffoldReport report =@@ -367,6 +424,7 @@     dispositionTag Overwritten = "(overwritten)"     dispositionTag Created = "(created)"     dispositionTag Skipped = "(skipped: already present)"+    dispositionTag Unchanged = "(unchanged)"     pad name = name <> T.replicate (nameWidth - T.length name) " "     generatedCount = length [() | (m, _) <- dispositions, kind m == Generated]     harnesses =
src/Keiro/Dsl/Validate.hs view
@@ -16,6 +16,7 @@     validateSpec,     derivedQueueTrio,     sagaCategoryError,+    nodeIdentity, ) where  import Data.Bits (xor)@@ -261,6 +262,23 @@     | CodecCompareDifference     | CodecCompareCoverageGap     | CodecCompareInvalidInput+    | -- MasterPlan 26 / EP-153: whole-service composition refusals, emitted by+      -- "Keiro.Dsl.Workspace" when several @.keiro@ members are composed into+      -- one service graph. They live in this registry, not a parallel enum, so+      -- every gate stays correlatable by code (ADR 0004). Manifest syntax and+      -- structure errors deliberately have no code here: like a @.keiro@ parse+      -- error, they are refused before any graph exists to diagnose.+      WorkspaceMemberUnreadable+    | WorkspaceMemberParseFailed+    | WorkspaceContextMismatch+    | WorkspaceAuthorityConflict+    | WorkspaceDuplicateDeclaration+    | WorkspaceDuplicateNodeName+    | WorkspacePathCollision+    | -- MasterPlan 26 / EP-155: whole-workspace diff facts. These are+      -- advisory consumer-build obligations, distinct from wire evolution.+      OwnershipMoved+    | WorkspaceAuthorityChanged     deriving stock (Eq, Show)  -- | A line-numbered, structured diagnostic.
+ src/Keiro/Dsl/Workspace.hs view
@@ -0,0 +1,1300 @@+{-# LANGUAGE DefaultSignatures #-}+{-# LANGUAGE UndecidableInstances #-}++{- | Service workspaces: several complete @.keiro@ member files validated,+scaffolded, and diffed as __one service contract__.++A /workspace manifest/ is a @.keiro-workspace@ file that names the service and+lists its member @.keiro@ files. It is deliberately a file rather than repeated+CLI flags: @keiro-dsl diff --since \<rev\>@ must be able to reconstruct the+member set as it existed at an older git revision from git alone, and the+scaffold record needs one durable identity that outlives member renames.++The manifest is line-oriented in the spirit of the member grammar: @#@ starts a+comment, blank lines are insignificant, and clause keywords drive structure.++@+# The demo-project service workspace.+service demo-project+module Demo.Modules.Project+layout collocated+spec domain/project-artifact.keiro+spec domain/project.keiro+spec domain/shared.keiro+@++Membership is a __set__: 'parseWorkspaceManifest' accepts @spec@ lines in any+order and canonically sorts them (codepoint order on the normalized relative+path), and 'renderWorkspaceManifest' always emits that canonical order. Source+order therefore never changes meaning or generated bytes.++This module owns the workspace file format only; the member @.keiro@ grammar in+"Keiro.Dsl.Parser" is untouched. Note the unrelated "Keiro.Dsl.Manifest", which+is the /scaffold build manifest/ (the record of emitted modules) — every+identifier here carries a @Workspace@ prefix to keep the two apart.+-}+module Keiro.Dsl.Workspace (+    -- * The workspace manifest+    WorkspaceManifest (..),+    WorkspaceMemberRef (..),+    parseWorkspaceManifest,+    renderWorkspaceManifest,++    -- * Input dispatch+    workspaceExtension,+    isWorkspacePath,++    -- * Member paths+    normalizeMemberPath,++    -- * Loading members+    ContentSource (..),+    fileContentSource,+    loadWorkspace,++    -- * The composed service graph+    WorkspaceSpec (..),+    WorkspaceMember (..),+    OwnershipIndex (..),+    declarationOwner,+    nodeOwner,+    LineMap (..),+    resolveWorkspaceLine,+    composeWorkspace,+    oneMemberWorkspace,+    checkWorkspace,++    -- * Multi-file diagnostics+    WorkspaceDiagnostic (..),+    WorkspaceLocation (..),+    WorkspaceFile (..),+    WorkspaceFailure (..),+    renderWorkspaceDiagnostic,+    renderWorkspaceFailure,+    workspaceDisplayPath,++    -- * Line relocation+    relocateLocs,+    collectLocs,+) where++import Control.Exception qualified as Exception+import Data.Bifunctor (first)+import Data.Char (isAscii, isDigit, isLetter, toLower)+import Data.Functor.Const (Const (..))+import Data.Functor.Identity (Identity (..))+import Data.List (nub, sort, sortOn)+import Data.List.NonEmpty (NonEmpty (..))+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe, listToMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Data.Void (Void)+import GHC.Generics+import Keiro.Dsl.Grammar+import Keiro.Dsl.Parser (ParseError, parseSpec)+import Keiro.Dsl.Scaffold (Context (..))+import Keiro.Dsl.ScaffoldRun (Refusal (..), planScaffoldWithGoldens)+import Keiro.Dsl.Validate (Diagnostic (..), DiagnosticCode (..), Severity (..), nodeIdentity, validateSpec)+import System.Directory (doesFileExist)+import System.FilePath (takeBaseName, takeDirectory, takeFileName, (</>))+import Text.Megaparsec hiding (ParseError)+import Text.Megaparsec.Char (char, space1)+import Text.Megaparsec.Char.Lexer qualified as L+import Text.Read (readMaybe)++{- | A parsed workspace manifest. Members are held in canonical order+(codepoint-sorted normalized paths), so two manifests that list the same+members in different source orders are 'Eq'-equal and render to identical+bytes.++Unlike 'Keiro.Dsl.Grammar.Spec', which records no location for its+@context@\/@module@\/@layout@ clauses, this type keeps a 'Loc' per clause: the+workspace composer needs to cite a manifest clause line when a member+contradicts it.+-}+data WorkspaceManifest = WorkspaceManifest+    { wmfService :: !Text+    -- ^ The stable workspace identity, e.g. @demo-project@.+    , wmfServiceLoc :: !Loc+    , wmfModuleRoot :: !(Maybe Text)+    -- ^ The optional @module@ clause: the workspace's module-root authority.+    , wmfModuleRootLoc :: !Loc+    -- ^ Meaningful only when 'wmfModuleRoot' is 'Just'.+    , wmfLayout :: !(Maybe Placement)+    -- ^ The optional @layout@ clause: the workspace's placement authority.+    , wmfLayoutLoc :: !Loc+    -- ^ Meaningful only when 'wmfLayout' is 'Just'.+    , wmfMembers :: !(NonEmpty WorkspaceMemberRef)+    -- ^ At least one member, in canonical order.+    }+    deriving stock (Eq, Show)++-- | One @spec \<path\>@ line: the normalized manifest-relative member path.+data WorkspaceMemberRef = WorkspaceMemberRef+    { wmrPath :: !FilePath+    -- ^ Normalized: forward slashes, no @./@ segments, relative, ends in @.keiro@.+    , wmrLoc :: !Loc+    }+    deriving stock (Eq, Show)++-- | The file extension that marks a workspace manifest.+workspaceExtension :: String+workspaceExtension = ".keiro-workspace"++{- | Does this @FILE@ argument name a workspace manifest? The test is on the+extension, case-insensitively, and never on the file's content: a file's role+must not depend on which parser happens to succeed, and a corrupted manifest+must produce a manifest parse error rather than a confusing @.keiro@ one.+-}+isWorkspacePath :: FilePath -> Bool+isWorkspacePath path = map toLower workspaceExtension `isSuffixOfString` map toLower path+  where+    isSuffixOfString needle haystack = length haystack > length needle && drop (length haystack - length needle) haystack == needle++--------------------------------------------------------------------------------+-- Member path normalization+--------------------------------------------------------------------------------++{- | Normalize and validate a @spec@ path token. Member paths must be relative,+use forward slashes, end in @.keiro@, and stay inside the manifest's directory+tree; @.\/@ segments are normalized away. A manifest may not list another+manifest (a @.keiro-workspace@ path fails the @.keiro@ suffix rule).++The restrictions exist so a workspace can be reconstructed at an arbitrary git+revision with @git show \<rev\>:\<repo-relative-path\>@: a path that escapes the+repository cannot be reconstructed at all. Every rule can be relaxed additively+later; none can be tightened without breaking users.++Returns 'Left' with a human-readable reason, or 'Right' the normalized path.+-}+normalizeMemberPath :: Text -> Either Text FilePath+normalizeMemberPath raw+    | T.null raw = Left "member path is empty"+    | T.isPrefixOf "/" raw = Left ("member path must be relative, not absolute: '" <> raw <> "'")+    | ".." `elem` segments = Left ("member path must not contain '..' segments: '" <> raw <> "'")+    | null kept = Left ("member path is empty after normalization: '" <> raw <> "'")+    | not (T.isSuffixOf ".keiro" normalized) =+        Left ("member path must name a .keiro spec: '" <> raw <> "'")+    | normalized == ".keiro" = Left ("member path must name a .keiro spec: '" <> raw <> "'")+    | otherwise = Right (T.unpack normalized)+  where+    segments = T.splitOn "/" raw+    kept = [s | s <- segments, not (T.null s), s /= "."]+    normalized = T.intercalate "/" kept++--------------------------------------------------------------------------------+-- Parser+--------------------------------------------------------------------------------++type WP = Parsec Void Text++{- | Parse a workspace manifest. The 'FilePath' is used only as the source name+in diagnostics (megaparsec's line reporting); it need not exist on disk.++Duplicate clauses, a missing or misplaced @service@ clause, an empty member+list, an invalid member path, and duplicate members (including two paths equal+under Unicode case folding — macOS's default filesystem is case-insensitive, so+two such paths can silently be one file) are all rejected here, at the manifest+boundary, with the manifest path and line.+-}+parseWorkspaceManifest :: FilePath -> Text -> Either ParseError WorkspaceManifest+parseWorkspaceManifest src input =+    case runParser (sc *> pManifest <* eof) src input of+        Left bundle -> Left (T.pack (errorBundlePretty bundle))+        Right manifest -> Right manifest++-- | One source clause, tagged with the offset used to position its diagnostics.+data Clause+    = ClService !Int !Loc !Text+    | ClModule !Int !Loc !Text+    | ClLayout !Int !Loc !Placement+    | ClSpec !Int !Loc !Text++clauseOffset :: Clause -> Int+clauseOffset (ClService o _ _) = o+clauseOffset (ClModule o _ _) = o+clauseOffset (ClLayout o _ _) = o+clauseOffset (ClSpec o _ _) = o++-- | Space consumer: spaces, newlines, and @#@ line comments are all whitespace.+sc :: WP ()+sc = L.space space1 (L.skipLineComment "#") empty++lexeme :: WP a -> WP a+lexeme = L.lexeme sc++-- | A literal keyword not followed by an identifier character.+keyword :: Text -> WP ()+keyword word = lexeme (try (chunk word *> notFollowedBy (satisfy pathChar)))++getLoc :: WP Loc+getLoc = (Loc . unPos . sourceLine) <$> getSourcePos++pManifest :: WP WorkspaceManifest+pManifest = do+    startOffset <- getOffset+    clauses <- many pClause+    buildManifest startOffset clauses++pClause :: WP Clause+pClause =+    choice+        [ mk ClService "service" pServiceName+        , mk ClModule "module" pModulePrefix+        , mk ClLayout "layout" pPlacement+        , mk ClSpec "spec" pPathToken+        ]+  where+    mk construct word value = try $ do+        offset <- getOffset+        loc <- getLoc+        keyword word+        construct offset loc <$> value++{- | The workspace identity uses the member grammar's wire-word spelling: an+ASCII letter or digit, then letters, digits, @_@, and @-@ (e.g. @mori-project@).+-}+pServiceName :: WP Text+pServiceName = lexeme $ do+    c <- satisfy asciiAlphaNum <?> "workspace service name"+    cs <- many (satisfy (\x -> asciiAlphaNum x || x == '_' || x == '-'))+    pure (T.pack (c : cs))++{- | A dotted module prefix: one-or-more PascalCase segments joined by dots,+matching the member grammar's @module@ clause (e.g. @Demo.Modules.Project@).+-}+pModulePrefix :: WP Text+pModulePrefix = lexeme $ do+    seg0 <- pSeg+    segs <- many (char '.' *> pSeg)+    pure (T.intercalate "." (seg0 : segs))+  where+    pSeg = do+        c <- satisfy (\x -> x >= 'A' && x <= 'Z') <?> "PascalCase module segment"+        cs <- many (satisfy (\x -> asciiAlphaNum x || x == '_'))+        pure (T.pack (c : cs))++pPlacement :: WP Placement+pPlacement =+    choice+        [ GeneratedPrefix <$ keyword "prefixed"+        , CollocatedLeaf <$ keyword "collocated"+        ]+        <?> "'prefixed' or 'collocated'"++-- | A relative path token: no spaces, no drive letters, no quoting.+pPathToken :: WP Text+pPathToken = lexeme (T.pack <$> some (satisfy pathChar)) <?> "relative .keiro member path"++pathChar :: Char -> Bool+pathChar c = asciiAlphaNum c || c == '.' || c == '_' || c == '-' || c == '/'++asciiAlphaNum :: Char -> Bool+asciiAlphaNum c = isAscii c && (isLetter c || isDigit c)++{- | Fold the parsed clauses into a manifest, rejecting every structural error+at the offending clause's own source position.+-}+buildManifest :: Int -> [Clause] -> WP WorkspaceManifest+buildManifest startOffset clauses = do+    case clauses of+        [] -> failAt startOffset "workspace manifest must begin with a 'service <name>' clause"+        leading : _ -> case leading of+            ClService{} -> pure ()+            other -> failAt (clauseOffset other) "the first clause of a workspace manifest must be 'service <name>'"+    (service, serviceLoc) <- case [(name, loc) | ClService _ loc name <- clauses] of+        [one] -> pure one+        _ -> failAt (secondOffset [c | c@ClService{} <- clauses]) "duplicate 'service' clause: a workspace has exactly one identity"+    (moduleRoot, moduleLoc) <- case [(root, loc) | ClModule _ loc root <- clauses] of+        [] -> pure (Nothing, Loc 0)+        [(root, loc)] -> pure (Just root, loc)+        _ -> failAt (secondOffset [c | c@ClModule{} <- clauses]) "duplicate 'module' clause"+    (layout, layoutLoc) <- case [(placement, loc) | ClLayout _ loc placement <- clauses] of+        [] -> pure (Nothing, Loc 0)+        [(placement, loc)] -> pure (Just placement, loc)+        _ -> failAt (secondOffset [c | c@ClLayout{} <- clauses]) "duplicate 'layout' clause"+    let specClauses = [(offset, loc, raw) | ClSpec offset loc raw <- clauses]+    normalized <- traverse normalizeOne specClauses+    case normalized of+        [] -> failAt startOffset "workspace manifest must list at least one 'spec <path>.keiro' member"+        _ -> pure ()+    rejectDuplicates normalized+    let sorted = sortOn (T.pack . snd3) normalized+    pure+        WorkspaceManifest+            { wmfService = service+            , wmfServiceLoc = serviceLoc+            , wmfModuleRoot = moduleRoot+            , wmfModuleRootLoc = moduleLoc+            , wmfLayout = layout+            , wmfLayoutLoc = layoutLoc+            , wmfMembers = NE.fromList [WorkspaceMemberRef path loc | (_, path, loc) <- sorted]+            }+  where+    snd3 (_, path, _) = path+    normalizeOne (offset, loc, raw) = case normalizeMemberPath raw of+        Left reason -> failAt offset (T.unpack reason)+        Right path -> pure (offset, path, loc)+    secondOffset cs = case cs of+        _ : second : _ -> clauseOffset second+        _ -> startOffset++{- | Refuse a member listed twice, and a member listed under two spellings that+case-fold to the same path. Detecting a source file assigned to /two different/+workspaces is deliberately out of scope here: one invocation sees one manifest,+and repository-wide manifest discovery is exactly the dynamic discovery this+design excludes.+-}+rejectDuplicates :: [(Int, FilePath, Loc)] -> WP ()+rejectDuplicates entries = go [] entries+  where+    go _ [] = pure ()+    go seen ((offset, path, _) : rest)+        | path `elem` map fst seen =+            failAt offset ("duplicate workspace member '" <> path <> "': membership is a set")+        | Just earlier <- lookup (T.toCaseFold (T.pack path)) (map swap seen) =+            failAt+                offset+                ( "workspace members '"+                    <> earlier+                    <> "' and '"+                    <> path+                    <> "' differ only by case; on a case-insensitive filesystem they are one file"+                )+        | otherwise = go ((path, T.toCaseFold (T.pack path)) : seen) rest+    swap (path, folded) = (folded, path)++-- | Fail with a plain message positioned at a specific source offset.+failAt :: Int -> String -> WP a+failAt offset message = setOffset offset >> fail message++--------------------------------------------------------------------------------+-- Renderer+--------------------------------------------------------------------------------++{- | Render a manifest in canonical form: @service@, then the optional @module@+and @layout@ clauses, then the members in codepoint order, one per line, with+no trailing newline (comments are not preserved, exactly like the member+pretty-printer). @parse . render@ is the identity on the AST and+@render . parse . render@ is the identity on bytes.+-}+renderWorkspaceManifest :: WorkspaceManifest -> Text+renderWorkspaceManifest manifest =+    T.intercalate "\n" $+        ["service " <> wmfService manifest]+            ++ maybe [] (\root -> ["module " <> root]) (wmfModuleRoot manifest)+            ++ maybe [] (\placement -> ["layout " <> renderPlacement placement]) (wmfLayout manifest)+            ++ [ "spec " <> T.pack (wmrPath member)+               | member <- sortOn (T.pack . wmrPath) (NE.toList (wmfMembers manifest))+               ]++renderPlacement :: Placement -> Text+renderPlacement GeneratedPrefix = "prefixed"+renderPlacement CollocatedLeaf = "collocated"++--------------------------------------------------------------------------------+-- Generic line relocation+--------------------------------------------------------------------------------++{- | Everything in the AST that carries source lines. The generic default walks+a value's 'Generic' representation and applies the function at every 'Loc'+field, however deeply nested.++The instance list below covers every type in "Keiro.Dsl.Grammar". Completeness+is compiler-enforced rather than reviewed by eye: the generic default demands a+'HasLocs' instance for each field type, so a new AST type is a build error here+until it is listed, and no 'Loc' can be silently missed.+-}+class HasLocs a where+    traverseLocs :: (Applicative f) => (Loc -> f Loc) -> a -> f a+    default traverseLocs :: (Generic a, GHasLocs (Rep a), Applicative f) => (Loc -> f Loc) -> a -> f a+    traverseLocs f = fmap to . gtraverseLocs f . from++class GHasLocs rep where+    gtraverseLocs :: (Applicative f) => (Loc -> f Loc) -> rep p -> f (rep p)++instance GHasLocs V1 where+    gtraverseLocs _ = pure++instance GHasLocs U1 where+    gtraverseLocs _ = pure++instance (GHasLocs a, GHasLocs b) => GHasLocs (a :*: b) where+    gtraverseLocs f (a :*: b) = (:*:) <$> gtraverseLocs f a <*> gtraverseLocs f b++instance (GHasLocs a, GHasLocs b) => GHasLocs (a :+: b) where+    gtraverseLocs f (L1 a) = L1 <$> gtraverseLocs f a+    gtraverseLocs f (R1 b) = R1 <$> gtraverseLocs f b++instance (GHasLocs a) => GHasLocs (M1 i c a) where+    gtraverseLocs f (M1 a) = M1 <$> gtraverseLocs f a++instance (HasLocs c) => GHasLocs (K1 i c) where+    gtraverseLocs f (K1 c) = K1 <$> traverseLocs f c++-- The one interesting instance: this is where the function actually fires.+instance HasLocs Loc where+    traverseLocs f = f++-- Leaf types that carry no location.+instance HasLocs Int where+    traverseLocs _ = pure++instance HasLocs Integer where+    traverseLocs _ = pure++instance HasLocs Double where+    traverseLocs _ = pure++instance HasLocs Bool where+    traverseLocs _ = pure++instance HasLocs Char where+    traverseLocs _ = pure++instance HasLocs Text where+    traverseLocs _ = pure++instance (HasLocs a) => HasLocs [a] where+    traverseLocs f = traverse (traverseLocs f)++instance (HasLocs a) => HasLocs (Maybe a) where+    traverseLocs f = traverse (traverseLocs f)++instance (HasLocs a, HasLocs b) => HasLocs (a, b) where+    traverseLocs f (a, b) = (,) <$> traverseLocs f a <*> traverseLocs f b++instance (HasLocs a, HasLocs b) => HasLocs (Either a b) where+    traverseLocs f (Left a) = Left <$> traverseLocs f a+    traverseLocs f (Right b) = Right <$> traverseLocs f b++{- | Rewrite every source line in a spec. This is a compiler line map, not+textual inclusion: only line numbers move, and 'Keiro.Dsl.Grammar.Loc''s 'Eq'+instance deliberately ignores the line, so relocation cannot change any+equality-based behavior.+-}+relocateLocs :: (Int -> Int) -> Spec -> Spec+relocateLocs shift = runIdentity . traverseLocs (Identity . Loc . shift . unLoc)++{- | Every source line the spec's AST carries, in traversal order. Exists so a+test can prove 'relocateLocs' misses nothing: relocate by a known offset and+assert the collected multiset shifted exactly.+-}+collectLocs :: Spec -> [Int]+collectLocs = getConst . traverseLocs (\l -> Const [unLoc l])++--------------------------------------------------------------------------------+-- Multi-file diagnostics+--------------------------------------------------------------------------------++{- | Which file a workspace diagnostic points at. Member paths are stored+manifest-relative — the canonical identity a scaffold record or diff report can+key on — and joined with the manifest's directory only at render time.+-}+data WorkspaceFile+    = -- | The manifest itself.+      WorkspaceManifestFile+    | -- | A member, by its normalized manifest-relative path.+      WorkspaceMemberFile !FilePath+    deriving stock (Eq, Ord, Show)++{- | One cited source position. 'wlRole' explains why a /secondary/ position is+relevant ("also declared here", "member declares context 'kotei'"); it is+unused for the primary position, which carries the diagnostic's own message.+-}+data WorkspaceLocation = WorkspaceLocation+    { wlFile :: !WorkspaceFile+    , wlLine :: !Int+    , wlRole :: !Text+    }+    deriving stock (Eq, Show)++{- | A diagnostic that can cite several files at once — the whole point of+whole-service checking. The first location is primary; the rest render as+indented notes. The code comes from the same append-only registry as+single-spec diagnostics ("Keiro.Dsl.Validate"), so every gate stays+correlatable by code.+-}+data WorkspaceDiagnostic = WorkspaceDiagnostic+    { wdLocations :: !(NonEmpty WorkspaceLocation)+    , wdSeverity :: !Severity+    , wdCode :: !DiagnosticCode+    , wdMessage :: !Text+    }+    deriving stock (Eq, Show)++{- | Why a workspace could not be produced. The three constructors are the+three stages at which loading can stop: the manifest could not be read, it+could not be parsed, or the members were read but the service refused to+compose.+-}+data WorkspaceFailure+    = WorkspaceManifestUnreadable !Text+    | WorkspaceManifestUnparseable !ParseError+    | WorkspaceRefused !(NonEmpty WorkspaceDiagnostic)+    deriving stock (Eq, Show)++{- | The clickable path for a cited file: the manifest as the user typed it, or+the manifest's directory joined with the member's relative path.+-}+workspaceDisplayPath :: FilePath -> WorkspaceFile -> FilePath+workspaceDisplayPath manifestPath = \case+    WorkspaceManifestFile -> manifestPath+    WorkspaceMemberFile relative ->+        let dir = takeDirectory manifestPath+         in if dir == "." then relative else dir </> relative++{- | Render one diagnostic. The primary location keeps the established+single-file shape so existing consumers and greps keep working; each additional+location follows on an indented continuation line.++@+…/domain/shared.keiro:3: error[WorkspaceDuplicateDeclaration]: duplicate declaration 'ProjectId' …+  …/domain/project.keiro:4: note: also declared here+@+-}+renderWorkspaceDiagnostic :: FilePath -> WorkspaceDiagnostic -> Text+renderWorkspaceDiagnostic manifestPath diagnostic =+    T.intercalate "\n" (primary : notes)+  where+    primaryLocation :| secondary = wdLocations diagnostic+    primary =+        renderAt primaryLocation+            <> ": "+            <> severityWord+            <> "["+            <> T.pack (show (wdCode diagnostic))+            <> "]: "+            <> wdMessage diagnostic+    notes = ["  " <> renderAt location <> ": note: " <> wlRole location | location <- secondary]+    renderAt location =+        T.pack (workspaceDisplayPath manifestPath (wlFile location))+            <> ":"+            <> T.pack (show (wlLine location))+    severityWord = case wdSeverity diagnostic of Error -> "error"; Warning -> "warning"++-- | Render a whole failure as the lines a command should print to stderr.+renderWorkspaceFailure :: FilePath -> WorkspaceFailure -> [Text]+renderWorkspaceFailure manifestPath = \case+    WorkspaceManifestUnreadable reason ->+        ["cannot read workspace manifest " <> T.pack manifestPath <> ": " <> reason]+    WorkspaceManifestUnparseable err -> [err]+    WorkspaceRefused diagnostics ->+        map (renderWorkspaceDiagnostic manifestPath) (NE.toList diagnostics)++--------------------------------------------------------------------------------+-- The composed graph+--------------------------------------------------------------------------------++{- | Maps a merged-spec line back to the member that owns it. Each entry is+@(exclusiveLow, inclusiveHigh, memberPath)@: merged line @n@ belongs to the+entry with @low < n <= high@, and the member's own line is @n - low@.+-}+newtype LineMap = LineMap {lmRanges :: [(Int, Int, FilePath)]}+    deriving stock (Eq, Show)++{- | Where each shared declaration and each node was defined. Keys are+@(namespace, name)@ — namespaces are @id@, @enum@, @rule@, @mapped@ for+declarations and the node kind ("aggregate", "readmodel", …) for nodes, the+same keying the single-spec duplicate-node rule uses. Values are the owning+member's manifest-relative path and its /original/ (unrelocated) location.+-}+data OwnershipIndex = OwnershipIndex+    { oiDeclarations :: !(Map (Text, Name) (FilePath, Loc))+    , oiNodes :: !(Map (Text, Name) (FilePath, Loc))+    }+    deriving stock (Eq, Show)++-- | Which member owns a shared declaration, e.g. @declarationOwner index "id" "ProjectId"@.+declarationOwner :: OwnershipIndex -> Text -> Name -> Maybe (FilePath, Loc)+declarationOwner index namespace name = Map.lookup (namespace, name) (oiDeclarations index)++-- | Which member owns a node, e.g. @nodeOwner index "aggregate" "Project"@.+nodeOwner :: OwnershipIndex -> Text -> Name -> Maybe (FilePath, Loc)+nodeOwner index kind name = Map.lookup (kind, name) (oiNodes index)++-- | One member of a composed workspace.+data WorkspaceMember = WorkspaceMember+    { wmPath :: !FilePath+    -- ^ Normalized, manifest-relative.+    , wmSpec :: !Spec+    -- ^ Exactly as parsed: line numbers are the member's own.+    , wmLineBase :: !Int+    -- ^ Added to this member's lines to place them in the merged spec.+    , wmLineCount :: !Int+    -- ^ Source lines in the member file.+    }+    deriving stock (Eq, Show)++{- | A whole service, composed from its members and ready to be checked,+scaffolded, or diffed as one contract.++'wsMergedSpec' is the load-bearing field: it is a single 'Spec' holding every+member's declarations and nodes in canonical member order, with line numbers+relocated into disjoint ranges. Because it is an ordinary 'Spec', the existing+whole-spec validation, type-graph resolution, coverage, and binding analysis+run over it unchanged — cross-file references resolve by name exactly as if the+members had been one file, with no risk of a node-specific rule diverging+between the single-file and workspace paths.+-}+data WorkspaceSpec = WorkspaceSpec+    { wsService :: !Text+    -- ^ The stable workspace identity (the manifest's @service@ name).+    , wsManifestPath :: !FilePath+    , wsContext :: !Name+    -- ^ The members' unanimous @context@.+    , wsModuleRoot :: !(Maybe Text)+    , wsLayout :: !(Maybe Placement)+    , wsMembers :: ![WorkspaceMember]+    -- ^ Canonical order.+    , wsMergedSpec :: !Spec+    , wsLineMap :: !LineMap+    , wsOwnership :: !OwnershipIndex+    }+    deriving stock (Eq, Show)++{- | Resolve a merged-spec line to @(member path, that member's own line)@.+'Nothing' means the line belongs to no member — render it against the manifest.+-}+resolveWorkspaceLine :: WorkspaceSpec -> Int -> Maybe (FilePath, Int)+resolveWorkspaceLine workspace n+    | n <= 0 = Nothing+    | otherwise =+        listToMaybe+            [ (path, n - low)+            | (low, high, path) <- lmRanges (wsLineMap workspace)+            , n > low+            , n <= high+            ]++{- | A single @.keiro@ file as a one-member workspace. The identity is the+file's base name, the merged spec is the spec itself, and the line map is the+identity, so @checkWorkspace (oneMemberWorkspace fp spec)@ yields exactly+@validateSpec spec@ attributed to @fp@. Downstream plans use this as the+uniform input type for single-file inputs.+-}+oneMemberWorkspace :: FilePath -> Spec -> WorkspaceSpec+oneMemberWorkspace path spec =+    WorkspaceSpec+        { wsService = T.pack (takeBaseName path)+        , wsManifestPath = path+        , wsContext = specContext spec+        , wsModuleRoot = specModuleRoot spec+        , wsLayout = specLayout spec+        , wsMembers =+            [ WorkspaceMember+                { wmPath = relative+                , wmSpec = spec+                , wmLineBase = 0+                , wmLineCount = maximum (0 : collectLocs spec)+                }+            ]+        , wsMergedSpec = spec+        , wsLineMap = LineMap [(0, maxBound, relative)]+        , wsOwnership = ownershipOf [(relative, spec)]+        }+  where+    relative = takeFileName path++{- | Validate a composed workspace. This runs the /existing/ whole-spec+validator over the merged spec once and maps each diagnostic's line back+through the line map, so the workspace and single-file paths can never diverge+on what counts as valid.+-}+checkWorkspace :: WorkspaceSpec -> [WorkspaceDiagnostic]+checkWorkspace workspace =+    [ WorkspaceDiagnostic+        { wdLocations = pure (locationFor (line diagnostic))+        , wdSeverity = severity diagnostic+        , wdCode = code diagnostic+        , wdMessage = message diagnostic+        }+    | diagnostic <- validateSpec (wsMergedSpec workspace)+    ]+  where+    locationFor n = case resolveWorkspaceLine workspace n of+        Just (path, original) -> WorkspaceLocation (WorkspaceMemberFile path) original ""+        -- A line owned by no member (the placeholder location 'Loc 0') is the+        -- workspace's own; point at the manifest rather than invent a member.+        Nothing -> WorkspaceLocation WorkspaceManifestFile (max 1 n) ""++--------------------------------------------------------------------------------+-- Composition+--------------------------------------------------------------------------------++{- | Compose parsed members into one service graph, or refuse with every+relevant file and line cited.++The third argument supplies one entry per manifest member as+@(normalized manifest-relative path, source text, parsed spec)@. The source+text is needed for two things the AST cannot provide: counting lines for the+line map, and locating the @context@\/@module@\/@layout@ clause lines that+'Keiro.Dsl.Grammar.Spec' does not record, so a refusal can point at the clause+an author actually wrote. The scan is used for diagnostics only, never for+semantics.++Composition proceeds in a fixed order, and every stage's refusals are collected+before any is reported — a workspace with two problems reports both. The stages+are: the members' @context@ must be unanimous; the manifest is the+@module@\/@layout@ authority and members must be absent-or-exactly-equal; every+shared declaration has exactly one owning member (identical duplicates are+refused, they never silently merge); every node identity has exactly one owning+member; and no two members may claim generated module paths that collide under+case folding.+-}+composeWorkspace ::+    FilePath ->+    WorkspaceManifest ->+    [(FilePath, Text, Spec)] ->+    Either (NonEmpty WorkspaceDiagnostic) WorkspaceSpec+composeWorkspace manifestPath manifest supplied+    | (d : ds) <- unsupplied = Left (d :| ds)+    | (d : ds) <- refusals = Left (d :| ds)+    | otherwise = Right composed+  where+    ordered =+        [ (ref, lookup (wmrPath ref) [(path, (text, spec)) | (path, text, spec) <- supplied])+        | ref <- NE.toList (wmfMembers manifest)+        ]+    unsupplied =+        [ WorkspaceDiagnostic+            { wdLocations = pure (manifestLocation (wmrLoc ref) "")+            , wdSeverity = Error+            , wdCode = WorkspaceMemberUnreadable+            , wdMessage = "workspace member '" <> T.pack (wmrPath ref) <> "' was not supplied to the composer"+            }+        | (ref, Nothing) <- ordered+        ]+    entries = [(ref, text, spec) | (ref, Just (text, spec)) <- ordered]++    refusals =+        contextRefusals+            <> moduleRefusals+            <> layoutRefusals+            <> declarationRefusals+            <> nodeRefusals+            <> collisionRefusals++    --------------------------------------------------------------------------+    -- Effective context+    --------------------------------------------------------------------------+    declaredContexts = nub [specContext spec | (_, _, spec) <- entries]+    effectiveContext = case entries of+        (_, _, spec) : _ -> specContext spec+        [] -> ""+    contextRefusals+        | length declaredContexts <= 1 = []+        | otherwise =+            [ WorkspaceDiagnostic+                { wdLocations =+                    NE.fromList+                        [ memberLocation ref (clauseLine "context" text) ("member declares context '" <> specContext spec <> "'")+                        | (ref, text, spec) <- entries+                        ]+                , wdSeverity = Error+                , wdCode = WorkspaceContextMismatch+                , wdMessage =+                    "workspace '"+                        <> wmfService manifest+                        <> "' members declare different contexts ("+                        <> T.intercalate ", " (sort declaredContexts)+                        <> "); every member of one workspace must declare the same context"+                }+            ]++    --------------------------------------------------------------------------+    -- Effective module root and layout+    --------------------------------------------------------------------------+    (effectiveModuleRoot, moduleRefusals) =+        resolveAuthority "module" id (wmfModuleRoot manifest) (wmfModuleRootLoc manifest) specModuleRoot+    (effectiveLayout, layoutRefusals) =+        resolveAuthority "layout" renderPlacement (wmfLayout manifest) (wmfLayoutLoc manifest) specLayout++    -- The absent-or-exactly-equal authority rule, shared by @module@ and+    -- @layout@. When the manifest declares the clause it is the authority and+    -- every member's clause must be absent or identical — never silently+    -- overridden. When the manifest is silent, the members that declare the+    -- clause must agree unanimously, and that value becomes effective. Both+    -- halves exist so adoption needs no member edits: a fleet whose members+    -- carry no clauses can put the authority wholly in the manifest, and a file+    -- that already declares one can keep it when it becomes a member.+    resolveAuthority ::+        (Eq a) =>+        Text ->+        (a -> Text) ->+        Maybe a ->+        Loc ->+        (Spec -> Maybe a) ->+        (Maybe a, [WorkspaceDiagnostic])+    resolveAuthority clauseKeyword renderValue manifestValue manifestLoc memberValue =+        case manifestValue of+            Just authority ->+                ( Just authority+                , [ WorkspaceDiagnostic+                        { wdLocations =+                            manifestLocation manifestLoc ""+                                :| [ memberLocation ref (clauseLine clauseKeyword text) ("member declares " <> clauseKeyword <> " " <> renderValue value)+                                   | (ref, text, value) <- disagreeing+                                   ]+                        , wdSeverity = Error+                        , wdCode = WorkspaceAuthorityConflict+                        , wdMessage =+                            "workspace manifest declares "+                                <> clauseKeyword+                                <> " "+                                <> renderValue authority+                                <> ", so every member's "+                                <> clauseKeyword+                                <> " clause must be absent or exactly equal"+                        }+                  | not (null disagreeing)+                  ]+                )+              where+                disagreeing =+                    [ (ref, text, value)+                    | (ref, text, spec) <- entries+                    , Just value <- [memberValue spec]+                    , value /= authority+                    ]+            Nothing+                | length (nub (map thd declared)) <= 1 -> (listToMaybe (map thd declared), [])+                | otherwise ->+                    ( Nothing+                    ,+                        [ WorkspaceDiagnostic+                            { wdLocations =+                                NE.fromList+                                    [ memberLocation ref (clauseLine clauseKeyword text) ("member declares " <> clauseKeyword <> " " <> renderValue value)+                                    | (ref, text, value) <- declared+                                    ]+                            , wdSeverity = Error+                            , wdCode = WorkspaceAuthorityConflict+                            , wdMessage =+                                "the workspace manifest declares no "+                                    <> clauseKeyword+                                    <> " clause, so the members that declare one must agree; they do not"+                            }+                        ]+                    )+              where+                declared = [(ref, text, value) | (ref, text, spec) <- entries, Just value <- [memberValue spec]]+      where+        thd (_, _, value) = value++    --------------------------------------------------------------------------+    -- Single-owner declarations and nodes+    --------------------------------------------------------------------------+    declarationSites =+        [ (name, (namespace, ref, loc))+        | (ref, _, spec) <- entries+        , (namespace, name, loc) <- sharedDeclarations spec+        ]+    declarationRefusals =+        [ WorkspaceDiagnostic+            { wdLocations =+                NE.fromList+                    [ memberLocation ref (Just (unLoc loc)) ("also declared here, as " <> namespace <> " '" <> name <> "'")+                    | (namespace, ref, loc) <- sites+                    ]+            , wdSeverity = Error+            , wdCode = WorkspaceDuplicateDeclaration+            , wdMessage =+                "duplicate declaration '"+                    <> name+                    <> "': a shared declaration has exactly one owning member (identical duplicates do not merge)"+            }+        | (name, sites) <- groupSites declarationSites+        , length (nub [wmrPath ref | (_, ref, _) <- sites]) > 1+        ]++    nodeSites =+        [ ((kind, name), (ref, loc))+        | (ref, _, spec) <- entries+        , node <- specNodes spec+        , let (kind, name, loc) = nodeIdentity node+        ]+    nodeRefusals =+        [ WorkspaceDiagnostic+            { wdLocations =+                NE.fromList+                    [ memberLocation ref (Just (unLoc loc)) ("also defined here")+                    | (ref, loc) <- sites+                    ]+            , wdSeverity = Error+            , wdCode = WorkspaceDuplicateNodeName+            , wdMessage =+                "duplicate "+                    <> kind+                    <> " node name '"+                    <> name+                    <> "': a node has exactly one owning member"+            }+        | ((kind, name), sites) <- groupSites nodeSites+        , length (nub [wmrPath ref | (ref, _) <- sites]) > 1+        ]++    --------------------------------------------------------------------------+    -- Merged spec and line map+    --------------------------------------------------------------------------+    lineCounts = [max 1 (length (T.lines text)) | (_, text, _) <- entries]+    lineBases = scanl (+) 0 lineCounts+    members =+        [ WorkspaceMember+            { wmPath = wmrPath ref+            , wmSpec = spec+            , wmLineBase = base+            , wmLineCount = memberLines+            }+        | ((ref, _, spec), base, memberLines) <- zip3 entries lineBases lineCounts+        ]+    relocatedSpecs = [relocateLocs (shiftBy (wmLineBase member)) (wmSpec member) | member <- members]+    -- The placeholder location 'Loc 0' must stay 0: shifting it would land it+    -- inside the previous member's range and mis-attribute the diagnostic.+    shiftBy base n = if n <= 0 then n else n + base+    lineMap =+        LineMap+            [ (wmLineBase member, wmLineBase member + wmLineCount member, wmPath member)+            | member <- members+            ]+    mergedSpec =+        Spec+            { specContext = effectiveContext+            , specModuleRoot = effectiveModuleRoot+            , specLayout = effectiveLayout+            , specIds = concatMap specIds relocatedSpecs+            , specEnums = concatMap specEnums relocatedSpecs+            , specRules = concatMap specRules relocatedSpecs+            , specMapped = concatMap specMapped relocatedSpecs+            , specNodes = concatMap specNodes relocatedSpecs+            }++    --------------------------------------------------------------------------+    -- Cross-member generated-path collisions+    --------------------------------------------------------------------------+    plannerContext =+        Context+            { contextName = effectiveContext+            , moduleRoot = fromMaybe "" effectiveModuleRoot+            , placement = fromMaybe GeneratedPrefix effectiveLayout+            }+    collisionRefusals+        -- The effective context/module/layout are only meaningful once the+        -- earlier stages agree; without them there is no honest planner input.+        | not (null contextRefusals && null moduleRefusals && null layoutRefusals) = []+        -- Only ask the scaffold planner about a spec that already validates.+        -- An invalid merged spec is 'checkWorkspace''s report to make, and the+        -- planner is only designed to see specs that passed validation.+        | any ((== Error) . severity) (validateSpec mergedSpec) = []+        | otherwise = case planScaffoldWithGoldens [] plannerContext mergedSpec of+            Right _ -> []+            Left plannerRefusals -> concatMap crossMemberCollision plannerRefusals+    crossMemberCollision (PathCollision path origins) =+        [ WorkspaceDiagnostic+            { wdLocations =+                NE.fromList+                    [ WorkspaceLocation (WorkspaceMemberFile owner) original ("claimed here by " <> origin)+                    | (origin, owner, original) <- resolved+                    ]+            , wdSeverity = Error+            , wdCode = WorkspacePathCollision+            , wdMessage =+                "generated module path '"+                    <> T.pack path+                    <> "' is claimed by nodes in more than one member; on a case-insensitive filesystem these are one file"+            }+        | length (nub [owner | (_, owner, _) <- resolved]) > 1+        ]+      where+        resolved =+            [ (origin, owner, original)+            | origin <- origins+            , Just mergedLine <- [originLine origin]+            , Just (owner, original) <- [lookupLine mergedLine]+            ]+    crossMemberCollision _ = []+    lookupLine n =+        listToMaybe+            [ (path, n - low)+            | (low, high, path) <- lmRanges lineMap+            , n > low+            , n <= high+            ]++    --------------------------------------------------------------------------+    -- Result+    --------------------------------------------------------------------------+    composed =+        WorkspaceSpec+            { wsService = wmfService manifest+            , wsManifestPath = manifestPath+            , wsContext = effectiveContext+            , wsModuleRoot = effectiveModuleRoot+            , wsLayout = effectiveLayout+            , wsMembers = members+            , wsMergedSpec = mergedSpec+            , wsLineMap = lineMap+            , wsOwnership = ownershipOf [(wmPath member, wmSpec member) | member <- members]+            }++    manifestLocation loc role = WorkspaceLocation WorkspaceManifestFile (max 1 (unLoc loc)) role+    memberLocation ref found role =+        WorkspaceLocation (WorkspaceMemberFile (wmrPath ref)) (fromMaybe 1 found) role++-- | Group @(key, site)@ pairs by key, preserving first-appearance order.+groupSites :: (Ord k) => [(k, v)] -> [(k, [v])]+groupSites pairs =+    [ (key, reverse sites)+    | key <- nub (map fst pairs)+    , Just sites <- [Map.lookup key grouped]+    ]+  where+    grouped = Map.fromListWith (<>) [(key, [value]) | (key, value) <- pairs]++-- | The four shared-declaration namespaces of one spec, with names and lines.+sharedDeclarations :: Spec -> [(Text, Name, Loc)]+sharedDeclarations spec =+    [("id", idName d, idLoc d) | d <- specIds spec]+        <> [("enum", enumName d, enumLoc d) | d <- specEnums spec]+        <> [("rule", ruleName d, ruleLoc d) | d <- specRules spec]+        <> [("mapped", mappedDeclName d, mappedDeclLoc d) | d <- specMapped spec]++mappedDeclName :: MappedDecl -> Name+mappedDeclName MappedStructural{msName = name} = name+mappedDeclName MappedOpaque{moName = name} = name++mappedDeclLoc :: MappedDecl -> Loc+mappedDeclLoc MappedStructural{msLoc = loc} = loc+mappedDeclLoc MappedOpaque{moLoc = loc} = loc++-- | Build the ownership index from members carrying their original locations.+ownershipOf :: [(FilePath, Spec)] -> OwnershipIndex+ownershipOf members =+    OwnershipIndex+        { oiDeclarations =+            Map.fromList+                [ ((namespace, name), (path, loc))+                | (path, spec) <- members+                , (namespace, name, loc) <- sharedDeclarations spec+                ]+        , oiNodes =+            Map.fromList+                [ ((kind, name), (path, loc))+                | (path, spec) <- members+                , node <- specNodes spec+                , let (kind, name, loc) = nodeIdentity node+                ]+        }++{- | The line of the first non-comment source line whose first word is the+given clause keyword. Used only to point a refusal at the @context@,+@module@, or @layout@ clause an author wrote, because 'Spec' records no+location for them.+-}+clauseLine :: Text -> Text -> Maybe Int+clauseLine clauseKeyword source =+    listToMaybe+        [ index+        | (index, raw) <- zip [1 ..] (T.lines source)+        , (leading : _) <- [T.words (T.takeWhile (/= '#') raw)]+        , leading == clauseKeyword+        ]++{- | The merged-spec line embedded in a scaffold module's origin string, which+"Keiro.Dsl.Scaffold" formats as @\<kind\> \<name\> (line N)@. Context-level+modules carry no line and yield 'Nothing', which is correct: they belong to the+workspace, not to any one member, so they can never be a cross-member+collision.+-}+originLine :: Text -> Maybe Int+originLine origin = do+    withoutClose <- T.stripSuffix ")" origin+    let (before, after) = T.breakOnEnd " (line " withoutClose+    if T.null before then Nothing else readMaybe (T.unpack after)++--------------------------------------------------------------------------------+-- Loading+--------------------------------------------------------------------------------++{- | How the loader obtains file contents. 'csRead' receives a path relative to+the workspace root (the manifest's own directory); 'Left' is a human-readable+read-failure reason.++This seam exists so the same loader can read from the working tree now and+from @git show \<rev\>:\<path\>@ blobs later, when whole-workspace @diff@ must+resolve a workspace as it existed at an older revision without re-implementing+composition.+-}+newtype ContentSource = ContentSource+    { csRead :: FilePath -> IO (Either Text Text)+    }++-- | Read files from a directory on disk.+fileContentSource :: FilePath -> ContentSource+fileContentSource root =+    ContentSource+        { csRead = \relative -> do+            let full = if root == "." then relative else root </> relative+            exists <- doesFileExist full+            if not exists+                then pure (Left ("no such file: " <> T.pack full))+                else do+                    attempt <- Exception.try (TIO.readFile full)+                    pure $ case attempt of+                        Left readError -> Left (T.pack (show (readError :: Exception.IOException)))+                        Right contents -> Right contents+        }++{- | Read a manifest and all its members through a content source, then compose+them into one service graph.++Member read and parse failures are collected, not fail-fast: a workspace with+two unreadable members reports both, which matters when a whole service is+being adopted at once.+-}+loadWorkspace :: ContentSource -> FilePath -> IO (Either WorkspaceFailure WorkspaceSpec)+loadWorkspace source manifestPath = do+    manifestRead <- csRead source (takeFileName manifestPath)+    case manifestRead of+        Left reason -> pure (Left (WorkspaceManifestUnreadable reason))+        Right manifestText -> case parseWorkspaceManifest manifestPath manifestText of+            Left err -> pure (Left (WorkspaceManifestUnparseable err))+            Right manifest -> do+                results <- traverse readMember (NE.toList (wmfMembers manifest))+                case [diagnostic | Left diagnostic <- results] of+                    (d : ds) -> pure (Left (WorkspaceRefused (d :| ds)))+                    [] ->+                        pure+                            ( first+                                WorkspaceRefused+                                (composeWorkspace manifestPath manifest [entry | Right entry <- results])+                            )+  where+    readMember ref = do+        result <- csRead source (wmrPath ref)+        pure $ case result of+            Left reason -> Left (memberFailure ref WorkspaceMemberUnreadable ("workspace member '" <> T.pack (wmrPath ref) <> "' could not be read: " <> reason))+            Right text -> case parseSpec (workspaceDisplayPath manifestPath (WorkspaceMemberFile (wmrPath ref))) text of+                Left err ->+                    Left+                        ( memberFailure+                            ref+                            WorkspaceMemberParseFailed+                            ("workspace member '" <> T.pack (wmrPath ref) <> "' failed to parse:\n" <> err)+                        )+                Right spec -> Right (wmrPath ref, text, spec)+    memberFailure ref failureCode note =+        WorkspaceDiagnostic+            { wdLocations = pure (WorkspaceLocation WorkspaceManifestFile (max 1 (unLoc (wmrLoc ref))) "")+            , wdSeverity = Error+            , wdCode = failureCode+            , wdMessage = note+            }++--------------------------------------------------------------------------------+-- 'HasLocs' coverage of the AST+--+-- One line per type in "Keiro.Dsl.Grammar". A new AST type fails to compile+-- here until it is added, which is what makes 'relocateLocs' provably total.+--------------------------------------------------------------------------------++instance HasLocs AdvanceNode+instance HasLocs Aggregate+instance HasLocs Atom+instance HasLocs BackoffSpec+instance HasLocs BindRow+instance HasLocs CmpOp+instance HasLocs Command+instance HasLocs Consistency+instance HasLocs ContractEvent+instance HasLocs ContractField+instance HasLocs ContractNode+instance HasLocs ContractType+instance HasLocs CorrelateDecl+instance HasLocs DecodeSpec+instance HasLocs DerivStrategy+instance HasLocs Derivation+instance HasLocs DeriveSpec+instance HasLocs Disp+instance HasLocs DispAction+instance HasLocs DispatchDisposition+instance HasLocs DispatchNode+instance HasLocs Disposition+instance HasLocs DispositionRow+instance HasLocs EmitMapRow+instance HasLocs EmitNode+instance HasLocs EnumDecl+instance HasLocs EnvelopeBinding+instance HasLocs EnvelopeLayer+instance HasLocs Event+instance HasLocs EventBody+instance HasLocs Expr+instance HasLocs Field+instance HasLocs FieldBinding+instance HasLocs FireAtExpr+instance HasLocs FireDisposition+instance HasLocs FireNode+instance HasLocs FireOutcome+instance HasLocs HandleNode+instance HasLocs HaskellSource+instance HasLocs Hole+instance HasLocs IdDecl+instance HasLocs IdExpr+instance HasLocs IdStrategy+instance HasLocs InboxAction+instance HasLocs InkPersist+instance HasLocs InputDecl+instance HasLocs IntakeNode+instance HasLocs MappedDecl+instance HasLocs MappedShape+instance HasLocs Mapping+instance HasLocs Node+instance HasLocs OnMissing+instance HasLocs OperationNode+instance HasLocs OperationShape+instance HasLocs PgmqDispatchNode+instance HasLocs Placement+instance HasLocs PolicyChoice+instance HasLocs Presence+instance HasLocs ProcessNode+instance HasLocs ProjectionSpec+instance HasLocs PublisherNode+instance HasLocs ReadModelNode+instance HasLocs RegDecl+instance HasLocs RegInitial+instance HasLocs ResolveDecl+instance HasLocs ResolveSource+instance HasLocs RmColumn+instance HasLocs RmFeed+instance HasLocs RmScope+instance HasLocs RouterDispatchNode+instance HasLocs RouterNode+instance HasLocs RuleDecl+instance HasLocs SagaRef+instance HasLocs SnapPolicy+instance HasLocs SnapshotSpec+instance HasLocs Spec+instance HasLocs StateDecl+instance HasLocs TimerNode+instance HasLocs Transition+instance HasLocs TransitionMode+instance HasLocs TypeExpr+instance HasLocs UnionEncoding+instance HasLocs UnknownFields+instance HasLocs WfBodyItem+instance HasLocs WireArm+instance HasLocs WireEnum+instance HasLocs WireField+instance HasLocs WireSource+instance HasLocs WireSpec+instance HasLocs WorkflowNode+instance HasLocs WorkqueueNode+instance HasLocs WqDispRow+instance HasLocs WqField+instance HasLocs WqGroupKey+instance HasLocs WqOrdering+instance HasLocs WqProvision
+ src/Keiro/Dsl/WorkspaceAdoption.hs view
@@ -0,0 +1,268 @@+{- | Adopting pre-workspace scaffold output into workspace history.++The first whole-workspace scaffold into an output directory that already holds+per-context scaffold output has to answer one question honestly: /which of these+files are mine?/ Guessing in either direction is harmful. Claiming everything+would take ownership of hand-written code and then overwrite it on the next run.+Claiming nothing would report the entire existing tree as unrelated and leave a+human to reconcile it by hand.++So adoption claims only what is __attributable__:++  * @record@ evidence — the file is listed in a legacy per-context scaffold+    record for this workspace's effective context, and the workspace still+    produces it.++  * @banner@ evidence — the file sits at a path this workspace produces as+    Generated and carries the @-- \@generated@ banner, but no surviving record+    lists it. This is the orphan case IR-2 describes: two same-context specs+    scaffolded into one directory, the second overwriting the first's record, so+    the first spec's files lost their only attribution.++Everything else is reported and left alone. Hole paths are never claimed — the+create-once rule keeps governing them. Files the plan never mentions are listed+as unclaimed. Files the legacy record lists that this workspace no longer+produces are listed as likely stale, and are deliberately __not__ merged into+the workspace record: the record states what this workspace produces and+adopted, not what an abandoned scaffold once produced.++Nothing is deleted and nothing is renamed. The legacy record gains exactly one+appended @superseded-by:@ line, which its own v1 parser ignores, so an older+keiro-dsl binary keeps reading it unchanged.+-}+module Keiro.Dsl.WorkspaceAdoption (+    ClaimEvidence (..),+    ClaimedFile (..),+    MigrationReport (..),+    adoptionReport,+    adoptedRows,+    renderMigrationReport,+    markLegacyRecordSuperseded,+    outputTreeFiles,+) where++import Data.List (sort)+import Data.Maybe (isNothing)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Keiro.Dsl.Scaffold (ModuleKind (..), ScaffoldModule (..))+import Keiro.Dsl.ScaffoldRecord (ScaffoldRecord (..), parseRecord, recordFileName)+import Keiro.Dsl.ScaffoldRun (StaleModule (..))+import Keiro.Dsl.WorkspaceRecord (AdoptedRow (..), supersededByLine, workspaceMigrationReportFileName)+import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)+import System.FilePath ((</>))++-- | Why a file could be claimed. See the module header.+data ClaimEvidence = ClaimedFromRecord | ClaimedFromBanner+    deriving stock (Eq, Show)++data ClaimedFile = ClaimedFile+    { cfPath :: !FilePath+    , cfEvidence :: !ClaimEvidence+    , cfSource :: !(Maybe Text)+    -- ^ The legacy record's file name, for @record@ evidence.+    , cfSpec :: !(Maybe Text)+    -- ^ The legacy record's @spec:@ field, for @record@ evidence.+    }+    deriving stock (Eq, Show)++{- | What one adopting run found. Printed in the scaffold output and persisted+beside the generated tree as the durable review artifact.+-}+data MigrationReport = MigrationReport+    { mrService :: !Text+    , mrLegacyRecord :: !(Maybe (FilePath, Text))+    -- ^ The legacy record consulted, as @(file name, its @spec:@ field)@.+    , mrClaimed :: ![ClaimedFile]+    , mrLikelyStale :: ![StaleModule]+    , mrUnclaimed :: ![FilePath]+    }+    deriving stock (Eq, Show)++{- | Compute the adoption report for an output directory, or 'Nothing' when+there is nothing to adopt or report (the ordinary case: a fresh directory, or+one this workspace already owns).++Only the workspace's __own effective context__ is consulted. A record for a+different context belongs to a different service and is never read, reported,+or marked.+-}+adoptionReport :: FilePath -> Text -> Text -> [ScaffoldModule] -> IO (Maybe MigrationReport)+adoptionReport out context service modules = do+    legacy <- readLegacyRecord (out </> legacyName)+    present <- Set.fromList <$> outputTreeFiles out+    let plannedGenerated = [modulePath m | m <- modules, kind m == Generated]+        plannedAll = Set.fromList (map modulePath modules)+        onDisk path = path `Set.member` present++        recordedFiles = maybe [] recFiles legacy+        recordSpec = fmap recSpecPath legacy++        claimedFromRecord =+            [ ClaimedFile+                { cfPath = path+                , cfEvidence = ClaimedFromRecord+                , cfSource = Just (T.pack legacyName)+                , cfSpec = recordSpec+                }+            | (Generated, path) <- recordedFiles+            , path `Set.member` plannedAll+            , onDisk path+            ]+        recordClaimedPaths = Set.fromList (map cfPath claimedFromRecord)++    -- A banner claim reads the file, so it is filtered before the read.+    bannerCandidates <-+        traverse+            (\path -> (,) path <$> hasGeneratedBanner (out </> path))+            [ path+            | path <- plannedGenerated+            , onDisk path+            , path `Set.notMember` recordClaimedPaths+            ]+    let claimedFromBanner =+            [ ClaimedFile{cfPath = path, cfEvidence = ClaimedFromBanner, cfSource = Nothing, cfSpec = Nothing}+            | (path, True) <- bannerCandidates+            ]+        claimed = claimedFromRecord <> claimedFromBanner++        likelyStale =+            [ StaleModule fileKind path+            | (fileKind, path) <- recordedFiles+            , path `Set.notMember` plannedAll+            , onDisk path+            ]+        staleOrGenerated =+            Set.fromList (map stalePath likelyStale) <> Set.fromList plannedGenerated++        -- Everything left on disk that this run neither produces nor+        -- attributes. Planned Generated paths are excluded because this run+        -- writes them; saying they were "left untouched" would be false.+        unclaimed = sort [path | path <- Set.toList present, path `Set.notMember` staleOrGenerated]++        report =+            MigrationReport+                { mrService = service+                , mrLegacyRecord = (,) legacyName <$> recordSpec+                , mrClaimed = claimed+                , mrLikelyStale = likelyStale+                , mrUnclaimed = unclaimed+                }+    pure $+        if null claimed && null likelyStale && null unclaimed && isNothing legacy+            then Nothing+            else Just report+  where+    legacyName = recordFileName context++-- | The record rows an adopting run adds to the new workspace record.+adoptedRows :: MigrationReport -> [AdoptedRow]+adoptedRows report =+    [ AdoptedRow+        { adPath = cfPath claimed+        , adEvidence = case cfEvidence claimed of+            ClaimedFromRecord -> "record"+            ClaimedFromBanner -> "banner"+        , adSource = cfSource claimed+        , adSpec = cfSpec claimed+        }+    | claimed <- mrClaimed report+    ]++{- | Append the supersession marker to a legacy record, once. Appending is+idempotent by inspection: a record that already carries the line is left exactly+as it is, so a re-run after an interrupted adoption cannot accumulate markers.+-}+markLegacyRecordSuperseded :: FilePath -> Text -> Text -> IO ()+markLegacyRecordSuperseded out context service = do+    let path = out </> recordFileName context+    exists <- doesFileExist path+    if not exists+        then pure ()+        else do+            contents <- TIO.readFile path+            let marker = supersededByLine service+            if marker `elem` T.lines contents+                then pure ()+                else TIO.writeFile path (ensureNewline contents <> marker <> "\n")+  where+    ensureNewline contents+        | T.null contents || T.isSuffixOf "\n" contents = contents+        | otherwise = contents <> "\n"++-- | Every @.hs@ file under a directory, as sorted paths relative to it.+outputTreeFiles :: FilePath -> IO [FilePath]+outputTreeFiles root = do+    exists <- doesDirectoryExist root+    if not exists then pure [] else sort <$> walk ""+  where+    walk relative = do+        entries <- listDirectory (root </> relative)+        fmap concat . traverse (visit relative) $ sort entries+    visit relative entry = do+        let child = if null relative then entry else relative </> entry+        isDirectory <- doesDirectoryExist (root </> child)+        if isDirectory+            then walk child+            else pure [child | ".hs" `T.isSuffixOf` T.pack child]++readLegacyRecord :: FilePath -> IO (Maybe ScaffoldRecord)+readLegacyRecord path = do+    exists <- doesFileExist path+    if exists then parseRecord <$> TIO.readFile path else pure Nothing++hasGeneratedBanner :: FilePath -> IO Bool+hasGeneratedBanner path = do+    contents <- TIO.readFile path+    pure (any (T.isPrefixOf "-- @generated") (T.lines contents))++{- | Render the report a human reviews. It is printed in the scaffold output and+written to @keiro-dsl-migration-report.workspace.\<service\>.txt@.+-}+renderMigrationReport :: MigrationReport -> [Text]+renderMigrationReport report =+    [ "migration: adopting pre-workspace scaffold output into workspace " <> mrService report+    ]+        <> legacySection+        <> claimedSection+        <> staleSection+        <> unclaimedSection+        <> [ "note: keiro-dsl never deletes files. The legacy record was marked superseded, not removed."+           , "note: the full report is kept at " <> T.pack (workspaceMigrationReportFileName (mrService report))+           ]+  where+    legacySection = case mrLegacyRecord report of+        Nothing -> ["  legacy record: (none for this context)"]+        Just (name, specPath) -> ["  legacy record: " <> T.pack name <> " (spec " <> specPath <> ")"]+    claimedSection = case mrClaimed report of+        [] -> ["  claimed: nothing was attributable to this workspace"]+        claimed ->+            ["  claimed " <> tshow (length claimed) <> " file(s) into workspace history:"]+                <> [ "    " <> evidenceTag (cfEvidence entry) <> "  " <> T.pack (cfPath entry)+                   | entry <- claimed+                   ]+    evidenceTag ClaimedFromRecord = "record"+    evidenceTag ClaimedFromBanner = "banner"+    staleSection = case mrLikelyStale report of+        [] -> []+        stale ->+            [ "  likely stale: "+                <> tshow (length stale)+                <> " file(s) the legacy scaffold recorded that this workspace does not produce:"+            ]+                <> map staleLine stale+    staleLine stale = case staleKind stale of+        Generated -> "    generated " <> T.pack (stalePath stale) <> "  (safe to delete; still on disk)"+        HoleStub -> "    hole      " <> T.pack (stalePath stale) <> "  (hand-owned — review before deleting)"+    unclaimedSection = case mrUnclaimed report of+        [] -> []+        unclaimed ->+            [ "  unclaimed: "+                <> tshow (length unclaimed)+                <> " file(s) this workspace does not own; left untouched:"+            ]+                <> ["    " <> T.pack path | path <- unclaimed]+    tshow :: (Show a) => a -> Text+    tshow = T.pack . show
+ src/Keiro/Dsl/WorkspaceDiff.hs view
@@ -0,0 +1,163 @@+{- | Whole-service evolution findings enriched with workspace source ownership.++The ordinary differ remains the single authority for compatibility. This module+runs it over the two composed 'WorkspaceSpec' graphs and adds only source+citations. Consequently, file layout can never manufacture, suppress, or demote+a wire finding.+-}+module Keiro.Dsl.WorkspaceDiff (+    OwnedSite (..),+    WorkspaceChange (..),+    WorkspaceMeta (..),+    WorkspaceDiffReport,+    workspaceDiffReport,+    diffWorkspaces,+    renderWorkspaceFinding,+) where++import Control.Applicative ((<|>))+import Data.Char (isSpace)+import Data.List (find)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Keiro.Dsl.Diff (Change (..), ChangeKind (..), advisoryAt, consumerBuildContext, diffSpecs)+import Keiro.Dsl.DiffReport (OwnedSite (..), WorkspaceChange (..), WorkspaceDiffReport, WorkspaceMeta (..), renderFinding, workspaceDiffReport)+import Keiro.Dsl.Grammar (Loc (..), Name, Placement (..))+import Keiro.Dsl.Validate (DiagnosticCode (..))+import Keiro.Dsl.Workspace (OwnershipIndex (..), WorkspaceSpec (..))++-- | Diff two composed service graphs and cite every participant we can resolve.+diffWorkspaces :: WorkspaceSpec -> WorkspaceSpec -> [WorkspaceChange]+diffWorkspaces old new =+    map annotate (diffSpecs (wsMergedSpec old) (wsMergedSpec new))+        <> ownershipMoveChanges old new+        <> authorityChanges old new+  where+    annotate change =+        WorkspaceChange+            { wcChange = change+            , wcDeclarationSite =+                ownedSiteForName new (declarationName kind)+                    <|> ownedSiteForName old (declarationName kind)+            , wcUseSites =+                [ (path, ownedSiteForName new (pathRoot path) <|> ownedSiteForName old (pathRoot path))+                | path <- ckPaths kind+                ]+            }+      where+        kind = changeKind change++-- | Preserve the existing headline/vector bytes and append indented citations.+renderWorkspaceFinding :: WorkspaceChange -> Text+renderWorkspaceFinding workspaceChange =+    T.intercalate "\n" (renderFinding (wcChange workspaceChange) : declarationLine <> useLines)+  where+    declarationLine = case wcDeclarationSite workspaceChange of+        Nothing -> []+        Just site -> ["    declared: " <> renderOwnedSite site]+    useLines =+        [ "    use-site: " <> path <> " (" <> renderOwnedSite site <> ")"+        | (path, Just site) <- wcUseSites workspaceChange+        ]++renderOwnedSite :: OwnedSite -> Text+renderOwnedSite site = T.pack (osFile site) <> ":" <> T.pack (show (osLine site))++ownedSiteForName :: WorkspaceSpec -> Name -> Maybe OwnedSite+ownedSiteForName workspace name = do+    (_, (file, Loc line)) <- find ((== name) . snd . fst) entries+    pure (OwnedSite file line)+  where+    ownership = wsOwnership workspace+    entries = Map.toAscList (oiDeclarations ownership) <> Map.toAscList (oiNodes ownership)++declarationName :: ChangeKind -> Name+declarationName kind+    | "mapped-" `T.isPrefixOf` ckFacet kind+    , Just mapped <- mappedNameFromSubject (ckSubject kind) =+        mapped+    | otherwise = ckNode kind++mappedNameFromSubject :: Text -> Maybe Name+mappedNameFromSubject subject =+    case T.breakOn " : " subject of+        (_, rest)+            | not (T.null rest)+            , let name = T.takeWhile (not . isSpace) (T.drop 3 rest)+            , not (T.null name) ->+                Just name+        _ -> Nothing++pathRoot :: Text -> Name+pathRoot = T.takeWhile (\c -> c /= '.' && not (isSpace c))++changeKind :: Change -> ChangeKind+changeKind (Additive kind) = kind+changeKind (Advisory kind) = kind+changeKind (Breaking kind) = kind++ownershipMoveChanges :: WorkspaceSpec -> WorkspaceSpec -> [WorkspaceChange]+ownershipMoveChanges old new =+    [ WorkspaceChange+        { wcChange =+            advisoryAt+                (consumerBuildContext name [])+                name+                "ownership"+                name+                OwnershipMoved+                ( "declaration moved "+                    <> T.pack oldFile+                    <> " -> "+                    <> T.pack newFile+                    <> "; source ownership changed while wire evolution remains independently classified"+                )+        , wcDeclarationSite = Just (OwnedSite newFile (unLoc newLoc))+        , wcUseSites = []+        }+    | (key@(_, name), (oldFile, _)) <- Map.toAscList (ownershipEntries (wsOwnership old))+    , Just (newFile, newLoc) <- [Map.lookup key (ownershipEntries (wsOwnership new))]+    , oldFile /= newFile+    ]++ownershipEntries :: OwnershipIndex -> Map.Map (Text, Name) (FilePath, Loc)+ownershipEntries ownership = oiDeclarations ownership <> oiNodes ownership++authorityChanges :: WorkspaceSpec -> WorkspaceSpec -> [WorkspaceChange]+authorityChanges old new =+    concat+        [ changed "service-identity" (wsService old) (wsService new) serviceDetail+        , changed "context" (wsContext old) (wsContext new) contextDetail+        , changed "module-root" (renderModuleRoot (wsModuleRoot old)) (renderModuleRoot (wsModuleRoot new)) moduleDetail+        , changed "layout" (renderLayout (wsLayout old)) (renderLayout (wsLayout new)) layoutDetail+        ]+  where+    changed field before after detail+        | before == after = []+        | otherwise =+            [ WorkspaceChange+                { wcChange =+                    advisoryAt+                        (consumerBuildContext (wsService new) [])+                        (wsService new)+                        "workspace-authority"+                        field+                        WorkspaceAuthorityChanged+                        (field <> " changed '" <> before <> "' -> '" <> after <> "'; " <> detail)+                , wcDeclarationSite = Nothing+                , wcUseSites = []+                }+            ]+    serviceDetail = "scaffold and compatibility history are re-keyed; follow the workspace adoption path"+    contextDetail = "generated module namespaces change, and read-model registry/subscription identities may emit separate DerivedIdentityChanged findings"+    moduleDetail = "generated module paths change without changing persisted wire identity"+    layoutDetail = "generated module placement changes without changing persisted wire identity"++renderModuleRoot :: Maybe Text -> Text+renderModuleRoot = maybe "(default)" id++renderLayout :: Maybe Placement -> Text+renderLayout Nothing = "(default)"+renderLayout (Just GeneratedPrefix) = "prefixed"+renderLayout (Just CollocatedLeaf) = "collocated"
+ src/Keiro/Dsl/WorkspaceRecord.hs view
@@ -0,0 +1,281 @@+{- | Versioned persistence for one successful __whole-workspace__ scaffold run.++A workspace record answers three questions a context-keyed+"Keiro.Dsl.ScaffoldRecord" cannot: which service produced this output tree,+which member files it was composed from, and __which member produced each+emitted module__. The last one is what makes moving an aggregate from one member+file to another an ownership move rather than a stale/new pair.++__Coexistence.__ Workspace history is keyed by the service name in a distinct+file-name slot, @keiro-dsl-scaffold-record.workspace.\<service\>.txt@, and never+by context. A context name is lexed as letters, digits, @_@ and @-@ and can+never contain a dot, so this slot provably cannot collide with a legacy+context-keyed name even when a service is named after its context. Legacy+records and a workspace record may therefore share one output directory: the+workspace path never writes a context-keyed name, and an older keiro-dsl binary+is structurally incapable of parsing — and therefore of clobbering — workspace+history. The one exception is the explicit adoption step, which /appends/ a+@superseded-by:@ line to a legacy record; the v1 parser ignores unknown lines,+so old binaries still read it.++The format is line-oriented like the v1 record, with a distinct header so no+reader can confuse the schemas:++@+keiro-dsl workspace scaffold record v1+service: demo-project+manifest: service.keiro-workspace+context: demo-project+module-root: Demo.Modules.Project+layout: collocated+member domain/project.keiro+module {"kind":"generated","path":"Demo/Project/Generated/StructuralProjections.hs"}+module {"kind":"generated","path":"Demo/Project/Project/Generated/Domain.hs","owner":"domain/project.keiro"}+mapping {…}+binding {…}+adopted {"path":"…","evidence":"record","source":"keiro-dsl-scaffold-record.demo-project.txt"}+@++@module@ rows are canonical single-line JSON, following the precedent set for+@mapping@ rows. An /absent/ @owner@ means the module is context-level: emitted+once for the whole merged graph (the structural projection facade, the+replay-audit assembly, or a binding skeleton shared by declarations from several+members). Unknown row kinds and unknown JSON keys are ignored so a later tool+version can extend the schema; paths that are absolute or contain @..@ are+rejected rather than joined to an output root.+-}+module Keiro.Dsl.WorkspaceRecord (+    WorkspaceRecord (..),+    WorkspaceModuleRow (..),+    AdoptedRow (..),+    renderWorkspaceRecord,+    parseWorkspaceRecord,+    workspaceRecordFileName,+    workspaceManifestFileName,+    workspaceMigrationReportFileName,+    supersededByLine,+) where++import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as BL+import Data.List (nub)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as Text+import Keiro.Dsl.ExplainBindings (BindingHole (..))+import Keiro.Dsl.MappedConsumer (MappingIdentity (..))+import Keiro.Dsl.Scaffold (ModuleKind (..))+import System.FilePath (isAbsolute, splitDirectories)++{- | One emitted module: what kind it is, where it landed relative to the output+directory, and which member file produced it ('Nothing' for context-level+modules emitted once from the merged graph).+-}+data WorkspaceModuleRow = WorkspaceModuleRow+    { wrmKind :: !ModuleKind+    , wrmPath :: !FilePath+    , wrmOwner :: !(Maybe FilePath)+    }+    deriving stock (Eq, Show)++instance ToJSON WorkspaceModuleRow where+    toJSON row =+        object $+            [ "kind" .= (case wrmKind row of Generated -> "generated" :: Text; HoleStub -> "hole")+            , "path" .= T.pack (wrmPath row)+            ]+                <> ["owner" .= T.pack owner | Just owner <- [wrmOwner row]]++instance FromJSON WorkspaceModuleRow where+    parseJSON = withObject "WorkspaceModuleRow" $ \fields -> do+        kindLabel <- fields .: "kind"+        moduleKind <- case (kindLabel :: Text) of+            "generated" -> pure Generated+            "hole" -> pure HoleStub+            other -> fail ("unknown module kind: " <> T.unpack other)+        path <- fields .: "path"+        owner <- fields .:? "owner"+        pure+            WorkspaceModuleRow+                { wrmKind = moduleKind+                , wrmPath = T.unpack (path :: Text)+                , wrmOwner = T.unpack <$> (owner :: Maybe Text)+                }++{- | One file imported into workspace history from pre-workspace scaffold+output. @adEvidence@ is @record@ when a legacy per-context scaffold record+listed the file, or @banner@ when the file sits at a planned Generated path and+carries the @-- \@generated@ banner but no surviving record lists it (the orphan+case created when one legacy record overwrote another).+-}+data AdoptedRow = AdoptedRow+    { adPath :: !FilePath+    , adEvidence :: !Text+    , adSource :: !(Maybe Text)+    -- ^ The legacy record's file name, when the evidence is @record@.+    , adSpec :: !(Maybe Text)+    -- ^ The legacy record's @spec:@ field, when available.+    }+    deriving stock (Eq, Show)++instance ToJSON AdoptedRow where+    toJSON row =+        object $+            ["path" .= T.pack (adPath row), "evidence" .= adEvidence row]+                <> ["source" .= source | Just source <- [adSource row]]+                <> ["spec" .= specPath | Just specPath <- [adSpec row]]++instance FromJSON AdoptedRow where+    parseJSON = withObject "AdoptedRow" $ \fields -> do+        path <- fields .: "path"+        evidence <- fields .: "evidence"+        source <- fields .:? "source"+        specPath <- fields .:? "spec"+        pure+            AdoptedRow+                { adPath = T.unpack (path :: Text)+                , adEvidence = evidence+                , adSource = source+                , adSpec = specPath+                }++-- | Everything one successful whole-workspace scaffold produced.+data WorkspaceRecord = WorkspaceRecord+    { wrService :: !Text+    -- ^ The manifest's @service@ name: the workspace's durable identity.+    , wrManifest :: !Text+    {- ^ The manifest's __file name__, not a path. Members are relative to its+    directory, so the directory is wherever the manifest currently sits;+    recording only the name keeps the record independent of the invoking+    working directory, which is what makes byte-identical output provable.+    -}+    , wrContext :: !Text+    , wrModuleRoot :: !Text+    , wrLayout :: !Text+    , wrMembers :: ![FilePath]+    -- ^ Canonically ordered manifest-relative member paths.+    , wrModules :: ![WorkspaceModuleRow]+    , wrMappings :: ![MappingIdentity]+    , wrBindingObligations :: ![BindingHole]+    , wrAdopted :: ![AdoptedRow]+    }+    deriving stock (Eq, Show)++workspaceRecordHeader :: Text+workspaceRecordHeader = "keiro-dsl workspace scaffold record v1"++renderWorkspaceRecord :: WorkspaceRecord -> Text+renderWorkspaceRecord record =+    T.unlines $+        [ workspaceRecordHeader+        , "service: " <> wrService record+        , "manifest: " <> wrManifest record+        , "context: " <> wrContext record+        , "module-root: " <> rootLabel+        , "layout: " <> wrLayout record+        ]+            <> ["member " <> T.pack path | path <- wrMembers record]+            <> ["module " <> encodeRow row | row <- wrModules record]+            <> ["mapping " <> encodeRow mapping | mapping <- wrMappings record]+            <> ["binding " <> encodeRow obligation | obligation <- wrBindingObligations record]+            <> ["adopted " <> encodeRow adopted | adopted <- wrAdopted record]+  where+    rootLabel = if T.null (wrModuleRoot record) then "(none)" else wrModuleRoot record++encodeRow :: (ToJSON a) => a -> Text+encodeRow = Text.decodeUtf8 . BL.toStrict . Aeson.encode++{- | Parse a workspace record. The header and the five @key: value@ fields must+each appear exactly once; unknown lines are ignored for forward compatibility;+unsafe paths are rejected rather than joined to an output root.+-}+parseWorkspaceRecord :: Text -> Maybe WorkspaceRecord+parseWorkspaceRecord contents = case T.lines contents of+    header : rows+        | header == workspaceRecordHeader -> do+            service <- exactlyOne "service: " rows+            manifest <- exactlyOne "manifest: " rows+            context <- exactlyOne "context: " rows+            rootLabel <- exactlyOne "module-root: " rows+            layout <- exactlyOne "layout: " rows+            members <- traverse safePath [path | row <- rows, Just path <- [T.stripPrefix "member " row]]+            modules <- traverse (decodeRow "module ") (rowsWith "module " rows)+            checkedModules <- traverse checkedModule modules+            mappings <- traverse (decodeRow "mapping ") (rowsWith "mapping " rows)+            obligations <- traverse (decodeRow "binding ") (rowsWith "binding " rows)+            adopted <- traverse (decodeRow "adopted ") (rowsWith "adopted " rows)+            checkedAdopted <- traverse checkedAdoption adopted+            if hasDuplicates members+                || hasDuplicates (map wrmPath checkedModules)+                || hasDuplicates (map mappingSpecName mappings)+                || hasDuplicates (map bindingKey obligations)+                then Nothing+                else+                    pure+                        WorkspaceRecord+                            { wrService = service+                            , wrManifest = manifest+                            , wrContext = context+                            , wrModuleRoot = if rootLabel == "(none)" then "" else rootLabel+                            , wrLayout = layout+                            , wrMembers = members+                            , wrModules = checkedModules+                            , wrMappings = mappings+                            , wrBindingObligations = obligations+                            , wrAdopted = checkedAdopted+                            }+    _ -> Nothing+  where+    exactlyOne prefix rows = case [value | row <- rows, Just value <- [T.stripPrefix prefix row]] of+        [value] -> Just value+        _ -> Nothing+    rowsWith prefix rows = [row | row <- rows, prefix `T.isPrefixOf` row]+    decodeRow prefix row = do+        payload <- T.stripPrefix prefix row+        Aeson.decodeStrict' (Text.encodeUtf8 payload)+    checkedModule row = do+        path <- safePath (T.pack (wrmPath row))+        owner <- traverse (safePath . T.pack) (wrmOwner row)+        pure row{wrmPath = path, wrmOwner = owner}+    checkedAdoption row = do+        path <- safePath (T.pack (adPath row))+        pure row{adPath = path}+    safePath raw =+        let path = T.unpack raw+         in if null path || isAbsolute path || ".." `elem` splitDirectories path+                then Nothing+                else Just path+    hasDuplicates :: (Eq a) => [a] -> Bool+    hasDuplicates values = length values /= length (nub values)+    bindingKey hole =+        ( holeMappedName hole+        , holeModule hole+        , holeSymbol hole+        , holeKind hole+        , holePath hole+        )++{- | @keiro-dsl-scaffold-record.workspace.\<service\>.txt@ — the workspace+history file. See the module header for why the @workspace.@ slot cannot+collide with a context-keyed name.+-}+workspaceRecordFileName :: Text -> FilePath+workspaceRecordFileName service = "keiro-dsl-scaffold-record.workspace." <> T.unpack service <> ".txt"++-- | @keiro-dsl-manifest.workspace.\<service\>.txt@ — the Cabal build manifest.+workspaceManifestFileName :: Text -> FilePath+workspaceManifestFileName service = "keiro-dsl-manifest.workspace." <> T.unpack service <> ".txt"++{- | @keiro-dsl-migration-report.workspace.\<service\>.txt@ — the durable review+artifact written once, on the run that adopts pre-workspace scaffold output.+-}+workspaceMigrationReportFileName :: Text -> FilePath+workspaceMigrationReportFileName service = "keiro-dsl-migration-report.workspace." <> T.unpack service <> ".txt"++{- | The single line adoption appends to a superseded legacy record. The v1+parser ignores unknown lines, so the legacy record keeps parsing for old+binaries and stays readable for humans; nothing is renamed or deleted.+-}+supersededByLine :: Text -> Text+supersededByLine service = "superseded-by: " <> T.pack (workspaceRecordFileName service)
+ src/Keiro/Dsl/WorkspaceScaffold.hs view
@@ -0,0 +1,611 @@+{- | Whole-__workspace__ scaffolding: one invocation plans and emits the+complete generated module set for every member of a service workspace.++The module exists separately from "Keiro.Dsl.ScaffoldRun" for a structural+reason, not a stylistic one: "Keiro.Dsl.Workspace" already imports+'Keiro.Dsl.ScaffoldRun' (its cross-member collision check asks the planner), so+workspace-aware scaffolding cannot live there without a module cycle. Everything+it needs from the single-spec pipeline is imported, never re-implemented — the+refusal gates, the stale comparison, the constraint plan, the drift computation+— so a workspace and a single spec can never disagree about what is legal.++Two properties are true __by construction__ rather than by test:++  * Emission runs once over the workspace's /merged/ 'Spec'+    ('Keiro.Dsl.Workspace.wsMergedSpec'), so the context-level artifacts — the+    structural projection facade and the replay-audit assembly — are emitted+    exactly once from the complete graph. Concatenating per-member scaffolds+    would emit them N times from N partial graphs, which is the defect this+    module fixes.++  * A one-member workspace produces exactly the single-file module set, in the+    same order, with identical bytes and identical metadata, because it calls+    the same emitters with the same inputs.++History is workspace-keyed ("Keiro.Dsl.WorkspaceRecord"). Each module remembers+which member produced it, so moving an aggregate between member files is an+/ownership move/ rather than a stale-plus-new pair.++Atomicity here means what it means for a single spec: every refusal is computed+before the first output byte changes. There are no staged temp-file writes.+-}+module Keiro.Dsl.WorkspaceScaffold (+    -- * Planning+    ModuleProvenance (..),+    WorkspacePlan (..),+    planWorkspaceScaffold,+    planWorkspaceScaffoldWithGoldens,+    provenanceOwner,++    -- * Golden payload roots+    goldenRootDivergence,++    -- * Execution+    OwnershipMove (..),+    WorkspaceScaffoldReport (..),+    executeWorkspaceScaffold,+    renderWorkspaceScaffoldReport,+) where++import Data.List (nub, sortOn)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Keiro.Dsl.ExplainBindings (BindingHole (..), bindingHoles)+import Keiro.Dsl.Goldens (GoldenPayload)+import Keiro.Dsl.Grammar+import Keiro.Dsl.Harness (harnessForWithGoldens, harnessProcess, harnessReadModel, harnessRouter, harnessWorkflow)+import Keiro.Dsl.Manifest (moduleNameOf, renderManifest)+import Keiro.Dsl.MappedConsumer (ConsumerPlan (..), consumerPlan)+import Keiro.Dsl.Scaffold+import Keiro.Dsl.ScaffoldRun (+    MappingDrift (..),+    Refusal (..),+    StaleModule (..),+    WriteDisposition (..),+    constraintPlan,+    mappingDrift,+    missingGeneratedBanners,+    newBindingObligations,+    obligationKindLabel,+    pureRefusals,+    renderMappingIdentity,+    staleAgainst,+ )+import Keiro.Dsl.Validate (nodeIdentity)+import Keiro.Dsl.Workspace (WorkspaceMember (..), WorkspaceSpec (..), declarationOwner, nodeOwner)+import Keiro.Dsl.WorkspaceAdoption (MigrationReport (..), adoptedRows, adoptionReport, markLegacyRecordSuperseded, renderMigrationReport)+import Keiro.Dsl.WorkspaceRecord+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.FilePath (takeDirectory, takeFileName, (</>))++--------------------------------------------------------------------------------+-- Planning+--------------------------------------------------------------------------------++{- | Which member file produced an emitted module. 'ContextLevel' means the+module belongs to the whole service rather than to any one member: the+structural projection facade, the replay-audit assembly, and any binding+skeleton shared by declarations owned by different members.+-}+data ModuleProvenance+    = ContextLevel+    | MemberOwned !FilePath+    deriving stock (Eq, Ord, Show)++-- | The owning member path, or 'Nothing' for a context-level module.+provenanceOwner :: ModuleProvenance -> Maybe FilePath+provenanceOwner ContextLevel = Nothing+provenanceOwner (MemberOwned path) = Just path++{- | The complete, refusal-free write set for one whole-workspace scaffold, with+each module's producing member attached.+-}+data WorkspacePlan = WorkspacePlan+    { wpWorkspace :: !WorkspaceSpec+    , wpContext :: !Context+    , wpGoldenRoot :: !FilePath+    {- ^ The one golden-payload root for the whole workspace. Carried here so+    execution can refuse a member-adjacent fixture the root lacks before it+    writes anything.+    -}+    , wpModules :: ![(ScaffoldModule, ModuleProvenance)]+    }+    deriving stock (Eq, Show)++-- | 'planWorkspaceScaffoldWithGoldens' with no golden payload fixtures.+planWorkspaceScaffold :: FilePath -> Context -> WorkspaceSpec -> Either [Refusal] WorkspacePlan+planWorkspaceScaffold = planWorkspaceScaffoldWithGoldens []++{- | Plan the whole workspace: build the merged module set once, attribute each+module to its owning member, then run every pure refusal gate over the complete+set. A refusal carries no write set, so it cannot be executed by accident.++Because the gates see the whole workspace, a case-folded module-path collision+between two members is caught here, with both member files named in the+collision's origins.+-}+planWorkspaceScaffoldWithGoldens ::+    [GoldenPayload] ->+    FilePath ->+    Context ->+    WorkspaceSpec ->+    Either [Refusal] WorkspacePlan+planWorkspaceScaffoldWithGoldens goldens goldenRoot ctx workspace =+    case pureRefusals ctx merged (map fst tagged) of+        [] ->+            Right+                WorkspacePlan+                    { wpWorkspace = workspace+                    , wpContext = ctx+                    , wpGoldenRoot = goldenRoot+                    , wpModules = tagged+                    }+        refusals -> Left refusals+  where+    merged = wsMergedSpec workspace+    tagged = workspaceModules goldens ctx workspace++{- | The tagged module set, in exactly the order+'Keiro.Dsl.ScaffoldRun.scaffoldModulesWithGoldens' produces for the merged spec.++Attribution is structural, never a re-parse of the human-readable @origin@+string: structural modules carry the mapped declarations they were emitted for+('scaffoldStructuralOwners') and nodes carry their own identity+('nodeIdentity'), both of which the workspace's ownership index resolves to a+member file.+-}+workspaceModules :: [GoldenPayload] -> Context -> WorkspaceSpec -> [(ScaffoldModule, ModuleProvenance)]+workspaceModules goldens ctx workspace =+    [attributed (declarationProvenance names) m | (m, names) <- scaffoldStructuralOwners ctx merged]+        <> [attributed ContextLevel m | m <- scaffoldReplayAudit ctx merged]+        <> concat+            [ map (attributed (nodeProvenance node)) (emittersFor node)+            | node <- specNodes merged+            ]+  where+    merged = wsMergedSpec workspace+    ownership = wsOwnership workspace++    emittersFor node = case node of+        NAggregate aggregate -> scaffoldAggregate ctx merged aggregate <> harnessForWithGoldens goldens ctx merged aggregate+        NProcess process -> scaffoldProcess ctx process <> harnessProcess ctx process+        NRouter router -> scaffoldRouter ctx router <> harnessRouter ctx router+        NContract contract -> scaffoldContract ctx contract+        NIntake intake -> scaffoldIntake ctx intake+        NPublisher publisher -> scaffoldPublisher ctx publisher+        NWorkqueue workqueue -> scaffoldWorkqueue ctx workqueue+        NReadModel readModel -> scaffoldReadModel ctx readModel <> harnessReadModel ctx readModel+        NWorkflow workflow -> harnessWorkflow ctx workflow+        NEmit _ -> []+        NPgmqDispatch _ -> []+        NOperation _ -> []++    nodeProvenance node =+        let (kind', name, _) = nodeIdentity node+         in maybe ContextLevel (MemberOwned . fst) (nodeOwner ownership kind' name)++    -- A structural module belongs to a member only when every declaration it+    -- was emitted for has the same owner. A binding skeleton shared by+    -- declarations from two members belongs to neither: attributing it to one+    -- would make the other member's obligations look like they moved whenever+    -- the map iteration order changed.+    declarationProvenance names = case nub owners of+        [owner] | length owners == length names -> MemberOwned owner+        _ -> ContextLevel+      where+        owners = [owner | name <- names, Just (owner, _) <- [declarationOwner ownership "mapped" name]]++    -- Name the producing member in refusal messages, so a cross-member path+    -- collision says which files claimed the path. `origin` is metadata read+    -- only by refusal rendering: it never reaches the module text, the record,+    -- or the build manifest. A single-member workspace adds no prefix, which is+    -- what keeps it identical to the single-file path down to this field.+    attributed provenance m = (annotate provenance m, provenance)+    annotate (MemberOwned path) m+        | length (wsMembers workspace) > 1 = m{origin = T.pack path <> ": " <> origin m}+    annotate _ m = m++--------------------------------------------------------------------------------+-- Golden payload roots+--------------------------------------------------------------------------------++{- | Refuse when a member has golden payload fixtures beside it that the+workspace's single golden root does not have.++Golden fixtures are keyed @\<context\>\/\<Aggregate\>\/\<Event\>.v\<N\>.json@ —+by aggregate, and an aggregate has exactly one owner across a workspace — so one+root per workspace cannot collide, while a per-member root would make a+fixture's location depend on which file currently owns the aggregate and break+the rule that an ownership move is not a content change.++Without this check the failure would be silent: a member-adjacent fixture the+workspace root lacks is simply not found, the harness embeds a synthesized weak+stand-in instead of the file-owned payload, and generated bytes change with no+diagnostic at all.+-}+goldenRootDivergence :: FilePath -> WorkspaceSpec -> IO [Refusal]+goldenRootDivergence workspaceRoot workspace = do+    stranded <- concat <$> traverse strandedFor (wsMembers workspace)+    pure [GoldenRootDivergence workspaceRoot stranded | not (null stranded)]+  where+    manifestDir = takeDirectory (wsManifestPath workspace)+    strandedFor member = concat <$> traverse (check member) (upcastFixtures (wmSpec member))+    check member relative = do+        let memberRoot = manifestDir </> takeDirectory (wmPath member) </> "golden-payloads"+        besideMember <- firstExisting memberRoot relative+        case besideMember of+            Nothing -> pure []+            Just found -> do+                atRoot <- firstExisting workspaceRoot relative+                pure (case atRoot of Nothing -> [found]; Just _ -> [])+    -- Mirror the two shapes `loadGoldenPayloads` accepts: a root holding+    -- context directories, or a root that already is the context directory.+    firstExisting root relative = firstJustM [root </> relative, root </> dropContext relative]+    dropContext relative = case break (== '/') relative of+        (_, '/' : rest) -> rest+        _ -> relative+    firstJustM [] = pure Nothing+    firstJustM (path : rest) = do+        exists <- doesFileExist path+        if exists then pure (Just path) else firstJustM rest++{- | The @\<context\>\/\<Aggregate\>\/\<Event\>.v\<N\>.json@ fixture paths a+spec's declared upcasters would load, in spec order.+-}+upcastFixtures :: Spec -> [FilePath]+upcastFixtures spec =+    [ T.unpack (specContext spec) </> T.unpack (aggName aggregate) </> fixtureName event sourceVersion+    | NAggregate aggregate <- specNodes spec+    , event <- aggEvents aggregate+    , Just (sourceVersion, _) <- [evUpcastFrom event]+    ]+  where+    fixtureName event sourceVersion = T.unpack (evName event) <> ".v" <> show sourceVersion <> ".json"++--------------------------------------------------------------------------------+-- Execution+--------------------------------------------------------------------------------++{- | A module the workspace still produces, but from a different member file+than last time. 'Nothing' on either side means context-level.++An ownership move is deliberately __not__ a stale entry and __not__ a new file:+the path is still produced, so nothing is orphaned. Reporting it separately is+what stops "I moved this aggregate to another file" from looking like "another+spec's leftovers". Whole-workspace diffing must classify it identically.+-}+data OwnershipMove = OwnershipMove+    { omPath :: !FilePath+    , omPrevious :: !(Maybe FilePath)+    , omCurrent :: !(Maybe FilePath)+    }+    deriving stock (Eq, Show)++-- | What one successful whole-workspace scaffold did.+data WorkspaceScaffoldReport = WorkspaceScaffoldReport+    { wsrManifestPath :: !FilePath+    , wsrOutDir :: !FilePath+    , wsrService :: !Text+    , wsrContext :: !Context+    , wsrMembers :: ![FilePath]+    , wsrDispositions :: ![(ScaffoldModule, ModuleProvenance, WriteDisposition)]+    , wsrBuildManifestPath :: !FilePath+    , wsrRecordPath :: !FilePath+    , wsrPreviousManifest :: !(Maybe Text)+    {- ^ The manifest file name the previous workspace record was written from,+    when it differs from this run's.+    -}+    , wsrStale :: ![StaleModule]+    , wsrOwnershipMoves :: ![OwnershipMove]+    , wsrConsumerPlan :: !ConsumerPlan+    , wsrConstraintPlan :: ![Text]+    , wsrMappingDrift :: ![MappingDrift]+    , wsrNewHoles :: ![BindingHole]+    , wsrMigration :: !(Maybe MigrationReport)+    -- ^ Present only on the run that adopted pre-workspace scaffold output.+    }+    deriving stock (Eq, Show)++{- | Execute a planned whole-workspace scaffold.++The shape mirrors 'Keiro.Dsl.ScaffoldRun.executeScaffold' step for step, with+three differences that matter:++  * Both preflights — stranded golden fixtures and Generated paths lacking the+    @-- \@generated@ banner — are evaluated over the __complete__ workspace set+    before the output directory is created or any file is touched. A bannerless+    file under any member's subtree therefore refuses the whole run, and a+    refused run leaves the tree, the record, and the build manifest untouched.++  * History is read from and written to the workspace-keyed record, so stale+    detection compares whole workspaces. A module produced by a sibling member+    is in the current set and can no longer be a false positive — the defect+    that made two same-context specs report each other's files as stale.++  * A Generated module whose bytes already match is reported 'Unchanged' and+    not rewritten, which is what makes idempotence observable rather than+    merely claimed.+-}+executeWorkspaceScaffold :: FilePath -> Bool -> WorkspacePlan -> IO (Either [Refusal] WorkspaceScaffoldReport)+executeWorkspaceScaffold out forceGeneratedOverwrite plan = do+    stranded <- goldenRootDivergence (wpGoldenRoot plan) workspace+    bannerless <- if forceGeneratedOverwrite then pure [] else missingGeneratedBanners out modules+    case stranded <> [MissingGeneratedBanner bannerless | not (null bannerless)] of+        refusals@(_ : _) -> pure (Left refusals)+        [] -> do+            previous <- readWorkspaceRecord recordPath+            stale <- staleAgainst out (map modulePath modules) (previousFiles previous)+            -- Adoption is a one-shot, guarded by the absence of workspace+            -- history: once this workspace owns the directory there is nothing+            -- left to import, and the migration report stays as written.+            migration <- case previous of+                Just _ -> pure Nothing+                Nothing -> adoptionReport out (wsContext workspace) service modules+            let currentPlan = consumerPlan merged+                drift = maybe [] (mappingDrift (consumerMappings currentPlan) . wrMappings) previous+                currentObligations = either (const []) id (bindingHoles merged)+                newHoles = maybe [] (newBindingObligations currentObligations . wrBindingObligations) previous+            createDirectoryIfMissing True out+            dispositions <- traverse (writeWorkspaceModule out) (wpModules plan)+            TIO.writeFile buildManifestPath (renderManifest (T.pack manifestName) modules merged)+            -- Adoption provenance is durable history, not a one-run note: a+            -- later run that adopts nothing carries the previous rows forward,+            -- or the record would silently forget where its files came from.+            let adopted = case migration of+                    Just report -> adoptedRows report+                    Nothing -> maybe [] wrAdopted previous+            TIO.writeFile recordPath (renderWorkspaceRecord (currentWorkspaceRecord plan adopted))+            case migration of+                Nothing -> pure ()+                Just report -> do+                    TIO.writeFile+                        (out </> workspaceMigrationReportFileName service)+                        (T.unlines (renderMigrationReport report))+                    markLegacyRecordSuperseded out (wsContext workspace) service+            pure $+                Right+                    WorkspaceScaffoldReport+                        { wsrManifestPath = wsManifestPath workspace+                        , wsrOutDir = out+                        , wsrService = wsService workspace+                        , wsrContext = wpContext plan+                        , wsrMembers = map wmPath (wsMembers workspace)+                        , wsrDispositions = dispositions+                        , wsrBuildManifestPath = buildManifestPath+                        , wsrRecordPath = recordPath+                        , wsrPreviousManifest = do+                            record <- previous+                            if wrManifest record == T.pack manifestName then Nothing else Just (wrManifest record)+                        , wsrStale = stale+                        , wsrOwnershipMoves = ownershipMoves previous (wpModules plan)+                        , wsrConsumerPlan = currentPlan+                        , wsrConstraintPlan = constraintPlan merged currentPlan+                        , wsrMappingDrift = drift+                        , wsrNewHoles = newHoles+                        , wsrMigration = migration+                        }+  where+    workspace = wpWorkspace plan+    merged = wsMergedSpec workspace+    modules = map fst (wpModules plan)+    service = wsService workspace+    manifestName = takeFileName (wsManifestPath workspace)+    recordPath = out </> workspaceRecordFileName service+    buildManifestPath = out </> workspaceManifestFileName service+    previousFiles previous = [(wrmKind row, wrmPath row) | row <- maybe [] wrModules previous]++readWorkspaceRecord :: FilePath -> IO (Maybe WorkspaceRecord)+readWorkspaceRecord path = do+    exists <- doesFileExist path+    if exists then parseWorkspaceRecord <$> TIO.readFile path else pure Nothing++{- | The record this run writes: the plan's modules with their owners, the+canonical member list, the merged graph's mappings and obligations, and any+files adopted from pre-workspace scaffold output.+-}+currentWorkspaceRecord :: WorkspacePlan -> [AdoptedRow] -> WorkspaceRecord+currentWorkspaceRecord plan adopted =+    WorkspaceRecord+        { wrService = wsService workspace+        , wrManifest = T.pack (takeFileName (wsManifestPath workspace))+        , wrContext = wsContext workspace+        , wrModuleRoot = moduleRoot ctx+        , wrLayout = layoutLabel ctx+        , wrMembers = map wmPath (wsMembers workspace)+        , wrModules =+            [ WorkspaceModuleRow+                { wrmKind = kind m+                , wrmPath = modulePath m+                , wrmOwner = provenanceOwner provenance+                }+            | (m, provenance) <- wpModules plan+            ]+        , wrMappings = consumerMappings (consumerPlan merged)+        , wrBindingObligations = either (const []) id (bindingHoles merged)+        , wrAdopted = adopted+        }+  where+    workspace = wpWorkspace plan+    merged = wsMergedSpec workspace+    ctx = wpContext plan++layoutLabel :: Context -> Text+layoutLabel ctx = case placement ctx of GeneratedPrefix -> "prefixed"; CollocatedLeaf -> "collocated"++{- | Paths this run still produces whose owning member changed. Computed against+the previous record before stale detection, and never overlapping it: a moved+module's path is still in the current plan, so it was never a removal.+-}+ownershipMoves :: Maybe WorkspaceRecord -> [(ScaffoldModule, ModuleProvenance)] -> [OwnershipMove]+ownershipMoves previous current =+    [ OwnershipMove+        { omPath = modulePath m+        , omPrevious = wrmOwner row+        , omCurrent = provenanceOwner provenance+        }+    | (m, provenance) <- current+    , Just row <- [Map.lookup (modulePath m) previousByPath]+    , wrmOwner row /= provenanceOwner provenance+    ]+  where+    previousByPath = Map.fromList [(wrmPath row, row) | row <- maybe [] wrModules previous]++{- | Write one module. Generated modules whose bytes already match are left+alone and reported 'Unchanged'; hole modules keep the create-once rule. The+single-spec 'Keiro.Dsl.ScaffoldRun.executeScaffold' is untouched, so its report+bytes are unaffected.+-}+writeWorkspaceModule ::+    FilePath ->+    (ScaffoldModule, ModuleProvenance) ->+    IO (ScaffoldModule, ModuleProvenance, WriteDisposition)+writeWorkspaceModule out (m, provenance) = do+    let path = out </> modulePath m+    exists <- doesFileExist path+    case kind m of+        HoleStub+            | exists -> pure (m, provenance, Skipped)+            | otherwise -> write path Created+        Generated+            | exists -> do+                existing <- TIO.readFile path+                if existing == moduleText m+                    then pure (m, provenance, Unchanged)+                    else write path Overwritten+            | otherwise -> write path Overwritten+  where+    write path disposition = do+        createDirectoryIfMissing True (takeDirectory path)+        TIO.writeFile path (moduleText m)+        pure (m, provenance, disposition)++{- | The report a successful whole-workspace scaffold prints, following the+single-spec report's shape so the two stay readable side by side: the header+names the service instead of a spec, each module line carries its owning member,+and the stale section keeps the exact "keiro-dsl never deletes files." sentence.+-}+renderWorkspaceScaffoldReport :: WorkspaceScaffoldReport -> [Text]+renderWorkspaceScaffoldReport report =+    [ "workspace: "+        <> wsrService report+        <> " ("+        <> T.pack (wsrManifestPath report)+        <> ") -> "+        <> T.pack (wsrOutDir report)+        <> " (module-root="+        <> rootLabel+        <> ", layout="+        <> layoutLabel ctx+        <> ")"+    , "members:  " <> T.intercalate ", " (map T.pack (wsrMembers report))+    ]+        <> map moduleLine dispositions+        <> [ "firewall: OK (" <> tshow generatedCount <> " generated modules scanned, 0 forbidden operators)"+           , harnessLine+           , dependencyLine+           , "manifest: " <> T.pack (wsrBuildManifestPath report)+           , "record:   " <> T.pack (wsrRecordPath report)+           ]+        <> previousManifestNote+        <> migrationSection+        <> constraintSection+        <> newHolesSection+        <> mappingDriftSection+        <> ownershipSection+        <> staleSection+  where+    ctx = wsrContext report+    dispositions = wsrDispositions report+    rootLabel = if T.null (moduleRoot ctx) then "(none)" else moduleRoot ctx+    names = [moduleNameOf (modulePath m) | (m, _, _) <- dispositions]+    nameWidth = maximum (1 : map T.length names)+    moduleLine (m, provenance, disposition) =+        "  "+            <> kindTag (kind m)+            <> "  "+            <> pad (moduleNameOf (modulePath m))+            <> "  "+            <> dispositionTag disposition+            <> "  "+            <> ownerTag provenance+    kindTag Generated = "generated"+    kindTag HoleStub = "hole     "+    dispositionTag Overwritten = "(overwritten)"+    dispositionTag Created = "(created)"+    dispositionTag Skipped = "(skipped: already present)"+    dispositionTag Unchanged = "(unchanged)"+    ownerTag ContextLevel = "(context-level)"+    ownerTag (MemberOwned path) = T.pack path+    pad name = name <> T.replicate (nameWidth - T.length name) " "+    generatedCount = length [() | (m, _, _) <- dispositions, kind m == Generated]+    harnesses =+        sortOn+            id+            [ moduleNameOf (modulePath m)+            | (m, _, _) <- dispositions+            , any (`T.isSuffixOf` moduleNameOf (modulePath m)) [".Harness", ".ProcessHarness", ".WorkflowFacts"]+            ]+    harnessLine = case harnesses of+        [] -> "harness:  (none emitted)"+        _ -> "harness:  run `cabal test <your-component>` over " <> T.unwords harnesses+    dependencyLine =+        "dependency plan: consumer packages "+            <> renderBracketed (consumerPackages (wsrConsumerPlan report))+            <> ", consumer modules "+            <> renderBracketed (consumerModules (wsrConsumerPlan report))+    previousManifestNote = case wsrPreviousManifest report of+        Just previous -> ["note: the previous workspace record was written from manifest " <> previous]+        Nothing -> []+    migrationSection = maybe [] renderMigrationReport (wsrMigration report)+    constraintSection = case wsrConstraintPlan report of+        [] -> []+        constraints -> "constraint plan:" : map ("  " <>) constraints+    newHolesSection = case wsrNewHoles report of+        [] -> []+        obligations ->+            ["newly required holes since last scaffold: " <> tshow (length obligations)]+                <> concatMap obligationLines obligations+    obligationLines hole =+        [ "  " <> holeModule hole+        , "    " <> holeSignature hole <> " (" <> obligationKindLabel (holeKind hole) <> ")"+        ]+    mappingDriftSection = case wsrMappingDrift report of+        [] -> []+        drifts ->+            ["mapping drift: " <> tshow (length drifts) <> " declaration(s) changed since the previous scaffold:"]+                <> concatMap driftLines drifts+    driftLines drift =+        [ "  " <> driftSpecName drift+        , "    previous: " <> maybe "(absent)" renderMappingIdentity (driftPrevious drift)+        , "    current:  " <> maybe "(absent)" renderMappingIdentity (driftCurrent drift)+        ]+    ownershipSection = case wsrOwnershipMoves report of+        [] -> []+        moves ->+            ["ownership moves: " <> tshow (length moves) <> " module(s) changed owning member (content unaffected):"]+                <> [ "  " <> T.pack (omPath move) <> "  " <> ownerName (omPrevious move) <> " -> " <> ownerName (omCurrent move)+                   | move <- moves+                   ]+    ownerName = maybe "(context-level)" T.pack+    staleSection = case wsrStale report of+        [] -> []+        stale ->+            [ "stale: "+                <> tshow (length stale)+                <> " file(s) from a previous scaffold of workspace "+                <> wsrService report+                <> " are no longer produced by this workspace:"+            ]+                <> map staleLine stale+                <> ["note: keiro-dsl never deletes files."]+    staleLine stale = case staleKind stale of+        Generated -> "  generated " <> T.pack (stalePath stale) <> "  (safe to delete; still on disk)"+        HoleStub -> "  hole      " <> T.pack (stalePath stale) <> "  (hand-owned — review before deleting)"++renderBracketed :: [Text] -> Text+renderBracketed values = "[" <> T.intercalate ", " values <> "]"++tshow :: (Show a) => a -> Text+tshow = T.pack . show
test/Main.hs view
@@ -12,2817 +12,4189 @@ import Data.Aeson qualified as Aeson import Data.Aeson.KeyMap qualified as KeyMap import Data.Either (isLeft, isRight)-import Data.List (partition, sort)-import Data.List.NonEmpty (NonEmpty (..))-import Data.Map.Strict qualified as Map-import Data.Set qualified as Set-import Data.Text qualified as T-import Data.Text.IO qualified as TIO-import Keiro.Codec (Codec (..), EventType (..), decodeRaw)-import Keiro.Dsl.CodecCompare-import Keiro.Dsl.Coverage qualified as Coverage-import Keiro.Dsl.Diff (Change (..), ChangeKind (..), CompatibilitySurface (..), CompatibilityVector (..), FamilyDiff (..), Label (..), NodeFamily, RolloutConstraint (..), SurfaceVerdict (..), defaultGate, deriveLabel, diffSpecs, familyRegistry, gateWith, gatedBreaking, isAdvisory, isBreaking, verdictFor)-import Keiro.Dsl.DiffReport (diffReport, parseSurfaceName, remediationFor, renderExplainBlock, renderFinding)-import Keiro.Dsl.ExplainBindings (BindingHole (..), BindingObligation (..), BindingObligationKind (..), bindingObligations, renderBindingObligations)-import Keiro.Dsl.FoldFingerprint (aggregateFoldFingerprint, aggregateFoldSurface)-import Keiro.Dsl.Goldens (GoldenEvidence (..), GoldenPayload (..), emitGoldenPayloads, goldenRelativePath, goldensForDiff)-import Keiro.Dsl.Grammar-import Keiro.Dsl.Harness (harnessFor, harnessForWithGoldens, harnessReadModel, harnessRouter, harnessWorkflow)-import Keiro.Dsl.Manifest (manifestDependencies, moduleNameOf, renderManifest)-import Keiro.Dsl.MappedConsumer (ConsumerPlan (..))-import Keiro.Dsl.Parser (parseSpec)-import Keiro.Dsl.PrettyPrint (renderSpec, renderTransition)-import Keiro.Dsl.ReadModelShape (canonicalShape, deriveShapeHash, registryNameFor, subscriptionNameFor)-import Keiro.Dsl.ReplayImpact (AggregateImpact (..), ReplayImpact (..))-import Keiro.Dsl.ReplayImpact qualified as ReplayImpact-import Keiro.Dsl.Scaffold (Context (..), ModuleKind (..), ScaffoldModule (..), codecComparisonBanner, codecComparisonModule, defaultContext, firewallBreaches, genPrefixFor, holePrefixFor, scaffoldAggregate, scaffoldIntake, scaffoldProcess, scaffoldPublisher, scaffoldReadModel, scaffoldRefusals, scaffoldReplayAudit, scaffoldRouter, scaffoldWorkqueue, windowSeconds)-import Keiro.Dsl.ScaffoldRecord (ScaffoldRecord (..), parseRecord, recordFileName)-import Keiro.Dsl.ScaffoldRun (MappingDrift (..), Refusal (..), ScaffoldReport (..), StaleModule (..), WriteDisposition (..), executeScaffold, planScaffold, renderRefusals, renderScaffoldReport, scaffoldModules)-import Keiro.Dsl.Skeleton (skeletonFor, skeletonKinds)-import Keiro.Dsl.TypeGraph-import Keiro.Dsl.Validate (Diagnostic (..), DiagnosticCode (..), Severity (..), derivedQueueTrio, validateSpec)-import System.Directory (createDirectory, createDirectoryIfMissing, doesFileExist, getTemporaryDirectory, removeFile, removePathForcibly)-import System.Environment (lookupEnv)-import System.Exit (ExitCode (..))-import System.FilePath (takeDirectory, (</>))-import System.IO (hClose, openTempFile)-import System.Process (readProcessWithExitCode)-import Test.Hspec hiding (Spec)-import Test.QuickCheck--main :: IO ()-main = hspec $ do-    describe "historical codec comparison" $ do-        it "treats object-key order as RFC 8785 parity" $ do-            let historical = object ["z" .= (1 :: Int), "a" .= (2 :: Int)]-                generated = object ["a" .= (2 :: Int), "z" .= (1 :: Int)]-            classifyObservation (EncodeObservation "ordered-object" historical generated)-                `shouldBe` Right JsonParity-        it "classifies an omitted key versus explicit null as version work at that pointer" $ do-            let historical = object []-                generated = object ["description" .= Aeson.Null]-            classifyObservation (EncodeObservation "absent-description" historical generated)-                `shouldBe` Right (RequiresVersionWork (EncodedValueDifference (JsonPointer "/description") historical generated))-        it "classifies generated rejection of a historical value as version work" $-            classifyObservation-                ( DecodeObservation-                    "legacy.json"-                    (object ["tag" .= ("legacy" :: T.Text)])-                    (DecodedShape (object ["tag" .= ("legacy" :: T.Text)]))-                    (DecodeFailed "unknown tag")-                )-                `shouldBe` Right (RequiresVersionWork (GeneratedDecodeRejected "unknown tag"))-        it "treats historical-codec rejection as invalid input rather than parity" $-            classifyObservation-                ( DecodeObservation-                    "corrupt.json"-                    Aeson.Null-                    (DecodeFailed "not historical data")-                    (DecodeFailed "not generated data")-                )-                `shouldBe` Left (HistoricalCodecRejected "corrupt.json" "not historical data")-        it "reports uncovered union arms separately by corpus origin" $ do-            let canonical = DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")-                local = DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "local_file")-                report = compareReport comparisonProvenance [] [] [canonical, local] [ObservedBranch HistoricalGolden (JsonPointer "/location") (UnionArm "local_file")]-            crCoverageGaps report-                `shouldBe` [CoverageGap HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")]-            reportSucceeded report `shouldBe` False-        it "derives optional, null, and union-arm observations from a generated branch schema" $ do-            let schema =-                    BranchRecord-                        [ BranchField "description" True (BranchOptional BranchScalar)-                        , BranchField "location" False (BranchUnion "tag" "contents" [BranchArm "local" (Just BranchScalar), BranchArm "canonical" Nothing])-                        ]-                historical = object ["location" .= object ["tag" .= ("canonical" :: T.Text)]]-            observedBranchesFor HistoricalGolden schema historical-                `shouldBe` [ ObservedBranch HistoricalGolden (JsonPointer "/description") OptionalMissing-                           , ObservedBranch HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")-                           ]-            let declared = declaredBranchesFor HistoricalGolden schema-            forM_-                [ DeclaredBranch HistoricalGolden (JsonPointer "/description") OptionalMissing-                , DeclaredBranch HistoricalGolden (JsonPointer "/description") OptionalPresent-                , DeclaredBranch HistoricalGolden (JsonPointer "/description") ExplicitNull-                , DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "local")-                , DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")-                ]-                (\branch -> declared `shouldContain` [branch])-        it "round-trips the stable machine report" $ do-            let observation = EncodeObservation "parity" (object ["a" .= (1 :: Int)]) (object ["a" .= (1 :: Int)])-                report = compareReport comparisonProvenance [] [observation] [] []-            Aeson.eitherDecode (Aeson.encode report) `shouldBe` Right report-        it "atomically writes and replaces the machine report" $-            withTempDirectory "keiro-dsl-codec-compare" $ \out -> do-                let path = out </> "report.json"-                    firstReport = compareReport comparisonProvenance [] [] [] []-                    secondReport = compareReport comparisonProvenance [HistoricalGoldenUnreadable "bad.json" "bad JSON"] [] [] []-                writeCompareReportAtomic path firstReport `shouldReturn` Right ()-                Aeson.eitherDecodeFileStrict path `shouldReturn` Right firstReport-                writeCompareReportAtomic path secondReport `shouldReturn` Right ()-                Aeson.eitherDecodeFileStrict path `shouldReturn` Right secondReport--    describe "historical codec comparison scaffold" $ do-        it "emits an opt-in non-production runner without entering the ordinary module registry" $ do-            spec <- specOf "test/fixtures/structural-conformance.keiro"-            let ctx = defaultContext (specContext spec)-                planned = codecComparisonModule ctx spec "ArtifactInfo"-                ordinary = scaffoldModules ctx spec-            case planned of-                Left err -> expectationFailure (T.unpack err)-                Right comparisonModule -> do-                    modulePath comparisonModule-                        `shouldBe` "Generated/StructuralConformance/Structural/CodecCompare/ArtifactInfo.hs"-                    moduleText comparisonModule `shouldSatisfy` T.isInfixOf codecComparisonBanner-                    moduleText comparisonModule `shouldSatisfy` T.isInfixOf "Generated.StructuralConformance.ArtifactCatalog.Codec qualified as GeneratedCodec"-                    moduleText comparisonModule `shouldSatisfy` T.isInfixOf "branchSchema = BranchRecord"-                    map modulePath ordinary `shouldNotContain` [modulePath comparisonModule]-        it "refuses opaque selections rather than upgrading their claim" $ do-            spec <- specOf "test/fixtures/structural-conformance.keiro"-            codecComparisonModule (defaultContext (specContext spec)) spec "VendorGeometry"-                `shouldSatisfy` either (T.isInfixOf "is opaque") (const False)--    describe "structural/opaque coverage reporting" $ do-        it "reports mapped private-event roots and consumer-json register boundaries without a percentage" $ do-            spec <- specOf "test/fixtures/structural-conformance.keiro"-            report <- shouldResolveCoverage "structural-conformance.keiro" spec-            Coverage.privateEventPayloads (Coverage.coverageSummary report)-                `shouldBe` Coverage.CoverageCounts 2 1 1 0-            Coverage.snapshotRegisters (Coverage.coverageSummary report)-                `shouldBe` Coverage.CoverageCounts 2 1 1 0-            map Coverage.opaqueMappedType (Coverage.coverageOpaqueBoundaries report)-                `shouldBe` ["VendorGeometry"]-            map Coverage.snapshotEncoding (Coverage.coverageSnapshotBoundaries report)-                `shouldBe` ["consumer-json-cache", "consumer-json-cache"]-            map Coverage.snapshotInvalidation (Coverage.coverageSnapshotBoundaries report)-                `shouldBe` ["tracked-by-mapped-wire-fingerprint", "tracked-by-mapped-wire-fingerprint"]-            map Coverage.findingCode (Coverage.coverageFindings report)-                `shouldBe` [CoverageOpaqueSurface]-            map Coverage.findingSeverity (Coverage.coverageFindings report)-                `shouldBe` [Warning]-            case Aeson.toJSON report of-                Aeson.Object values ->-                    forM_ ["spec", "roots", "opaqueBoundaries", "snapshotBoundaries", "unsupportedSurfaces"] $-                        \key -> KeyMap.member key values `shouldBe` True-                value -> expectationFailure ("coverage report was not an object: " <> show value)-        it "reports explicit Json leaves by their complete persisted path" $ do-            spec <- withMetadataJson <$> specOf "test/fixtures/structural-conformance.keiro"-            report <- shouldResolveCoverage "structural-conformance-json.keiro" spec-            Coverage.jsonBoundaries (Coverage.privateEventPayloads (Coverage.coverageSummary report))-                `shouldBe` 1-            map Coverage.jsonPath (Coverage.coverageJsonBoundaries report)-                `shouldBe` ["ArtifactCatalog event ArtifactRecorded .artifact : ArtifactInfo .metadata : ArtifactMetadata .note"]-        it "keeps a zero-opaque spec advisory-free and makes rejection explicitly opt-in" $ do-            original <- specOf "test/fixtures/structural-conformance.keiro"-            clear <- shouldResolveCoverage "structural-only.keiro" (withoutVendorGeometry original)-            Coverage.opaqueRoots (Coverage.privateEventPayloads (Coverage.coverageSummary clear)) `shouldBe` 0-            Coverage.coverageOpaqueBoundaries clear `shouldBe` []-            Coverage.coverageFindings clear `shouldBe` []-            opaque <- shouldResolveCoverage "structural-conformance.keiro" original-            Coverage.coverageSucceeded opaque `shouldBe` True-            let gated = Coverage.failOnOpaque opaque-            Coverage.coverageSucceeded gated `shouldBe` False-            map Coverage.findingCode (Coverage.coverageFindings gated)-                `shouldBe` [CoverageOpaqueSurface, CoverageOpaqueGateExceeded]-            map Coverage.findingSeverity (Coverage.coverageFindings gated)-                `shouldBe` [Warning, Error]-        it "diffs named opaque boundaries and fails only an explicitly gated increase" $ do-            newSpec <- specOf "test/fixtures/structural-conformance.keiro"-            report <- case Coverage.coverageDiffReport "structural-conformance.keiro" "HEAD" (withoutVendorGeometry newSpec) newSpec of-                Left err -> expectationFailure (show err) >> fail "unreachable"-                Right value -> pure value-            fmap Coverage.opaqueBoundaryDelta (Coverage.coverageDelta report) `shouldBe` Just 1-            fmap (map Coverage.opaqueMappedType . Coverage.addedOpaqueBoundaries) (Coverage.coverageDelta report)-                `shouldBe` Just ["VendorGeometry"]-            map Coverage.findingCode (Coverage.coverageFindings report)-                `shouldBe` [CoverageOpaqueSurface, CoverageOpaqueBoundaryAdded]-            Coverage.coverageSucceeded report `shouldBe` True-            let gated = Coverage.failOnOpaqueIncrease report-            Coverage.coverageSucceeded gated `shouldBe` False-            map Coverage.findingCode (Coverage.coverageFindings gated)-                `shouldBe` [CoverageOpaqueSurface, CoverageOpaqueBoundaryAdded, CoverageOpaqueGateExceeded]-        it "appends the six stable coverage and comparison registry codes" $-            map-                show-                [ CoverageOpaqueSurface-                , CoverageOpaqueBoundaryAdded-                , CoverageOpaqueGateExceeded-                , CodecCompareDifference-                , CodecCompareCoverageGap-                , CodecCompareInvalidInput-                ]-                `shouldBe` [ "CoverageOpaqueSurface"-                           , "CoverageOpaqueBoundaryAdded"-                           , "CoverageOpaqueGateExceeded"-                           , "CodecCompareDifference"-                           , "CodecCompareCoverageGap"-                           , "CodecCompareInvalidInput"-                           ]--    describe "parse . pretty round-trip" $-        do-            it "re-parses any generated spec to an equal AST (modulo source locations)" $-                checkCoverage $-                    forAll genSpec $ \s ->-                        let families = map nodeTag (specNodes s)-                            roundTrip = parseSpec "<gen>" (renderSpec s) === Right s-                         in cover 5 (not (null (specMapped s))) "mapped" $-                                foldr (\family -> cover 1 (family `elem` families) family) roundTrip allNodeTags-            it "round-trips an aggregate with no states" $-                parseSpec "<empty-states>" (renderSpec emptyStatesSpec) `shouldBe` Right emptyStatesSpec-            it "separates transition emit clauses from following nodes" $ do-                spec <- parseInlineSpec "<cross-family-boundaries>" crossFamilyBoundarySpec-                case specNodes spec of-                    [NAggregate first, NEmit _, NAggregate second, NPgmqDispatch _] -> do-                        concatMap tEmits (aggTransitions first) `shouldBe` ["Changed"]-                        aggStates second `shouldBe` []-                    nodes -> expectationFailure ("unexpected node sequence: " <> show (map nodeTag nodes))--    describe "mapped types (EP-149)" $ do-        it "round-trips the canonical structural and opaque consumer fixture" $ do-            source <- TIO.readFile "test/fixtures/consumer-types.keiro"-            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source-            parseSpec "<consumer-types-round-trip>" (renderSpec spec) `shouldBe` Right spec-            length (specMapped spec) `shouldBe` 4-        it "preserves every missing-value policy, nested type expression, and unit union arm" $ do-            source <- TIO.readFile "test/fixtures/consumer-types.keiro"-            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source-            let fields = [field | MappedStructural{msShape = ShapeRecord _ _ recordFields} <- specMapped spec, field <- recordFields]-                arms = [arm | MappedStructural{msShape = ShapeUnion _ unionArms} <- specMapped spec, arm <- unionArms]-            [value | field <- fields, Just value <- [wfOnMissing field]]-                `shouldBe` [OmCtor "Guide", OmNull, OmInt 0, OmBool False, OmEmptyList, OmEmptyMap]-            [wfType field | field <- fields, wfHaskell field == "labels"]-                `shouldBe` [TList (TOptional TText)]-            [waCtor arm | arm <- arms, waPayload arm == Nothing]-                `shouldBe` ["Unknown"]-        it "rejects every mapped validation fixture with its stable diagnostic code" $ do-            let cases =-                    [ ("mapped-unresolved.keiro", MappedUnresolvedName)-                    , ("mapped-ambiguous.keiro", MappedAmbiguousName)-                    , ("mapped-dup-fieldname.keiro", MappedDuplicateFieldName)-                    , ("mapped-dup-wirekey.keiro", MappedDuplicateWireKey)-                    , ("mapped-dup-armname.keiro", MappedDuplicateArmName)-                    , ("mapped-dup-tag.keiro", MappedDuplicateWireTag)-                    , ("mapped-recursive.keiro", MappedRecursiveType)-                    , ("mapped-recursive-mutual.keiro", MappedRecursiveType)-                    , ("mapped-bad-encoding.keiro", MappedUnsupportedEncoding)-                    , ("mapped-union-key-collision.keiro", MappedUnsupportedEncoding)-                    , ("mapped-optional-json.keiro", MappedNonInjectiveNullability)-                    , ("mapped-optional-optional.keiro", MappedNonInjectiveNullability)-                    , ("mapped-optional-opaque.keiro", MappedNonInjectiveNullability)-                    , ("mapped-missing-binding.keiro", MappedMissingIngredient)-                    , ("mapped-missing-binding-version.keiro", MappedMissingIngredient)-                    , ("mapped-missing-canonical.keiro", MappedMissingIngredient)-                    , ("mapped-missing-fixture.keiro", MappedMissingIngredient)-                    , ("mapped-missing-initial.keiro", MappedMissingInitialValue)-                    , ("mapped-bad-haskell-name.keiro", MappedInvalidHaskellName)-                    , ("mapped-empty-identity.keiro", MappedInvalidIdentity)-                    , ("mapped-import-conflict.keiro", MappedImportConflict)-                    , ("mapped-illtyped-default.keiro", MappedDefaultIllTyped)-                    , ("mapped-guard.keiro", MappedGuardUnsupported)-                    , ("mapped-guard-natural.keiro", MappedGuardUnsupported)-                    ]-            forM_ cases $ \(fixture, expected) ->-                errorCodesOf ("test/fixtures/" <> fixture) `shouldReturn` [expected]-        it "keeps Time in Keiki's curated guard set while rejecting Natural" $-            errorCodesOf "test/fixtures/mapped-guard-time.keiro" `shouldReturn` []-        it "rejects required defaults, missing optional policies, Int overflow, and negative Natural defaults" $ do-            let invalidFields =-                    [ WireField "requiredDefault" "requiredDefault" TText PRequired (Just (OmText "x")) noLoc-                    , WireField "missingPolicy" "missingPolicy" TText POptional Nothing noLoc-                    , WireField "overflow" "overflow" TInt POptional (Just (OmInt (toInteger (maxBound :: Int) + 1))) noLoc-                    , WireField "negativeNatural" "negativeNatural" TNatural POptional (Just (OmInt (-1))) noLoc-                    ]-                declaration = completeStructural "Defaults" (ShapeRecord "Defaults" RejectUnknown invalidFields)-            errorCodes (mappedSpec [declaration])-                `shouldBe` [MappedDefaultIllTyped, MappedMissingIngredient, MappedDefaultIllTyped, MappedDefaultIllTyped]--    describe "mapped type graph (EP-149)" $ do-        it "resolves checked declarations, transitive reachability, and every aggregate root path" $ do-            source <- TIO.readFile "test/fixtures/consumer-types.keiro"-            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source-            graph <- shouldResolveTypeGraph spec-            Map.size (tgDeclarations graph) `shouldBe` 4-            Map.lookup (MappedKey "ArtifactInfo") (tgReachability graph)-                `shouldBe` Just (Set.fromList [MappedKey "ArtifactKind", MappedKey "ArtifactLocation"])-            map renderUsePath (usePaths graph "ArtifactLocation")-                `shouldBe` [ "Catalog command ObserveArtifact .artifact : ArtifactInfo .location : ArtifactLocation"-                           , "Catalog event ArtifactObserved .artifact : ArtifactInfo .location : ArtifactLocation"-                           , "Catalog register currentArtifact : ArtifactInfo .location : ArtifactLocation"-                           ]-        it "resolves every builtin through the complete expression algebra" $ do-            source <- TIO.readFile "test/fixtures/consumer-types.keiro"-            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source-            graph <- shouldResolveTypeGraph spec-            case Map.lookup (MappedKey "ArtifactInfo") (tgDeclarations graph) of-                Just (ResolvedStructural _ (RRecord _ _ fields)) ->-                    Set.fromList (concatMap (foldTypeExpr expressionTags . rwfType) fields)-                        `shouldBe` Set.fromList ["text", "int", "bool", "natural", "time", "json", "optional", "list", "map", "ref:ArtifactKind", "ref:ArtifactLocation"]-                declaration -> expectationFailure ("unexpected ArtifactInfo declaration: " <> show declaration)-        it "rejects direct, mutual, wrapped, and union-arm recursion" $ do-            let direct = mappedSpec [completeStructural "A" (recordShape [TRef "A"])]-                mutual = mappedSpec [completeStructural "A" (recordShape [TRef "B"]), completeStructural "B" (recordShape [TRef "A"])]-                wrapped = mappedSpec [completeStructural "A" (recordShape [TList (TOptional (TRef "A"))])]-                throughArm = mappedSpec [completeStructural "A" (ShapeUnion (TaggedObject "tag" "contents" RejectUnknown) [WireArm "Again" "again" (Just (TRef "A")) noLoc])]-            map (hasTypeGraphError isRecursive . resolveTypeGraph) [direct, mutual, wrapped, throughArm]-                `shouldBe` replicate 4 True-        it "keeps existing ids and enums outside the mapped-reference namespace" $ do-            let spec =-                    (mappedSpec [completeStructural "A" (recordShape [TRef "ExistingId"])])-                        { specIds = [IdDecl "ExistingId" "id" noLoc]-                        }-            resolveTypeGraph spec `shouldSatisfy` hasTypeGraphError isUnresolved-        it "fingerprints wire identity while ignoring Haskell selector names" $ do-            source <- TIO.readFile "test/fixtures/consumer-types.keiro"-            base <- parseInlineSpec "test/fixtures/consumer-types.keiro" source-            baseGraph <- shouldResolveTypeGraph base-            haskellRenameGraph <- shouldResolveTypeGraph (mapArtifactField (\field -> field{wfHaskell = "renamedKey"}) base)-            wireRenameGraph <- shouldResolveTypeGraph (mapArtifactField (\field -> field{wfKey = "renamed_key"}) base)-            wireFingerprint haskellRenameGraph "ArtifactInfo" `shouldBe` wireFingerprint baseGraph "ArtifactInfo"-            wireFingerprint wireRenameGraph "ArtifactInfo" `shouldNotBe` wireFingerprint baseGraph "ArtifactInfo"--    describe "string literal integrity" $ do-        it "parses an escaped emit-map value as exactly one row" $ do-            let src =-                    T.unlines-                        [ "context svc"-                        , ""-                        , "emit e {"-                        , "  contract c"-                        , "  topic events"-                        , "  source \"svc\""-                        , "  key thingId"-                        , "  map status {"-                        , "    \"a\\\" => Wat \\\"b\" => ThingAccepted"-                        , "    _ => skip"-                        , "  }"-                        , "  messageId derive hole"-                        , "  idempotencyKey derive hole"-                        , "}"-                        ]-            case parseSpec "<escaped-map>" src of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> case [row | NEmit e <- specNodes spec, row <- emMap e] of-                    [row] -> do-                        emrValue row `shouldBe` "a\" => Wat \"b"-                        emrEvent row `shouldBe` "ThingAccepted"-                    rows -> expectationFailure ("expected one emit-map row, got " <> show (length rows))-        it "rejects a raw newline inside a quoted string" $ do-            let src = "context svc\n\ncontract c {\n  schemaVersion 1\n  discriminator kind\n  topic events \"first\nsecond\"\n}\n"-            parseSpec "<raw-newline>" src `shouldSatisfy` leftContains "unescaped newline"-        it "rejects an unknown escape sequence" $ do-            let src = "context svc\n\ncontract c {\n  schemaVersion 1\n  discriminator kind\n  topic events \"bad\\q\"\n}\n"-            parseSpec "<unknown-escape>" src `shouldSatisfy` leftContains "unknown escape"-        it "round-trips adversarial text through topics, emit maps, and quoted bindings" $-            property $-                forAll genAdversarialText $ \t ->-                    let spec = escapedSpec t-                        rendered = renderSpec spec-                     in counterexample (T.unpack rendered) (parseSpec "<escaped-round-trip>" rendered === Right spec)--    describe "partial status maps" $ do-        it "suppresses totality only when the partial marker is present" $ do-            partial <- parseInlineSpec "<partial-status-map>" (statusMapSpec " partial")-            totalSpec <- parseInlineSpec "<total-status-map>" (statusMapSpec "")-            map code (validateSpec partial) `shouldNotContain` [StatusMapNotTotal]-            map code (validateSpec totalSpec) `shouldContain` [StatusMapNotTotal]-            parseSpec "<partial-round-trip>" (renderSpec partial) `shouldBe` Right partial--    describe "positioned parser diagnostics" $ do-        it "rejects a duplicate goto at the second clause" $ do-            err <- parseErrorOf "<duplicate-goto>" duplicateGotoSpec-            err `shouldSatisfy` T.isInfixOf "duplicate goto"-            err `shouldSatisfy` T.isInfixOf "<duplicate-goto>:10:"-        it "rejects duplicate wire and projection blocks at their second occurrences" $ do-            wireErr <- parseErrorOf "<duplicate-wire>" duplicateWireSpec-            wireErr `shouldSatisfy` T.isInfixOf "duplicate wire block"-            wireErr `shouldSatisfy` T.isInfixOf "<duplicate-wire>:8:"-            projectionErr <- parseErrorOf "<duplicate-projection>" duplicateProjectionSpec-            projectionErr `shouldSatisfy` T.isInfixOf "duplicate projection block"-            projectionErr `shouldSatisfy` T.isInfixOf "<duplicate-projection>:9:"-        it "anchors a missing goto on the transition line" $ do-            err <- parseErrorOf "<missing-goto>" missingGotoSpec-            err `shouldSatisfy` T.isInfixOf "missing a goto clause"-            err `shouldSatisfy` T.isInfixOf "<missing-goto>:8:"-        it "stops before a misplaced dispatch-id and expects schedule at its start" $ do-            let src = misplacedDispatchIdSpec-                expectedPosition =-                    "<misplaced-dispatch-id>:"-                        <> T.pack (show (lineNumberContaining "dispatch-id" src))-                        <> ":5:"-            err <- parseErrorOf "<misplaced-dispatch-id>" src-            err `shouldSatisfy` T.isInfixOf "schedule"-            err `shouldSatisfy` T.isInfixOf expectedPosition-        it "keeps a malformed register declaration's equals error" $ do-            err <- parseErrorOf "<malformed-register>" malformedRegisterSpec-            err `shouldSatisfy` T.isInfixOf "expecting '='"--    describe "bounded decimal literals" $ do-        forM_ decimalOverflowSpecs $ \(site, src) ->-            it ("rejects overflow at " <> site) $ do-                err <- parseErrorOf ("<overflow-" <> site <> ">") src-                err `shouldSatisfy` T.isInfixOf ("decimal literal " <> decimalOverflow <> " is out of range")-        it "accepts maxBound without changing its value" $ do-            spec <- parseInlineSpec "<max-bound>" (wireDecimalSpec (T.pack (show (maxBound :: Int))))-            [wireSchemaVersion wire | NAggregate aggregate <- specNodes spec, Just wire <- [aggWire aggregate]]-                `shouldBe` [maxBound]--    describe "identifier hygiene" $ do-        it "reports constructor shape and Haskell keywords at their owning declarations" $ do-            spec <- parseInlineSpec "<identifier-hygiene>" identifierHygieneSpec-            [(code diagnostic, line diagnostic) | diagnostic <- validateSpec spec, code diagnostic `elem` [IdentNotConstructorSafe, IdentHaskellKeyword]]-                `shouldContain` [(IdentNotConstructorSafe, 3), (IdentHaskellKeyword, 7)]-        it "rejects generated vertex constructors that collide with event constructors" $ do-            spec <- parseInlineSpec "<vertex-collision>" vertexCollisionSpec-            [(code diagnostic, line diagnostic) | diagnostic <- validateSpec spec, code diagnostic == VertexCtorCollision]-                `shouldBe` [(VertexCtorCollision, 3)]-        it "rejects underscore-leading names whose title-casing cannot make a module segment" $ do-            spec <- parseInlineSpec "<underscore-node>" underscoreNodeSpec-            [(code diagnostic, line diagnostic) | diagnostic <- validateSpec spec, code diagnostic == IdentNotConstructorSafe]-                `shouldBe` [(IdentNotConstructorSafe, 3)]-        it "rejects non-ASCII identifier characters in the parser" $-            parseSpec "<unicode-identifier>" unicodeIdentifierSpec `shouldSatisfy` leftContains "unexpected"--    describe "canonical reservation.keiro" $-        it "parses into the expected aggregate shape" $ do-            input <- readTestText "test/fixtures/reservation.keiro"-            case parseSpec "test/fixtures/reservation.keiro" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> do-                    specContext spec `shouldBe` "hospital-capacity"-                    length (specIds spec) `shouldBe` 3-                    length (specEnums spec) `shouldBe` 3-                    length (specRules spec) `shouldBe` 1-                    case specNodes spec of-                        [NAggregate a] -> do-                            aggName a `shouldBe` "Reservation"-                            length (aggStates a) `shouldBe` 6-                            length (aggCommands a) `shouldBe` 2-                            length (aggEvents a) `shouldBe` 2-                            length (aggTransitions a) `shouldBe` 2-                            map stTerminal (aggStates a) `shouldBe` [False, False, False, True, True, True]-                        other -> expectationFailure ("expected one aggregate node, got " <> show (length other))--    describe "validator" $ do-        it "accepts the canonical reservation.keiro" $ do-            codes <- errorCodesOf "test/fixtures/reservation.keiro"-            codes `shouldBe` []-        it "rejects a missing status-map as StatusMapNotTotal" $ do-            codes <- diagnosticCodesOf "test/fixtures/reservation-no-statusmap.keiro"-            codes `shouldContain` [StatusMapNotTotal]-        it "rejects an undeclared command as UndeclaredCommand" $ do-            codes <- diagnosticCodesOf "test/fixtures/reservation-bad-command.keiro"-            codes `shouldContain` [UndeclaredCommand]-        it "rejects a wall-clock guard atom as ClockSampled" $ do-            codes <- diagnosticCodesOf "test/fixtures/reservation-clock.keiro"-            codes `shouldContain` [ClockSampled]-        it "accepts a v2 event with a contiguous upcaster hole" $ do-            codes <- errorCodesOf "test/fixtures/reservation-v2.keiro"-            codes `shouldBe` []-        it "rejects a v2 event with no upcaster as EvtVersionMissingUpcaster" $ do-            codes <- diagnosticCodesOf "test/fixtures/reservation-v2-noupcast.keiro"-            codes `shouldContain` [EvtVersionMissingUpcaster]-        it "accepts shared upcaster sources for different event kinds" $ do-            codes <- errorCodesOf "test/fixtures/reservation-dup-upcast-source.keiro"-            codes `shouldNotContain` [DuplicateUpcasterSource]-        it "rejects a gap in the aggregate-global upcaster chain" $ do-            codes <- errorCodesOf "test/fixtures/reservation-chain-gap.keiro"-            codes `shouldContain` [UpcasterChainGap]-        it "warns while a retiring event keeps its live emitting transition" $ do-            diagnostics <- diagnosticsOf "test/fixtures/reservation-retiring.keiro"-            [code d | d <- diagnostics, severity d == Error] `shouldBe` []-            [code d | d <- diagnostics, severity d == Warning]-                `shouldContain` [EventRetirementInProgress]-        it "rejects a retiring event after its live emitting transition disappears" $ do-            source <- readTestText "test/fixtures/reservation-retiring.keiro"-            spec <- parseInlineSpec "<retiring-without-emitter>" (T.replace " ; emit TransferReservationConfirmed" "" source)-            [code d | d <- validateSpec spec, severity d == Error]-                `shouldContain` [EventRetirementInProgress]-        it "warns when a deprecated event has no replay-only emitting transition" $ do-            diagnostics <- diagnosticsOf "test/fixtures/reservation-deprecated.keiro"-            [code d | d <- diagnostics, severity d == Error] `shouldBe` []-            [code d | d <- diagnostics, severity d == Warning]-                `shouldContain` [DeprecatedEventReplayHazard]-        it "recognises deprecated plus replay-only as the replay-safe cutover" $ do-            diagnostics <- diagnosticsOf "test/fixtures/reservation-deprecated-replay-only.keiro"-            [code d | d <- diagnostics, severity d == Error] `shouldBe` []-            [code d | d <- diagnostics, severity d == Warning]-                `shouldContain` [EventRetirementInProgress]-            [code d | d <- diagnostics] `shouldNotContain` [DeprecatedEventReplayHazard]-        it "requires exact, unique status-map event keys" $ do-            dangling <- errorCodesOf "test/fixtures/statusmap-dangling.keiro"-            mapM_ (\expected -> dangling `shouldContain` [expected]) [StatusMapDanglingKey, StatusMapNotTotal]-            duplicate <- errorCodesOf "test/fixtures/statusmap-dup-key.keiro"-            duplicate `shouldContain` [StatusMapDuplicateKey]-        it "rejects duplicate spec and aggregate names" $ do-            codes <- errorCodesOf "test/fixtures/duplicate-names.keiro"-            mapM_-                (\expected -> codes `shouldContain` [expected])-                [ DuplicateNodeName-                , DuplicateEnumCtor-                , DuplicateEnumWire-                , DuplicateIdPrefix-                , DuplicateCommandName-                , DuplicateEventName-                ]-        it "rejects aggregate-local references that do not resolve" $ do-            codes <- errorCodesOf "test/fixtures/aggregate-bad-refs.keiro"-            codes `shouldContain` [RegisterInitialOutOfScope, UndeclaredCommand, WriteTargetNotRegister]-        it "anchors UnreachableState on the state row" $ do-            let src =-                    T.unlines-                        [ "context repro"-                        , ""-                        , "aggregate Thing"-                        , "  regs"-                        , "  states"-                        , "    Initial"-                        , "    Unreachable"-                        ]-            case parseSpec "<unreachable-row>" src of-                Left err -> expectationFailure (T.unpack err)-                Right spec ->-                    [line d | d <- validateSpec spec, code d == UnreachableState]-                        `shouldBe` [7]-        it "accepts a replay-only twin with a live sibling (plan 143)" $ do-            codes <- errorCodesOf "test/fixtures/reservation-guard-tightened-twin.keiro"-            codes `shouldBe` []-        it "rejects a replay-only transition that emits nothing" $ do-            case parseSpec "<replay-only-no-emit>" (replayOnlySpecWith ["    write reservationState := Held", "    goto  Held"]) of-                Left err -> expectationFailure (T.unpack err)-                Right spec ->-                    [code d | d <- validateSpec spec, severity d == Error]-                        `shouldContain` [ReplayOnlyEmitsNothing]-        it "warns when a replay-only transition has no live sibling" $ do-            case parseSpec "<replay-only-orphan>" (replayOnlySpecWith ["    emit  TransferReservationCreated", "    goto  Held"]) of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> do-                    [code d | d <- validateSpec spec, severity d == Warning]-                        `shouldContain` [ReplayOnlyCommandStillLive]-                    [code d | d <- validateSpec spec, severity d == Error]-                        `shouldNotContain` [ReplayOnlyCommandStillLive]--    describe "complementExpr (plan 143)" $ do-        it "applies De Morgan over and/or and flips comparison operators" $ do-            let a = EAtom (AName "a")-                b = EAtom (AName "b")-            complementExpr (EAnd a b)-                `shouldBe` EOr (ECmp OpEq a (EAtom (ABool False))) (ECmp OpEq b (EAtom (ABool False)))-            complementExpr (ECmp OpLt a b) `shouldBe` ECmp OpGe a b-            complementExpr (ECmp OpEq a b) `shouldBe` ECmp OpNeq a b-            complementExpr (ECmp OpLe a b) `shouldBe` ECmp OpGt a b-            complementExpr (ECmp OpGt a b) `shouldBe` ECmp OpLe a b-            complementExpr (ECmp OpGe a b) `shouldBe` ECmp OpLt a b-            complementExpr (ECmp OpNeq a b) `shouldBe` ECmp OpEq a b-        it "flips boolean literals and grounds bare names as == false" $ do-            complementExpr (EAtom (ABool True)) `shouldBe` EAtom (ABool False)-            complementExpr (EAtom (AName "open"))-                `shouldBe` ECmp OpEq (EAtom (AName "open")) (EAtom (ABool False))-        it "stays inside the grammar: the complement of any guard re-parses" $-            property $-                forAll genExpr $ \e ->-                    let twin =-                            replayOnlySpecWith-                                [ "    guard " <> renderExprText (complementExpr e)-                                , "    emit  TransferReservationCreated"-                                , "    goto  Held"-                                ]-                     in case parseSpec "<complement>" twin of-                            Left err -> counterexample (T.unpack err) False-                            Right spec ->-                                [tGuard t | NAggregate a <- specNodes spec, t <- aggTransitions a]-                                    === [Just (complementExpr e)]--    describe "evolution parsing" $ do-        it "parses event version and upcaster from reservation-v2.keiro" $ do-            input <- readTestText "test/fixtures/reservation-v2.keiro"-            case parseSpec "test/fixtures/reservation-v2.keiro" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> case [e | NAggregate a <- specNodes spec, e <- aggEvents a, evName e == "TransferReservationCreated"] of-                    (e : _) -> do-                        evVersion e `shouldBe` 2-                        evUpcastFrom e `shouldBe` Just (1, Hole)-                    [] -> expectationFailure "TransferReservationCreated not found"-        it "round-trips the retiring marker" $ do-            spec <- specOf "test/fixtures/reservation-retiring.keiro"-            parseSpec "<retiring-round-trip>" (renderSpec spec) `shouldBe` Right spec-            [evRetiring event | NAggregate aggregate <- specNodes spec, event <- aggEvents aggregate, evName event == "TransferReservationConfirmed"]-                `shouldBe` [True]-        it "rejects an event marked both retiring and deprecated" $ do-            source <- readTestText "test/fixtures/reservation-retiring.keiro"-            let conflicting = T.replace "retiring event TransferReservationConfirmed" "retiring deprecated event TransferReservationConfirmed" source-            parseSpec "<conflicting-retirement-markers>" conflicting `shouldSatisfy` isLeft--    describe "aggregate snapshots (EP-109)" $ do-        it "parses, validates, and round-trips a snapshot policy with codec fixture" $ do-            spec <- specOf "test/fixtures/reservation-snapshot.keiro"-            errorCodesOf "test/fixtures/reservation-snapshot.keiro" `shouldReturn` []-            parseSpec "<snapshot-round-trip>" (renderSpec spec) `shouldBe` Right spec-            case [aggregate | NAggregate aggregate <- specNodes spec] of-                [aggregate] -> aggSnapshot aggregate `shouldBe` Just (SnapshotSpec (SnapEvery 100) 1 "7eb3a94f62f947231375d44083e2a1c8029d91ffe0329107d55092ed3430efcc" noLoc)-                aggregates -> expectationFailure ("expected one snapshot aggregate, got " <> show (length aggregates))-        it "rejects disabled intervals and invalid codec fixtures" $ do-            source <- readTestText "test/fixtures/reservation-snapshot.keiro"-            interval <- parseInlineSpec "<snapshot-zero>" (T.replace "snapshot every 100" "snapshot every 0" source)-            map code (validateSpec interval) `shouldContain` [SnapshotIntervalInvalid]-            version <- parseInlineSpec "<snapshot-version-zero>" (T.replace "state-codec version=1" "state-codec version=0" source)-            map code (validateSpec version) `shouldContain` [SnapshotCodecFixtureInvalid]-            emptyHash <- parseInlineSpec "<snapshot-empty-hash>" (T.replace "shape-hash=\"7eb3a94f62f947231375d44083e2a1c8029d91ffe0329107d55092ed3430efcc\"" "shape-hash=\"\"" source)-            map code (validateSpec emptyHash) `shouldContain` [SnapshotCodecFixtureInvalid]-        it "conditionally lowers JSON instances and the live defaultStateCodec" $ do-            snapshot <- specOf "test/fixtures/reservation-snapshot.keiro"-            ordinary <- specOf "test/fixtures/reservation.keiro"-            case ([aggregate | NAggregate aggregate <- specNodes snapshot], [aggregate | NAggregate aggregate <- specNodes ordinary]) of-                ([snapshotAggregate], [ordinaryAggregate]) -> do-                    let snapshotModules = scaffoldAggregate (defaultContext (specContext snapshot)) snapshot snapshotAggregate-                        ordinaryModules = scaffoldAggregate (defaultContext (specContext ordinary)) ordinary ordinaryAggregate-                        snapshotDomain = generatedTextEndingIn "Domain.hs" snapshotModules-                        snapshotStream = generatedTextEndingIn "EventStream.hs" snapshotModules-                        ordinaryDomain = generatedTextEndingIn "Domain.hs" ordinaryModules-                        ordinaryStream = generatedTextEndingIn "EventStream.hs" ordinaryModules-                    snapshotDomain `shouldSatisfy` T.isInfixOf "deriving anyclass (ToJSON, FromJSON)"-                    snapshotStream `shouldSatisfy` T.isInfixOf "snapshotPolicy = Every 100"-                    snapshotStream `shouldSatisfy` T.isInfixOf "stateCodec = Just (withFoldFingerprint"-                    snapshotStream `shouldSatisfy` T.isInfixOf "Spec-visible fold changes invalidate old"-                    snapshotStream `shouldSatisfy` T.isInfixOf "module are invisible here"-                    snapshotStream `shouldSatisfy` T.isInfixOf "reservationSnapshotFixture = (1, \"7eb3a94f62f947231375d44083e2a1c8029d91ffe0329107d55092ed3430efcc\")"-                    ordinaryDomain `shouldNotSatisfy` T.isInfixOf "DeriveAnyClass"-                    ordinaryStream `shouldSatisfy` T.isInfixOf "snapshotPolicy = Never"-                    ordinaryStream `shouldSatisfy` T.isInfixOf "stateCodec = Nothing"-                    ordinaryStream `shouldSatisfy` T.isInfixOf "reservationCategory = Stream.categoryUnsafe \"reservation\""-                    firewallBreaches snapshotModules `shouldBe` []-                _ -> expectationFailure "expected one aggregate in each snapshot test spec"--    describe "aggregate fold fingerprints (plan 138)" $ do-        it "is deterministic across repeated parses and formatting-only changes" $ do-            source <- readTestText "test/fixtures/reservation.keiro"-            first <- parseInlineSpec "<first>" source-            second <- parseInlineSpec "<second>" ("\n\n" <> renderSpec first <> "\n")-            aggregateFoldFingerprint first (onlyAggregate first)-                `shouldBe` aggregateFoldFingerprint second (onlyAggregate second)-        it "changes for transition writes, guards, and referenced rule bodies" $ do-            base <- specOf "test/fixtures/reservation.keiro"-            writeChanged <- specOf "test/fixtures/reservation-foldchange.keiro"-            guardChanged <- specOf "test/fixtures/reservation-guard-tightened.keiro"-            source <- readTestText "test/fixtures/reservation.keiro"-            ruleChanged <- parseInlineSpec "<rule-change>" (T.replace "RedTag => true" "RedTag => false" source)-            let baseFingerprint = aggregateFoldFingerprint base (onlyAggregate base)-            aggregateFoldFingerprint writeChanged (onlyAggregate writeChanged) `shouldNotBe` baseFingerprint-            aggregateFoldFingerprint guardChanged (onlyAggregate guardChanged) `shouldNotBe` baseFingerprint-            aggregateFoldFingerprint ruleChanged (onlyAggregate ruleChanged) `shouldNotBe` baseFingerprint-        it "ignores wire and projection changes" $ do-            base <- specOf "test/fixtures/reservation.keiro"-            wireChanged <- specOf "test/fixtures/reservation-wire.keiro"-            source <- readTestText "test/fixtures/reservation.keiro"-            projectionChanged <- parseInlineSpec "<projection-change>" (T.replace "projection transfer_decisions" "projection renamed_projection" source)-            let surface = aggregateFoldSurface base (onlyAggregate base)-            aggregateFoldSurface wireChanged (onlyAggregate wireChanged) `shouldBe` surface-            aggregateFoldSurface projectionChanged (onlyAggregate projectionChanged) `shouldBe` surface-        it "invalidates mapped-register snapshots when binding or wire identity changes" $ do-            base <- specOf "test/fixtures/consumer-types.keiro"-            bindingChanged <- specOf "test/fixtures/consumer-types-binding-change.keiro"-            wireChanged <- specOf "test/fixtures/consumer-types-wirekey.keiro"-            let baseFingerprint = aggregateFoldFingerprint base (onlyAggregate base)-            aggregateFoldFingerprint bindingChanged (onlyAggregate bindingChanged) `shouldNotBe` baseFingerprint-            aggregateFoldFingerprint wireChanged (onlyAggregate wireChanged) `shouldNotBe` baseFingerprint--    describe "process/timer (EP-3)" $ do-        it "parses the hospital-surge process + nested timer" $ do-            input <- readTestText "test/fixtures/hospital-surge.keiro"-            case parseSpec "test/fixtures/hospital-surge.keiro" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> case [p | NProcess p <- specNodes spec] of-                    (p : _) -> do-                        procId p `shouldBe` "HospitalSurge"-                        procName p `shouldBe` "hospital-surge"-                        procRejected p `shouldBe` PolHalt-                        procPoison p `shouldBe` PolHalt-                        sagaCategory (procSaga p) `shouldBe` "hospitalSurge"-                        tmName (procTimer p) `shouldBe` "surgeFollowUp"-                        onReject (fireDisposition (tmFire (procTimer p))) `shouldBe` OFired-                        onAmbiguous (fireDisposition (tmFire (procTimer p))) `shouldBe` ORetry-                        tmMaxAttempts (procTimer p) `shouldBe` 5-                    [] -> expectationFailure "no process node parsed"-        it "round-trips the hospital-surge spec through parse . pretty" $ do-            input <- readTestText "test/fixtures/hospital-surge.keiro"-            case parseSpec "in" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "accepts the hospital-surge spec (no errors; benign-inversion warnings only)" $ do-            codes <- errorCodesOf "test/fixtures/hospital-surge.keiro"-            codes `shouldBe` []-        it "rejects illegal saga categories and no longer parses the raw stream-prefix clause" $ do-            spec <- specOf "test/fixtures/hospital-surge.keiro"-            mapM_-                (\categoryName -> processErrorCodes (\process -> process{procSaga = (procSaga process){sagaCategory = categoryName}}) spec `shouldContain` [SagaCategoryIllegal])-                ["", "$all", "hospital-surge", "hospital surge", "wf:surge"]-            source <- readTestText "test/fixtures/hospital-surge.keiro"-            parseSpec "<legacy-saga>" (T.replace "saga Surge category \"hospitalSurge\"" "saga Surge stream=\"hospital-surge-\" <> correlationId" source)-                `shouldSatisfy` isLeft-        it "rejects a wall-clock fireAt as ProcessFireAtNotInjected" $ do-            codes <- errorCodesOf "test/fixtures/hospital-surge-clock.keiro"-            codes `shouldContain` [ProcessFireAtNotInjected]-        it "reports one ProcessFireAtNotInjected for a wholly unknown fireAt field" $ do-            codes <- errorCodesOf "test/fixtures/hospital-surge-clock.keiro"-            length (filter (== ProcessFireAtNotInjected) codes) `shouldBe` 1-        it "rejects a user-supplied dispatch id as ProcessDispatchIdSupplied" $ do-            codes <- errorCodesOf "test/fixtures/hospital-surge-dispatchid.keiro"-            codes `shouldContain` [ProcessDispatchIdSupplied]-        it "rejects an unresolved saga reference as ProcessUnresolvedRef" $ do-            codes <- errorCodesOf "test/fixtures/hospital-surge-badref.keiro"-            codes `shouldContain` [ProcessUnresolvedRef]-        it "rejects unresolved process commands, projections, schedules, and advance ids" $ do-            codes <- errorCodesOf "test/fixtures/process-ghost-refs.keiro"-            length (filter (== ProcessUnresolvedRef) codes) `shouldBe` 5-            codes `shouldContain` [ProcessDispatchIdSupplied]--    describe "router (EP-108)" $ do-        it "parses the incident-paging router shape" $ do-            input <- readTestText "test/fixtures/incident-paging/incident-paging.keiro"-            case parseSpec "test/fixtures/incident-paging/incident-paging.keiro" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> case [router | NRouter router <- specNodes spec] of-                    [router] -> do-                        rtId router `shouldBe` "PagingRouter"-                        rtName router `shouldBe` "jitsurei-paging"-                        corrField (rtKey router) `shouldBe` "incidentId"-                        rvSource (rtResolve router) `shouldBe` ResolveReadModel "service_oncall"-                        rvRow (rtResolve router) `shouldBe` ["responderId"]-                        rdCommand (rtDispatch router) `shouldBe` "SendPage"-                        rtRejected router `shouldBe` PolDeadLetter-                        rtPoison router `shouldBe` PolHalt-                    routers -> expectationFailure ("expected one router, got " <> show (length routers))-        it "round-trips the incident-paging spec through parse . pretty" $ do-            input <- readTestText "test/fixtures/incident-paging/incident-paging.keiro"-            case parseSpec "in" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "accepts the incident-paging router with warnings only" $ do-            codes <- errorCodesOf "test/fixtures/incident-paging/incident-paging.keiro"-            codes `shouldBe` []-            diagnostics <- diagnosticCodesOf "test/fixtures/incident-paging/incident-paging.keiro"-            diagnostics `shouldContain` [PolicyDeadLetterUnused, AmbiguousFollowsRejectedPolicy]-        it "rejects unresolved targets, keys, commands, and binding scopes" $ do-            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"-            routerErrorCodes (\router -> router{rtTarget = "Pge"}) spec `shouldContain` [RouterUnresolvedRef]-            routerErrorCodes (\router -> router{rtKey = (rtKey router){corrField = "incidntId"}}) spec `shouldContain` [RouterKeyFieldUnknown]-            routerErrorCodes (\router -> router{rtDispatch = (rtDispatch router){rdCommand = "SendPag"}}) spec `shouldContain` [RouterCommandUnknown]-            routerErrorCodes-                ( \router ->-                    let dispatch = rtDispatch router-                     in router{rtDispatch = dispatch{rdFields = [FieldBinding "responderId" (Just "resolved.responder")]}}-                )-                spec-                `shouldContain` [RouterBindingUnscoped]-        it "rejects unresolved read models and contradictory rejection policies" $ do-            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"-            let withoutReadModel = removeReadModel "service_oncall" spec-            errorCodes withoutReadModel `shouldContain` [RouterUnresolvedRef]-            routerErrorCodes-                ( \router ->-                    let dispatch = rtDispatch router-                        disposition = rdDisposition dispatch-                     in router-                            { rtRejected = PolHalt-                            , rtDispatch = dispatch{rdDisposition = disposition{onFailed = DDeadLetter "page rejected"}}-                            }-                )-                spec-                `shouldContain` [PolicyContradiction]-        it "rejects on-ambiguous Fired for process timers" $ do-            spec <- specOf "test/fixtures/hospital-surge.keiro"-            let changed =-                    spec-                        { specNodes =-                            [ case node of-                                NProcess process ->-                                    let timer = procTimer process-                                        fire = tmFire timer-                                        disposition = fireDisposition fire-                                     in NProcess process{procTimer = timer{tmFire = fire{fireDisposition = disposition{onAmbiguous = OFired}}}}-                                _ -> node-                            | node <- specNodes spec-                            ]-                        }-            errorCodes changed `shouldContain` [AmbiguousMarkedBenign]-        it "requires explicit policy and ambiguity clauses in the grammar" $ do-            source <- readTestText "test/fixtures/hospital-surge.keiro"-            parseSpec "<missing-poison>" (T.replace "  poison => halt\n" "" source) `shouldSatisfy` isLeft-            parseSpec "<missing-ambiguous>" (T.replace " ; on-ambiguous Retry" "" source) `shouldSatisfy` isLeft-        it "scaffolds firewall-clean router wiring, policies, and typed-hole guidance" $ do-            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"-            case [router | NRouter router <- specNodes spec] of-                [router] -> do-                    let ctx = defaultContext (specContext spec)-                        modules = scaffoldRouter ctx router-                        generated = [m | m <- modules, kind m == Generated]-                        holes = [m | m <- modules, kind m == HoleStub]-                    firewallBreaches generated `shouldBe` []-                    case (generated, holes) of-                        ([generatedModule], [holeModule]) -> do-                            moduleText generatedModule `shouldSatisfy` T.isInfixOf "pagingRouterWorkerOptions"-                            moduleText generatedModule `shouldSatisfy` T.isInfixOf "rejectedCommandPolicy = RejectedDeadLetter"-                            moduleText holeModule `shouldSatisfy` T.isInfixOf "UNION of resolved target identities"-                            moduleText holeModule `shouldSatisfy` T.isInfixOf "confirmBenignDuplicate"-                        _ -> expectationFailure "expected one generated router module and one router hole module"-                routers -> expectationFailure ("expected one router, got " <> show (length routers))-        it "requires a caller callback for non-halting poison policies" $ do-            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"-            case [router | NRouter router <- specNodes spec] of-                [router] -> do-                    let ctx = defaultContext (specContext spec)-                        generatedFor choice = [moduleText m | m <- scaffoldRouter ctx router{rtPoison = choice}, kind m == Generated]-                    mapM_-                        ( \(choice, constructor) -> case generatedFor choice of-                            [generatedModule] -> do-                                generatedModule `shouldSatisfy` T.isInfixOf "(Envelope msg -> Eff es ()) -> WorkerOptions es msg"-                                generatedModule `shouldSatisfy` T.isInfixOf (constructor <> " poisonCallback")-                            _ -> expectationFailure "expected one generated router module"-                        )-                        [(PolDeadLetter, "PoisonDeadLetter"), (PolSkip, "PoisonSkip")]-                    case [moduleText m | m <- scaffoldRouter ctx router{rtRejected = PolSkip}, kind m == Generated] of-                        [generatedModule] -> generatedModule `shouldSatisfy` T.isInfixOf "rejectedCommandPolicy = RejectedSkip"-                        _ -> expectationFailure "expected one generated router module"-                routers -> expectationFailure ("expected one router, got " <> show (length routers))-        it "emits router harness facts that pin policy and target-keyed identity" $ do-            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"-            case [router | NRouter router <- specNodes spec] of-                [router] -> case harnessRouter (defaultContext (specContext spec)) router of-                    [facts] -> do-                        moduleText facts `shouldSatisfy` T.isInfixOf "(\"rejectedPolicy\", \"deadLetter\")"-                        moduleText facts `shouldSatisfy` T.isInfixOf "targetStreamName, occurrence"-                    modules -> expectationFailure ("expected one router harness, got " <> show (length modules))-                routers -> expectationFailure ("expected one router, got " <> show (length routers))-        it "rejects invalid timer ceilings and target field bindings" $ do-            codes <- errorCodesOf "test/fixtures/process-bad-timer.keiro"-            mapM_-                (\expected -> codes `shouldContain` [expected])-                [ProcessTimerCeilingInvalid, ProcessFieldBindingUnresolved]-        it "accepts resolved process projection references" $ do-            codes <- errorCodesOf "test/fixtures/surge-service.keiro"-            codes `shouldBe` []-        it "scaffolds the process: Generated wiring is firewall-clean + a HoleStub" $ do-            mods <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"-            let gens = [m | m <- mods, kind m == Generated]-                holes = [m | m <- mods, kind m == HoleStub]-            length holes `shouldBe` 1-            firewallBreaches gens `shouldBe` []-            case gens of-                [generatedModule] -> do-                    -- the worker uses the spec's ceiling, never the dangerous default-                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "max-attempts = 5"-                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "hospitalSurgeProcessWorkerOptions"-                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "hospitalSurgeCategory = Stream.categoryUnsafe \"hospitalSurge\""-                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "confirmBenignDuplicate"-                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "StreamName -> EventId -> CommandError -> Eff es Bool"-                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "Left (CommandAmbiguous _)"-                    case holes of-                        [holeModule] -> moduleText holeModule `shouldSatisfy` T.isInfixOf "entityStream hospitalSurgeCategory"-                        _ -> expectationFailure "expected one process hole module"-                _ -> expectationFailure "expected one generated process module"-        it "process scaffold is deterministic" $ do-            a <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"-            b <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"-            map moduleText a `shouldBe` map moduleText b--    describe "contract (EP-4)" $ do-        it "parses the emergency contract (topics + events-on-topic + typed fields)" $ do-            input <- readTestText "test/fixtures/contract.keiro"-            case parseSpec "test/fixtures/contract.keiro" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> case [c | NContract c <- specNodes spec] of-                    (c : _) -> do-                        ctrName c `shouldBe` "emergency"-                        ctrDiscriminator c `shouldBe` "messageType"-                        map fst (ctrTopics c) `shouldBe` ["incidentEvents", "hospitalEvents"]-                        map ceName (ctrEvents c) `shouldBe` ["IncidentTransferNeedDeclared", "TransferReservationAccepted"]-                    [] -> expectationFailure "no contract node parsed"-        it "round-trips the contract spec through parse . pretty" $ do-            input <- readTestText "test/fixtures/contract.keiro"-            case parseSpec "in" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "round-trips the intake (inbox) spec through parse . pretty" $ do-            input <- readTestText "test/fixtures/intake.keiro"-            case parseSpec "in" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "accepts the intake spec (complete disposition, no inversions)" $ do-            codes <- errorCodesOf "test/fixtures/intake.keiro"-            codes `shouldBe` []-        it "lowers explicit dedupe-only persistence and defaults omission to full-envelope" $ do-            spec <- specOf "test/fixtures/intake.keiro"-            ordinary <- specOf "test/fixtures/intake-decode.keiro"-            case ([intake | NIntake intake <- specNodes spec], [intake | NIntake intake <- specNodes ordinary]) of-                ([intake], [defaultIntake]) -> do-                    inkPersist intake `shouldBe` InkPersistDedupeOnly-                    inkPersist defaultIntake `shouldBe` InkPersistFull-                    renderSpec spec `shouldSatisfy` T.isInfixOf "persist = dedupe-only"-                    renderSpec ordinary `shouldNotSatisfy` T.isInfixOf "persist ="-                    let inbox = generatedTextEndingIn "Inbox.hs" (scaffoldIntake (defaultContext (specContext spec)) intake)-                    inbox `shouldSatisfy` T.isInfixOf "inboxPersistence = PersistDedupeOnly"-                (intakes, defaultIntakes) ->-                    expectationFailure ("expected one intake in each fixture, got " <> show (length intakes, length defaultIntakes))-        it "rejects duplicate => retry (inversion 1)" $ do-            codes <- errorCodesOf "test/fixtures/intake-dup-retry.keiro"-            codes `shouldContain` [DispositionDuplicateRetry]-        it "rejects previouslyFailed => retry (inversion 2)" $ do-            codes <- errorCodesOf "test/fixtures/intake-pf-retry.keiro"-            codes `shouldContain` [DispositionPreviouslyFailedRetry]-        it "rejects an incomplete disposition table" $ do-            codes <- errorCodesOf "test/fixtures/intake-incomplete.keiro"-            codes `shouldContain` [DispositionIncomplete]-        it "rejects a shadowing duplicate intake disposition row" $ do-            codes <- errorCodesOf "test/fixtures/intake-dup-row.keiro"-            codes `shouldContain` [DispositionDuplicateOutcome]-        it "rejects intake events declared on another topic" $ do-            codes <- errorCodesOf "test/fixtures/intake-topic-mismatch.keiro"-            codes `shouldContain` [TopicAffinityMismatch]-        it "round-trips the emit/publisher spec through parse . pretty" $ do-            input <- readTestText "test/fixtures/emit.keiro"-            case parseSpec "in" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "accepts the emit/publisher spec (skip present, coupling resolves)" $ do-            codes <- errorCodesOf "test/fixtures/emit.keiro"-            codes `shouldBe` []-        it "rejects a missing _ => skip catch-all as EmitSkipMissing" $ do-            codes <- errorCodesOf "test/fixtures/emit-noskip.keiro"-            codes `shouldContain` [EmitSkipMissing]-        it "rejects mapping to an undeclared contract event as EmitUnresolvedContract" $ do-            codes <- errorCodesOf "test/fixtures/emit-badevent.keiro"-            codes `shouldContain` [EmitUnresolvedContract]-        it "rejects emit events declared on another topic" $ do-            codes <- errorCodesOf "test/fixtures/emit-topic-mismatch.keiro"-            codes `shouldContain` [TopicAffinityMismatch]--    describe "pgmq workqueue/dispatch (EP-5)" $ do-        it "round-trips the reservation-work spec through parse . pretty" $ do-            input <- readTestText "test/fixtures/reservation-work.keiro"-            case parseSpec "in" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "accepts the reservation-work spec (physical matches, no inversions)" $ do-            codes <- errorCodesOf "test/fixtures/reservation-work.keiro"-            codes `shouldBe` []-        it "rejects a divergent captured physical name as WqPhysicalDivergence" $ do-            codes <- errorCodesOf "test/fixtures/reservation-work-divergent.keiro"-            codes `shouldContain` [WqPhysicalDivergence]-        it "rejects storeFailure => deadLetter as WqStoreFailureNotRetry" $ do-            codes <- errorCodesOf "test/fixtures/reservation-work-sf-deadletter.keiro"-            codes `shouldContain` [WqStoreFailureNotRetry]-        it "rejects decodeFailure => retry as WqDecodeFailureNotDeadLetter" $ do-            codes <- errorCodesOf "test/fixtures/reservation-work-df-retry.keiro"-            codes `shouldContain` [WqDecodeFailureNotDeadLetter]-        it "requires complete, unique workqueue disposition rows" $ do-            incomplete <- errorCodesOf "test/fixtures/workqueue-incomplete.keiro"-            incomplete `shouldContain` [WqDispositionIncomplete]-            duplicateSpec <- specOf "test/fixtures/workqueue-dup-row.keiro"-            let duplicateDiagnostics = [d | d <- validateSpec duplicateSpec, code d == DispositionDuplicateOutcome]-            map line duplicateDiagnostics `shouldBe` [17]-        it "checks the captured queueRef dlq and table fixtures" $ do-            dlqCodes <- errorCodesOf "test/fixtures/workqueue-dlq-divergent.keiro"-            dlqCodes `shouldContain` [WqDlqDivergence]-            tableCodes <- errorCodesOf "test/fixtures/workqueue-table-divergent.keiro"-            tableCodes `shouldContain` [WqTableDivergence]-        it "matches queueRef for upper-case, punctuation, and hashed logical names" $ do-            upper <- errorCodesOf "test/fixtures/workqueue-uppercase-logical.keiro"-            upper `shouldBe` []-            hashed <- errorCodesOf "test/fixtures/workqueue-hashed-logical.keiro"-            hashed `shouldBe` []-            derivedQueueTrio "hospital_capacity.reservation_work.per_hospital_fifo_lane_assignments"-                `shouldBe` ( "hospital_capacity_reservat_757040df00976c33"-                           , "hospital_capacity_reservat_757040df00976c33_dlq"-                           , "pgmq.q_hospital_capacity_reservat_757040df00976c33"-                           )-        it "resolves dispatch dedup queues and payload wire fields" $ do-            ghost <- errorCodesOf "test/fixtures/dispatch-dedup-ghost-queue.keiro"-            ghost `shouldContain` [DispatchDedupQueueUnresolved]-            field <- errorCodesOf "test/fixtures/dispatch-dedup-bad-field.keiro"-            field `shouldContain` [DispatchDedupFieldUnresolved]-        it "requires a resolvable group key exactly when ordering is FIFO" $ do-            noKey <- errorCodesOf "test/fixtures/reservation-work-fifo-nokey.keiro"-            noKey `shouldContain` [WqGroupKeyMissing]-            unordered <- errorCodesOf "test/fixtures/reservation-work-key-unordered.keiro"-            unordered `shouldContain` [WqGroupKeyWithoutFifo]-            source <- readTestText "test/fixtures/reservation-work.keiro"-            unresolved <- parseInlineSpec "<unresolved-group-key>" (T.replace "group key from reservationId" "group key from missingId" source)-            map code (validateSpec unresolved) `shouldContain` [WqGroupKeyUnresolved]-        it "warns on unlogged storage and rejects empty partition settings" $ do-            warningCodes <- diagnosticCodesOf "test/fixtures/reservation-work-unlogged.keiro"-            warningCodes `shouldContain` [WqUnloggedDurability]-            partitionCodes <- errorCodesOf "test/fixtures/reservation-work-partitioned-empty.keiro"-            partitionCodes `shouldContain` [WqPartitionSpecEmpty]-        it "lowers ordering, provisioning, and raw group-key projection" $ do-            spec <- specOf "test/fixtures/reservation-work.keiro"-            case [workqueue | NWorkqueue workqueue <- specNodes spec] of-                workqueue : _ -> do-                    let modules = scaffoldWorkqueue (defaultContext (specContext spec)) workqueue-                        queue = generatedTextEndingIn "Queue.hs" modules-                        policy = generatedTextEndingIn "QueuePolicy.hs" modules-                    queue `shouldSatisfy` T.isInfixOf "groupKeyFor payload = payload.reservationId"-                    policy `shouldSatisfy` T.isInfixOf "jobOrdering = FifoThroughput"-                    policy `shouldSatisfy` T.isInfixOf "withFifoIndexProvision (standardProvision)"-                    firewallBreaches modules `shouldBe` []-                [] -> expectationFailure "reservation-work fixture has no workqueue"--    describe "readmodel (EP-107)" $ do-        it "parses and round-trips first-class read models" $ do-            spec <- specOf "test/fixtures/readmodel.keiro"-            case [readModel | NReadModel readModel <- specNodes spec] of-                [subscriptionModel, inlineModel] -> do-                    rmName subscriptionModel `shouldBe` "transfer_decisions"-                    rmColumns subscriptionModel-                        `shouldBe` [ RmColumn "reservation_id" "text" True-                                   , RmColumn "hospital_id" "text" True-                                   , RmColumn "status" "text" True-                                   , RmColumn "decided_at" "timestamptz" False-                                   ]-                    rmScope subscriptionModel `shouldBe` Just (RmCategory "reservation")-                    rmFeed subscriptionModel `shouldBe` RmSubscription-                    rmSubscription subscriptionModel `shouldBe` Just "hospital-capacity-transfer-decisions-sub"-                    rmName inlineModel `shouldBe` "subscriptions"-                    rmScope inlineModel `shouldBe` Nothing-                    rmFeed inlineModel `shouldBe` RmInline-                nodes -> expectationFailure ("expected two readmodel nodes, got " <> show (length nodes))-            parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "accepts an aggregate projection without a consistency clause" $ do-            spec <- parseInlineSpec "<projection-without-consistency>" projectionWithoutConsistencySpec-            case [projection | NAggregate aggregate <- specNodes spec, Just projection <- [aggProjection aggregate]] of-                [projection] -> projConsistency projection `shouldBe` Nothing-                projections -> expectationFailure ("expected one projection, got " <> show (length projections))-        it "pins the canonical UTF-8 shape digest and runtime identities" $ do-            spec <- specOf "test/fixtures/readmodel.keiro"-            case [readModel | NReadModel readModel <- specNodes spec] of-                (subscriptionModel : inlineModel : _) -> do-                    canonicalShape subscriptionModel-                        `shouldBe` "transfer_decisions|reservation_id:text:req|hospital_id:text:req|status:text:req|decided_at:timestamptz:null"-                    deriveShapeHash subscriptionModel `shouldBe` "fnv1a:3717f6d9e3c44bd6"-                    deriveShapeHash inlineModel `shouldBe` "fnv1a:f54d9bb2f40a6738"-                    registryNameFor (specContext spec) subscriptionModel `shouldBe` "hospital-capacity-transfer-decisions"-                    subscriptionNameFor (specContext spec) subscriptionModel `shouldBe` "hospital-capacity-transfer-decisions-sub"-                    subscriptionNameFor "billing" inlineModel `shouldBe` "billing-subscriptions-sub"-                nodes -> expectationFailure ("expected readmodel nodes, got " <> show (length nodes))-        it "accepts the positive readmodel fixture with all references resolved" $ do-            spec <- specOf "test/fixtures/readmodel.keiro"-            validateSpec spec `shouldBe` []-        it "rejects shape drift and unknown SQL column types" $ do-            codes <- errorCodesOf "test/fixtures/readmodel-shape-drift.keiro"-            codes `shouldContain` [RmShapeHashDrift, RmUnknownColumnType]-        it "rejects Strong on inline and standalone projections" $ do-            inlineCodes <- errorCodesOf "test/fixtures/readmodel-strong-inline.keiro"-            inlineCodes `shouldContain` [RmStrongInlineOnly]-            standalone <- specOf "test/fixtures/readmodel-strong-standalone.keiro"-            let diagnostics = validateSpec standalone-            map code diagnostics `shouldContain` [RmStrongInlineOnly, RmProjectionWithoutNode]-            [severity diagnostic | diagnostic <- diagnostics, code diagnostic == RmProjectionWithoutNode]-                `shouldBe` [Warning]-        it "rejects scope without Strong and an unreferenced inline feed" $ do-            scopeCodes <- errorCodesOf "test/fixtures/readmodel-scope-eventual.keiro"-            scopeCodes `shouldContain` [RmScopeWithoutStrong]-            inlineCodes <- errorCodesOf "test/fixtures/readmodel-inline-unreferenced.keiro"-            inlineCodes `shouldContain` [RmInlineFeedUnreferenced]-        it "rejects projection consistency conflicts" $ do-            codes <- errorCodesOf "test/fixtures/readmodel-consistency-conflict.keiro"-            codes `shouldContain` [RmConsistencyConflict]-        it "resolves query read models and validates query consistency" $ do-            codes <- errorCodesOf "test/fixtures/readmodel-query-unresolved.keiro"-            codes `shouldContain` [QueryUnresolvedReadModel, QueryConsistencyInvalid]-        it "resolves dispatch read models and declared dedup columns" $ do-            codes <- errorCodesOf "test/fixtures/readmodel-dispatch-unresolved.keiro"-            codes `shouldContain` [DispatchReadModelUnresolved, DispatchReadModelFieldUnknown]-        it "scaffolds runtime records, rebuild helpers, async wiring, and typed holes" $ do-            spec <- specOf "test/fixtures/readmodel.keiro"-            let ctx = defaultContext (specContext spec)-                readModels = [readModel | NReadModel readModel <- specNodes spec]-                modules = concatMap (scaffoldReadModel ctx) readModels-                transfer = generatedTextEndingIn "Transfer_decisions/ReadModel.hs" modules-                inline = generatedTextEndingIn "Subscriptions/ReadModel.hs" modules-                transferHoles = [moduleText m | m <- modules, "Transfer_decisions/ReadModelHoles.hs" `T.isSuffixOf` T.pack (modulePath m)]-            length modules `shouldBe` 6-            length [m | m <- modules, kind m == Generated] `shouldBe` 4-            length [m | m <- modules, kind m == HoleStub] `shouldBe` 2-            firewallBreaches modules `shouldBe` []-            transfer `shouldSatisfy` T.isInfixOf "registerTransferDecisions"-            transfer `shouldSatisfy` T.isInfixOf "Rebuild.startRebuild transferDecisionsReadModel [\"hospital-capacity-transfer-decisions-async\"]"-            transfer `shouldSatisfy` T.isInfixOf "strongScope = CategoryHead \"reservation\""-            transfer `shouldSatisfy` T.isInfixOf "transferDecisionsAsyncProjection"-            inline `shouldSatisfy` T.isInfixOf "Rebuild.startRebuild subscriptionsReadModel []"-            inline `shouldNotSatisfy` T.isInfixOf "AsyncProjection"-            transferHoles `shouldSatisfy` any (T.isInfixOf "RecordedEvent -> Tx.Transaction ()")-        it "threads qualified table and column guidance into aggregate projection holes" $ do-            spec <- specOf "test/fixtures/readmodel.keiro"-            case [aggregate | NAggregate aggregate <- specNodes spec] of-                [aggregate] -> do-                    let modules = scaffoldAggregate (defaultContext (specContext spec)) spec aggregate-                        holes = [moduleText m | m <- modules, kind m == HoleStub]-                        projection = generatedTextEndingIn "Projection.hs" modules-                    holes `shouldSatisfy` any (T.isInfixOf "subscriptionsQualifiedTable")-                    holes `shouldSatisfy` any (T.isInfixOf "Table: \"billing\".\"subscriptions\"")-                    projection `shouldSatisfy` T.isInfixOf "ReadModelTable.subscriptionsQualifiedTable"-                aggregates -> expectationFailure ("expected one aggregate, got " <> show (length aggregates))-        it "emits runtime-free derivation facts for each read model" $ do-            spec <- specOf "test/fixtures/readmodel.keiro"-            case [readModel | NReadModel readModel <- specNodes spec] of-                (subscriptionModel : _) -> do-                    let modules = harnessReadModel (defaultContext (specContext spec)) subscriptionModel-                        harnessText = generatedTextEndingIn "ReadModelHarness.hs" modules-                    length modules `shouldBe` 1-                    firewallBreaches modules `shouldBe` []-                    harnessText `shouldSatisfy` T.isInfixOf "(\"shapeHash\", \"fnv1a:3717f6d9e3c44bd6\", \"fnv1a:3717f6d9e3c44bd6\")"-                    harnessText `shouldSatisfy` T.isInfixOf "(\"strongScope\", \"CategoryHead reservation\", \"CategoryHead reservation\")"-                    harnessText `shouldSatisfy` T.isInfixOf "runReadModelFacts"-                nodes -> expectationFailure ("expected readmodel nodes, got " <> show (length nodes))--    describe "workflow/operation (EP-6)" $ do-        it "round-trips the workflow spec through parse . pretty" $ do-            input <- readTestText "test/fixtures/workflow.keiro"-            case parseSpec "in" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec-        it "accepts the workflow spec (await<->signal matches, run resolves)" $ do-            codes <- errorCodesOf "test/fixtures/workflow.keiro"-            codes `shouldBe` []-        it "rejects a signal label with no matching await as AwaitSignalMismatch" $ do-            codes <- errorCodesOf "test/fixtures/workflow-signal-mismatch.keiro"-            codes `shouldContain` [AwaitSignalMismatch]-        it "rejects duplicate workflow labels" $ do-            codes <- errorCodesOf "test/fixtures/workflow-dup-label.keiro"-            codes `shouldContain` [WorkflowDuplicateLabel]-        it "rejects unresolved workflow id and sleep fields" $ do-            codes <- errorCodesOf "test/fixtures/workflow-unresolved-fields.keiro"-            codes `shouldContain` [WorkflowIdFieldUnresolved, WorkflowSleepDelayUnresolved]-        it "validates rule domains, totality, case constructors, and bodies" $ do-            unresolved <- errorCodesOf "test/fixtures/rule-bad-domain.keiro"-            unresolved `shouldBe` [RuleDomainUnresolved]-            codes <- errorCodesOf "test/fixtures/rule-not-total.keiro"-            mapM_-                (\expected -> codes `shouldContain` [expected])-                [RuleNotTotal, RuleCaseUnknownCtor, ClockSampled, GuardAtomOutOfScope]-        it "rejects unresolved command operation references" $ do-            codes <- errorCodesOf "test/fixtures/operation-ghost-aggregate.keiro"-            codes `shouldContain` [OperationUnresolvedRef]-        it "rejects a signal value type that differs from its await" $ do-            codes <- errorCodesOf "test/fixtures/operation-signal-value.keiro"-            codes `shouldContain` [AwaitSignalValueMismatch]-        it "round-trips guarded patches and terminal continueAsNew" $ do-            input <- readTestText "test/fixtures/workflow-evolution.keiro"-            case parseSpec "workflow-evolution" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> do-                    parseSpec "workflow-evolution" (renderSpec spec) `shouldBe` Right spec-                    errorCodes spec `shouldBe` []-        it "rejects duplicate patch ids anywhere in the workflow body" $ do-            codes <- errorCodesOf "test/fixtures/workflow-patch-dup.keiro"-            codes `shouldBe` [WorkflowPatchDuplicate]-        it "rejects non-terminal and nested continueAsNew" $ do-            codes <- errorCodesOf "test/fixtures/workflow-can-mid.keiro"-            codes `shouldBe` [WorkflowContinueAsNewNotTerminal, WorkflowContinueAsNewNotTerminal]-        it "rejects a colon in a patch id with a workflow diagnostic" $ do-            codes <- errorCodesOf "test/fixtures/workflow-patch-colon.keiro"-            codes `shouldBe` [WorkflowPatchIdInvalid]-        it "lowers patch facts and live runtime declarations" $ do-            spec <- specOf "test/fixtures/workflow-evolution.keiro"-            case [workflow | NWorkflow workflow <- specNodes spec] of-                [workflow] -> do-                    let modules = harnessWorkflow (defaultContext (specContext spec)) workflow-                        facts = generatedTextEndingIn "WorkflowFacts.hs" modules-                        runtime = generatedTextEndingIn "WorkflowRuntime.hs" modules-                    facts `shouldSatisfy` T.isInfixOf "patch:fraud-check-v2(step:fraud-check)"-                    facts `shouldSatisfy` T.isInfixOf "continueAsNew:RolloverSeed"-                    facts `shouldSatisfy` T.isInfixOf "(\"patches\", \"fraud-check-v2\")"-                    runtime `shouldSatisfy` T.isInfixOf "declaredPatches = Set.fromList [PatchId \"fraud-check-v2\"]"-                    runtime `shouldSatisfy` T.isInfixOf "opts{activePatches = declaredPatches}"-                workflows -> expectationFailure ("expected one workflow, got " <> show (length workflows))--    describe "replay impact" $ do-        it "treats new events and transitions as replay-neutral" $ do-            old <- specOf "test/fixtures/reservation.keiro"-            let aggregate = onlyAggregate old-            case (aggEvents aggregate, aggTransitions aggregate) of-                (event : _, transition : _) -> do-                    let newEvent =-                            event-                                { evName = "ReservationReviewed"-                                , evLoc = noLoc-                                }-                        newTransition =-                            transition-                                { tEmits = ["ReservationReviewed"]-                                , tLoc = noLoc-                                }-                        new =-                            modifyAggregate-                                "Reservation"-                                ( \candidate ->-                                    candidate-                                        { aggEvents = aggEvents candidate <> [newEvent]-                                        , aggTransitions = aggTransitions candidate <> [newTransition]-                                        }-                                )-                                old-                    ReplayImpact.replayImpact old new `shouldBe` ReplayNeutral-                _ -> expectationFailure "reservation fixture must contain an event and transition"--        it "narrows a guard edit to that transition's event types" $ do-            impact <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-guard-tightened.keiro"-            impact-                `shouldBe` ReplayAffected-                    ( Map.singleton-                        "Reservation"-                        AggregateImpact-                            { eventTypes = Set.singleton "TransferReservationCreated"-                            , includeSnapshotStreams = True-                            }-                    )--        it "proves a syntactic guard loosening replay-neutral" $ do-            old <- specOf "test/fixtures/reservation.keiro"-            let loosened =-                    modifyAggregate-                        "Reservation"-                        ( \aggregate ->-                            aggregate-                                { aggTransitions =-                                    [ transition{tGuard = Nothing}-                                    | transition <- aggTransitions aggregate-                                    ]-                                }-                        )-                        old-            ReplayImpact.replayImpact old loosened `shouldBe` ReplayNeutral--        it "marks every existing event when the aggregate wire convention changes" $ do-            impact <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-wire.keiro"-            case impact of-                ReplayAffected aggregates ->-                    ReplayImpact.eventTypes <$> Map.lookup "Reservation" aggregates-                        `shouldBe` Just (Set.fromList ["TransferReservationCreated", "TransferReservationConfirmed"])-                ReplayNeutral -> expectationFailure "expected a wire-clause replay impact"--        it "includes snapshot streams when a write expression changes" $ do-            impact <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-foldchange.keiro"-            case impact of-                ReplayAffected aggregates ->-                    includeSnapshotStreams <$> Map.lookup "Reservation" aggregates-                        `shouldBe` Just True-                ReplayNeutral -> expectationFailure "expected a fold replay impact"--        it "detects codec evolution and ignores formatting-only rewrites" $ do-            changed <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-v2.keiro"-            changed `shouldSatisfy` (/= ReplayNeutral)-            old <- specOf "test/fixtures/reservation.keiro"-            formatted <- parseInlineSpec "<formatted>" (renderSpec old)-            ReplayImpact.replayImpact old formatted `shouldBe` ReplayNeutral--        it "names mapped nested event and snapshot roots while ignoring Haskell-only changes" $ do-            nested <- replayImpactFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-nested-propagation.keiro"-            case nested of-                ReplayAffected aggregates ->-                    Map.lookup "Catalog" aggregates-                        `shouldBe` Just AggregateImpact{eventTypes = Set.singleton "ArtifactObserved", includeSnapshotStreams = True}-                ReplayNeutral -> expectationFailure "expected nested mapped wire change to affect replay"-            sourceOnly <- replayImpactFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-haskell-rename.keiro"-            sourceOnly `shouldBe` ReplayNeutral--        it "generates one context target for every aggregate, including the process saga" $ do-            spec <- specOf "test/fixtures/surge-service.keiro"-            case scaffoldReplayAudit (defaultContext (specContext spec)) spec of-                [assembly] -> do-                    modulePath assembly `shouldBe` "Generated/SurgeDemo/ReplayAudit.hs"-                    moduleText assembly `shouldSatisfy` T.isInfixOf "Hospital.hospitalEventStream"-                    moduleText assembly `shouldSatisfy` T.isInfixOf "Surge.surgeEventStream"-                    T.count "      AuditTarget" (moduleText assembly) `shouldBe` 2-                assemblies -> expectationFailure ("expected one replay-audit assembly, got " <> show (length assemblies))--    describe "diff (evolution classification)" $ do-        it "covers every node family exactly once and explains exclusions" $ do-            sort (map fst familyRegistry) `shouldBe` ([minBound .. maxBound] :: [NodeFamily])-            [reason | (_, OutOfDiffScope reason) <- familyRegistry, T.null reason] `shouldBe` []-        it "derives every exercised headline from its vector under the default gate" $ do-            changes <--                concat-                    <$> mapM-                        (uncurry diffFixtures)-                        [ ("test/fixtures/reservation.keiro", "test/fixtures/reservation-fieldadd.keiro")-                        , ("test/fixtures/reservation.keiro", "test/fixtures/reservation-v2.keiro")-                        , ("test/fixtures/reservation.keiro", "test/fixtures/reservation-enumadd.keiro")-                        , ("test/fixtures/contract.keiro", "test/fixtures/contract-fieldadd.keiro")-                        , ("test/fixtures/reservation-work.keiro", "test/fixtures/reservation-work-rename.keiro")-                        ]-            forM_ changes $ \change ->-                do-                    deriveLabel defaultGate (ckVector (kindOfChange change))-                        `shouldBe` labelOfChange change-                    gatedBreaking defaultGate change `shouldBe` isBreaking change-        it "never removes a breaking result when the gate grows" $-            property $-                forAll genCompatibilityVector $ \compatibility ->-                    forAll genSurfaceSet $ \gate ->-                        forAll genSurfaceSet $ \extra ->-                            deriveLabel gate compatibility-                                == LabelBreaking-                                    ==> deriveLabel (gate <> extra) compatibility-                                == LabelBreaking-        it "renders the consumer-neutral matrix with separate private, snapshot, and public surfaces" $ do-            changes <- diffFixtures "test/fixtures/compatibility-vector-old.keiro" "test/fixtures/compatibility-vector-new.keiro"-            golden <- readTestText "test/fixtures/compatibility-vector.diff.golden"-            let rendered = T.intercalate "\n" (map renderFinding changes)-                explained = T.intercalate "\n" (map renderExplainBlock changes)-                reportJson = T.pack (show (Aeson.toJSON (diffReport defaultGate changes)))-            T.stripEnd rendered `shouldBe` T.stripEnd golden-            rendered `shouldSatisfy` T.isInfixOf "Reservation.event.TransferReservationCreated.patientAcuity"-            rendered `shouldSatisfy` T.isInfixOf "old-binary-read-new-events=breaking"-            rendered `shouldSatisfy` T.isInfixOf "snapshot-hydration=advisory"-            rendered `shouldSatisfy` T.isInfixOf "public-consumer=breaking"-            explained `shouldSatisfy` T.isInfixOf "invalidate and rebuild snapshots"-            reportJson `shouldSatisfy` T.isInfixOf "keiro-dsl/diff-report/1"-            reportJson `shouldSatisfy` T.isInfixOf "Reservation.event.TransferReservationCreated.patientAcuity"-            let eventEnumFindings =-                    [ change-                    | change@(Advisory kind) <- changes-                    , ckCode kind == EnumCtorAdded-                    , verdictFor OldBinaryReadNewEvents (ckVector kind) == VBreaking-                    ]-            eventEnumFindings `shouldSatisfy` all (not . gatedBreaking defaultGate)-            eventEnumFindings `shouldSatisfy` all (gatedBreaking (gateWith [OldBinaryReadNewEvents]))-            forM_ changes $ \change ->-                remediationFor (ckContext (kindOfChange change)) (ckCode (kindOfChange change))-                    `shouldSatisfy` (not . null)-        it "rejects unknown --gate values with the valid surface list" $ do-            parseSurfaceName "mystery-surface"-                `shouldSatisfy` either (T.isInfixOf "old-binary-read-new-events" . T.pack) (const False)-        it "covers the mapped evolution matrix with stable codes and non-empty remedies" $ do-            let cases =-                    [ ("consumer-types-fieldadd-default.keiro", MappedFieldAddedWithDefault)-                    , ("consumer-types-fieldadd-nodefault.keiro", MappedFieldAddedNoDefault)-                    , ("consumer-types-fieldremove.keiro", MappedFieldRemoved)-                    , ("consumer-types-wirekey.keiro", MappedWireKeyChanged)-                    , ("consumer-types-haskell-rename.keiro", MappedHaskellSourceChanged)-                    , ("consumer-types-binding-change.keiro", MappedBindingChanged)-                    , ("consumer-types-fixtures-change.keiro", MappedFixturesChanged)-                    , ("consumer-types-initial-change.keiro", MappedInitialChanged)-                    , ("consumer-types-armadd.keiro", MappedArmAdded)-                    , ("consumer-types-tagchange.keiro", MappedArmTagChanged)-                    , ("consumer-types-enumadd.keiro", MappedEnumValueAdded)-                    , ("consumer-types-enumremove.keiro", MappedEnumValueRemoved)-                    , ("consumer-types-enumspelling.keiro", MappedEnumSpellingChanged)-                    , ("consumer-types-encoding.keiro", MappedUnionEncodingChanged)-                    , ("consumer-types-opaque-version.keiro", MappedOpaqueCodecChanged)-                    , ("consumer-types-mode-cross.keiro", MappedModeCrossed)-                    , ("consumer-types-nested-propagation.keiro", MappedArmTagChanged)-                    ]-            forM_ cases $ \(fixture, expectedCode) -> do-                changes <- diffFixtures "test/fixtures/consumer-types.keiro" ("test/fixtures/" <> fixture)-                map (ckCode . kindOfChange) changes `shouldContain` [expectedCode]-                forM_ changes $ \change ->-                    remediationFor (ckContext (kindOfChange change)) (ckCode (kindOfChange change))-                        `shouldSatisfy` (not . null)-        it "separates mapped event migration, snapshot invalidation, and directional rollout" $ do-            breakingAdd <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-fieldadd-nodefault.keiro"-            let noDefault = [change | change <- breakingAdd, ckCode (kindOfChange change) == MappedFieldAddedNoDefault]-            [ckFacet kind | Breaking kind <- noDefault] `shouldContain` ["mapped-event"]-            [ckFacet kind | Advisory kind <- noDefault] `shouldContain` ["mapped-register"]-            defaulted <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-fieldadd-default.keiro"-            [change | change <- defaulted, isBreaking change] `shouldBe` []-            let eventDefaults = [kind | Advisory kind <- defaulted, ckCode kind == MappedFieldAddedWithDefault, ckFacet kind == "mapped-event"]-            eventDefaults `shouldSatisfy` any ((== VBreaking) . verdictFor OldBinaryReadNewEvents . ckVector)-            armAdded <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-armadd.keiro"-            [change | change <- armAdded, isBreaking change] `shouldBe` []-            [kind | Advisory kind <- armAdded, ckCode kind == MappedArmAdded, ckFacet kind == "mapped-event"]-                `shouldSatisfy` any ((== VBreaking) . verdictFor OldBinaryReadNewEvents . ckVector)-        it "propagates a nested mapped leaf to complete command, event, and register paths" $ do-            changes <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-nested-propagation.keiro"-            let subjects =-                    [ ckSubject kind-                    | change <- changes-                    , let kind = kindOfChange change-                    , ckCode kind == MappedArmTagChanged-                    ]-            subjects-                `shouldContain` [ "Catalog command ObserveArtifact .artifact : ArtifactInfo .location : ArtifactLocation .arm RepoPath[\"repository_path\"]"-                                , "Catalog event ArtifactObserved .artifact : ArtifactInfo .location : ArtifactLocation .arm RepoPath[\"repository_path\"]"-                                , "Catalog register currentArtifact : ArtifactInfo .location : ArtifactLocation .arm RepoPath[\"repository_path\"]"-                                ]-        it "classifies every remaining mapped field and declaration evolution row" $ do-            base <- specOf "test/fixtures/consumer-types.keiro"-            let mutationCodes =-                    [ (mapArtifactNamedField "key" (\field -> field{wfType = TInt}) base, MappedFieldTypeChanged)-                    , (mapArtifactNamedField "key" (\field -> field{wfPresence = POptional, wfOnMissing = Just (OmText "")}) base, MappedPresenceChanged)-                    , (mapArtifactNamedField "key" (\field -> field{wfType = TOptional TText}) base, MappedNullabilityChanged)-                    , (mapArtifactNamedField "description" (\field -> field{wfOnMissing = Nothing}) base, MappedDefaultRemoved)-                    , (mapArtifactNamedField "count" (\field -> field{wfOnMissing = Just (OmInt 1)}) base, MappedDefaultChanged)-                    , (mapMappedStructural "ArtifactInfo" renameMappedRecordConstructor base, MappedRecordConstructorChanged)-                    , (mapMappedStructural "ArtifactInfo" changeMappedCanonical base, MappedCanonicalTypeChanged)-                    ]-            forM_ mutationCodes $ \(candidate, expectedCode) ->-                map (ckCode . kindOfChange) (diffSpecs base candidate) `shouldContain` [expectedCode]-            let declarationA = completeStructural "A" (recordShape [TText])-                declarationB = completeStructural "B" (recordShape [TInt])-                onlyA = mappedSpec [declarationA]-                withB = mappedSpec [declarationA, declarationB]-            map (ckCode . kindOfChange) (diffSpecs onlyA withB) `shouldContain` [MappedDeclAdded]-            map (ckCode . kindOfChange) (diffSpecs withB onlyA) `shouldContain` [MappedDeclRemoved]-            diffSpecs base (mapArtifactNamedField "key" (\field -> field{wfHaskell = "renamedKey"}) base)-                `shouldBe` []-        it "visits every mapped wire mutation and reports every complete root path" $ do-            base <- specOf "test/fixtures/consumer-types.keiro"-            let mutations = mappedWireMutations base-            mutations `shouldSatisfy` (not . null)-            visited <- fmap Set.unions . forM mutations $ \mutation -> do-                let changes =-                        [ change-                        | change <- diffSpecs base (mmCandidate mutation)-                        , ckCode (kindOfChange change) == mmCode mutation-                        ]-                    actualSubjects = Set.fromList (map (ckSubject . kindOfChange) changes)-                changes `shouldSatisfy` any (not . isAdditiveChange)-                actualSubjects `shouldBe` mmExpectedSubjects mutation-                pure actualSubjects-            visited `shouldBe` Set.unions (map mmExpectedSubjects mutations)-        it "reports the exact ingredient code when every required mapped fact is deleted" $ do-            base <- specOf "test/fixtures/consumer-types.keiro"-            forM_ (mappedIngredientMutations base) $ \(candidate, expectedCode) ->-                errorCodes candidate `shouldContain` [expectedCode]-        it "classifies a field added without a version bump as BREAKING" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-fieldadd.keiro"-            any isBreaking cs `shouldBe` True-            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldAddedWithoutBump]-        it "classifies the same field wrapped as v2 + upcaster as ADDITIVE" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-v2.keiro"-            any isBreaking cs `shouldBe` False-            [ck | Additive ck <- cs] `shouldSatisfy` any ((== "TransferReservationCreated") . ckSubject)-        it "reports no breaking change when the spec is unchanged" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation.keiro"-            any isBreaking cs `shouldBe` False-        it "classifies a direct event field type change as EvtFieldTypeChanged" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-fieldtype.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldTypeChanged]-        it "resolves fields(Command) before comparing event field types" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-cmdfieldtype.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldTypeChanged]-        it "uses EvtFieldRemovedSameVersion for an unchanged-version removal" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-fieldremove.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldRemovedSameVersion]-        it "uses EvtVersionDecreased for a version decrease" $ do-            cs <- diffFixtures "test/fixtures/reservation-v2.keiro" "test/fixtures/reservation.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [EvtVersionDecreased]-        it "rejects a v1 to v3 jump whose only upcaster starts at v2" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-v3-dangling.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [EvtVersionMissingUpcaster]-        it "classifies a vanished historical upcaster rung as UpcasterChainGap" $ do-            cs <- diffFixtures "test/fixtures/reservation-v2.keiro" "test/fixtures/reservation-chain-gap.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [UpcasterChainGap]-        it "classifies an enum constructor removal as EnumCtorRemoved" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-enumdrop.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [EnumCtorRemoved]-        it "classifies an enum wire-spelling change as EnumWireSpellingChanged" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-enumwire.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [EnumWireSpellingChanged]-        it "classifies an enum constructor addition per use site as advisory" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-enumadd.keiro"-            any isBreaking cs `shouldBe` False-            let enumFindings = [k | Advisory k <- cs, ckCode k == EnumCtorAdded]-            [ckSubject k | k <- enumFindings] `shouldContain` ["BlackTag"]-            [verdictFor SnapshotHydration (ckVector k) | k <- enumFindings]-                `shouldContain` [VAdvisory]-        it "classifies an effective wire convention change as WireSpecChanged" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-wire.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [WireSpecChanged]-        it "advises when the aggregate fold surface changes" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-foldchange.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldContain` [AggFoldSurfaceChanged]-        it "advises on hazardous deprecation and reports un-deprecation" $ do-            deprecated <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-deprecated.keiro"-            any isBreaking deprecated `shouldBe` False-            [ckCode k | Advisory k <- deprecated] `shouldContain` [DeprecatedEventReplayHazard]-            restored <- diffFixtures "test/fixtures/reservation-deprecated.keiro" "test/fixtures/reservation.keiro"-            any isAdvisory restored `shouldBe` True-            [ckCode k | Advisory k <- restored] `shouldContain` [EventUndeprecated]-        it "recognises replay-only deprecation as a replay-safe retirement cutover" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-deprecated-replay-only.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldContain` [EventRetirementInProgress]-            [ckCode k | Advisory k <- cs] `shouldNotContain` [DeprecatedEventReplayHazard]-        it "advises when event retirement starts" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-retiring.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldContain` [EventRetirementInProgress]-        it "does not recommend decode-only deprecation for an event removal" $ do-            old <- specOf "test/fixtures/reservation.keiro"-            let new =-                    old-                        { specNodes =-                            [ case node of-                                NAggregate aggregate ->-                                    NAggregate-                                        aggregate-                                            { aggEvents =-                                                [ event-                                                | event <- aggEvents aggregate-                                                , evName event /= "TransferReservationConfirmed"-                                                ]-                                            }-                                _ -> node-                            | node <- specNodes old-                            ]-                        }-                removals = [change | change@(Breaking kind) <- diffSpecs old new, ckCode kind == EvtRemovedNotDeprecated]-            removals `shouldSatisfy` (not . null)-            [ckDetail kind | Breaking kind <- removals]-                `shouldSatisfy` all (not . T.isInfixOf "so old payloads still decode")-        it "prints a paste-ready replay-only twin when a guard tightens (plan 143)" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-guard-tightened.keiro"-            any isBreaking cs `shouldBe` False-            let advisories = [k | Advisory k <- cs, ckCode k == AggGuardTightened]-            map ckSubject advisories `shouldBe` ["Unrequested -- RequestTransferReservation"]-            detail <- case advisories of-                [k] -> pure (ckDetail k)-                other -> expectationFailure ("expected one advisory, got " <> show other) >> pure ""-            detail `shouldSatisfy` T.isInfixOf "replay-only Unrequested -- RequestTransferReservation"-            -- The printed twin is paste-ready: appended to the new spec it-            -- parses, validates without errors, and silences the advisory.-            tightened <- readTestText "test/fixtures/reservation-guard-tightened.keiro"-            let twinText = snd (T.breakOnEnd "\n\n" detail)-                pasted = tightened <> "\n" <> twinText <> "\n"-            case parseSpec "<pasted-twin>" pasted of-                Left err -> expectationFailure (T.unpack err)-                Right pastedSpec -> do-                    [code d | d <- validateSpec pastedSpec, severity d == Error] `shouldBe` []-                    base <- specOf "test/fixtures/reservation.keiro"-                    [k | Advisory k <- diffSpecs base pastedSpec, ckCode k == AggGuardTightened]-                        `shouldBe` []-        it "omits the twin advisory when the twin is already present (plan 143)" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-guard-tightened-twin.keiro"-            [k | Advisory k <- cs, ckCode k == AggGuardTightened] `shouldBe` []-        it "classifies a removed contract event as ContractEventRemoved" $ do-            cs <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-eventdrop.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [ContractEventRemoved]-        it "classifies contract field type changes and unversioned additions as ContractFieldChanged" $ do-            changed <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-fieldtype.keiro"-            [ckCode k | Breaking k <- changed] `shouldContain` [ContractFieldChanged]-            added <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-fieldadd.keiro"-            [ckCode k | Breaking k <- added] `shouldContain` [ContractFieldChanged]-        it "reports a field addition with a contract version bump as an advisory" $ do-            cs <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-bump-fieldadd.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldContain` [ContractSchemaVersionBumped]-        it "classifies a contract schema version decrease separately" $ do-            cs <- diffFixtures "test/fixtures/contract-bump-fieldadd.keiro" "test/fixtures/contract.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [ContractSchemaVersionDecreased]-        it "classifies contract topic and discriminator changes separately" $ do-            topic <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-topic.keiro"-            [ckCode k | Breaking k <- topic] `shouldContain` [ContractTopicChanged]-            discriminator <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-discriminator.keiro"-            [ckCode k | Breaking k <- discriminator] `shouldContain` [ContractDiscriminatorChanged]-        it "classifies a new contract event as additive" $ do-            cs <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-eventadd.keiro"-            any isBreaking cs `shouldBe` False-            [ckSubject k | Additive k <- cs] `shouldContain` ["IncidentTransferNeedCancelled"]-        it "classifies workqueue wire names, types, and required additions as WqPayloadFieldChanged" $ do-            wire <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-wirename.keiro"-            [ckCode k | Breaking k <- wire] `shouldContain` [WqPayloadFieldChanged]-            fieldTypeChange <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-fieldtype.keiro"-            [ckCode k | Breaking k <- fieldTypeChange] `shouldContain` [WqPayloadFieldChanged]-            required <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-reqfield.keiro"-            [ckCode k | Breaking k <- required] `shouldContain` [WqPayloadFieldChanged]-        it "classifies a new optional workqueue payload field as additive" $ do-            cs <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-optfield.keiro"-            any isBreaking cs `shouldBe` False-            [ckSubject k | Additive k <- cs] `shouldContain` ["note"]-        it "classifies workqueue ordering changes as breaking delivery-contract changes" $ do-            cs <- diffFixtures "test/fixtures/workqueue-policy-base.keiro" "test/fixtures/workqueue-ordering-change.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [WqOrderingChanged]-            [ckDetail k | Breaking k <- cs, ckCode k == WqOrderingChanged]-                `shouldSatisfy` any (T.isInfixOf "delivery-order contract")-        it "classifies workqueue provision changes as operational migrations" $ do-            cs <- diffFixtures "test/fixtures/workqueue-policy-base.keiro" "test/fixtures/workqueue-provision-change.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [WqProvisionChanged]-            [ckDetail k | Breaking k <- cs, ckCode k == WqProvisionChanged]-                `shouldSatisfy` any (T.isInfixOf "migrate the existing queue operationally")-        it "classifies workqueue group-key changes as breaking repartitioning" $ do-            cs <- diffFixtures "test/fixtures/workqueue-policy-base.keiro" "test/fixtures/workqueue-group-key-change.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [WqGroupKeyChanged]-            [ckDetail k | Breaking k <- cs, ckCode k == WqGroupKeyChanged]-                `shouldSatisfy` any (T.isInfixOf "re-partitioned")-        it "classifies a process input type change as ProcessInputChanged" $ do-            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-inputtype.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [ProcessInputChanged]-        it "classifies workflow input and output changes as WorkflowShapeChanged" $ do-            input <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-inputfield.keiro"-            [ckCode k | Breaking k <- input] `shouldContain` [WorkflowShapeChanged]-            output <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-output.keiro"-            [ckCode k | Breaking k <- output] `shouldContain` [WorkflowShapeChanged]-        it "classifies workflow relabeling and appends as WorkflowBodyChanged" $ do-            relabeled <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-body.keiro"-            [ckCode k | Breaking k <- relabeled] `shouldContain` [WorkflowBodyChanged]-            appended <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-stepadd.keiro"-            [ckCode k | Breaking k <- appended] `shouldContain` [WorkflowBodyChanged]-            [ckDetail k | Breaking k <- appended, ckCode k == WorkflowBodyChanged]-                `shouldSatisfy` any (T.isInfixOf "new patch guard")-        it "classifies a body addition wholly guarded by a new patch as additive" $ do-            cs <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-evolution-diff.keiro"-            any isBreaking cs `shouldBe` False-            [ckSubject k | Additive k <- cs, ckFacet k == "workflow-patch"] `shouldContain` ["fraud-check-v2"]-            [ckSubject k | Additive k <- cs, ckFacet k == "workflow-continue-as-new"] `shouldContain` ["RolloverSeed"]-        it "classifies removing an existing patch as breaking" $ do-            cs <- diffFixtures "test/fixtures/workflow-evolution-diff.keiro" "test/fixtures/workflow-continue.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [WorkflowPatchRemoved]-            [ckDetail k | Breaking k <- cs, ckCode k == WorkflowPatchRemoved]-                `shouldSatisfy` any (T.isInfixOf "cannot prove")-        it "classifies terminal continueAsNew append as additive and seed drift as breaking" $ do-            appended <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-continue.keiro"-            any isBreaking appended `shouldBe` False-            [ckFacet k | Additive k <- appended] `shouldContain` ["workflow-continue-as-new"]-            changed <- diffFixtures "test/fixtures/workflow-continue.keiro" "test/fixtures/workflow-continue-seed-v2.keiro"-            [ckCode k | Breaking k <- changed] `shouldContain` [WorkflowContinueSeedChanged]-            [ckDetail k | Breaking k <- changed, ckCode k == WorkflowContinueSeedChanged]-                `shouldSatisfy` any (T.isInfixOf "restoreSeed")-        it "classifies a workflow stable-name change as WorkflowStableNameChanged" $ do-            cs <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-rename.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [WorkflowStableNameChanged]-        it "classifies workflow id-derivation changes as DerivedIdentityChanged" $ do-            cs <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-idfield.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [DerivedIdentityChanged]-        it "classifies an id prefix change as IdPrefixChanged" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-idprefix.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [IdPrefixChanged]-        it "classifies intake dedupe key and policy changes as DedupeIdentityChanged" $ do-            policy <- diffFixtures "test/fixtures/intake.keiro" "test/fixtures/intake-dedupepolicy.keiro"-            [ckCode k | Breaking k <- policy] `shouldContain` [DedupeIdentityChanged]-            key <- diffFixtures "test/fixtures/intake.keiro" "test/fixtures/intake-dedupekey.keiro"-            [ckCode k | Breaking k <- key] `shouldContain` [DedupeIdentityChanged]-        it "reports intake decode-posture changes as warnings" $ do-            cs <- diffFixtures "test/fixtures/intake.keiro" "test/fixtures/intake-decode.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldContain` [DecodePostureChanged]-            [ckCode k | Advisory k <- cs] `shouldContain` [IntakePersistenceChanged]-        it "classifies process and timer derivation changes as DerivedIdentityChanged" $ do-            processName <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-procname.keiro"-            [ckCode k | Breaking k <- processName] `shouldContain` [DerivedIdentityChanged]-            timerId <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-timerid.keiro"-            [ckCode k | Breaking k <- timerId] `shouldContain` [DerivedIdentityChanged]-            base <- specOf "test/fixtures/hospital-surge.keiro"-            let categoryChange = diffSpecs base (modifyProcess "HospitalSurge" (\process -> process{procSaga = (procSaga process){sagaCategory = "hospitalSurgeV2"}}) base)-            [ckCode k | Breaking k <- categoryChange] `shouldContain` [DerivedIdentityChanged]-        it "classifies router stable names, keys, and targets as identity-bearing" $ do-            base <- specOf "test/fixtures/incident-paging/incident-paging.keiro"-            let stableName = diffSpecs base (modifyRouter "PagingRouter" (\router -> router{rtName = "paging-v2"}) base)-                keyDerivation = diffSpecs base (modifyRouter "PagingRouter" (\router -> router{rtKey = (rtKey router){corrVia = "otherIdText"}}) base)-                target = diffSpecs base (modifyRouter "PagingRouter" (\router -> router{rtTarget = "OtherPage"}) base)-            [ckCode k | Breaking k <- stableName] `shouldContain` [RouterStableNameChanged]-            [ckCode k | Breaking k <- keyDerivation] `shouldContain` [DerivedIdentityChanged]-            [ckCode k | Breaking k <- target] `shouldContain` [DerivedIdentityChanged]-        it "advises on router dispatch-surface changes without making them breaking" $ do-            cs <- diffFixtures "test/fixtures/incident-paging/incident-paging.keiro" "test/fixtures/incident-paging/incident-paging-dispatch.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldBe` [RouterDecideSurfaceChanged]-        it "advises on process dispatch-surface changes without making them breaking" $ do-            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-handle.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldBe` [ProcessDecideSurfaceChanged]-        it "advises on unversioned timer payload changes without making them breaking" $ do-            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-payload.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldBe` [ProcessTimerPayloadChanged]-        it "ignores formatting-only process and timer surface rewrites" $ do-            original <- specOf "test/fixtures/hospital-surge.keiro"-            formatted <- parseInlineSpec "<formatted-process>" (renderSpec original)-            diffSpecs original formatted `shouldBe` []-        it "reports a timer window change as a warning" $ do-            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-window.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldContain` [TimerWindowChanged]-        it "reports emit-map changes as warnings and derive changes as breaking" $ do-            mapping <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-mapchange.keiro"-            any isBreaking mapping `shouldBe` False-            [ckCode k | Advisory k <- mapping] `shouldContain` [EmitMappingChanged]-            derive <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-derive.keiro"-            [ckCode k | Breaking k <- derive] `shouldContain` [DerivedIdentityChanged]-        it "classifies publisher outbox identity and ordering independently" $ do-            outbox <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-outboxfield.keiro"-            [ckCode k | Breaking k <- outbox] `shouldContain` [DerivedIdentityChanged]-            ordering <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-ordering.keiro"-            any isBreaking ordering `shouldBe` False-            [ckCode k | Advisory k <- ordering] `shouldContain` [PublisherPolicyChanged]-        it "classifies workqueue names as QueueIdentityChanged" $ do-            cs <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-rename.keiro"-            [ckCode k | Breaking k <- cs] `shouldContain` [QueueIdentityChanged]-        it "classifies pgmq dispatch dedupe and retargeting independently" $ do-            dedupe <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-dedupkey.keiro"-            [ckCode k | Breaking k <- dedupe] `shouldContain` [DedupeIdentityChanged]-            retarget <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-retarget.keiro"-            any isBreaking retarget `shouldBe` False-            [ckCode k | Advisory k <- retarget] `shouldContain` [DispatchRetargeted]-        it "reports aggregate projection changes as warnings" $ do-            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-projection.keiro"-            any isBreaking cs `shouldBe` False-            [ckCode k | Advisory k <- cs] `shouldContain` [ProjectionChanged]-        it "classifies read-model version and unversioned shape changes" $ do-            base <- specOf "test/fixtures/readmodel-runtime.keiro"-            let versionTwo = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmVersion = 2}) base-                changedShape = modifyReadModel "transfer_decisions" changeReadModelShape base-                bumpedShape = modifyReadModel "transfer_decisions" (\readModel -> (changeReadModelShape readModel){rmVersion = 2}) base-                decreased = diffSpecs versionTwo base-                unversioned = diffSpecs base changedShape-                bumped = diffSpecs base bumpedShape-            [ckCode k | Breaking k <- decreased] `shouldContain` [ReadModelVersionDecreased]-            [ckCode k | Breaking k <- unversioned] `shouldContain` [ReadModelShapeChangedWithoutBump]-            any isBreaking bumped `shouldBe` False-            [ckFacet k | Additive k <- bumped] `shouldContain` ["read-model-version"]-        it "classifies read-model registry, table, subscription, and removal identities" $ do-            base <- specOf "test/fixtures/readmodel-runtime.keiro"-            let tableChanged = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmTable = "transfer_decisions_v2"}) base-                subscriptionChanged = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmSubscription = Just "transfer-decisions-v2"}) base-                renamed = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmName = "reservation_decisions"}) base-                removed = removeReadModel "transfer_decisions" base-            mapM_-                (\changes -> [ckCode k | Breaking k <- changes] `shouldContain` [DerivedIdentityChanged])-                [diffSpecs base tableChanged, diffSpecs base subscriptionChanged, diffSpecs base renamed, diffSpecs base removed]-        it "classifies read-model feed flips and consistency/scope weakening as breaking" $ do-            base <- specOf "test/fixtures/readmodel-runtime.keiro"-            let feedChanged = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmFeed = RmInline}) base-                consistencyWeakened = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmConsistency = Eventual}) base-                entireLog = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmScope = Just RmEntireLog}) base-            [ckCode k | Breaking k <- diffSpecs base feedChanged] `shouldContain` [ReadModelFeedChanged]-            [ckCode k | Breaking k <- diffSpecs base consistencyWeakened] `shouldContain` [ReadModelConsistencyWeakened]-            [ckCode k | Breaking k <- diffSpecs entireLog base] `shouldContain` [ReadModelConsistencyWeakened]-        it "classifies Eventual to Strong read-model consistency as additive" $ do-            strong <- specOf "test/fixtures/readmodel-runtime.keiro"-            let eventual = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmConsistency = Eventual}) strong-                changes = diffSpecs eventual strong-            any isBreaking changes `shouldBe` False-            [ckFacet k | Additive k <- changes] `shouldContain` ["read-model-consistency"]--    describe "module placement (M1)" $ do-        it "GeneratedPrefix is today's namespace (Generated.<Ctx>.<Node>, holes at <Ctx>.<Node>)" $ do-            let ctx = defaultContext "hospital-capacity"-            genPrefixFor ctx "Reservation" `shouldBe` "Generated.HospitalCapacity.Reservation"-            holePrefixFor ctx "Reservation" `shouldBe` "HospitalCapacity.Reservation"-        it "module-root prefixes both layers" $ do-            let ctx = (defaultContext "hospital-capacity"){moduleRoot = "Acme"}-            genPrefixFor ctx "Reservation" `shouldBe` "Acme.Generated.HospitalCapacity.Reservation"-            holePrefixFor ctx "Reservation" `shouldBe` "Acme.HospitalCapacity.Reservation"-        it "CollocatedLeaf places the generated layer under the domain leaf" $ do-            let ctx = (defaultContext "hospital-capacity"){moduleRoot = "Acme", placement = CollocatedLeaf}-            genPrefixFor ctx "Reservation" `shouldBe` "Acme.HospitalCapacity.Reservation.Generated"-            holePrefixFor ctx "Reservation" `shouldBe` "Acme.HospitalCapacity.Reservation"-        it "parses and preserves the module/layout clauses through parse . pretty" $ do-            let src = "context hospital-capacity\nmodule Acme.Services\nlayout collocated\n\naggregate Reservation\n  regs\n  states Open\n"-            case parseSpec "<m1>" src of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> do-                    specModuleRoot spec `shouldBe` Just "Acme.Services"-                    specLayout spec `shouldBe` Just CollocatedLeaf-                    parseSpec "<m1>" (renderSpec spec) `shouldBe` Right spec-        it "a spec without the clauses leaves placement at the default" $ do-            input <- readTestText "test/fixtures/reservation.keiro"-            case parseSpec "test/fixtures/reservation.keiro" input of-                Left err -> expectationFailure (T.unpack err)-                Right spec -> do-                    specModuleRoot spec `shouldBe` Nothing-                    specLayout spec `shouldBe` Nothing--    describe "structural scaffold" $ do-        it "emits one private shape module per structural declaration and one context facade" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let modules = scaffoldModules (defaultContext (specContext spec)) spec-                paths = map modulePath modules-            paths-                `shouldContain` [ "Generated/ConsumerDemo/Structural/Shape/ArtifactInfo.hs"-                                , "Generated/ConsumerDemo/Structural/Shape/ArtifactKind.hs"-                                , "Generated/ConsumerDemo/Structural/Shape/ArtifactLocation.hs"-                                , "Generated/ConsumerDemo/StructuralProjections.hs"-                                ]-            paths `shouldNotContain` ["Generated/ConsumerDemo/Structural/Shape/VendorGeometry.hs"]-            firewallBreaches modules `shouldBe` []-        it "emits one create-once binding skeleton per owning module and derives Generic for private shapes" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let modules = scaffoldModules (defaultContext (specContext spec)) spec-                skeletons = [moduleValue | moduleValue <- modules, kind moduleValue == HoleStub, modulePath moduleValue == "Example/Artifact/KeiroBindings.hs"]-                shape = generatedTextEndingIn "Structural/Shape/ArtifactInfo.hs" modules-            case skeletons of-                [skeleton] -> do-                    moduleText skeleton `shouldSatisfy` T.isInfixOf "artifactInfoBinding :: StructuralBinding"-                    moduleText skeleton `shouldSatisfy` T.isInfixOf "artifactKindBinding :: StructuralBinding"-                    moduleText skeleton `shouldSatisfy` T.isInfixOf "artifactLocationBinding :: StructuralBinding"-                    moduleText skeleton `shouldSatisfy` T.isInfixOf "HOLE: fill ArtifactInfo bindingToShape.key"-                _ -> expectationFailure ("expected exactly one shared binding skeleton, got " <> show (map modulePath skeletons))-            shape `shouldSatisfy` T.isInfixOf "deriving stock (Eq, Generic, Show)"-            shape `shouldSatisfy` T.isInfixOf "import GHC.Generics (Generic)"-        it "never overwrites an existing binding skeleton" $-            withTempDirectory "keiro-dsl-binding-create-once" $ \out -> do-                spec <- specOf "test/fixtures/consumer-types.keiro"-                let ctx = defaultContext (specContext spec)-                    bindingPath = out </> "Example/Artifact/KeiroBindings.hs"-                _ <- executePlannedScaffold out "consumer-types.keiro" ctx spec-                TIO.writeFile bindingPath "hand-owned binding\n"-                second <- executePlannedScaffold out "consumer-types.keiro" ctx spec-                TIO.readFile bindingPath `shouldReturn` "hand-owned binding\n"-                reportDispositions second-                    `shouldSatisfy` any (\(moduleValue, disposition) -> modulePath moduleValue == "Example/Artifact/KeiroBindings.hs" && disposition == Skipped)-        it "fresh binding skeletons compile at the application boundary" $-            withTempDirectory "keiro-dsl-binding-compiles" $ \out -> do-                spec <- specOf "test/fixtures/structural-conformance.keiro"-                let ctx = defaultContext (specContext spec)-                    bindingSource = out </> "Conformance/Structural/Bindings.hs"-                    ghcOutput = out </> ".ghc"-                _ <- executePlannedScaffold out "structural-conformance.keiro" ctx spec-                createDirectoryIfMissing True ghcOutput-                (exitCode, standardOutput, standardError) <--                    readProcessWithExitCode-                        "cabal"-                        [ "exec"-                        , "--"-                        , "ghc"-                        , "-XGHC2024"-                        , "-XOverloadedStrings"-                        , "-fno-code"-                        , "-fforce-recomp"-                        , "-outputdir"-                        , ghcOutput-                        , "-i" <> out-                        , "-itest/conformance-structural"-                        , "-i../keiro-core/src"-                        , bindingSource-                        ]-                        ""-                unless (exitCode == ExitSuccess) $-                    expectationFailure (standardOutput <> standardError)-        it "keeps consumer types in Domain while the generated Codec owns keys, tags, and defaults" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let modules = scaffoldModules (defaultContext (specContext spec)) spec-                domain = generatedTextEndingIn "Catalog/Domain.hs" modules-                codec = generatedTextEndingIn "Catalog/Codec.hs" modules-            domain `shouldSatisfy` T.isInfixOf "Example.Artifact.Domain.ArtifactInfo"-            domain `shouldSatisfy` T.isInfixOf "Vendor.Geometry.Geometry"-            domain `shouldSatisfy` T.isInfixOf "Example.Artifact.KeiroBindings.emptyArtifactInfo"-            codec `shouldSatisfy` T.isInfixOf "\"location\" .= encodeArtifactLocationShape"-            codec `shouldSatisfy` T.isInfixOf "\"local_file\""-            codec `shouldSatisfy` T.isInfixOf "Nothing -> pure Generated.ConsumerDemo.Structural.Shape.ArtifactKind.Guide"-            codec `shouldSatisfy` T.isInfixOf "rejectUnknownFields \"ArtifactInfo\""-            codec `shouldSatisfy` T.isInfixOf "toJSON payload.geometry"-            codec `shouldSatisfy` (not . T.isInfixOf "vendor.geometry.json")-        it "generates shape-only nested types and schema-derived Keiki witnesses" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let modules = scaffoldModules (defaultContext (specContext spec)) spec-                shape = generatedTextEndingIn "Structural/Shape/ArtifactInfo.hs" modules-                facade = generatedTextEndingIn "StructuralProjections.hs" modules-            shape `shouldSatisfy` T.isInfixOf "data ArtifactInfoShape = ArtifactInfo"-            shape `shouldSatisfy` T.isInfixOf "ArtifactKind.ArtifactKindShape"-            shape `shouldSatisfy` (not . T.isInfixOf "KeiroBindings")-            facade `shouldSatisfy` T.isInfixOf "type FieldName"-            facade `shouldSatisfy` T.isInfixOf "= \"/key\""-            facade `shouldSatisfy` T.isInfixOf "fieldShapeId _ = \"example.artifact.ArtifactInfo.v1\""-            facade `shouldSatisfy` T.isInfixOf "bindingToShape Example.Artifact.KeiroBindings.artifactInfoBinding owner"--    describe "structural manifest" $ do-        it "lists consumer packages and every domain, binding, fixture, and initial module" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let modules = scaffoldModules (defaultContext (specContext spec)) spec-                manifest = renderManifest "consumer-types.keiro" modules spec-            mapM_ (\packageName -> manifestDependencies spec `shouldContain` [packageName]) ["artifact-domain", "vendor-geometry"]-            manifest `shouldSatisfy` T.isInfixOf "consumer-packages:\n    artifact-domain\n    vendor-geometry"-            mapM_-                (\moduleName -> manifest `shouldSatisfy` T.isInfixOf moduleName)-                [ "Example.Artifact.Domain"-                , "Example.Artifact.KeiroBindings"-                , "Vendor.Geometry"-                , "Vendor.Geometry.KeiroBindings"-                ]--    describe "structural scaffold record" $ do-        it "round-trips canonical mapping rows and reports binding drift on the next run" $-            withTempDirectory "keiro-dsl-mapping-record" $ \out -> do-                spec <- specOf "test/fixtures/consumer-types.keiro"-                let ctx = defaultContext (specContext spec)-                first <- executePlannedScaffold out "consumer-types.keiro" ctx spec-                length (consumerMappings (reportConsumerPlan first)) `shouldBe` 4-                recordText <- TIO.readFile (out </> recordFileName (specContext spec))-                let mappingRows = filter (T.isPrefixOf "mapping ") (T.lines recordText)-                    bindingRows = filter (T.isPrefixOf "binding ") (T.lines recordText)-                length mappingRows `shouldBe` 4-                bindingRows `shouldSatisfy` (not . null)-                fmap recMappings (parseRecord recordText) `shouldSatisfy` maybe False ((== 4) . length)-                fmap recBindingObligations (parseRecord recordText) `shouldSatisfy` maybe False ((== length bindingRows) . length)-                let bumped = spec{specMapped = map bumpArtifactBindingVersion (specMapped spec)}-                second <- executePlannedScaffold out "consumer-types.keiro" ctx bumped-                reportMappingDrift second-                    `shouldSatisfy` any (\drift -> driftSpecName drift == "ArtifactInfo" && driftPrevious drift /= driftCurrent drift)-                renderScaffoldReport second `shouldSatisfy` any (T.isInfixOf "mapping drift:")-                case mappingRows of-                    row : _ -> parseRecord (recordText <> row <> "\n") `shouldBe` Nothing-                    [] -> expectationFailure "expected mapping rows"-                case bindingRows of-                    row : _ -> parseRecord (recordText <> row <> "\n") `shouldBe` Nothing-                    [] -> expectationFailure "expected binding rows"-        it "reports exactly the newly added binding field without rewriting the shared skeleton" $-            withTempDirectory "keiro-dsl-binding-drift" $ \out -> do-                spec <- specOf "test/fixtures/consumer-types.keiro"-                let ctx = defaultContext (specContext spec)-                _ <- executePlannedScaffold out "consumer-types.keiro" ctx spec-                let extended = spec{specMapped = map addArtifactSummaryField (specMapped spec)}-                second <- executePlannedScaffold out "consumer-types.keiro" ctx extended-                reportNewHoles second-                    `shouldBe` [ BindingHole-                                    { holeMappedName = "ArtifactInfo"-                                    , holeModule = "Example.Artifact.KeiroBindings"-                                    , holeSymbol = "artifactInfoBinding"-                                    , holeKind = BindingValue-                                    , holePath = Just "summary"-                                    , holeSignature = "artifactInfoBinding.summary :: Text"-                                    }-                               ]-                renderScaffoldReport second `shouldSatisfy` any (T.isInfixOf "artifactInfoBinding.summary :: Text")-        it "rejects malformed known mapping JSON while ignoring unrelated future rows" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            withTempDirectory "keiro-dsl-mapping-malformed" $ \out -> do-                report <- executePlannedScaffold out "consumer-types.keiro" (defaultContext (specContext spec)) spec-                recordText <- TIO.readFile (reportRecordPath report)-                parseRecord (recordText <> "mapping {not-json}\n") `shouldBe` Nothing-                parseRecord (recordText <> "future-row retained\n") `shouldBe` parseRecord recordText--    describe "structural import plan" $ do-        it "reports the successful dependency plan in the scaffold report" $-            withTempDirectory "keiro-dsl-dependency-plan" $ \out -> do-                spec <- specOf "test/fixtures/consumer-types.keiro"-                report <- executePlannedScaffold out "consumer-types.keiro" (defaultContext (specContext spec)) spec-                renderScaffoldReport report-                    `shouldSatisfy` any (T.isInfixOf "dependency plan: consumer packages [artifact-domain, vendor-geometry]")-        it "refuses a binding module inside the generated namespace with the exact cycle" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let cyclic = spec{specMapped = map moveArtifactBindingIntoGenerated (specMapped spec)}-            case planScaffold (defaultContext (specContext cyclic)) cyclic of-                Left refusals -> do-                    refusals `shouldSatisfy` any isImportCycle-                    renderRefusals refusals `shouldSatisfy` any (T.isInfixOf "Generated.ConsumerDemo.Bindings")-                Right _ -> expectationFailure "expected an import-cycle refusal"-        it "refuses missing mapped register initials but permits command/event-only use" $ do-            missing <- specOf "test/fixtures/mapped-missing-initial.keiro"-            planScaffold (defaultContext (specContext missing)) missing `shouldSatisfy` isLoweringRefusal-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let commandOnly = removeMappedRegisterRequirements spec-            planScaffold (defaultContext (specContext commandOnly)) commandOnly `shouldSatisfy` isRight--    describe "binding explanations" $ do-        it "lists binding, fixture, and use-site-scoped initial obligations deterministically" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            obligations <- either (\errors -> expectationFailure (show errors) >> pure []) pure (bindingObligations spec)-            length obligations `shouldBe` 7-            obligations-                `shouldSatisfy` any-                    ( \obligation ->-                        obligationKind obligation == BindingValue-                            && obligationSymbol obligation == "artifactInfoBinding"-                            && obligationBindingVersion obligation == Just "1"-                    )-            obligations-                `shouldSatisfy` any-                    ( \obligation ->-                        obligationKind obligation == InitialValue-                            && obligationSymbol obligation == "emptyArtifactInfo"-                            && any (T.isInfixOf "Catalog register currentArtifact") (obligationUseSites obligation)-                    )-            let rendered = renderBindingObligations (specContext spec) obligations-            rendered `shouldSatisfy` T.isInfixOf "binding obligations for context consumer-demo"-            rendered `shouldSatisfy` T.isInfixOf "artifactInfoBinding :: StructuralBinding Example.Artifact.Domain.ArtifactInfo ArtifactInfoShape"-            rendered `shouldSatisfy` T.isInfixOf "provenance: binding-version \"1\""-        it "states explicitly when a spec has no structural obligations" $ do-            spec <- specOf "test/fixtures/reservation.keiro"-            obligations <- either (\errors -> expectationFailure (show errors) >> pure []) pure (bindingObligations spec)-            renderBindingObligations (specContext spec) obligations-                `shouldBe` "no binding obligations for context hospital-capacity"--    describe "exact generic structural bindings" $ do-        forM_-            [ ("renamed-field", "selector mismatch")-            , ("reordered-field", "selector mismatch")-            , ("arity-mismatch", "no exact nominal correspondence")-            , ("incompatible-type", "no exact nominal correspondence")-            ]-            $ \(fixture, diagnostic) ->-                it ("rejects " <> fixture <> " and directs the author to the scaffolded module") $-                    expectGenericCompileFailure fixture diagnostic--    describe "structural harness" $ do-        it "emits every structural, wire-policy, projection, and replay assertion family" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let aggregate = onlyAggregate spec-                ctx = defaultContext (specContext spec)-                harness = generatedTextEndingIn "Harness.hs" (harnessFor ctx spec aggregate)-            mapM_-                (\needle -> harness `shouldSatisfy` T.isInfixOf needle)-                [ "binding domain round-trip: example.artifact.ArtifactInfo.v1/"-                , "binding shape round-trip: example.artifact.ArtifactInfo.v1/"-                , "mapped codec round-trip: ArtifactObserved/artifact/"-                , "fixture coverage: example.artifact.ArtifactLocation.v1"-                , "wire policy missing default: example.artifact.ArtifactInfo.v1/description"-                , "wire policy explicit null: example.artifact.ArtifactInfo.v1/description"-                , "wire policy unknown fields: example.artifact.ArtifactInfo.v1"-                , "wire union arm: example.artifact.ArtifactLocation.v1/local_file"-                , "canonical identity: example.artifact.ArtifactInfo.v1"-                , "projection witness agreement: example.artifact.ArtifactInfo.v1/key"-                , "forward/replay equality: ObserveArtifact from CatalogEmpty -- "-                , "register currentArtifact"-                ]-        it "keeps opaque assertions at the declared codec boundary" $ do-            spec <- specOf "test/fixtures/consumer-types.keiro"-            let aggregate = onlyAggregate spec-                ctx = defaultContext (specContext spec)-                modules = scaffoldAggregate ctx spec aggregate <> harnessFor ctx spec aggregate-                harness = generatedTextEndingIn "Harness.hs" modules-                codec = generatedTextEndingIn "Codec.hs" modules-            harness `shouldSatisfy` T.isInfixOf "opaque codec round-trip: vendor.geometry.json@3/"-            harness `shouldNotSatisfy` T.isInfixOf "wire policy unknown fields: vendor.geometry.json"-            harness `shouldNotSatisfy` T.isInfixOf "fixture coverage: vendor.geometry"-            codec `shouldNotSatisfy` T.isInfixOf "encodeVendorGeometryShape"--    describe "manifest (M2)" $ do-        it "lists exactly the modules the scaffolder produced" $ do-            mods <- scaffoldFixture "test/fixtures/reservation.keiro"-            spec <- specOf "test/fixtures/reservation.keiro"-            let manifest = renderManifest "reservation.keiro" mods spec-                expectedNames = sort (map (moduleNameOf . modulePath) mods)-            -- every produced module name appears in the manifest…-            mapM_ (\m -> (m `T.isInfixOf` manifest) `shouldBe` True) expectedNames-            -- …and the module list is exactly the scaffolder's output set.-            expectedNames-                `shouldBe` sort-                    [ "Generated.HospitalCapacity.Reservation.Codec"-                    , "Generated.HospitalCapacity.Reservation.Domain"-                    , "Generated.HospitalCapacity.Reservation.EventStream"-                    , "Generated.HospitalCapacity.Reservation.Harness"-                    , "Generated.HospitalCapacity.Reservation.Projection"-                    , "HospitalCapacity.Reservation.Holes"-                    ]-        it "derives the dependency set from the node kinds present (aggregate)" $ do-            spec <- specOf "test/fixtures/reservation.keiro"-            manifestDependencies spec `shouldBe` ["aeson", "base", "keiki", "keiro", "text"]-        it "derives the process dependency set, including worker-policy runtime imports" $ do-            spec <- specOf "test/fixtures/hospital-surge.keiro"-            let dependencies = manifestDependencies spec-            mapM_ (\dependency -> dependencies `shouldContain` [dependency]) ["time", "uuid", "shibuya-core", "keiki", "keiro"]-        it "uses the registered shibuya-core package name for router scaffolds" $ do-            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"-            let dependencies = manifestDependencies spec-            mapM_ (\dependency -> dependencies `shouldContain` [dependency]) ["effectful-core", "keiro", "shibuya-core"]-            dependencies `shouldNotContain` ["shibuya"]--    describe "new <kind> skeletons (M5)" $ do-        it "every skeleton parses and validates with zero error diagnostics" $-            mapM_ assertSkeletonValid skeletonKinds-        it "every skeleton passes the scaffold refusal gates" $-            mapM_ assertSkeletonScaffoldable skeletonKinds-        it "fresh skeleton scaffolds match the committed compiling modules" $-            mapM_ (uncurry assertSkeletonMatchesCommitted) skeletonModuleRoots-        it "rejects an unknown kind with a helpful message" $-            case skeletonFor "bogus" of-                Left msg -> ("Valid kinds:" `T.isInfixOf` msg) `shouldBe` True-                Right _ -> expectationFailure "expected an error for an unknown kind"--    describe "firewall self-check (M3)" $ do-        it "flags a forbidden operator in a Generated module" $ do-            let m = ScaffoldModule{modulePath = "Gen/Foo.hs", moduleText = "x = a ./= b", kind = Generated, origin = "test"}-            firewallBreaches [m] `shouldBe` [("Gen/Foo.hs", "./=", 1)]-        it "ignores forbidden operators in a HoleStub module (holes own them)" $ do-            let m = ScaffoldModule{modulePath = "Foo/Holes.hs", moduleText = "x = lit 1 .== y", kind = HoleStub, origin = "test"}-            firewallBreaches [m] `shouldBe` []-        it "matches `lit` as a word, not a substring of quality/split" $ do-            let clean = ScaffoldModule{modulePath = "Gen/Q.hs", moduleText = "quality = split facility", kind = Generated, origin = "test"}-                dirty = ScaffoldModule{modulePath = "Gen/L.hs", moduleText = "v = lit foo", kind = Generated, origin = "test"}-            firewallBreaches [clean] `shouldBe` []-            firewallBreaches [dirty] `shouldBe` [("Gen/L.hs", "lit", 1)]-        it "skips strings and comments and maximal-munches symbolic tokens" $ do-            let clean = syntheticGenerated "Gen/Clean.hs" "wire = \"lit .== B.slot\"\n-- x =: y\nx = a .<= b"-                dirty = syntheticGenerated "Gen/Dirty.hs" "x = a .< b\ny = c =: d"-            firewallBreaches [clean] `shouldBe` [("Gen/Clean.hs", ".<=", 3)]-            firewallBreaches [dirty] `shouldBe` [("Gen/Dirty.hs", ".<", 1), ("Gen/Dirty.hs", "=:", 2)]-        it "guards keiki imports while allowing the generated Core allowlist" $ do-            let forbidden = syntheticGenerated "Gen/Builder.hs" "import Keiki.Builder"-                restricted = syntheticGenerated "Gen/CoreBad.hs" "import Keiki.Core (lit)"-                allowed = syntheticGenerated "Gen/CoreGood.hs" "import Keiki.Core (RegFile (..), HsPred, step)"-            firewallBreaches [forbidden] `shouldBe` [("Gen/Builder.hs", "import:Keiki.Builder", 1)]-            firewallBreaches [restricted] `shouldBe` [("Gen/CoreBad.hs", "import:Keiki.Core", 1)]-            firewallBreaches [allowed] `shouldBe` []-        it "finds no breach in real scaffolder output (aggregate + process fixtures)" $ do-            aggMods <- scaffoldFixture "test/fixtures/reservation.keiro"-            procMods <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"-            firewallBreaches (aggMods <> procMods) `shouldBe` []--    describe "scaffold gates" $ do-        it "refuses duplicate and case-folded module paths with both origins" $ do-            spec <- specOf "test/fixtures/reservation.keiro"-            case [aggregate | NAggregate aggregate <- specNodes spec] of-                aggregate : _ -> do-                    let duplicate = spec{specNodes = [NAggregate aggregate, NAggregate aggregate]}-                        caseVariant = spec{specNodes = [NAggregate aggregate, NAggregate aggregate{aggName = T.toUpper (aggName aggregate)}]}-                    planScaffold (defaultContext (specContext spec)) duplicate `shouldSatisfy` hasPathCollisionWithTwoOrigins-                    planScaffold (defaultContext (specContext spec)) caseVariant `shouldSatisfy` hasPathCollisionWithTwoOrigins-                [] -> expectationFailure "reservation fixture has no aggregate"-        it "refuses a bannerless Generated target without changing its bytes" $-            withTempDirectory "keiro-dsl-banner" $ \out -> do-                spec <- specOf "test/fixtures/reservation.keiro"-                let ctx = defaultContext (specContext spec)-                case planScaffold ctx spec of-                    Left refusals -> expectationFailure ("unexpected planning refusal: " <> show refusals)-                    Right modules -> case [m | m <- modules, kind m == Generated] of-                        generated : _ -> do-                            let target = out </> modulePath generated-                            createDirectoryIfMissing True (takeDirectory target)-                            TIO.writeFile target "hand owned\n"-                            result <- executeScaffold out False "test/fixtures/reservation.keiro" ctx spec modules-                            result `shouldSatisfy` isMissingBannerRefusal-                            TIO.readFile target `shouldReturn` "hand owned\n"-                            forced <- executeScaffold out True "test/fixtures/reservation.keiro" ctx spec modules-                            forced `shouldSatisfy` isSuccessfulScaffold-                            TIO.readFile target `shouldReturn` moduleText generated-                        [] -> expectationFailure "reservation scaffold has no Generated module"-        it "reports renamed-node modules as stale without deleting them" $-            withTempDirectory "keiro-dsl-stale-rename" $ \out -> do-                spec <- parseInlineSpec "<stale-rename>" loweringAggregateSpec-                first <- executePlannedScaffold out "counter.keiro" (defaultContext (specContext spec)) spec-                let renamed = spec{specNodes = map renameCounter (specNodes spec)}-                second <- executePlannedScaffold out "counter.keiro" (defaultContext (specContext renamed)) renamed-                let oldDomain = onlyPathEndingIn "Counter/Domain.hs" (map fst (reportDispositions first))-                    oldHoles = onlyPathEndingIn "Counter/Holes.hs" (map fst (reportDispositions first))-                reportStale second `shouldSatisfy` \stale -> StaleModule Generated oldDomain `elem` stale && StaleModule HoleStub oldHoles `elem` stale-                doesFileExist (out </> oldDomain) `shouldReturn` True-                doesFileExist (out </> oldHoles) `shouldReturn` True-        it "reports the entire old tree across a module-root flip" $-            withTempDirectory "keiro-dsl-stale-root" $ \out -> do-                spec <- parseInlineSpec "<stale-root>" loweringAggregateSpec-                let initialCtx = defaultContext (specContext spec)-                    rootedCtx = initialCtx{moduleRoot = "Acme"}-                first <- executePlannedScaffold out "counter.keiro" initialCtx spec-                second <- executePlannedScaffold out "moved-counter.keiro" rootedCtx spec-                reportStale second-                    `shouldMatchList` [StaleModule (kind m) (modulePath m) | (m, _) <- reportDispositions first]-                forM_ (reportStale second) $ \stale -> doesFileExist (out </> stalePath stale) `shouldReturn` True-                renderScaffoldReport second `shouldSatisfy` any (T.isInfixOf "previous scaffold record used spec counter.keiro")-        it "reports moved generated modules across a layout flip" $-            withTempDirectory "keiro-dsl-stale-layout" $ \out -> do-                spec <- parseInlineSpec "<stale-layout>" loweringAggregateSpec-                let initialCtx = defaultContext (specContext spec)-                    collocatedCtx = initialCtx{placement = CollocatedLeaf}-                first <- executePlannedScaffold out "counter.keiro" initialCtx spec-                second <- executePlannedScaffold out "counter.keiro" collocatedCtx spec-                let oldGenerated = [StaleModule Generated (modulePath m) | (m, _) <- reportDispositions first, kind m == Generated]-                reportStale second `shouldSatisfy` all (`elem` oldGenerated)-                length (reportStale second) `shouldBe` length oldGenerated-        it "writes a parseable record and no stale section for a fresh output" $-            withTempDirectory "keiro-dsl-record" $ \out -> do-                spec <- parseInlineSpec "<fresh-record>" loweringAggregateSpec-                let ctx = defaultContext (specContext spec)-                report <- executePlannedScaffold out "counter.keiro" ctx spec-                reportStale report `shouldBe` []-                renderScaffoldReport report `shouldSatisfy` all (not . T.isPrefixOf "stale:")-                contents <- TIO.readFile (out </> recordFileName (specContext spec))-                parseRecord contents-                    `shouldBe` Just-                        ScaffoldRecord-                            { recSpecPath = "counter.keiro"-                            , recModuleRoot = ""-                            , recLayout = "prefixed"-                            , recFiles = [(kind m, modulePath m) | (m, _) <- reportDispositions report]-                            , recMappings = []-                            , recBindingObligations = []-                            }-                parseRecord (T.replace "spec: " "future-field: retained\nspec: " contents) `shouldBe` parseRecord contents-                parseRecord (T.replace "record v1" "record v2" contents) `shouldBe` Nothing--    describe "faithful scaffold lowering" $ do-        it "escapes a trailing-backslash payload literal exactly once" $ do-            spec <- specOf "test/fixtures/hospital-surge.keiro"-            case [process | NProcess process <- specNodes spec] of-                process : _ -> do-                    let timer = (procTimer process){tmPayload = [FieldBinding "kind" (Just "\"follow-up\\\"")]}-                        modules = scaffoldProcess (defaultContext (specContext spec)) process{procTimer = timer}-                    generatedTextEndingIn "Process.hs" modules-                        `shouldSatisfy` T.isInfixOf "\"kind\" .= (\"follow-up\\\\\" :: Value)"-                [] -> expectationFailure "hospital-surge fixture has no process"-        it "preserves quoted Text register initials and refuses unsafe register shapes" $ do-            spec <- parseInlineSpec "<register-initials>" loweringAggregateSpec-            let modules = scaffoldAggregate (defaultContext (specContext spec)) spec =<< [aggregate | NAggregate aggregate <- specNodes spec]-                domain = generatedTextEndingIn "Domain.hs" modules-            domain `shouldSatisfy` T.isInfixOf "RCons (Proxy @\"note\") \"hello world\""-            scaffoldRefusals spec `shouldBe` []-            bare <- parseInlineSpec "<bare-text-initial>" (T.replace "\"hello world\"" "hello" loweringAggregateSpec)-            scaffoldRefusals bare `shouldSatisfy` any (T.isInfixOf "RegTextInitialNotQuoted")-            unsupported <- parseInlineSpec "<unsupported-field>" (T.replace "count:Int" "count:Time" loweringAggregateSpec)-            scaffoldRefusals unsupported `shouldSatisfy` any (T.isInfixOf "FieldTypeUnrepresentable")-        it "lowers seconds, minutes, hours, and both backoff constructors faithfully" $ do-            windowSeconds "90s" `shouldBe` Right 90-            windowSeconds "5m" `shouldBe` Right 300-            windowSeconds "2h" `shouldBe` Right 7200-            emitSource <- readTestText "test/fixtures/emit.keiro"-            let exponentialSource = T.replace "backoff constant 2s" "backoff exponential 2s max=60s multiplier=2.0" emitSource-            exponential <- parseInlineSpec "<exponential-backoff>" exponentialSource-            case [publisher | NPublisher publisher <- specNodes exponential] of-                publisher : _ -> do-                    let generated = generatedTextEndingIn "Publisher.hs" (scaffoldPublisher (defaultContext (specContext exponential)) publisher)-                    generated `shouldSatisfy` T.isInfixOf "ExponentialBackoff ExponentialBackoffOptions { initial = 2, maxDelay = 60, multiplier = 2.0 }"-                    parseSpec "<exponential-round-trip>" (renderSpec exponential) `shouldBe` Right exponential-                [] -> expectationFailure "emit fixture has no publisher"-            constant <- parseInlineSpec "<constant-backoff>" (T.replace "backoff constant 2s" "backoff constant 2m" emitSource)-            case [publisher | NPublisher publisher <- specNodes constant] of-                publisher : _ -> generatedTextEndingIn "Publisher.hs" (scaffoldPublisher (defaultContext (specContext constant)) publisher) `shouldSatisfy` T.isInfixOf "ConstantBackoff 120"-                [] -> expectationFailure "emit fixture has no publisher"-        it "refuses incomplete exponential backoff and rejects unknown window units" $ do-            emitSource <- readTestText "test/fixtures/emit.keiro"-            incomplete <- parseInlineSpec "<incomplete-backoff>" (T.replace "backoff constant 2s" "backoff exponential 2s" emitSource)-            scaffoldRefusals incomplete `shouldSatisfy` any (T.isInfixOf "BackoffExponentialIncomplete")-            parseSpec "<bad-window>" (T.replace "backoff constant 2s" "backoff constant 2x" emitSource)-                `shouldSatisfy` leftContains "time unit: s, m, or h"-        it "lowers workqueue retry windows in minutes to seconds" $ do-            queueSource <- readTestText "test/fixtures/reservation-work.keiro"-            queueSpec <- parseInlineSpec "<minute-queue>" (T.replace "5s" "5m" queueSource)-            case [workqueue | NWorkqueue workqueue <- specNodes queueSpec] of-                workqueue : _ -> do-                    let policy = generatedTextEndingIn "QueuePolicy.hs" (scaffoldWorkqueue (defaultContext (specContext queueSpec)) workqueue)-                    policy `shouldSatisfy` T.isInfixOf "defaultRetryDelay = RetryDelay 300"-                    policy `shouldSatisfy` T.isInfixOf "Retry (RetryDelay 300)"-                [] -> expectationFailure "queue fixture has no workqueue"-        it "uses exact status-map keys and emits total Int harness samples" $ do-            statusSpec <- parseInlineSpec "<exact-status>" exactStatusSpec-            case [aggregate | NAggregate aggregate <- specNodes statusSpec] of-                aggregate : _ -> do-                    let ctx = defaultContext (specContext statusSpec)-                        projection = generatedTextEndingIn "Projection.hs" (scaffoldAggregate ctx statusSpec aggregate)-                        harness = generatedTextEndingIn "Harness.hs" (harnessFor ctx statusSpec aggregate)-                    projection `shouldSatisfy` T.isInfixOf "ReservationUnHeld {} -> Just \"available\""-                    harness `shouldSatisfy` T.isInfixOf "CountBumpedData 0"-                    harness `shouldNotSatisfy` T.isInfixOf "sample: unsupported"-                [] -> expectationFailure "exact-status spec has no aggregate"--    describe "scaffold" $ do-        it "synthesizes the exact old wire shape and embeds it in the harness" $ do-            oldSpec <- specOf "test/fixtures/reservation.keiro"-            newSpec <- specOf "test/fixtures/reservation-v2.keiro"-            case goldensForDiff oldSpec newSpec of-                [golden] -> do-                    goldenRelativePath golden-                        `shouldBe` "hospital-capacity/Reservation/TransferReservationCreated.v1.json"-                    goldenJson golden-                        `shouldBe` "{\"commandId\":\"cmd_01hzy3v7q2e8kaw2m5x0d41n9c\",\"divertStatus\":\"open\",\"hospitalId\":\"hosp_01hzy3v7q2e8kaw2m5x0d41n9c\",\"kind\":\"TransferReservationCreated\",\"lifeCriticalOverride\":true,\"patientAcuity\":\"red\",\"reservationId\":\"rsv_01hzy3v7q2e8kaw2m5x0d41n9c\"}\n"-                    goldenEvidence golden `shouldBe` SynthesizedWeakStandIn-                    let aggregate = onlyAggregate newSpec-                        modules =-                            harnessForWithGoldens-                                [golden]-                                (defaultContext (specContext newSpec))-                                newSpec-                                aggregate-                        harness = generatedTextEndingIn "Harness.hs" modules-                    harness `shouldSatisfy` T.isInfixOf "golden TransferReservationCreated.v1 decodes"-                    harness `shouldSatisfy` T.isInfixOf "\\\"reservationId\\\":\\\"rsv_"-                    harness `shouldSatisfy` (not . T.isInfixOf "current-shape stand-in")-                goldens -> expectationFailure ("expected one synthesized golden, got " <> show goldens)-        it "synthesizes complete nested mapped old shapes deterministically and never overwrites captured evidence" $ do-            oldSpec <- specOf "test/fixtures/consumer-types.keiro"-            newSpec <- specOf "test/fixtures/consumer-types-v2.keiro"-            case goldensForDiff oldSpec newSpec of-                [golden] -> do-                    goldenEvidence golden `shouldBe` SynthesizedWeakStandIn-                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"artifact\":{"-                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"location\":{\"contents\":\"sample\",\"tag\":\"local_file\"}"-                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"labels\":[\"sample\"]"-                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"revision\":1"-                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"observedAt\":\"2026-01-01T00:00:00Z\""-                    goldensForDiff oldSpec newSpec `shouldBe` [golden]-                    withTempDirectory "keiro-golden-preserve" $ \root -> do-                        let target = root </> goldenRelativePath golden-                        createDirectoryIfMissing True (takeDirectory target)-                        TIO.writeFile target "hand captured\n"-                        emitGoldenPayloads root oldSpec newSpec `shouldReturn` []-                        TIO.readFile target `shouldReturn` "hand captured\n"-                    withTempDirectory "keiro-golden-write" $ \root -> do-                        let target = root </> goldenRelativePath golden-                        emitGoldenPayloads root oldSpec newSpec `shouldReturn` [target]-                        TIO.readFile target `shouldReturn` goldenJson golden-                goldens -> expectationFailure ("expected one nested synthesized golden, got " <> show goldens)-        it "dispatches shared-version upcasters by wire event type and passes foreign kinds through" $ do-            source <- readTestText "test/fixtures/reservation-dup-upcast-source.keiro"-            spec <- parseInlineSpec "<shared-upcaster-source>" source-            case [aggregate | NAggregate aggregate <- specNodes spec] of-                [aggregate] -> do-                    let modules = scaffoldAggregate (defaultContext (specContext spec)) spec aggregate-                        codec = generatedTextEndingIn "Codec.hs" modules-                        holes = case [moduleText m | m <- modules, "Holes.hs" `T.isSuffixOf` T.pack (modulePath m)] of-                            [text] -> text-                            _ -> ""-                    codec `shouldSatisfy` T.isInfixOf "upcasters = [(1, upcastRungV1)]"-                    codec `shouldSatisfy` T.isInfixOf "upcastRungV1 (EventType \"TransferReservationCreated\") value = upcastTransferReservationCreatedV1 value"-                    codec `shouldSatisfy` T.isInfixOf "upcastRungV1 (EventType \"TransferReservationConfirmed\") value = upcastTransferReservationConfirmedV1 value"-                    codec `shouldSatisfy` T.isInfixOf "upcastRungV1 _ value = Right value"-                    holes `shouldSatisfy` T.isInfixOf "receives ONLY TransferReservationCreated payloads"-                _ -> expectationFailure "expected exactly one aggregate"-        it "keeps foreign payloads byte-for-byte and invokes both same-rung event upcasters" $ do-            let payloadA = object ["kind" .= ("AmountScaled" :: T.Text), "amount" .= (2 :: Int)]-                payloadB = object ["kind" .= ("AmountRenamed" :: T.Text), "amount" .= (3 :: Int)]-                foreignPayload = object ["kind" .= ("AmountObserved" :: T.Text), "amount" .= (7 :: Int)]-                upcastA _ = Right (object ["kind" .= ("AmountScaled" :: T.Text), "amount" .= (200 :: Int)])-                upcastB _ = Right (object ["kind" .= ("AmountRenamed" :: T.Text), "amountInCents" .= (300 :: Int)])-                rung (EventType "AmountScaled") = upcastA-                rung (EventType "AmountRenamed") = upcastB-                rung _ = Right-                codec =-                    Codec-                        { eventTypes = EventType "AmountScaled" :| [EventType "AmountRenamed", EventType "AmountObserved"]-                        , eventType = const (EventType "AmountObserved")-                        , schemaVersion = 2-                        , encode = id-                        , decode = \_ -> Right-                        , upcasters = [(1, rung)]-                        } ::-                        Codec Value-            decodeRaw codec (EventType "AmountObserved") 1 foreignPayload `shouldBe` Right foreignPayload-            decodeRaw codec (EventType "AmountScaled") 1 payloadA-                `shouldBe` Right (object ["kind" .= ("AmountScaled" :: T.Text), "amount" .= (200 :: Int)])-            decodeRaw codec (EventType "AmountRenamed") 1 payloadB-                `shouldBe` Right (object ["kind" .= ("AmountRenamed" :: T.Text), "amountInCents" .= (300 :: Int)])-        it "never emits a keiki symbolic operator into a Generated module (firewall)" $ do-            mods <- scaffoldFixture "test/fixtures/reservation.keiro"-            firewallBreaches mods `shouldBe` []-        it "marks the Holes module HoleStub and the rest Generated" $ do-            mods <- scaffoldFixture "test/fixtures/reservation.keiro"-            let holes = [m | m <- mods, "Holes.hs" `T.isSuffixOf` T.pack (modulePath m)]-            map kind holes `shouldBe` [HoleStub]-            -- Domain, Codec, EventStream, Projection, Harness.-            length [m | m <- mods, kind m == Generated] `shouldBe` 5-        it "is deterministic (re-scaffolding yields byte-identical text)" $ do-            a <- scaffoldFixture "test/fixtures/reservation.keiro"-            b <- scaffoldFixture "test/fixtures/reservation.keiro"-            map moduleText a `shouldBe` map moduleText b-        it "keeps retiring as validator-only metadata in generated modules" $ do-            ordinary <- scaffoldFixture "test/fixtures/reservation.keiro"-            retiring <- scaffoldFixture "test/fixtures/reservation-retiring.keiro"-            map (\m -> (modulePath m, kind m, moduleText m)) retiring-                `shouldBe` map (\m -> (modulePath m, kind m, moduleText m)) ordinary-        it "matches the committed compiling Generated conformance modules (modulo whitespace)" $ do-            mods <- scaffoldFixture "test/fixtures/reservation.keiro"-            mapM_ assertMatchesCommitted [m | m <- mods, kind m == Generated]-        it "matches every committed new-surface Generated module (modulo formatting)" $ do-            spec <- specOf "test/fixtures/transfer-routing.keiro"-            let modules = scaffoldModules (defaultContext (specContext spec)) spec-            forM_ [m | m <- modules, kind m == Generated] $ \m -> do-                committed <- readTestText ("test/conformance-newsurface/" <> modulePath m)-                normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText m)-        it "scaffolds the register-free OrderStream smoke target without error" $ do-            mods <- scaffoldFixture "test/fixtures/order.keiro"-            -- 5 Generated (Domain/Codec/EventStream/Projection/Harness) + 1 Holes.-            length mods `shouldBe` 6-            firewallBreaches mods `shouldBe` []-            let harness = generatedTextEndingIn "Harness.hs" mods-            harness `shouldSatisfy` T.isInfixOf "prefix = \"forward/replay equality: PlaceOrder from OrderNotStarted -- \""-            harness `shouldSatisfy` T.isInfixOf "prefix <> \"final vertex\""-            harness `shouldNotSatisfy` T.isInfixOf "prefix <> \"register "-        it "emits forward/replay checks with field-distinct Text samples" $ do-            spec <- parseInlineSpec "<forward-replay-samples>" (T.replace "command Bump { count:Int }" "command Bump { count:Int noteText:Text echo:Text }" loweringAggregateSpec)-            case [aggregate | NAggregate aggregate <- specNodes spec] of-                aggregate : _ -> do-                    let ctx = defaultContext (specContext spec)-                        harness = generatedTextEndingIn "Harness.hs" (harnessFor ctx spec aggregate)-                    harness `shouldSatisfy` T.isInfixOf "\"sample-noteText\" \"sample-echo\""-                    harness `shouldSatisfy` T.isInfixOf "prefix = \"forward/replay equality: Bump from CounterPending -- \""-                    harness `shouldSatisfy` T.isInfixOf "prefix <> \"register note\""-                [] -> expectationFailure "forward/replay sample spec has no aggregate"-        it "emits the canonical reservation register checks" $ do-            mods <- scaffoldFixture "test/fixtures/reservation.keiro"-            let harness = generatedTextEndingIn "Harness.hs" mods-            harness `shouldSatisfy` T.isInfixOf "prefix = \"forward/replay equality: RequestTransferReservation from ReservationUnrequested -- \""-            harness `shouldSatisfy` T.isInfixOf "prefix <> \"register reservationState\""-        it "lowers a replay-only transition to B.replayOnly in the holes skeleton (plan 143)" $ do-            twinMods <- scaffoldFixture "test/fixtures/reservation-guard-tightened-twin.keiro"-            let twinHoles = [moduleText m | m <- twinMods, kind m == HoleStub]-            twinHoles `shouldSatisfy` any (T.isInfixOf "B.replayOnly")-            let twinHarness = generatedTextEndingIn "Harness.hs" twinMods-            T.count "forwardReplayRequestTransferReservation ::" twinHarness `shouldBe` 1-            plainMods <- scaffoldFixture "test/fixtures/reservation.keiro"-            let plainHoles = [moduleText m | m <- plainMods, kind m == HoleStub]-            plainHoles `shouldSatisfy` all (not . T.isInfixOf "B.replayOnly")--comparisonProvenance :: CompareProvenance-comparisonProvenance =-    CompareProvenance-        { cpHistoricalCodecIdentity = "example.historical"-        , cpHistoricalCodecVersion = "legacy-v1"-        , cpCanonicalType = CanonicalTypeId "example.Artifact.v1"-        , cpBindingSymbol = QualifiedValueName "Example.Bindings.artifactBinding"-        , cpBindingVersion = BindingVersion "1"-        , cpWireFingerprint = "deadbeef"-        }--syntheticGenerated :: FilePath -> T.Text -> ScaffoldModule-syntheticGenerated path contents =-    ScaffoldModule{modulePath = path, moduleText = contents, kind = Generated, origin = "test"}--generatedTextEndingIn :: T.Text -> [ScaffoldModule] -> T.Text-generatedTextEndingIn suffix modules = case [moduleText m | m <- modules, kind m == Generated, suffix `T.isSuffixOf` T.pack (modulePath m)] of-    contents : _ -> contents-    [] -> ""--onlyAggregate :: Spec -> Aggregate-onlyAggregate spec = case [aggregate | NAggregate aggregate <- specNodes spec] of-    [aggregate] -> aggregate-    aggregates -> error ("expected one aggregate, got " <> show (length aggregates))--loweringAggregateSpec :: T.Text-loweringAggregateSpec =-    T.unlines-        [ "context samples"-        , ""-        , "aggregate Counter"-        , "  regs"-        , "    note Text = \"hello world\""-        , "    count Int = 0"-        , "    state CounterVertex = Pending"-        , "  states Pending Done!"-        , "  command Bump { count:Int }"-        , "  event CountBumped { count:Int }"-        , "  Pending -- Bump --> emit CountBumped ; goto Done"-        ]--exactStatusSpec :: T.Text-exactStatusSpec =-    T.unlines-        [ "context samples"-        , ""-        , "aggregate Reservation"-        , "  regs"-        , "    state ReservationVertex = Open"-        , "  states Open Closed!"-        , "  command Bump { count:Int }"-        , "  event ReservationHeld { count:Int }"-        , "  event ReservationUnHeld { count:Int }"-        , "  event CountBumped { count:Int }"-        , "  Open -- Bump --> emit CountBumped ; goto Closed"-        , "  projection reservation_status consistency=Eventual key=count"-        , "    status-map { ReservationHeld=>held ReservationUnHeld=>available CountBumped=>bumped }"-        ]--hasPathCollisionWithTwoOrigins :: Either [Refusal] [ScaffoldModule] -> Bool-hasPathCollisionWithTwoOrigins = \case-    Left refusals -> any hasTwo refusals-    Right _ -> False-  where-    hasTwo (PathCollision _ origins) = length origins == 2-    hasTwo _ = False--isMissingBannerRefusal :: Either [Refusal] a -> Bool-isMissingBannerRefusal = \case-    Left [MissingGeneratedBanner paths] -> not (null paths)-    _ -> False--isSuccessfulScaffold :: Either [Refusal] a -> Bool-isSuccessfulScaffold = \case-    Right _ -> True-    Left _ -> False--executePlannedScaffold :: FilePath -> FilePath -> Context -> Spec -> IO ScaffoldReport-executePlannedScaffold out specPath ctx spec = case planScaffold ctx spec of-    Left refusals -> expectationFailure ("unexpected scaffold refusal: " <> show refusals) >> error "unreachable"-    Right modules -> do-        result <- executeScaffold out False specPath ctx spec modules-        case result of-            Left refusals -> expectationFailure ("unexpected execution refusal: " <> show refusals) >> error "unreachable"-            Right report -> pure report--renameCounter :: Node -> Node-renameCounter (NAggregate aggregate) =-    NAggregate-        aggregate-            { aggName = "Widget"-            , aggRegs = [reg{regType = if regType reg == "CounterVertex" then "WidgetVertex" else regType reg} | reg <- aggRegs aggregate]-            }-renameCounter node = node--onlyPathEndingIn :: FilePath -> [ScaffoldModule] -> FilePath-onlyPathEndingIn suffix modules = case [modulePath m | m <- modules, T.pack suffix `T.isSuffixOf` T.pack (modulePath m)] of-    [path] -> path-    paths -> error ("expected one path ending in " <> suffix <> ", got " <> show paths)--withTempDirectory :: String -> (FilePath -> IO a) -> IO a-withTempDirectory template = bracket acquire removePathForcibly-  where-    acquire = do-        base <- getTemporaryDirectory-        (path, handle) <- openTempFile base template-        hClose handle-        removeFile path-        createDirectory path-        pure path--{- | Parse a fixture and return the validator's diagnostic codes (failing the-test on a parse error).--}-diagnosticCodesOf :: FilePath -> IO [DiagnosticCode]-diagnosticCodesOf path = do-    map code <$> diagnosticsOf path---- | Parse a fixture and return all validator diagnostics.-diagnosticsOf :: FilePath -> IO [Diagnostic]-diagnosticsOf path = do-    input <- readTestText path-    case parseSpec path input of-        Left err -> expectationFailure (T.unpack err) >> pure []-        Right spec -> pure (validateSpec spec)--{- | Like 'diagnosticCodesOf' but only the Error-severity codes (warnings, e.g.-the benign-inversion notices, are excluded).--}-errorCodesOf :: FilePath -> IO [DiagnosticCode]-errorCodesOf path = do-    diagnostics <- diagnosticsOf path-    pure [code d | d <- diagnostics, severity d == Error]--{- | Parse two fixtures and diff them (old, new).-| Plan 143: render an Expr in concrete guard syntax by printing a dummy-transition through the real pretty-printer and slicing its guard clause,-so the test exercises the exact printer the diff advisory uses.--}-renderExprText :: Expr -> T.Text-renderExprText e =-    case [T.strip l | l <- T.lines rendered, "guard " `T.isPrefixOf` T.strip l] of-        [guardLine] -> T.strip (T.drop (T.length "guard ") guardLine)-        _ -> error ("renderExprText: unexpected printer output: " <> T.unpack rendered)-  where-    rendered =-        renderTransition-            Transition-                { tSource = "S"-                , tCommand = "C"-                , tGuard = Just e-                , tWrites = []-                , tEmits = []-                , tGoto = "S"-                , tMode = TmLive-                , tLoc = noLoc-                }--{- | Plan 143: a minimal spec whose only transition is replay-only, with the-supplied clause lines spliced into its body.--}-replayOnlySpecWith :: [T.Text] -> T.Text-replayOnlySpecWith clauseLines =-    T.unlines $-        [ "context hospital-capacity"-        , ""-        , "id TransferReservationId prefix=rsv"-        , ""-        , "aggregate Reservation"-        , "  regs"-        , "    reservationId    TransferReservationId = placeholder"-        , "    reservationState ReservationVertex     = Unrequested"-        , "  states Unrequested Held"-        , ""-        , "  command RequestTransferReservation { reservationId }"-        , ""-        , "  event TransferReservationCreated = fields(RequestTransferReservation)"-        , ""-        , "  replay-only Unrequested -- RequestTransferReservation -->"-        ]-            ++ clauseLines--diffFixtures :: FilePath -> FilePath -> IO [Change]-diffFixtures oldP newP = do-    old <- readTestText oldP-    new <- readTestText newP-    case (,) <$> parseSpec oldP old <*> parseSpec newP new of-        Left err -> expectationFailure (T.unpack err) >> pure []-        Right (o, n) -> pure (diffSpecs o n)--kindOfChange :: Change -> ChangeKind-kindOfChange (Additive kind) = kind-kindOfChange (Advisory kind) = kind-kindOfChange (Breaking kind) = kind--labelOfChange :: Change -> Label-labelOfChange Additive{} = LabelAdditive-labelOfChange Advisory{} = LabelAdvisory-labelOfChange Breaking{} = LabelBreaking--genSurfaceSet :: Gen (Set.Set CompatibilitySurface)-genSurfaceSet = Set.fromList <$> listOf (elements [minBound .. maxBound])--genCompatibilityVector :: Gen CompatibilityVector-genCompatibilityVector =-    CompatibilityVector-        <$> genVerdict-        <*> genVerdict-        <*> genVerdict-        <*> genVerdict-        <*> genVerdict-        <*> genVerdict-        <*> (Set.fromList <$> listOf (elements rolloutConstraints))-  where-    genVerdict = elements [VCompatible, VAdvisory, VBreaking, VNotApplicable]-    rolloutConstraints =-        [ RolloutStopTheWorld-        , RolloutWorkersFirst-        , RolloutDrainRequired-        , RolloutProducerLast-        ]--replayImpactFixtures :: FilePath -> FilePath -> IO ReplayImpact-replayImpactFixtures oldPath newPath = do-    old <- specOf oldPath-    new <- specOf newPath-    pure (ReplayImpact.replayImpact old new)--modifyAggregate :: Name -> (Aggregate -> Aggregate) -> Spec -> Spec-modifyAggregate target update spec =-    spec-        { specNodes =-            [ case node of-                NAggregate aggregate | aggName aggregate == target -> NAggregate (update aggregate)-                _ -> node-            | node <- specNodes spec-            ]-        }--modifyReadModel :: Name -> (ReadModelNode -> ReadModelNode) -> Spec -> Spec-modifyReadModel target update spec =-    spec-        { specNodes =-            [ case node of-                NReadModel readModel | rmName readModel == target -> NReadModel (update readModel)-                _ -> node-            | node <- specNodes spec-            ]-        }--removeReadModel :: Name -> Spec -> Spec-removeReadModel target spec =-    spec{specNodes = [node | node <- specNodes spec, not (isTarget node)]}-  where-    isTarget (NReadModel readModel) = rmName readModel == target-    isTarget _ = False--modifyRouter :: Name -> (RouterNode -> RouterNode) -> Spec -> Spec-modifyRouter target update spec =-    spec-        { specNodes =-            [ case node of-                NRouter router | rtId router == target -> NRouter (update router)-                _ -> node-            | node <- specNodes spec-            ]-        }--routerErrorCodes :: (RouterNode -> RouterNode) -> Spec -> [DiagnosticCode]-routerErrorCodes update = errorCodes . modifyRouter "PagingRouter" update--modifyProcess :: Name -> (ProcessNode -> ProcessNode) -> Spec -> Spec-modifyProcess target update spec =-    spec-        { specNodes =-            [ case node of-                NProcess process | procId process == target -> NProcess (update process)-                _ -> node-            | node <- specNodes spec-            ]-        }--processErrorCodes :: (ProcessNode -> ProcessNode) -> Spec -> [DiagnosticCode]-processErrorCodes update = errorCodes . modifyProcess "HospitalSurge" update--errorCodes :: Spec -> [DiagnosticCode]-errorCodes spec = [code diagnostic | diagnostic <- validateSpec spec, severity diagnostic == Error]--changeReadModelShape :: ReadModelNode -> ReadModelNode-changeReadModelShape readModel =-    readModel-        { rmColumns = rmColumns readModel <> [RmColumn "reviewed_by" "text" False]-        , rmShape = "fnv1a:0000000000000000"-        }--{- | Assert a @new \<kind\>@ skeleton parses and validates with zero-error-severity diagnostics.--}-assertSkeletonValid :: T.Text -> IO ()-assertSkeletonValid kind = case skeletonFor kind of-    Left err -> expectationFailure (T.unpack ("skeleton for " <> kind <> ": " <> err))-    Right src -> case parseSpec ("new:" <> T.unpack kind) src of-        Left perr -> expectationFailure (T.unpack ("skeleton for " <> kind <> " failed to parse: " <> perr))-        Right spec ->-            [code d | d <- validateSpec spec, severity d == Error]-                `shouldBe` ([] :: [DiagnosticCode])--assertSkeletonScaffoldable :: T.Text -> IO ()-assertSkeletonScaffoldable kind = case skeletonFor kind of-    Left err -> expectationFailure (T.unpack ("skeleton for " <> kind <> ": " <> err))-    Right src -> case parseSpec ("new:" <> T.unpack kind) src of-        Left perr -> expectationFailure (T.unpack perr)-        Right spec -> planScaffold (defaultContext (specContext spec)) spec `shouldSatisfy` isSuccessfulScaffold--skeletonModuleRoots :: [(T.Text, T.Text)]-skeletonModuleRoots =-    [ ("aggregate", "SkelAggregate")-    , ("process", "SkelProcess")-    , ("router", "SkelRouter")-    , ("contract", "SkelContract")-    , ("intake", "SkelIntake")-    , ("emit", "SkelEmit")-    , ("workqueue", "SkelQueue")-    , ("workflow", "SkelWorkflow")-    ]--assertSkeletonMatchesCommitted :: T.Text -> T.Text -> IO ()-assertSkeletonMatchesCommitted kind root = case skeletonFor kind of-    Left err -> expectationFailure (T.unpack err)-    Right source -> case parseSpec ("new:" <> T.unpack kind) source of-        Left err -> expectationFailure (T.unpack err)-        Right spec -> do-            let ctx = (defaultContext (specContext spec)){moduleRoot = root}-            forM_ [m | m <- scaffoldModules ctx spec, kindOf m == Generated] $ \m -> do-                committed <- readTestText ("test/conformance-skeletons/" <> modulePath m)-                normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText m)-  where-    kindOf = Keiro.Dsl.Scaffold.kind--bumpArtifactBindingVersion :: MappedDecl -> MappedDecl-bumpArtifactBindingVersion declaration@MappedStructural{msName = "ArtifactInfo"} =-    declaration{msBindingVersion = Just "2"}-bumpArtifactBindingVersion declaration = declaration--addArtifactSummaryField :: MappedDecl -> MappedDecl-addArtifactSummaryField declaration@MappedStructural{msName = "ArtifactInfo", msShape = ShapeRecord constructor unknownFields fields} =-    declaration-        { msShape =-            ShapeRecord-                constructor-                unknownFields-                ( fields-                    <> [ WireField-                            { wfHaskell = "summary"-                            , wfKey = "summary"-                            , wfType = TText-                            , wfPresence = PRequired-                            , wfOnMissing = Nothing-                            , wfLoc = Loc 0-                            }-                       ]-                )-        }-addArtifactSummaryField declaration = declaration--expectGenericCompileFailure :: FilePath -> String -> Expectation-expectGenericCompileFailure fixture expectedDiagnostic = do-    let fixtureDir = "../keiro-core/test/compile-fail" </> fixture-        fixtureSource = fixtureDir </> "Fixture.hs"-    (exitCode, standardOutput, standardError) <--        readProcessWithExitCode-            "cabal"-            [ "exec"-            , "--"-            , "ghc"-            , "-XGHC2024"-            , "-fno-code"-            , "-fforce-recomp"-            , "-i../keiro-core/src"-            , "-i" <> fixtureDir-            , fixtureSource-            ]-            ""-    exitCode `shouldSatisfy` (/= ExitSuccess)-    let compilerOutput = standardOutput <> standardError-    compilerOutput `shouldContain` expectedDiagnostic-    compilerOutput `shouldContain` "Run keiro-dsl scaffold and fill the binding by hand at this error location in the scaffolded module."-    compilerOutput `shouldContain` fixtureSource--moveArtifactBindingIntoGenerated :: MappedDecl -> MappedDecl-moveArtifactBindingIntoGenerated declaration@MappedStructural{msName = "ArtifactInfo"} =-    declaration{msBinding = Just "Generated.ConsumerDemo.Bindings.artifactInfoBinding"}-moveArtifactBindingIntoGenerated declaration = declaration--removeMappedRegisterRequirements :: Spec -> Spec-removeMappedRegisterRequirements spec =-    spec-        { specMapped = map removeInitial (specMapped spec)-        , specNodes = map removeRegisters (specNodes spec)-        }-  where-    removeInitial declaration@MappedStructural{} = declaration{msInitial = Nothing}-    removeInitial declaration@MappedOpaque{} = declaration{moInitial = Nothing}-    removeRegisters (NAggregate aggregate) =-        NAggregate-            aggregate-                { aggRegs = []-                , aggTransitions = [transition{tWrites = []} | transition <- aggTransitions aggregate]-                }-    removeRegisters node = node--isImportCycle :: Refusal -> Bool-isImportCycle ImportCycle{} = True-isImportCycle _ = False--isLoweringRefusal :: Either [Refusal] modules -> Bool-isLoweringRefusal (Left refusals) = any isLowering refusals-  where-    isLowering LoweringRefusal{} = True-    isLowering _ = False-isLoweringRefusal (Right _) = False+import Data.Foldable (toList)+import Data.List (partition, sort, (\\))+import Data.List.NonEmpty (NonEmpty (..))+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Keiro.Codec (Codec (..), EventType (..), decodeRaw)+import Keiro.Dsl.CodecCompare+import Keiro.Dsl.Coverage qualified as Coverage+import Keiro.Dsl.Diff (Change (..), ChangeKind (..), CompatibilitySurface (..), CompatibilityVector (..), FamilyDiff (..), Label (..), NodeFamily, RolloutConstraint (..), SurfaceVerdict (..), defaultGate, deriveLabel, diffSpecs, familyRegistry, gateWith, gatedBreaking, isAdvisory, isBreaking, verdictFor)+import Keiro.Dsl.DiffReport (Remedy (..), diffReport, parseSurfaceName, remediationFor, renderExplainBlock, renderFinding)+import Keiro.Dsl.ExplainBindings (BindingHole (..), BindingObligation (..), BindingObligationKind (..), bindingHoles, bindingObligations, renderBindingObligations)+import Keiro.Dsl.FoldFingerprint (aggregateFoldFingerprint, aggregateFoldSurface)+import Keiro.Dsl.Goldens (GoldenEvidence (..), GoldenPayload (..), emitGoldenPayloads, goldenRelativePath, goldensForDiff)+import Keiro.Dsl.Grammar+import Keiro.Dsl.Harness (harnessFor, harnessForWithGoldens, harnessReadModel, harnessRouter, harnessWorkflow)+import Keiro.Dsl.Manifest (manifestDependencies, moduleNameOf, renderManifest)+import Keiro.Dsl.MappedConsumer (ConsumerPlan (..), consumerPlan)+import Keiro.Dsl.Parser (parseSpec)+import Keiro.Dsl.PrettyPrint (renderSpec, renderTransition)+import Keiro.Dsl.ReadModelShape (canonicalShape, deriveShapeHash, registryNameFor, subscriptionNameFor)+import Keiro.Dsl.ReplayImpact (AggregateImpact (..), ReplayImpact (..))+import Keiro.Dsl.ReplayImpact qualified as ReplayImpact+import Keiro.Dsl.Scaffold (Context (..), ModuleKind (..), ScaffoldModule (..), codecComparisonBanner, codecComparisonModule, defaultContext, firewallBreaches, genPrefixFor, holePrefixFor, scaffoldAggregate, scaffoldIntake, scaffoldProcess, scaffoldPublisher, scaffoldReadModel, scaffoldRefusals, scaffoldReplayAudit, scaffoldRouter, scaffoldWorkqueue, windowSeconds)+import Keiro.Dsl.ScaffoldRecord (ScaffoldRecord (..), parseRecord, recordFileName)+import Keiro.Dsl.ScaffoldRun (MappingDrift (..), Refusal (..), ScaffoldReport (..), StaleModule (..), WriteDisposition (..), executeScaffold, planScaffold, renderRefusals, renderScaffoldReport, scaffoldModules)+import Keiro.Dsl.Skeleton (skeletonFor, skeletonKinds)+import Keiro.Dsl.TypeGraph+import Keiro.Dsl.Validate (Diagnostic (..), DiagnosticCode (..), Severity (..), derivedQueueTrio, renderDiagnostic, validateSpec)+import Keiro.Dsl.Workspace+import Keiro.Dsl.WorkspaceAdoption+import Keiro.Dsl.WorkspaceDiff+import Keiro.Dsl.WorkspaceRecord+import Keiro.Dsl.WorkspaceScaffold+import System.Directory (createDirectory, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, listDirectory, removeFile, removePathForcibly)+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO (hClose, openTempFile)+import System.Process (readProcessWithExitCode)+import Test.Hspec hiding (Spec)+import Test.QuickCheck++main :: IO ()+main = hspec $ do+    describe "historical codec comparison" $ do+        it "treats object-key order as RFC 8785 parity" $ do+            let historical = object ["z" .= (1 :: Int), "a" .= (2 :: Int)]+                generated = object ["a" .= (2 :: Int), "z" .= (1 :: Int)]+            classifyObservation (EncodeObservation "ordered-object" historical generated)+                `shouldBe` Right JsonParity+        it "classifies an omitted key versus explicit null as version work at that pointer" $ do+            let historical = object []+                generated = object ["description" .= Aeson.Null]+            classifyObservation (EncodeObservation "absent-description" historical generated)+                `shouldBe` Right (RequiresVersionWork (EncodedValueDifference (JsonPointer "/description") historical generated))+        it "classifies generated rejection of a historical value as version work" $+            classifyObservation+                ( DecodeObservation+                    "legacy.json"+                    (object ["tag" .= ("legacy" :: T.Text)])+                    (DecodedShape (object ["tag" .= ("legacy" :: T.Text)]))+                    (DecodeFailed "unknown tag")+                )+                `shouldBe` Right (RequiresVersionWork (GeneratedDecodeRejected "unknown tag"))+        it "treats historical-codec rejection as invalid input rather than parity" $+            classifyObservation+                ( DecodeObservation+                    "corrupt.json"+                    Aeson.Null+                    (DecodeFailed "not historical data")+                    (DecodeFailed "not generated data")+                )+                `shouldBe` Left (HistoricalCodecRejected "corrupt.json" "not historical data")+        it "reports uncovered union arms separately by corpus origin" $ do+            let canonical = DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")+                local = DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "local_file")+                report = compareReport comparisonProvenance [] [] [canonical, local] [ObservedBranch HistoricalGolden (JsonPointer "/location") (UnionArm "local_file")]+            crCoverageGaps report+                `shouldBe` [CoverageGap HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")]+            reportSucceeded report `shouldBe` False+        it "derives optional, null, and union-arm observations from a generated branch schema" $ do+            let schema =+                    BranchRecord+                        [ BranchField "description" True (BranchOptional BranchScalar)+                        , BranchField "location" False (BranchUnion "tag" "contents" [BranchArm "local" (Just BranchScalar), BranchArm "canonical" Nothing])+                        ]+                historical = object ["location" .= object ["tag" .= ("canonical" :: T.Text)]]+            observedBranchesFor HistoricalGolden schema historical+                `shouldBe` [ ObservedBranch HistoricalGolden (JsonPointer "/description") OptionalMissing+                           , ObservedBranch HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")+                           ]+            let declared = declaredBranchesFor HistoricalGolden schema+            forM_+                [ DeclaredBranch HistoricalGolden (JsonPointer "/description") OptionalMissing+                , DeclaredBranch HistoricalGolden (JsonPointer "/description") OptionalPresent+                , DeclaredBranch HistoricalGolden (JsonPointer "/description") ExplicitNull+                , DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "local")+                , DeclaredBranch HistoricalGolden (JsonPointer "/location") (UnionArm "canonical")+                ]+                (\branch -> declared `shouldContain` [branch])+        it "round-trips the stable machine report" $ do+            let observation = EncodeObservation "parity" (object ["a" .= (1 :: Int)]) (object ["a" .= (1 :: Int)])+                report = compareReport comparisonProvenance [] [observation] [] []+            Aeson.eitherDecode (Aeson.encode report) `shouldBe` Right report+        it "atomically writes and replaces the machine report" $+            withTempDirectory "keiro-dsl-codec-compare" $ \out -> do+                let path = out </> "report.json"+                    firstReport = compareReport comparisonProvenance [] [] [] []+                    secondReport = compareReport comparisonProvenance [HistoricalGoldenUnreadable "bad.json" "bad JSON"] [] [] []+                writeCompareReportAtomic path firstReport `shouldReturn` Right ()+                Aeson.eitherDecodeFileStrict path `shouldReturn` Right firstReport+                writeCompareReportAtomic path secondReport `shouldReturn` Right ()+                Aeson.eitherDecodeFileStrict path `shouldReturn` Right secondReport++    describe "historical codec comparison scaffold" $ do+        it "emits an opt-in non-production runner without entering the ordinary module registry" $ do+            spec <- specOf "test/fixtures/structural-conformance.keiro"+            let ctx = defaultContext (specContext spec)+                planned = codecComparisonModule ctx spec "ArtifactInfo"+                ordinary = scaffoldModules ctx spec+            case planned of+                Left err -> expectationFailure (T.unpack err)+                Right comparisonModule -> do+                    modulePath comparisonModule+                        `shouldBe` "Generated/StructuralConformance/Structural/CodecCompare/ArtifactInfo.hs"+                    moduleText comparisonModule `shouldSatisfy` T.isInfixOf codecComparisonBanner+                    moduleText comparisonModule `shouldSatisfy` T.isInfixOf "Generated.StructuralConformance.ArtifactCatalog.Codec qualified as GeneratedCodec"+                    moduleText comparisonModule `shouldSatisfy` T.isInfixOf "branchSchema = BranchRecord"+                    map modulePath ordinary `shouldNotContain` [modulePath comparisonModule]+        it "refuses opaque selections rather than upgrading their claim" $ do+            spec <- specOf "test/fixtures/structural-conformance.keiro"+            codecComparisonModule (defaultContext (specContext spec)) spec "VendorGeometry"+                `shouldSatisfy` either (T.isInfixOf "is opaque") (const False)++    describe "structural/opaque coverage reporting" $ do+        it "reports mapped private-event roots and consumer-json register boundaries without a percentage" $ do+            spec <- specOf "test/fixtures/structural-conformance.keiro"+            report <- shouldResolveCoverage "structural-conformance.keiro" spec+            Coverage.privateEventPayloads (Coverage.coverageSummary report)+                `shouldBe` Coverage.CoverageCounts 2 1 1 0+            Coverage.snapshotRegisters (Coverage.coverageSummary report)+                `shouldBe` Coverage.CoverageCounts 2 1 1 0+            map Coverage.opaqueMappedType (Coverage.coverageOpaqueBoundaries report)+                `shouldBe` ["VendorGeometry"]+            map Coverage.snapshotEncoding (Coverage.coverageSnapshotBoundaries report)+                `shouldBe` ["consumer-json-cache", "consumer-json-cache"]+            map Coverage.snapshotInvalidation (Coverage.coverageSnapshotBoundaries report)+                `shouldBe` ["tracked-by-mapped-wire-fingerprint", "tracked-by-mapped-wire-fingerprint"]+            map Coverage.findingCode (Coverage.coverageFindings report)+                `shouldBe` [CoverageOpaqueSurface]+            map Coverage.findingSeverity (Coverage.coverageFindings report)+                `shouldBe` [Warning]+            case Aeson.toJSON report of+                Aeson.Object values ->+                    forM_ ["spec", "roots", "opaqueBoundaries", "snapshotBoundaries", "unsupportedSurfaces"] $+                        \key -> KeyMap.member key values `shouldBe` True+                value -> expectationFailure ("coverage report was not an object: " <> show value)+        it "reports explicit Json leaves by their complete persisted path" $ do+            spec <- withMetadataJson <$> specOf "test/fixtures/structural-conformance.keiro"+            report <- shouldResolveCoverage "structural-conformance-json.keiro" spec+            Coverage.jsonBoundaries (Coverage.privateEventPayloads (Coverage.coverageSummary report))+                `shouldBe` 1+            map Coverage.jsonPath (Coverage.coverageJsonBoundaries report)+                `shouldBe` ["ArtifactCatalog event ArtifactRecorded .artifact : ArtifactInfo .metadata : ArtifactMetadata .note"]+        it "keeps a zero-opaque spec advisory-free and makes rejection explicitly opt-in" $ do+            original <- specOf "test/fixtures/structural-conformance.keiro"+            clear <- shouldResolveCoverage "structural-only.keiro" (withoutVendorGeometry original)+            Coverage.opaqueRoots (Coverage.privateEventPayloads (Coverage.coverageSummary clear)) `shouldBe` 0+            Coverage.coverageOpaqueBoundaries clear `shouldBe` []+            Coverage.coverageFindings clear `shouldBe` []+            opaque <- shouldResolveCoverage "structural-conformance.keiro" original+            Coverage.coverageSucceeded opaque `shouldBe` True+            let gated = Coverage.failOnOpaque opaque+            Coverage.coverageSucceeded gated `shouldBe` False+            map Coverage.findingCode (Coverage.coverageFindings gated)+                `shouldBe` [CoverageOpaqueSurface, CoverageOpaqueGateExceeded]+            map Coverage.findingSeverity (Coverage.coverageFindings gated)+                `shouldBe` [Warning, Error]+        it "diffs named opaque boundaries and fails only an explicitly gated increase" $ do+            newSpec <- specOf "test/fixtures/structural-conformance.keiro"+            report <- case Coverage.coverageDiffReport "structural-conformance.keiro" "HEAD" (withoutVendorGeometry newSpec) newSpec of+                Left err -> expectationFailure (show err) >> fail "unreachable"+                Right value -> pure value+            fmap Coverage.opaqueBoundaryDelta (Coverage.coverageDelta report) `shouldBe` Just 1+            fmap (map Coverage.opaqueMappedType . Coverage.addedOpaqueBoundaries) (Coverage.coverageDelta report)+                `shouldBe` Just ["VendorGeometry"]+            map Coverage.findingCode (Coverage.coverageFindings report)+                `shouldBe` [CoverageOpaqueSurface, CoverageOpaqueBoundaryAdded]+            Coverage.coverageSucceeded report `shouldBe` True+            let gated = Coverage.failOnOpaqueIncrease report+            Coverage.coverageSucceeded gated `shouldBe` False+            map Coverage.findingCode (Coverage.coverageFindings gated)+                `shouldBe` [CoverageOpaqueSurface, CoverageOpaqueBoundaryAdded, CoverageOpaqueGateExceeded]+        it "appends the six stable coverage and comparison registry codes" $+            map+                show+                [ CoverageOpaqueSurface+                , CoverageOpaqueBoundaryAdded+                , CoverageOpaqueGateExceeded+                , CodecCompareDifference+                , CodecCompareCoverageGap+                , CodecCompareInvalidInput+                ]+                `shouldBe` [ "CoverageOpaqueSurface"+                           , "CoverageOpaqueBoundaryAdded"+                           , "CoverageOpaqueGateExceeded"+                           , "CodecCompareDifference"+                           , "CodecCompareCoverageGap"+                           , "CodecCompareInvalidInput"+                           ]++    describe "parse . pretty round-trip" $+        do+            it "re-parses any generated spec to an equal AST (modulo source locations)" $+                checkCoverage $+                    forAll genSpec $ \s ->+                        let families = map nodeTag (specNodes s)+                            roundTrip = parseSpec "<gen>" (renderSpec s) === Right s+                         in cover 5 (not (null (specMapped s))) "mapped" $+                                foldr (\family -> cover 1 (family `elem` families) family) roundTrip allNodeTags+            it "round-trips an aggregate with no states" $+                parseSpec "<empty-states>" (renderSpec emptyStatesSpec) `shouldBe` Right emptyStatesSpec+            it "separates transition emit clauses from following nodes" $ do+                spec <- parseInlineSpec "<cross-family-boundaries>" crossFamilyBoundarySpec+                case specNodes spec of+                    [NAggregate first, NEmit _, NAggregate second, NPgmqDispatch _] -> do+                        concatMap tEmits (aggTransitions first) `shouldBe` ["Changed"]+                        aggStates second `shouldBe` []+                    nodes -> expectationFailure ("unexpected node sequence: " <> show (map nodeTag nodes))++    describe "mapped types (EP-149)" $ do+        it "round-trips the canonical structural and opaque consumer fixture" $ do+            source <- TIO.readFile "test/fixtures/consumer-types.keiro"+            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source+            parseSpec "<consumer-types-round-trip>" (renderSpec spec) `shouldBe` Right spec+            length (specMapped spec) `shouldBe` 4+        it "preserves every missing-value policy, nested type expression, and unit union arm" $ do+            source <- TIO.readFile "test/fixtures/consumer-types.keiro"+            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source+            let fields = [field | MappedStructural{msShape = ShapeRecord _ _ recordFields} <- specMapped spec, field <- recordFields]+                arms = [arm | MappedStructural{msShape = ShapeUnion _ unionArms} <- specMapped spec, arm <- unionArms]+            [value | field <- fields, Just value <- [wfOnMissing field]]+                `shouldBe` [OmCtor "Guide", OmNull, OmInt 0, OmBool False, OmEmptyList, OmEmptyMap]+            [wfType field | field <- fields, wfHaskell field == "labels"]+                `shouldBe` [TList (TOptional TText)]+            [waCtor arm | arm <- arms, waPayload arm == Nothing]+                `shouldBe` ["Unknown"]+        it "rejects every mapped validation fixture with its stable diagnostic code" $ do+            let cases =+                    [ ("mapped-unresolved.keiro", MappedUnresolvedName)+                    , ("mapped-ambiguous.keiro", MappedAmbiguousName)+                    , ("mapped-dup-fieldname.keiro", MappedDuplicateFieldName)+                    , ("mapped-dup-wirekey.keiro", MappedDuplicateWireKey)+                    , ("mapped-dup-armname.keiro", MappedDuplicateArmName)+                    , ("mapped-dup-tag.keiro", MappedDuplicateWireTag)+                    , ("mapped-recursive.keiro", MappedRecursiveType)+                    , ("mapped-recursive-mutual.keiro", MappedRecursiveType)+                    , ("mapped-bad-encoding.keiro", MappedUnsupportedEncoding)+                    , ("mapped-union-key-collision.keiro", MappedUnsupportedEncoding)+                    , ("mapped-optional-json.keiro", MappedNonInjectiveNullability)+                    , ("mapped-optional-optional.keiro", MappedNonInjectiveNullability)+                    , ("mapped-optional-opaque.keiro", MappedNonInjectiveNullability)+                    , ("mapped-missing-binding.keiro", MappedMissingIngredient)+                    , ("mapped-missing-binding-version.keiro", MappedMissingIngredient)+                    , ("mapped-missing-canonical.keiro", MappedMissingIngredient)+                    , ("mapped-missing-fixture.keiro", MappedMissingIngredient)+                    , ("mapped-missing-initial.keiro", MappedMissingInitialValue)+                    , ("mapped-bad-haskell-name.keiro", MappedInvalidHaskellName)+                    , ("mapped-empty-identity.keiro", MappedInvalidIdentity)+                    , ("mapped-import-conflict.keiro", MappedImportConflict)+                    , ("mapped-illtyped-default.keiro", MappedDefaultIllTyped)+                    , ("mapped-guard.keiro", MappedGuardUnsupported)+                    , ("mapped-guard-natural.keiro", MappedGuardUnsupported)+                    ]+            forM_ cases $ \(fixture, expected) ->+                errorCodesOf ("test/fixtures/" <> fixture) `shouldReturn` [expected]+        it "keeps Time in Keiki's curated guard set while rejecting Natural" $+            errorCodesOf "test/fixtures/mapped-guard-time.keiro" `shouldReturn` []+        it "rejects required defaults, missing optional policies, Int overflow, and negative Natural defaults" $ do+            let invalidFields =+                    [ WireField "requiredDefault" "requiredDefault" TText PRequired (Just (OmText "x")) noLoc+                    , WireField "missingPolicy" "missingPolicy" TText POptional Nothing noLoc+                    , WireField "overflow" "overflow" TInt POptional (Just (OmInt (toInteger (maxBound :: Int) + 1))) noLoc+                    , WireField "negativeNatural" "negativeNatural" TNatural POptional (Just (OmInt (-1))) noLoc+                    ]+                declaration = completeStructural "Defaults" (ShapeRecord "Defaults" RejectUnknown invalidFields)+            errorCodes (mappedSpec [declaration])+                `shouldBe` [MappedDefaultIllTyped, MappedMissingIngredient, MappedDefaultIllTyped, MappedDefaultIllTyped]++    describe "mapped type graph (EP-149)" $ do+        it "resolves checked declarations, transitive reachability, and every aggregate root path" $ do+            source <- TIO.readFile "test/fixtures/consumer-types.keiro"+            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source+            graph <- shouldResolveTypeGraph spec+            Map.size (tgDeclarations graph) `shouldBe` 4+            Map.lookup (MappedKey "ArtifactInfo") (tgReachability graph)+                `shouldBe` Just (Set.fromList [MappedKey "ArtifactKind", MappedKey "ArtifactLocation"])+            map renderUsePath (usePaths graph "ArtifactLocation")+                `shouldBe` [ "Catalog command ObserveArtifact .artifact : ArtifactInfo .location : ArtifactLocation"+                           , "Catalog event ArtifactObserved .artifact : ArtifactInfo .location : ArtifactLocation"+                           , "Catalog register currentArtifact : ArtifactInfo .location : ArtifactLocation"+                           ]+        it "resolves every builtin through the complete expression algebra" $ do+            source <- TIO.readFile "test/fixtures/consumer-types.keiro"+            spec <- parseInlineSpec "test/fixtures/consumer-types.keiro" source+            graph <- shouldResolveTypeGraph spec+            case Map.lookup (MappedKey "ArtifactInfo") (tgDeclarations graph) of+                Just (ResolvedStructural _ (RRecord _ _ fields)) ->+                    Set.fromList (concatMap (foldTypeExpr expressionTags . rwfType) fields)+                        `shouldBe` Set.fromList ["text", "int", "bool", "natural", "time", "json", "optional", "list", "map", "ref:ArtifactKind", "ref:ArtifactLocation"]+                declaration -> expectationFailure ("unexpected ArtifactInfo declaration: " <> show declaration)+        it "rejects direct, mutual, wrapped, and union-arm recursion" $ do+            let direct = mappedSpec [completeStructural "A" (recordShape [TRef "A"])]+                mutual = mappedSpec [completeStructural "A" (recordShape [TRef "B"]), completeStructural "B" (recordShape [TRef "A"])]+                wrapped = mappedSpec [completeStructural "A" (recordShape [TList (TOptional (TRef "A"))])]+                throughArm = mappedSpec [completeStructural "A" (ShapeUnion (TaggedObject "tag" "contents" RejectUnknown) [WireArm "Again" "again" (Just (TRef "A")) noLoc])]+            map (hasTypeGraphError isRecursive . resolveTypeGraph) [direct, mutual, wrapped, throughArm]+                `shouldBe` replicate 4 True+        it "keeps existing ids and enums outside the mapped-reference namespace" $ do+            let spec =+                    (mappedSpec [completeStructural "A" (recordShape [TRef "ExistingId"])])+                        { specIds = [IdDecl "ExistingId" "id" noLoc]+                        }+            resolveTypeGraph spec `shouldSatisfy` hasTypeGraphError isUnresolved+        it "fingerprints wire identity while ignoring Haskell selector names" $ do+            source <- TIO.readFile "test/fixtures/consumer-types.keiro"+            base <- parseInlineSpec "test/fixtures/consumer-types.keiro" source+            baseGraph <- shouldResolveTypeGraph base+            haskellRenameGraph <- shouldResolveTypeGraph (mapArtifactField (\field -> field{wfHaskell = "renamedKey"}) base)+            wireRenameGraph <- shouldResolveTypeGraph (mapArtifactField (\field -> field{wfKey = "renamed_key"}) base)+            wireFingerprint haskellRenameGraph "ArtifactInfo" `shouldBe` wireFingerprint baseGraph "ArtifactInfo"+            wireFingerprint wireRenameGraph "ArtifactInfo" `shouldNotBe` wireFingerprint baseGraph "ArtifactInfo"++    describe "string literal integrity" $ do+        it "parses an escaped emit-map value as exactly one row" $ do+            let src =+                    T.unlines+                        [ "context svc"+                        , ""+                        , "emit e {"+                        , "  contract c"+                        , "  topic events"+                        , "  source \"svc\""+                        , "  key thingId"+                        , "  map status {"+                        , "    \"a\\\" => Wat \\\"b\" => ThingAccepted"+                        , "    _ => skip"+                        , "  }"+                        , "  messageId derive hole"+                        , "  idempotencyKey derive hole"+                        , "}"+                        ]+            case parseSpec "<escaped-map>" src of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> case [row | NEmit e <- specNodes spec, row <- emMap e] of+                    [row] -> do+                        emrValue row `shouldBe` "a\" => Wat \"b"+                        emrEvent row `shouldBe` "ThingAccepted"+                    rows -> expectationFailure ("expected one emit-map row, got " <> show (length rows))+        it "rejects a raw newline inside a quoted string" $ do+            let src = "context svc\n\ncontract c {\n  schemaVersion 1\n  discriminator kind\n  topic events \"first\nsecond\"\n}\n"+            parseSpec "<raw-newline>" src `shouldSatisfy` leftContains "unescaped newline"+        it "rejects an unknown escape sequence" $ do+            let src = "context svc\n\ncontract c {\n  schemaVersion 1\n  discriminator kind\n  topic events \"bad\\q\"\n}\n"+            parseSpec "<unknown-escape>" src `shouldSatisfy` leftContains "unknown escape"+        it "round-trips adversarial text through topics, emit maps, and quoted bindings" $+            property $+                forAll genAdversarialText $ \t ->+                    let spec = escapedSpec t+                        rendered = renderSpec spec+                     in counterexample (T.unpack rendered) (parseSpec "<escaped-round-trip>" rendered === Right spec)++    describe "partial status maps" $ do+        it "suppresses totality only when the partial marker is present" $ do+            partial <- parseInlineSpec "<partial-status-map>" (statusMapSpec " partial")+            totalSpec <- parseInlineSpec "<total-status-map>" (statusMapSpec "")+            map code (validateSpec partial) `shouldNotContain` [StatusMapNotTotal]+            map code (validateSpec totalSpec) `shouldContain` [StatusMapNotTotal]+            parseSpec "<partial-round-trip>" (renderSpec partial) `shouldBe` Right partial++    describe "positioned parser diagnostics" $ do+        it "rejects a duplicate goto at the second clause" $ do+            err <- parseErrorOf "<duplicate-goto>" duplicateGotoSpec+            err `shouldSatisfy` T.isInfixOf "duplicate goto"+            err `shouldSatisfy` T.isInfixOf "<duplicate-goto>:10:"+        it "rejects duplicate wire and projection blocks at their second occurrences" $ do+            wireErr <- parseErrorOf "<duplicate-wire>" duplicateWireSpec+            wireErr `shouldSatisfy` T.isInfixOf "duplicate wire block"+            wireErr `shouldSatisfy` T.isInfixOf "<duplicate-wire>:8:"+            projectionErr <- parseErrorOf "<duplicate-projection>" duplicateProjectionSpec+            projectionErr `shouldSatisfy` T.isInfixOf "duplicate projection block"+            projectionErr `shouldSatisfy` T.isInfixOf "<duplicate-projection>:9:"+        it "anchors a missing goto on the transition line" $ do+            err <- parseErrorOf "<missing-goto>" missingGotoSpec+            err `shouldSatisfy` T.isInfixOf "missing a goto clause"+            err `shouldSatisfy` T.isInfixOf "<missing-goto>:8:"+        it "stops before a misplaced dispatch-id and expects schedule at its start" $ do+            let src = misplacedDispatchIdSpec+                expectedPosition =+                    "<misplaced-dispatch-id>:"+                        <> T.pack (show (lineNumberContaining "dispatch-id" src))+                        <> ":5:"+            err <- parseErrorOf "<misplaced-dispatch-id>" src+            err `shouldSatisfy` T.isInfixOf "schedule"+            err `shouldSatisfy` T.isInfixOf expectedPosition+        it "keeps a malformed register declaration's equals error" $ do+            err <- parseErrorOf "<malformed-register>" malformedRegisterSpec+            err `shouldSatisfy` T.isInfixOf "expecting '='"++    describe "bounded decimal literals" $ do+        forM_ decimalOverflowSpecs $ \(site, src) ->+            it ("rejects overflow at " <> site) $ do+                err <- parseErrorOf ("<overflow-" <> site <> ">") src+                err `shouldSatisfy` T.isInfixOf ("decimal literal " <> decimalOverflow <> " is out of range")+        it "accepts maxBound without changing its value" $ do+            spec <- parseInlineSpec "<max-bound>" (wireDecimalSpec (T.pack (show (maxBound :: Int))))+            [wireSchemaVersion wire | NAggregate aggregate <- specNodes spec, Just wire <- [aggWire aggregate]]+                `shouldBe` [maxBound]++    describe "identifier hygiene" $ do+        it "reports constructor shape and Haskell keywords at their owning declarations" $ do+            spec <- parseInlineSpec "<identifier-hygiene>" identifierHygieneSpec+            [(code diagnostic, line diagnostic) | diagnostic <- validateSpec spec, code diagnostic `elem` [IdentNotConstructorSafe, IdentHaskellKeyword]]+                `shouldContain` [(IdentNotConstructorSafe, 3), (IdentHaskellKeyword, 7)]+        it "rejects generated vertex constructors that collide with event constructors" $ do+            spec <- parseInlineSpec "<vertex-collision>" vertexCollisionSpec+            [(code diagnostic, line diagnostic) | diagnostic <- validateSpec spec, code diagnostic == VertexCtorCollision]+                `shouldBe` [(VertexCtorCollision, 3)]+        it "rejects underscore-leading names whose title-casing cannot make a module segment" $ do+            spec <- parseInlineSpec "<underscore-node>" underscoreNodeSpec+            [(code diagnostic, line diagnostic) | diagnostic <- validateSpec spec, code diagnostic == IdentNotConstructorSafe]+                `shouldBe` [(IdentNotConstructorSafe, 3)]+        it "rejects non-ASCII identifier characters in the parser" $+            parseSpec "<unicode-identifier>" unicodeIdentifierSpec `shouldSatisfy` leftContains "unexpected"++    describe "canonical reservation.keiro" $+        it "parses into the expected aggregate shape" $ do+            input <- readTestText "test/fixtures/reservation.keiro"+            case parseSpec "test/fixtures/reservation.keiro" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> do+                    specContext spec `shouldBe` "hospital-capacity"+                    length (specIds spec) `shouldBe` 3+                    length (specEnums spec) `shouldBe` 3+                    length (specRules spec) `shouldBe` 1+                    case specNodes spec of+                        [NAggregate a] -> do+                            aggName a `shouldBe` "Reservation"+                            length (aggStates a) `shouldBe` 6+                            length (aggCommands a) `shouldBe` 2+                            length (aggEvents a) `shouldBe` 2+                            length (aggTransitions a) `shouldBe` 2+                            map stTerminal (aggStates a) `shouldBe` [False, False, False, True, True, True]+                        other -> expectationFailure ("expected one aggregate node, got " <> show (length other))++    describe "validator" $ do+        it "accepts the canonical reservation.keiro" $ do+            codes <- errorCodesOf "test/fixtures/reservation.keiro"+            codes `shouldBe` []+        it "rejects a missing status-map as StatusMapNotTotal" $ do+            codes <- diagnosticCodesOf "test/fixtures/reservation-no-statusmap.keiro"+            codes `shouldContain` [StatusMapNotTotal]+        it "rejects an undeclared command as UndeclaredCommand" $ do+            codes <- diagnosticCodesOf "test/fixtures/reservation-bad-command.keiro"+            codes `shouldContain` [UndeclaredCommand]+        it "rejects a wall-clock guard atom as ClockSampled" $ do+            codes <- diagnosticCodesOf "test/fixtures/reservation-clock.keiro"+            codes `shouldContain` [ClockSampled]+        it "accepts a v2 event with a contiguous upcaster hole" $ do+            codes <- errorCodesOf "test/fixtures/reservation-v2.keiro"+            codes `shouldBe` []+        it "rejects a v2 event with no upcaster as EvtVersionMissingUpcaster" $ do+            codes <- diagnosticCodesOf "test/fixtures/reservation-v2-noupcast.keiro"+            codes `shouldContain` [EvtVersionMissingUpcaster]+        it "accepts shared upcaster sources for different event kinds" $ do+            codes <- errorCodesOf "test/fixtures/reservation-dup-upcast-source.keiro"+            codes `shouldNotContain` [DuplicateUpcasterSource]+        it "rejects a gap in the aggregate-global upcaster chain" $ do+            codes <- errorCodesOf "test/fixtures/reservation-chain-gap.keiro"+            codes `shouldContain` [UpcasterChainGap]+        it "warns while a retiring event keeps its live emitting transition" $ do+            diagnostics <- diagnosticsOf "test/fixtures/reservation-retiring.keiro"+            [code d | d <- diagnostics, severity d == Error] `shouldBe` []+            [code d | d <- diagnostics, severity d == Warning]+                `shouldContain` [EventRetirementInProgress]+        it "rejects a retiring event after its live emitting transition disappears" $ do+            source <- readTestText "test/fixtures/reservation-retiring.keiro"+            spec <- parseInlineSpec "<retiring-without-emitter>" (T.replace " ; emit TransferReservationConfirmed" "" source)+            [code d | d <- validateSpec spec, severity d == Error]+                `shouldContain` [EventRetirementInProgress]+        it "warns when a deprecated event has no replay-only emitting transition" $ do+            diagnostics <- diagnosticsOf "test/fixtures/reservation-deprecated.keiro"+            [code d | d <- diagnostics, severity d == Error] `shouldBe` []+            [code d | d <- diagnostics, severity d == Warning]+                `shouldContain` [DeprecatedEventReplayHazard]+        it "recognises deprecated plus replay-only as the replay-safe cutover" $ do+            diagnostics <- diagnosticsOf "test/fixtures/reservation-deprecated-replay-only.keiro"+            [code d | d <- diagnostics, severity d == Error] `shouldBe` []+            [code d | d <- diagnostics, severity d == Warning]+                `shouldContain` [EventRetirementInProgress]+            [code d | d <- diagnostics] `shouldNotContain` [DeprecatedEventReplayHazard]+        it "requires exact, unique status-map event keys" $ do+            dangling <- errorCodesOf "test/fixtures/statusmap-dangling.keiro"+            mapM_ (\expected -> dangling `shouldContain` [expected]) [StatusMapDanglingKey, StatusMapNotTotal]+            duplicate <- errorCodesOf "test/fixtures/statusmap-dup-key.keiro"+            duplicate `shouldContain` [StatusMapDuplicateKey]+        it "rejects duplicate spec and aggregate names" $ do+            codes <- errorCodesOf "test/fixtures/duplicate-names.keiro"+            mapM_+                (\expected -> codes `shouldContain` [expected])+                [ DuplicateNodeName+                , DuplicateEnumCtor+                , DuplicateEnumWire+                , DuplicateIdPrefix+                , DuplicateCommandName+                , DuplicateEventName+                ]+        it "rejects aggregate-local references that do not resolve" $ do+            codes <- errorCodesOf "test/fixtures/aggregate-bad-refs.keiro"+            codes `shouldContain` [RegisterInitialOutOfScope, UndeclaredCommand, WriteTargetNotRegister]+        it "anchors UnreachableState on the state row" $ do+            let src =+                    T.unlines+                        [ "context repro"+                        , ""+                        , "aggregate Thing"+                        , "  regs"+                        , "  states"+                        , "    Initial"+                        , "    Unreachable"+                        ]+            case parseSpec "<unreachable-row>" src of+                Left err -> expectationFailure (T.unpack err)+                Right spec ->+                    [line d | d <- validateSpec spec, code d == UnreachableState]+                        `shouldBe` [7]+        it "accepts a replay-only twin with a live sibling (plan 143)" $ do+            codes <- errorCodesOf "test/fixtures/reservation-guard-tightened-twin.keiro"+            codes `shouldBe` []+        it "rejects a replay-only transition that emits nothing" $ do+            case parseSpec "<replay-only-no-emit>" (replayOnlySpecWith ["    write reservationState := Held", "    goto  Held"]) of+                Left err -> expectationFailure (T.unpack err)+                Right spec ->+                    [code d | d <- validateSpec spec, severity d == Error]+                        `shouldContain` [ReplayOnlyEmitsNothing]+        it "warns when a replay-only transition has no live sibling" $ do+            case parseSpec "<replay-only-orphan>" (replayOnlySpecWith ["    emit  TransferReservationCreated", "    goto  Held"]) of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> do+                    [code d | d <- validateSpec spec, severity d == Warning]+                        `shouldContain` [ReplayOnlyCommandStillLive]+                    [code d | d <- validateSpec spec, severity d == Error]+                        `shouldNotContain` [ReplayOnlyCommandStillLive]++    describe "complementExpr (plan 143)" $ do+        it "applies De Morgan over and/or and flips comparison operators" $ do+            let a = EAtom (AName "a")+                b = EAtom (AName "b")+            complementExpr (EAnd a b)+                `shouldBe` EOr (ECmp OpEq a (EAtom (ABool False))) (ECmp OpEq b (EAtom (ABool False)))+            complementExpr (ECmp OpLt a b) `shouldBe` ECmp OpGe a b+            complementExpr (ECmp OpEq a b) `shouldBe` ECmp OpNeq a b+            complementExpr (ECmp OpLe a b) `shouldBe` ECmp OpGt a b+            complementExpr (ECmp OpGt a b) `shouldBe` ECmp OpLe a b+            complementExpr (ECmp OpGe a b) `shouldBe` ECmp OpLt a b+            complementExpr (ECmp OpNeq a b) `shouldBe` ECmp OpEq a b+        it "flips boolean literals and grounds bare names as == false" $ do+            complementExpr (EAtom (ABool True)) `shouldBe` EAtom (ABool False)+            complementExpr (EAtom (AName "open"))+                `shouldBe` ECmp OpEq (EAtom (AName "open")) (EAtom (ABool False))+        it "stays inside the grammar: the complement of any guard re-parses" $+            property $+                forAll genExpr $ \e ->+                    let twin =+                            replayOnlySpecWith+                                [ "    guard " <> renderExprText (complementExpr e)+                                , "    emit  TransferReservationCreated"+                                , "    goto  Held"+                                ]+                     in case parseSpec "<complement>" twin of+                            Left err -> counterexample (T.unpack err) False+                            Right spec ->+                                [tGuard t | NAggregate a <- specNodes spec, t <- aggTransitions a]+                                    === [Just (complementExpr e)]++    describe "evolution parsing" $ do+        it "parses event version and upcaster from reservation-v2.keiro" $ do+            input <- readTestText "test/fixtures/reservation-v2.keiro"+            case parseSpec "test/fixtures/reservation-v2.keiro" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> case [e | NAggregate a <- specNodes spec, e <- aggEvents a, evName e == "TransferReservationCreated"] of+                    (e : _) -> do+                        evVersion e `shouldBe` 2+                        evUpcastFrom e `shouldBe` Just (1, Hole)+                    [] -> expectationFailure "TransferReservationCreated not found"+        it "round-trips the retiring marker" $ do+            spec <- specOf "test/fixtures/reservation-retiring.keiro"+            parseSpec "<retiring-round-trip>" (renderSpec spec) `shouldBe` Right spec+            [evRetiring event | NAggregate aggregate <- specNodes spec, event <- aggEvents aggregate, evName event == "TransferReservationConfirmed"]+                `shouldBe` [True]+        it "rejects an event marked both retiring and deprecated" $ do+            source <- readTestText "test/fixtures/reservation-retiring.keiro"+            let conflicting = T.replace "retiring event TransferReservationConfirmed" "retiring deprecated event TransferReservationConfirmed" source+            parseSpec "<conflicting-retirement-markers>" conflicting `shouldSatisfy` isLeft++    describe "aggregate snapshots (EP-109)" $ do+        it "parses, validates, and round-trips a snapshot policy with codec fixture" $ do+            spec <- specOf "test/fixtures/reservation-snapshot.keiro"+            errorCodesOf "test/fixtures/reservation-snapshot.keiro" `shouldReturn` []+            parseSpec "<snapshot-round-trip>" (renderSpec spec) `shouldBe` Right spec+            case [aggregate | NAggregate aggregate <- specNodes spec] of+                [aggregate] -> aggSnapshot aggregate `shouldBe` Just (SnapshotSpec (SnapEvery 100) 1 "7eb3a94f62f947231375d44083e2a1c8029d91ffe0329107d55092ed3430efcc" noLoc)+                aggregates -> expectationFailure ("expected one snapshot aggregate, got " <> show (length aggregates))+        it "rejects disabled intervals and invalid codec fixtures" $ do+            source <- readTestText "test/fixtures/reservation-snapshot.keiro"+            interval <- parseInlineSpec "<snapshot-zero>" (T.replace "snapshot every 100" "snapshot every 0" source)+            map code (validateSpec interval) `shouldContain` [SnapshotIntervalInvalid]+            version <- parseInlineSpec "<snapshot-version-zero>" (T.replace "state-codec version=1" "state-codec version=0" source)+            map code (validateSpec version) `shouldContain` [SnapshotCodecFixtureInvalid]+            emptyHash <- parseInlineSpec "<snapshot-empty-hash>" (T.replace "shape-hash=\"7eb3a94f62f947231375d44083e2a1c8029d91ffe0329107d55092ed3430efcc\"" "shape-hash=\"\"" source)+            map code (validateSpec emptyHash) `shouldContain` [SnapshotCodecFixtureInvalid]+        it "conditionally lowers JSON instances and the live defaultStateCodec" $ do+            snapshot <- specOf "test/fixtures/reservation-snapshot.keiro"+            ordinary <- specOf "test/fixtures/reservation.keiro"+            case ([aggregate | NAggregate aggregate <- specNodes snapshot], [aggregate | NAggregate aggregate <- specNodes ordinary]) of+                ([snapshotAggregate], [ordinaryAggregate]) -> do+                    let snapshotModules = scaffoldAggregate (defaultContext (specContext snapshot)) snapshot snapshotAggregate+                        ordinaryModules = scaffoldAggregate (defaultContext (specContext ordinary)) ordinary ordinaryAggregate+                        snapshotDomain = generatedTextEndingIn "Domain.hs" snapshotModules+                        snapshotStream = generatedTextEndingIn "EventStream.hs" snapshotModules+                        ordinaryDomain = generatedTextEndingIn "Domain.hs" ordinaryModules+                        ordinaryStream = generatedTextEndingIn "EventStream.hs" ordinaryModules+                    snapshotDomain `shouldSatisfy` T.isInfixOf "deriving anyclass (ToJSON, FromJSON)"+                    snapshotStream `shouldSatisfy` T.isInfixOf "snapshotPolicy = Every 100"+                    snapshotStream `shouldSatisfy` T.isInfixOf "stateCodec = Just (withFoldFingerprint"+                    snapshotStream `shouldSatisfy` T.isInfixOf "Spec-visible fold changes invalidate old"+                    snapshotStream `shouldSatisfy` T.isInfixOf "module are invisible here"+                    snapshotStream `shouldSatisfy` T.isInfixOf "reservationSnapshotFixture = (1, \"7eb3a94f62f947231375d44083e2a1c8029d91ffe0329107d55092ed3430efcc\")"+                    ordinaryDomain `shouldNotSatisfy` T.isInfixOf "DeriveAnyClass"+                    ordinaryStream `shouldSatisfy` T.isInfixOf "snapshotPolicy = Never"+                    ordinaryStream `shouldSatisfy` T.isInfixOf "stateCodec = Nothing"+                    ordinaryStream `shouldSatisfy` T.isInfixOf "reservationCategory = Stream.categoryUnsafe \"reservation\""+                    firewallBreaches snapshotModules `shouldBe` []+                _ -> expectationFailure "expected one aggregate in each snapshot test spec"++    describe "aggregate fold fingerprints (plan 138)" $ do+        it "is deterministic across repeated parses and formatting-only changes" $ do+            source <- readTestText "test/fixtures/reservation.keiro"+            first <- parseInlineSpec "<first>" source+            second <- parseInlineSpec "<second>" ("\n\n" <> renderSpec first <> "\n")+            aggregateFoldFingerprint first (onlyAggregate first)+                `shouldBe` aggregateFoldFingerprint second (onlyAggregate second)+        it "changes for transition writes, guards, and referenced rule bodies" $ do+            base <- specOf "test/fixtures/reservation.keiro"+            writeChanged <- specOf "test/fixtures/reservation-foldchange.keiro"+            guardChanged <- specOf "test/fixtures/reservation-guard-tightened.keiro"+            source <- readTestText "test/fixtures/reservation.keiro"+            ruleChanged <- parseInlineSpec "<rule-change>" (T.replace "RedTag => true" "RedTag => false" source)+            let baseFingerprint = aggregateFoldFingerprint base (onlyAggregate base)+            aggregateFoldFingerprint writeChanged (onlyAggregate writeChanged) `shouldNotBe` baseFingerprint+            aggregateFoldFingerprint guardChanged (onlyAggregate guardChanged) `shouldNotBe` baseFingerprint+            aggregateFoldFingerprint ruleChanged (onlyAggregate ruleChanged) `shouldNotBe` baseFingerprint+        it "ignores wire and projection changes" $ do+            base <- specOf "test/fixtures/reservation.keiro"+            wireChanged <- specOf "test/fixtures/reservation-wire.keiro"+            source <- readTestText "test/fixtures/reservation.keiro"+            projectionChanged <- parseInlineSpec "<projection-change>" (T.replace "projection transfer_decisions" "projection renamed_projection" source)+            let surface = aggregateFoldSurface base (onlyAggregate base)+            aggregateFoldSurface wireChanged (onlyAggregate wireChanged) `shouldBe` surface+            aggregateFoldSurface projectionChanged (onlyAggregate projectionChanged) `shouldBe` surface+        it "invalidates mapped-register snapshots when binding or wire identity changes" $ do+            base <- specOf "test/fixtures/consumer-types.keiro"+            bindingChanged <- specOf "test/fixtures/consumer-types-binding-change.keiro"+            wireChanged <- specOf "test/fixtures/consumer-types-wirekey.keiro"+            let baseFingerprint = aggregateFoldFingerprint base (onlyAggregate base)+            aggregateFoldFingerprint bindingChanged (onlyAggregate bindingChanged) `shouldNotBe` baseFingerprint+            aggregateFoldFingerprint wireChanged (onlyAggregate wireChanged) `shouldNotBe` baseFingerprint++    describe "process/timer (EP-3)" $ do+        it "parses the hospital-surge process + nested timer" $ do+            input <- readTestText "test/fixtures/hospital-surge.keiro"+            case parseSpec "test/fixtures/hospital-surge.keiro" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> case [p | NProcess p <- specNodes spec] of+                    (p : _) -> do+                        procId p `shouldBe` "HospitalSurge"+                        procName p `shouldBe` "hospital-surge"+                        procRejected p `shouldBe` PolHalt+                        procPoison p `shouldBe` PolHalt+                        sagaCategory (procSaga p) `shouldBe` "hospitalSurge"+                        tmName (procTimer p) `shouldBe` "surgeFollowUp"+                        onReject (fireDisposition (tmFire (procTimer p))) `shouldBe` OFired+                        onAmbiguous (fireDisposition (tmFire (procTimer p))) `shouldBe` ORetry+                        tmMaxAttempts (procTimer p) `shouldBe` 5+                    [] -> expectationFailure "no process node parsed"+        it "round-trips the hospital-surge spec through parse . pretty" $ do+            input <- readTestText "test/fixtures/hospital-surge.keiro"+            case parseSpec "in" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "accepts the hospital-surge spec (no errors; benign-inversion warnings only)" $ do+            codes <- errorCodesOf "test/fixtures/hospital-surge.keiro"+            codes `shouldBe` []+        it "rejects illegal saga categories and no longer parses the raw stream-prefix clause" $ do+            spec <- specOf "test/fixtures/hospital-surge.keiro"+            mapM_+                (\categoryName -> processErrorCodes (\process -> process{procSaga = (procSaga process){sagaCategory = categoryName}}) spec `shouldContain` [SagaCategoryIllegal])+                ["", "$all", "hospital-surge", "hospital surge", "wf:surge"]+            source <- readTestText "test/fixtures/hospital-surge.keiro"+            parseSpec "<legacy-saga>" (T.replace "saga Surge category \"hospitalSurge\"" "saga Surge stream=\"hospital-surge-\" <> correlationId" source)+                `shouldSatisfy` isLeft+        it "rejects a wall-clock fireAt as ProcessFireAtNotInjected" $ do+            codes <- errorCodesOf "test/fixtures/hospital-surge-clock.keiro"+            codes `shouldContain` [ProcessFireAtNotInjected]+        it "reports one ProcessFireAtNotInjected for a wholly unknown fireAt field" $ do+            codes <- errorCodesOf "test/fixtures/hospital-surge-clock.keiro"+            length (filter (== ProcessFireAtNotInjected) codes) `shouldBe` 1+        it "rejects a user-supplied dispatch id as ProcessDispatchIdSupplied" $ do+            codes <- errorCodesOf "test/fixtures/hospital-surge-dispatchid.keiro"+            codes `shouldContain` [ProcessDispatchIdSupplied]+        it "rejects an unresolved saga reference as ProcessUnresolvedRef" $ do+            codes <- errorCodesOf "test/fixtures/hospital-surge-badref.keiro"+            codes `shouldContain` [ProcessUnresolvedRef]+        it "rejects unresolved process commands, projections, schedules, and advance ids" $ do+            codes <- errorCodesOf "test/fixtures/process-ghost-refs.keiro"+            length (filter (== ProcessUnresolvedRef) codes) `shouldBe` 5+            codes `shouldContain` [ProcessDispatchIdSupplied]++    describe "router (EP-108)" $ do+        it "parses the incident-paging router shape" $ do+            input <- readTestText "test/fixtures/incident-paging/incident-paging.keiro"+            case parseSpec "test/fixtures/incident-paging/incident-paging.keiro" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> case [router | NRouter router <- specNodes spec] of+                    [router] -> do+                        rtId router `shouldBe` "PagingRouter"+                        rtName router `shouldBe` "jitsurei-paging"+                        corrField (rtKey router) `shouldBe` "incidentId"+                        rvSource (rtResolve router) `shouldBe` ResolveReadModel "service_oncall"+                        rvRow (rtResolve router) `shouldBe` ["responderId"]+                        rdCommand (rtDispatch router) `shouldBe` "SendPage"+                        rtRejected router `shouldBe` PolDeadLetter+                        rtPoison router `shouldBe` PolHalt+                    routers -> expectationFailure ("expected one router, got " <> show (length routers))+        it "round-trips the incident-paging spec through parse . pretty" $ do+            input <- readTestText "test/fixtures/incident-paging/incident-paging.keiro"+            case parseSpec "in" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "accepts the incident-paging router with warnings only" $ do+            codes <- errorCodesOf "test/fixtures/incident-paging/incident-paging.keiro"+            codes `shouldBe` []+            diagnostics <- diagnosticCodesOf "test/fixtures/incident-paging/incident-paging.keiro"+            diagnostics `shouldContain` [PolicyDeadLetterUnused, AmbiguousFollowsRejectedPolicy]+        it "rejects unresolved targets, keys, commands, and binding scopes" $ do+            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"+            routerErrorCodes (\router -> router{rtTarget = "Pge"}) spec `shouldContain` [RouterUnresolvedRef]+            routerErrorCodes (\router -> router{rtKey = (rtKey router){corrField = "incidntId"}}) spec `shouldContain` [RouterKeyFieldUnknown]+            routerErrorCodes (\router -> router{rtDispatch = (rtDispatch router){rdCommand = "SendPag"}}) spec `shouldContain` [RouterCommandUnknown]+            routerErrorCodes+                ( \router ->+                    let dispatch = rtDispatch router+                     in router{rtDispatch = dispatch{rdFields = [FieldBinding "responderId" (Just "resolved.responder")]}}+                )+                spec+                `shouldContain` [RouterBindingUnscoped]+        it "rejects unresolved read models and contradictory rejection policies" $ do+            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"+            let withoutReadModel = removeReadModel "service_oncall" spec+            errorCodes withoutReadModel `shouldContain` [RouterUnresolvedRef]+            routerErrorCodes+                ( \router ->+                    let dispatch = rtDispatch router+                        disposition = rdDisposition dispatch+                     in router+                            { rtRejected = PolHalt+                            , rtDispatch = dispatch{rdDisposition = disposition{onFailed = DDeadLetter "page rejected"}}+                            }+                )+                spec+                `shouldContain` [PolicyContradiction]+        it "rejects on-ambiguous Fired for process timers" $ do+            spec <- specOf "test/fixtures/hospital-surge.keiro"+            let changed =+                    spec+                        { specNodes =+                            [ case node of+                                NProcess process ->+                                    let timer = procTimer process+                                        fire = tmFire timer+                                        disposition = fireDisposition fire+                                     in NProcess process{procTimer = timer{tmFire = fire{fireDisposition = disposition{onAmbiguous = OFired}}}}+                                _ -> node+                            | node <- specNodes spec+                            ]+                        }+            errorCodes changed `shouldContain` [AmbiguousMarkedBenign]+        it "requires explicit policy and ambiguity clauses in the grammar" $ do+            source <- readTestText "test/fixtures/hospital-surge.keiro"+            parseSpec "<missing-poison>" (T.replace "  poison => halt\n" "" source) `shouldSatisfy` isLeft+            parseSpec "<missing-ambiguous>" (T.replace " ; on-ambiguous Retry" "" source) `shouldSatisfy` isLeft+        it "scaffolds firewall-clean router wiring, policies, and typed-hole guidance" $ do+            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"+            case [router | NRouter router <- specNodes spec] of+                [router] -> do+                    let ctx = defaultContext (specContext spec)+                        modules = scaffoldRouter ctx router+                        generated = [m | m <- modules, kind m == Generated]+                        holes = [m | m <- modules, kind m == HoleStub]+                    firewallBreaches generated `shouldBe` []+                    case (generated, holes) of+                        ([generatedModule], [holeModule]) -> do+                            moduleText generatedModule `shouldSatisfy` T.isInfixOf "pagingRouterWorkerOptions"+                            moduleText generatedModule `shouldSatisfy` T.isInfixOf "rejectedCommandPolicy = RejectedDeadLetter"+                            moduleText holeModule `shouldSatisfy` T.isInfixOf "UNION of resolved target identities"+                            moduleText holeModule `shouldSatisfy` T.isInfixOf "confirmBenignDuplicate"+                        _ -> expectationFailure "expected one generated router module and one router hole module"+                routers -> expectationFailure ("expected one router, got " <> show (length routers))+        it "requires a caller callback for non-halting poison policies" $ do+            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"+            case [router | NRouter router <- specNodes spec] of+                [router] -> do+                    let ctx = defaultContext (specContext spec)+                        generatedFor choice = [moduleText m | m <- scaffoldRouter ctx router{rtPoison = choice}, kind m == Generated]+                    mapM_+                        ( \(choice, constructor) -> case generatedFor choice of+                            [generatedModule] -> do+                                generatedModule `shouldSatisfy` T.isInfixOf "(Envelope msg -> Eff es ()) -> WorkerOptions es msg"+                                generatedModule `shouldSatisfy` T.isInfixOf (constructor <> " poisonCallback")+                            _ -> expectationFailure "expected one generated router module"+                        )+                        [(PolDeadLetter, "PoisonDeadLetter"), (PolSkip, "PoisonSkip")]+                    case [moduleText m | m <- scaffoldRouter ctx router{rtRejected = PolSkip}, kind m == Generated] of+                        [generatedModule] -> generatedModule `shouldSatisfy` T.isInfixOf "rejectedCommandPolicy = RejectedSkip"+                        _ -> expectationFailure "expected one generated router module"+                routers -> expectationFailure ("expected one router, got " <> show (length routers))+        it "emits router harness facts that pin policy and target-keyed identity" $ do+            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"+            case [router | NRouter router <- specNodes spec] of+                [router] -> case harnessRouter (defaultContext (specContext spec)) router of+                    [facts] -> do+                        moduleText facts `shouldSatisfy` T.isInfixOf "(\"rejectedPolicy\", \"deadLetter\")"+                        moduleText facts `shouldSatisfy` T.isInfixOf "targetStreamName, occurrence"+                    modules -> expectationFailure ("expected one router harness, got " <> show (length modules))+                routers -> expectationFailure ("expected one router, got " <> show (length routers))+        it "rejects invalid timer ceilings and target field bindings" $ do+            codes <- errorCodesOf "test/fixtures/process-bad-timer.keiro"+            mapM_+                (\expected -> codes `shouldContain` [expected])+                [ProcessTimerCeilingInvalid, ProcessFieldBindingUnresolved]+        it "accepts resolved process projection references" $ do+            codes <- errorCodesOf "test/fixtures/surge-service.keiro"+            codes `shouldBe` []+        it "scaffolds the process: Generated wiring is firewall-clean + a HoleStub" $ do+            mods <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"+            let gens = [m | m <- mods, kind m == Generated]+                holes = [m | m <- mods, kind m == HoleStub]+            length holes `shouldBe` 1+            firewallBreaches gens `shouldBe` []+            case gens of+                [generatedModule] -> do+                    -- the worker uses the spec's ceiling, never the dangerous default+                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "max-attempts = 5"+                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "hospitalSurgeProcessWorkerOptions"+                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "hospitalSurgeCategory = Stream.categoryUnsafe \"hospitalSurge\""+                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "confirmBenignDuplicate"+                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "StreamName -> EventId -> CommandError -> Eff es Bool"+                    moduleText generatedModule `shouldSatisfy` T.isInfixOf "Left (CommandAmbiguous _)"+                    case holes of+                        [holeModule] -> moduleText holeModule `shouldSatisfy` T.isInfixOf "entityStream hospitalSurgeCategory"+                        _ -> expectationFailure "expected one process hole module"+                _ -> expectationFailure "expected one generated process module"+        it "process scaffold is deterministic" $ do+            a <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"+            b <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"+            map moduleText a `shouldBe` map moduleText b++    describe "contract (EP-4)" $ do+        it "parses the emergency contract (topics + events-on-topic + typed fields)" $ do+            input <- readTestText "test/fixtures/contract.keiro"+            case parseSpec "test/fixtures/contract.keiro" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> case [c | NContract c <- specNodes spec] of+                    (c : _) -> do+                        ctrName c `shouldBe` "emergency"+                        ctrDiscriminator c `shouldBe` "messageType"+                        map fst (ctrTopics c) `shouldBe` ["incidentEvents", "hospitalEvents"]+                        map ceName (ctrEvents c) `shouldBe` ["IncidentTransferNeedDeclared", "TransferReservationAccepted"]+                    [] -> expectationFailure "no contract node parsed"+        it "round-trips the contract spec through parse . pretty" $ do+            input <- readTestText "test/fixtures/contract.keiro"+            case parseSpec "in" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "round-trips the intake (inbox) spec through parse . pretty" $ do+            input <- readTestText "test/fixtures/intake.keiro"+            case parseSpec "in" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "accepts the intake spec (complete disposition, no inversions)" $ do+            codes <- errorCodesOf "test/fixtures/intake.keiro"+            codes `shouldBe` []+        it "lowers explicit dedupe-only persistence and defaults omission to full-envelope" $ do+            spec <- specOf "test/fixtures/intake.keiro"+            ordinary <- specOf "test/fixtures/intake-decode.keiro"+            case ([intake | NIntake intake <- specNodes spec], [intake | NIntake intake <- specNodes ordinary]) of+                ([intake], [defaultIntake]) -> do+                    inkPersist intake `shouldBe` InkPersistDedupeOnly+                    inkPersist defaultIntake `shouldBe` InkPersistFull+                    renderSpec spec `shouldSatisfy` T.isInfixOf "persist = dedupe-only"+                    renderSpec ordinary `shouldNotSatisfy` T.isInfixOf "persist ="+                    let inbox = generatedTextEndingIn "Inbox.hs" (scaffoldIntake (defaultContext (specContext spec)) intake)+                    inbox `shouldSatisfy` T.isInfixOf "inboxPersistence = PersistDedupeOnly"+                (intakes, defaultIntakes) ->+                    expectationFailure ("expected one intake in each fixture, got " <> show (length intakes, length defaultIntakes))+        it "rejects duplicate => retry (inversion 1)" $ do+            codes <- errorCodesOf "test/fixtures/intake-dup-retry.keiro"+            codes `shouldContain` [DispositionDuplicateRetry]+        it "rejects previouslyFailed => retry (inversion 2)" $ do+            codes <- errorCodesOf "test/fixtures/intake-pf-retry.keiro"+            codes `shouldContain` [DispositionPreviouslyFailedRetry]+        it "rejects an incomplete disposition table" $ do+            codes <- errorCodesOf "test/fixtures/intake-incomplete.keiro"+            codes `shouldContain` [DispositionIncomplete]+        it "rejects a shadowing duplicate intake disposition row" $ do+            codes <- errorCodesOf "test/fixtures/intake-dup-row.keiro"+            codes `shouldContain` [DispositionDuplicateOutcome]+        it "rejects intake events declared on another topic" $ do+            codes <- errorCodesOf "test/fixtures/intake-topic-mismatch.keiro"+            codes `shouldContain` [TopicAffinityMismatch]+        it "round-trips the emit/publisher spec through parse . pretty" $ do+            input <- readTestText "test/fixtures/emit.keiro"+            case parseSpec "in" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "accepts the emit/publisher spec (skip present, coupling resolves)" $ do+            codes <- errorCodesOf "test/fixtures/emit.keiro"+            codes `shouldBe` []+        it "rejects a missing _ => skip catch-all as EmitSkipMissing" $ do+            codes <- errorCodesOf "test/fixtures/emit-noskip.keiro"+            codes `shouldContain` [EmitSkipMissing]+        it "rejects mapping to an undeclared contract event as EmitUnresolvedContract" $ do+            codes <- errorCodesOf "test/fixtures/emit-badevent.keiro"+            codes `shouldContain` [EmitUnresolvedContract]+        it "rejects emit events declared on another topic" $ do+            codes <- errorCodesOf "test/fixtures/emit-topic-mismatch.keiro"+            codes `shouldContain` [TopicAffinityMismatch]++    describe "pgmq workqueue/dispatch (EP-5)" $ do+        it "round-trips the reservation-work spec through parse . pretty" $ do+            input <- readTestText "test/fixtures/reservation-work.keiro"+            case parseSpec "in" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "accepts the reservation-work spec (physical matches, no inversions)" $ do+            codes <- errorCodesOf "test/fixtures/reservation-work.keiro"+            codes `shouldBe` []+        it "rejects a divergent captured physical name as WqPhysicalDivergence" $ do+            codes <- errorCodesOf "test/fixtures/reservation-work-divergent.keiro"+            codes `shouldContain` [WqPhysicalDivergence]+        it "rejects storeFailure => deadLetter as WqStoreFailureNotRetry" $ do+            codes <- errorCodesOf "test/fixtures/reservation-work-sf-deadletter.keiro"+            codes `shouldContain` [WqStoreFailureNotRetry]+        it "rejects decodeFailure => retry as WqDecodeFailureNotDeadLetter" $ do+            codes <- errorCodesOf "test/fixtures/reservation-work-df-retry.keiro"+            codes `shouldContain` [WqDecodeFailureNotDeadLetter]+        it "requires complete, unique workqueue disposition rows" $ do+            incomplete <- errorCodesOf "test/fixtures/workqueue-incomplete.keiro"+            incomplete `shouldContain` [WqDispositionIncomplete]+            duplicateSpec <- specOf "test/fixtures/workqueue-dup-row.keiro"+            let duplicateDiagnostics = [d | d <- validateSpec duplicateSpec, code d == DispositionDuplicateOutcome]+            map line duplicateDiagnostics `shouldBe` [17]+        it "checks the captured queueRef dlq and table fixtures" $ do+            dlqCodes <- errorCodesOf "test/fixtures/workqueue-dlq-divergent.keiro"+            dlqCodes `shouldContain` [WqDlqDivergence]+            tableCodes <- errorCodesOf "test/fixtures/workqueue-table-divergent.keiro"+            tableCodes `shouldContain` [WqTableDivergence]+        it "matches queueRef for upper-case, punctuation, and hashed logical names" $ do+            upper <- errorCodesOf "test/fixtures/workqueue-uppercase-logical.keiro"+            upper `shouldBe` []+            hashed <- errorCodesOf "test/fixtures/workqueue-hashed-logical.keiro"+            hashed `shouldBe` []+            derivedQueueTrio "hospital_capacity.reservation_work.per_hospital_fifo_lane_assignments"+                `shouldBe` ( "hospital_capacity_reservat_757040df00976c33"+                           , "hospital_capacity_reservat_757040df00976c33_dlq"+                           , "pgmq.q_hospital_capacity_reservat_757040df00976c33"+                           )+        it "resolves dispatch dedup queues and payload wire fields" $ do+            ghost <- errorCodesOf "test/fixtures/dispatch-dedup-ghost-queue.keiro"+            ghost `shouldContain` [DispatchDedupQueueUnresolved]+            field <- errorCodesOf "test/fixtures/dispatch-dedup-bad-field.keiro"+            field `shouldContain` [DispatchDedupFieldUnresolved]+        it "requires a resolvable group key exactly when ordering is FIFO" $ do+            noKey <- errorCodesOf "test/fixtures/reservation-work-fifo-nokey.keiro"+            noKey `shouldContain` [WqGroupKeyMissing]+            unordered <- errorCodesOf "test/fixtures/reservation-work-key-unordered.keiro"+            unordered `shouldContain` [WqGroupKeyWithoutFifo]+            source <- readTestText "test/fixtures/reservation-work.keiro"+            unresolved <- parseInlineSpec "<unresolved-group-key>" (T.replace "group key from reservationId" "group key from missingId" source)+            map code (validateSpec unresolved) `shouldContain` [WqGroupKeyUnresolved]+        it "warns on unlogged storage and rejects empty partition settings" $ do+            warningCodes <- diagnosticCodesOf "test/fixtures/reservation-work-unlogged.keiro"+            warningCodes `shouldContain` [WqUnloggedDurability]+            partitionCodes <- errorCodesOf "test/fixtures/reservation-work-partitioned-empty.keiro"+            partitionCodes `shouldContain` [WqPartitionSpecEmpty]+        it "lowers ordering, provisioning, and raw group-key projection" $ do+            spec <- specOf "test/fixtures/reservation-work.keiro"+            case [workqueue | NWorkqueue workqueue <- specNodes spec] of+                workqueue : _ -> do+                    let modules = scaffoldWorkqueue (defaultContext (specContext spec)) workqueue+                        queue = generatedTextEndingIn "Queue.hs" modules+                        policy = generatedTextEndingIn "QueuePolicy.hs" modules+                    queue `shouldSatisfy` T.isInfixOf "groupKeyFor payload = payload.reservationId"+                    policy `shouldSatisfy` T.isInfixOf "jobOrdering = FifoThroughput"+                    policy `shouldSatisfy` T.isInfixOf "withFifoIndexProvision (standardProvision)"+                    firewallBreaches modules `shouldBe` []+                [] -> expectationFailure "reservation-work fixture has no workqueue"++    describe "readmodel (EP-107)" $ do+        it "parses and round-trips first-class read models" $ do+            spec <- specOf "test/fixtures/readmodel.keiro"+            case [readModel | NReadModel readModel <- specNodes spec] of+                [subscriptionModel, inlineModel] -> do+                    rmName subscriptionModel `shouldBe` "transfer_decisions"+                    rmColumns subscriptionModel+                        `shouldBe` [ RmColumn "reservation_id" "text" True+                                   , RmColumn "hospital_id" "text" True+                                   , RmColumn "status" "text" True+                                   , RmColumn "decided_at" "timestamptz" False+                                   ]+                    rmScope subscriptionModel `shouldBe` Just (RmCategory "reservation")+                    rmFeed subscriptionModel `shouldBe` RmSubscription+                    rmSubscription subscriptionModel `shouldBe` Just "hospital-capacity-transfer-decisions-sub"+                    rmName inlineModel `shouldBe` "subscriptions"+                    rmScope inlineModel `shouldBe` Nothing+                    rmFeed inlineModel `shouldBe` RmInline+                nodes -> expectationFailure ("expected two readmodel nodes, got " <> show (length nodes))+            parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "accepts an aggregate projection without a consistency clause" $ do+            spec <- parseInlineSpec "<projection-without-consistency>" projectionWithoutConsistencySpec+            case [projection | NAggregate aggregate <- specNodes spec, Just projection <- [aggProjection aggregate]] of+                [projection] -> projConsistency projection `shouldBe` Nothing+                projections -> expectationFailure ("expected one projection, got " <> show (length projections))+        it "pins the canonical UTF-8 shape digest and runtime identities" $ do+            spec <- specOf "test/fixtures/readmodel.keiro"+            case [readModel | NReadModel readModel <- specNodes spec] of+                (subscriptionModel : inlineModel : _) -> do+                    canonicalShape subscriptionModel+                        `shouldBe` "transfer_decisions|reservation_id:text:req|hospital_id:text:req|status:text:req|decided_at:timestamptz:null"+                    deriveShapeHash subscriptionModel `shouldBe` "fnv1a:3717f6d9e3c44bd6"+                    deriveShapeHash inlineModel `shouldBe` "fnv1a:f54d9bb2f40a6738"+                    registryNameFor (specContext spec) subscriptionModel `shouldBe` "hospital-capacity-transfer-decisions"+                    subscriptionNameFor (specContext spec) subscriptionModel `shouldBe` "hospital-capacity-transfer-decisions-sub"+                    subscriptionNameFor "billing" inlineModel `shouldBe` "billing-subscriptions-sub"+                nodes -> expectationFailure ("expected readmodel nodes, got " <> show (length nodes))+        it "accepts the positive readmodel fixture with all references resolved" $ do+            spec <- specOf "test/fixtures/readmodel.keiro"+            validateSpec spec `shouldBe` []+        it "rejects shape drift and unknown SQL column types" $ do+            codes <- errorCodesOf "test/fixtures/readmodel-shape-drift.keiro"+            codes `shouldContain` [RmShapeHashDrift, RmUnknownColumnType]+        it "rejects Strong on inline and standalone projections" $ do+            inlineCodes <- errorCodesOf "test/fixtures/readmodel-strong-inline.keiro"+            inlineCodes `shouldContain` [RmStrongInlineOnly]+            standalone <- specOf "test/fixtures/readmodel-strong-standalone.keiro"+            let diagnostics = validateSpec standalone+            map code diagnostics `shouldContain` [RmStrongInlineOnly, RmProjectionWithoutNode]+            [severity diagnostic | diagnostic <- diagnostics, code diagnostic == RmProjectionWithoutNode]+                `shouldBe` [Warning]+        it "rejects scope without Strong and an unreferenced inline feed" $ do+            scopeCodes <- errorCodesOf "test/fixtures/readmodel-scope-eventual.keiro"+            scopeCodes `shouldContain` [RmScopeWithoutStrong]+            inlineCodes <- errorCodesOf "test/fixtures/readmodel-inline-unreferenced.keiro"+            inlineCodes `shouldContain` [RmInlineFeedUnreferenced]+        it "rejects projection consistency conflicts" $ do+            codes <- errorCodesOf "test/fixtures/readmodel-consistency-conflict.keiro"+            codes `shouldContain` [RmConsistencyConflict]+        it "resolves query read models and validates query consistency" $ do+            codes <- errorCodesOf "test/fixtures/readmodel-query-unresolved.keiro"+            codes `shouldContain` [QueryUnresolvedReadModel, QueryConsistencyInvalid]+        it "resolves dispatch read models and declared dedup columns" $ do+            codes <- errorCodesOf "test/fixtures/readmodel-dispatch-unresolved.keiro"+            codes `shouldContain` [DispatchReadModelUnresolved, DispatchReadModelFieldUnknown]+        it "scaffolds runtime records, rebuild helpers, async wiring, and typed holes" $ do+            spec <- specOf "test/fixtures/readmodel.keiro"+            let ctx = defaultContext (specContext spec)+                readModels = [readModel | NReadModel readModel <- specNodes spec]+                modules = concatMap (scaffoldReadModel ctx) readModels+                transfer = generatedTextEndingIn "Transfer_decisions/ReadModel.hs" modules+                inline = generatedTextEndingIn "Subscriptions/ReadModel.hs" modules+                transferHoles = [moduleText m | m <- modules, "Transfer_decisions/ReadModelHoles.hs" `T.isSuffixOf` T.pack (modulePath m)]+            length modules `shouldBe` 6+            length [m | m <- modules, kind m == Generated] `shouldBe` 4+            length [m | m <- modules, kind m == HoleStub] `shouldBe` 2+            firewallBreaches modules `shouldBe` []+            transfer `shouldSatisfy` T.isInfixOf "registerTransferDecisions"+            transfer `shouldSatisfy` T.isInfixOf "Rebuild.startRebuild transferDecisionsReadModel [\"hospital-capacity-transfer-decisions-async\"]"+            transfer `shouldSatisfy` T.isInfixOf "strongScope = CategoryHead \"reservation\""+            transfer `shouldSatisfy` T.isInfixOf "transferDecisionsAsyncProjection"+            inline `shouldSatisfy` T.isInfixOf "Rebuild.startRebuild subscriptionsReadModel []"+            inline `shouldNotSatisfy` T.isInfixOf "AsyncProjection"+            transferHoles `shouldSatisfy` any (T.isInfixOf "RecordedEvent -> Tx.Transaction ()")+        it "threads qualified table and column guidance into aggregate projection holes" $ do+            spec <- specOf "test/fixtures/readmodel.keiro"+            case [aggregate | NAggregate aggregate <- specNodes spec] of+                [aggregate] -> do+                    let modules = scaffoldAggregate (defaultContext (specContext spec)) spec aggregate+                        holes = [moduleText m | m <- modules, kind m == HoleStub]+                        projection = generatedTextEndingIn "Projection.hs" modules+                    holes `shouldSatisfy` any (T.isInfixOf "subscriptionsQualifiedTable")+                    holes `shouldSatisfy` any (T.isInfixOf "Table: \"billing\".\"subscriptions\"")+                    projection `shouldSatisfy` T.isInfixOf "ReadModelTable.subscriptionsQualifiedTable"+                aggregates -> expectationFailure ("expected one aggregate, got " <> show (length aggregates))+        it "emits runtime-free derivation facts for each read model" $ do+            spec <- specOf "test/fixtures/readmodel.keiro"+            case [readModel | NReadModel readModel <- specNodes spec] of+                (subscriptionModel : _) -> do+                    let modules = harnessReadModel (defaultContext (specContext spec)) subscriptionModel+                        harnessText = generatedTextEndingIn "ReadModelHarness.hs" modules+                    length modules `shouldBe` 1+                    firewallBreaches modules `shouldBe` []+                    harnessText `shouldSatisfy` T.isInfixOf "(\"shapeHash\", \"fnv1a:3717f6d9e3c44bd6\", \"fnv1a:3717f6d9e3c44bd6\")"+                    harnessText `shouldSatisfy` T.isInfixOf "(\"strongScope\", \"CategoryHead reservation\", \"CategoryHead reservation\")"+                    harnessText `shouldSatisfy` T.isInfixOf "runReadModelFacts"+                nodes -> expectationFailure ("expected readmodel nodes, got " <> show (length nodes))++    describe "workflow/operation (EP-6)" $ do+        it "round-trips the workflow spec through parse . pretty" $ do+            input <- readTestText "test/fixtures/workflow.keiro"+            case parseSpec "in" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> parseSpec "in" (renderSpec spec) `shouldBe` Right spec+        it "accepts the workflow spec (await<->signal matches, run resolves)" $ do+            codes <- errorCodesOf "test/fixtures/workflow.keiro"+            codes `shouldBe` []+        it "rejects a signal label with no matching await as AwaitSignalMismatch" $ do+            codes <- errorCodesOf "test/fixtures/workflow-signal-mismatch.keiro"+            codes `shouldContain` [AwaitSignalMismatch]+        it "rejects duplicate workflow labels" $ do+            codes <- errorCodesOf "test/fixtures/workflow-dup-label.keiro"+            codes `shouldContain` [WorkflowDuplicateLabel]+        it "rejects unresolved workflow id and sleep fields" $ do+            codes <- errorCodesOf "test/fixtures/workflow-unresolved-fields.keiro"+            codes `shouldContain` [WorkflowIdFieldUnresolved, WorkflowSleepDelayUnresolved]+        it "validates rule domains, totality, case constructors, and bodies" $ do+            unresolved <- errorCodesOf "test/fixtures/rule-bad-domain.keiro"+            unresolved `shouldBe` [RuleDomainUnresolved]+            codes <- errorCodesOf "test/fixtures/rule-not-total.keiro"+            mapM_+                (\expected -> codes `shouldContain` [expected])+                [RuleNotTotal, RuleCaseUnknownCtor, ClockSampled, GuardAtomOutOfScope]+        it "rejects unresolved command operation references" $ do+            codes <- errorCodesOf "test/fixtures/operation-ghost-aggregate.keiro"+            codes `shouldContain` [OperationUnresolvedRef]+        it "rejects a signal value type that differs from its await" $ do+            codes <- errorCodesOf "test/fixtures/operation-signal-value.keiro"+            codes `shouldContain` [AwaitSignalValueMismatch]+        it "round-trips guarded patches and terminal continueAsNew" $ do+            input <- readTestText "test/fixtures/workflow-evolution.keiro"+            case parseSpec "workflow-evolution" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> do+                    parseSpec "workflow-evolution" (renderSpec spec) `shouldBe` Right spec+                    errorCodes spec `shouldBe` []+        it "rejects duplicate patch ids anywhere in the workflow body" $ do+            codes <- errorCodesOf "test/fixtures/workflow-patch-dup.keiro"+            codes `shouldBe` [WorkflowPatchDuplicate]+        it "rejects non-terminal and nested continueAsNew" $ do+            codes <- errorCodesOf "test/fixtures/workflow-can-mid.keiro"+            codes `shouldBe` [WorkflowContinueAsNewNotTerminal, WorkflowContinueAsNewNotTerminal]+        it "rejects a colon in a patch id with a workflow diagnostic" $ do+            codes <- errorCodesOf "test/fixtures/workflow-patch-colon.keiro"+            codes `shouldBe` [WorkflowPatchIdInvalid]+        it "lowers patch facts and live runtime declarations" $ do+            spec <- specOf "test/fixtures/workflow-evolution.keiro"+            case [workflow | NWorkflow workflow <- specNodes spec] of+                [workflow] -> do+                    let modules = harnessWorkflow (defaultContext (specContext spec)) workflow+                        facts = generatedTextEndingIn "WorkflowFacts.hs" modules+                        runtime = generatedTextEndingIn "WorkflowRuntime.hs" modules+                    facts `shouldSatisfy` T.isInfixOf "patch:fraud-check-v2(step:fraud-check)"+                    facts `shouldSatisfy` T.isInfixOf "continueAsNew:RolloverSeed"+                    facts `shouldSatisfy` T.isInfixOf "(\"patches\", \"fraud-check-v2\")"+                    runtime `shouldSatisfy` T.isInfixOf "declaredPatches = Set.fromList [PatchId \"fraud-check-v2\"]"+                    runtime `shouldSatisfy` T.isInfixOf "opts{activePatches = declaredPatches}"+                workflows -> expectationFailure ("expected one workflow, got " <> show (length workflows))++    describe "replay impact" $ do+        it "treats new events and transitions as replay-neutral" $ do+            old <- specOf "test/fixtures/reservation.keiro"+            let aggregate = onlyAggregate old+            case (aggEvents aggregate, aggTransitions aggregate) of+                (event : _, transition : _) -> do+                    let newEvent =+                            event+                                { evName = "ReservationReviewed"+                                , evLoc = noLoc+                                }+                        newTransition =+                            transition+                                { tEmits = ["ReservationReviewed"]+                                , tLoc = noLoc+                                }+                        new =+                            modifyAggregate+                                "Reservation"+                                ( \candidate ->+                                    candidate+                                        { aggEvents = aggEvents candidate <> [newEvent]+                                        , aggTransitions = aggTransitions candidate <> [newTransition]+                                        }+                                )+                                old+                    ReplayImpact.replayImpact old new `shouldBe` ReplayNeutral+                _ -> expectationFailure "reservation fixture must contain an event and transition"++        it "narrows a guard edit to that transition's event types" $ do+            impact <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-guard-tightened.keiro"+            impact+                `shouldBe` ReplayAffected+                    ( Map.singleton+                        "Reservation"+                        AggregateImpact+                            { eventTypes = Set.singleton "TransferReservationCreated"+                            , includeSnapshotStreams = True+                            }+                    )++        it "proves a syntactic guard loosening replay-neutral" $ do+            old <- specOf "test/fixtures/reservation.keiro"+            let loosened =+                    modifyAggregate+                        "Reservation"+                        ( \aggregate ->+                            aggregate+                                { aggTransitions =+                                    [ transition{tGuard = Nothing}+                                    | transition <- aggTransitions aggregate+                                    ]+                                }+                        )+                        old+            ReplayImpact.replayImpact old loosened `shouldBe` ReplayNeutral++        it "marks every existing event when the aggregate wire convention changes" $ do+            impact <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-wire.keiro"+            case impact of+                ReplayAffected aggregates ->+                    ReplayImpact.eventTypes <$> Map.lookup "Reservation" aggregates+                        `shouldBe` Just (Set.fromList ["TransferReservationCreated", "TransferReservationConfirmed"])+                ReplayNeutral -> expectationFailure "expected a wire-clause replay impact"++        it "includes snapshot streams when a write expression changes" $ do+            impact <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-foldchange.keiro"+            case impact of+                ReplayAffected aggregates ->+                    includeSnapshotStreams <$> Map.lookup "Reservation" aggregates+                        `shouldBe` Just True+                ReplayNeutral -> expectationFailure "expected a fold replay impact"++        it "detects codec evolution and ignores formatting-only rewrites" $ do+            changed <- replayImpactFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-v2.keiro"+            changed `shouldSatisfy` (/= ReplayNeutral)+            old <- specOf "test/fixtures/reservation.keiro"+            formatted <- parseInlineSpec "<formatted>" (renderSpec old)+            ReplayImpact.replayImpact old formatted `shouldBe` ReplayNeutral++        it "names mapped nested event and snapshot roots while ignoring Haskell-only changes" $ do+            nested <- replayImpactFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-nested-propagation.keiro"+            case nested of+                ReplayAffected aggregates ->+                    Map.lookup "Catalog" aggregates+                        `shouldBe` Just AggregateImpact{eventTypes = Set.singleton "ArtifactObserved", includeSnapshotStreams = True}+                ReplayNeutral -> expectationFailure "expected nested mapped wire change to affect replay"+            sourceOnly <- replayImpactFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-haskell-rename.keiro"+            sourceOnly `shouldBe` ReplayNeutral++        it "generates one context target for every aggregate, including the process saga" $ do+            spec <- specOf "test/fixtures/surge-service.keiro"+            case scaffoldReplayAudit (defaultContext (specContext spec)) spec of+                [assembly] -> do+                    modulePath assembly `shouldBe` "Generated/SurgeDemo/ReplayAudit.hs"+                    moduleText assembly `shouldSatisfy` T.isInfixOf "Hospital.hospitalEventStream"+                    moduleText assembly `shouldSatisfy` T.isInfixOf "Surge.surgeEventStream"+                    T.count "      AuditTarget" (moduleText assembly) `shouldBe` 2+                assemblies -> expectationFailure ("expected one replay-audit assembly, got " <> show (length assemblies))++    describe "diff (evolution classification)" $ do+        it "covers every node family exactly once and explains exclusions" $ do+            sort (map fst familyRegistry) `shouldBe` ([minBound .. maxBound] :: [NodeFamily])+            [reason | (_, OutOfDiffScope reason) <- familyRegistry, T.null reason] `shouldBe` []+        it "derives every exercised headline from its vector under the default gate" $ do+            changes <-+                concat+                    <$> mapM+                        (uncurry diffFixtures)+                        [ ("test/fixtures/reservation.keiro", "test/fixtures/reservation-fieldadd.keiro")+                        , ("test/fixtures/reservation.keiro", "test/fixtures/reservation-v2.keiro")+                        , ("test/fixtures/reservation.keiro", "test/fixtures/reservation-enumadd.keiro")+                        , ("test/fixtures/contract.keiro", "test/fixtures/contract-fieldadd.keiro")+                        , ("test/fixtures/reservation-work.keiro", "test/fixtures/reservation-work-rename.keiro")+                        ]+            forM_ changes $ \change ->+                do+                    deriveLabel defaultGate (ckVector (kindOfChange change))+                        `shouldBe` labelOfChange change+                    gatedBreaking defaultGate change `shouldBe` isBreaking change+        it "never removes a breaking result when the gate grows" $+            property $+                forAll genCompatibilityVector $ \compatibility ->+                    forAll genSurfaceSet $ \gate ->+                        forAll genSurfaceSet $ \extra ->+                            deriveLabel gate compatibility+                                == LabelBreaking+                                    ==> deriveLabel (gate <> extra) compatibility+                                == LabelBreaking+        it "renders the consumer-neutral matrix with separate private, snapshot, and public surfaces" $ do+            changes <- diffFixtures "test/fixtures/compatibility-vector-old.keiro" "test/fixtures/compatibility-vector-new.keiro"+            golden <- readTestText "test/fixtures/compatibility-vector.diff.golden"+            let rendered = T.intercalate "\n" (map renderFinding changes)+                explained = T.intercalate "\n" (map renderExplainBlock changes)+                reportJson = T.pack (show (Aeson.toJSON (diffReport defaultGate changes)))+            T.stripEnd rendered `shouldBe` T.stripEnd golden+            rendered `shouldSatisfy` T.isInfixOf "Reservation.event.TransferReservationCreated.patientAcuity"+            rendered `shouldSatisfy` T.isInfixOf "old-binary-read-new-events=breaking"+            rendered `shouldSatisfy` T.isInfixOf "snapshot-hydration=advisory"+            rendered `shouldSatisfy` T.isInfixOf "public-consumer=breaking"+            explained `shouldSatisfy` T.isInfixOf "invalidate and rebuild snapshots"+            reportJson `shouldSatisfy` T.isInfixOf "keiro-dsl/diff-report/1"+            reportJson `shouldSatisfy` T.isInfixOf "Reservation.event.TransferReservationCreated.patientAcuity"+            let eventEnumFindings =+                    [ change+                    | change@(Advisory kind) <- changes+                    , ckCode kind == EnumCtorAdded+                    , verdictFor OldBinaryReadNewEvents (ckVector kind) == VBreaking+                    ]+            eventEnumFindings `shouldSatisfy` all (not . gatedBreaking defaultGate)+            eventEnumFindings `shouldSatisfy` all (gatedBreaking (gateWith [OldBinaryReadNewEvents]))+            forM_ changes $ \change ->+                remediationFor (ckContext (kindOfChange change)) (ckCode (kindOfChange change))+                    `shouldSatisfy` (not . null)+        it "rejects unknown --gate values with the valid surface list" $ do+            parseSurfaceName "mystery-surface"+                `shouldSatisfy` either (T.isInfixOf "old-binary-read-new-events" . T.pack) (const False)+        it "covers the mapped evolution matrix with stable codes and non-empty remedies" $ do+            let cases =+                    [ ("consumer-types-fieldadd-default.keiro", MappedFieldAddedWithDefault)+                    , ("consumer-types-fieldadd-nodefault.keiro", MappedFieldAddedNoDefault)+                    , ("consumer-types-fieldremove.keiro", MappedFieldRemoved)+                    , ("consumer-types-wirekey.keiro", MappedWireKeyChanged)+                    , ("consumer-types-haskell-rename.keiro", MappedHaskellSourceChanged)+                    , ("consumer-types-binding-change.keiro", MappedBindingChanged)+                    , ("consumer-types-fixtures-change.keiro", MappedFixturesChanged)+                    , ("consumer-types-initial-change.keiro", MappedInitialChanged)+                    , ("consumer-types-armadd.keiro", MappedArmAdded)+                    , ("consumer-types-tagchange.keiro", MappedArmTagChanged)+                    , ("consumer-types-enumadd.keiro", MappedEnumValueAdded)+                    , ("consumer-types-enumremove.keiro", MappedEnumValueRemoved)+                    , ("consumer-types-enumspelling.keiro", MappedEnumSpellingChanged)+                    , ("consumer-types-encoding.keiro", MappedUnionEncodingChanged)+                    , ("consumer-types-opaque-version.keiro", MappedOpaqueCodecChanged)+                    , ("consumer-types-mode-cross.keiro", MappedModeCrossed)+                    , ("consumer-types-nested-propagation.keiro", MappedArmTagChanged)+                    ]+            forM_ cases $ \(fixture, expectedCode) -> do+                changes <- diffFixtures "test/fixtures/consumer-types.keiro" ("test/fixtures/" <> fixture)+                map (ckCode . kindOfChange) changes `shouldContain` [expectedCode]+                forM_ changes $ \change ->+                    remediationFor (ckContext (kindOfChange change)) (ckCode (kindOfChange change))+                        `shouldSatisfy` (not . null)+        it "separates mapped event migration, snapshot invalidation, and directional rollout" $ do+            breakingAdd <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-fieldadd-nodefault.keiro"+            let noDefault = [change | change <- breakingAdd, ckCode (kindOfChange change) == MappedFieldAddedNoDefault]+            [ckFacet kind | Breaking kind <- noDefault] `shouldContain` ["mapped-event"]+            [ckFacet kind | Advisory kind <- noDefault] `shouldContain` ["mapped-register"]+            defaulted <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-fieldadd-default.keiro"+            [change | change <- defaulted, isBreaking change] `shouldBe` []+            let eventDefaults = [kind | Advisory kind <- defaulted, ckCode kind == MappedFieldAddedWithDefault, ckFacet kind == "mapped-event"]+            eventDefaults `shouldSatisfy` any ((== VBreaking) . verdictFor OldBinaryReadNewEvents . ckVector)+            armAdded <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-armadd.keiro"+            [change | change <- armAdded, isBreaking change] `shouldBe` []+            [kind | Advisory kind <- armAdded, ckCode kind == MappedArmAdded, ckFacet kind == "mapped-event"]+                `shouldSatisfy` any ((== VBreaking) . verdictFor OldBinaryReadNewEvents . ckVector)+        it "propagates a nested mapped leaf to complete command, event, and register paths" $ do+            changes <- diffFixtures "test/fixtures/consumer-types.keiro" "test/fixtures/consumer-types-nested-propagation.keiro"+            let subjects =+                    [ ckSubject kind+                    | change <- changes+                    , let kind = kindOfChange change+                    , ckCode kind == MappedArmTagChanged+                    ]+            subjects+                `shouldContain` [ "Catalog command ObserveArtifact .artifact : ArtifactInfo .location : ArtifactLocation .arm RepoPath[\"repository_path\"]"+                                , "Catalog event ArtifactObserved .artifact : ArtifactInfo .location : ArtifactLocation .arm RepoPath[\"repository_path\"]"+                                , "Catalog register currentArtifact : ArtifactInfo .location : ArtifactLocation .arm RepoPath[\"repository_path\"]"+                                ]+        it "classifies every remaining mapped field and declaration evolution row" $ do+            base <- specOf "test/fixtures/consumer-types.keiro"+            let mutationCodes =+                    [ (mapArtifactNamedField "key" (\field -> field{wfType = TInt}) base, MappedFieldTypeChanged)+                    , (mapArtifactNamedField "key" (\field -> field{wfPresence = POptional, wfOnMissing = Just (OmText "")}) base, MappedPresenceChanged)+                    , (mapArtifactNamedField "key" (\field -> field{wfType = TOptional TText}) base, MappedNullabilityChanged)+                    , (mapArtifactNamedField "description" (\field -> field{wfOnMissing = Nothing}) base, MappedDefaultRemoved)+                    , (mapArtifactNamedField "count" (\field -> field{wfOnMissing = Just (OmInt 1)}) base, MappedDefaultChanged)+                    , (mapMappedStructural "ArtifactInfo" renameMappedRecordConstructor base, MappedRecordConstructorChanged)+                    , (mapMappedStructural "ArtifactInfo" changeMappedCanonical base, MappedCanonicalTypeChanged)+                    ]+            forM_ mutationCodes $ \(candidate, expectedCode) ->+                map (ckCode . kindOfChange) (diffSpecs base candidate) `shouldContain` [expectedCode]+            let declarationA = completeStructural "A" (recordShape [TText])+                declarationB = completeStructural "B" (recordShape [TInt])+                onlyA = mappedSpec [declarationA]+                withB = mappedSpec [declarationA, declarationB]+            map (ckCode . kindOfChange) (diffSpecs onlyA withB) `shouldContain` [MappedDeclAdded]+            map (ckCode . kindOfChange) (diffSpecs withB onlyA) `shouldContain` [MappedDeclRemoved]+            diffSpecs base (mapArtifactNamedField "key" (\field -> field{wfHaskell = "renamedKey"}) base)+                `shouldBe` []+        it "visits every mapped wire mutation and reports every complete root path" $ do+            base <- specOf "test/fixtures/consumer-types.keiro"+            let mutations = mappedWireMutations base+            mutations `shouldSatisfy` (not . null)+            visited <- fmap Set.unions . forM mutations $ \mutation -> do+                let changes =+                        [ change+                        | change <- diffSpecs base (mmCandidate mutation)+                        , ckCode (kindOfChange change) == mmCode mutation+                        ]+                    actualSubjects = Set.fromList (map (ckSubject . kindOfChange) changes)+                changes `shouldSatisfy` any (not . isAdditiveChange)+                actualSubjects `shouldBe` mmExpectedSubjects mutation+                pure actualSubjects+            visited `shouldBe` Set.unions (map mmExpectedSubjects mutations)+        it "reports the exact ingredient code when every required mapped fact is deleted" $ do+            base <- specOf "test/fixtures/consumer-types.keiro"+            forM_ (mappedIngredientMutations base) $ \(candidate, expectedCode) ->+                errorCodes candidate `shouldContain` [expectedCode]+        it "classifies a field added without a version bump as BREAKING" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-fieldadd.keiro"+            any isBreaking cs `shouldBe` True+            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldAddedWithoutBump]+        it "classifies the same field wrapped as v2 + upcaster as ADDITIVE" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-v2.keiro"+            any isBreaking cs `shouldBe` False+            [ck | Additive ck <- cs] `shouldSatisfy` any ((== "TransferReservationCreated") . ckSubject)+        it "reports no breaking change when the spec is unchanged" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation.keiro"+            any isBreaking cs `shouldBe` False+        it "classifies a direct event field type change as EvtFieldTypeChanged" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-fieldtype.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldTypeChanged]+        it "resolves fields(Command) before comparing event field types" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-cmdfieldtype.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldTypeChanged]+        it "uses EvtFieldRemovedSameVersion for an unchanged-version removal" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-fieldremove.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [EvtFieldRemovedSameVersion]+        it "uses EvtVersionDecreased for a version decrease" $ do+            cs <- diffFixtures "test/fixtures/reservation-v2.keiro" "test/fixtures/reservation.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [EvtVersionDecreased]+        it "rejects a v1 to v3 jump whose only upcaster starts at v2" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-v3-dangling.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [EvtVersionMissingUpcaster]+        it "classifies a vanished historical upcaster rung as UpcasterChainGap" $ do+            cs <- diffFixtures "test/fixtures/reservation-v2.keiro" "test/fixtures/reservation-chain-gap.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [UpcasterChainGap]+        it "classifies an enum constructor removal as EnumCtorRemoved" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-enumdrop.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [EnumCtorRemoved]+        it "classifies an enum wire-spelling change as EnumWireSpellingChanged" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-enumwire.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [EnumWireSpellingChanged]+        it "classifies an enum constructor addition per use site as advisory" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-enumadd.keiro"+            any isBreaking cs `shouldBe` False+            let enumFindings = [k | Advisory k <- cs, ckCode k == EnumCtorAdded]+            [ckSubject k | k <- enumFindings] `shouldContain` ["BlackTag"]+            [verdictFor SnapshotHydration (ckVector k) | k <- enumFindings]+                `shouldContain` [VAdvisory]+        it "classifies an effective wire convention change as WireSpecChanged" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-wire.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [WireSpecChanged]+        it "advises when the aggregate fold surface changes" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-foldchange.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldContain` [AggFoldSurfaceChanged]+        it "advises on hazardous deprecation and reports un-deprecation" $ do+            deprecated <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-deprecated.keiro"+            any isBreaking deprecated `shouldBe` False+            [ckCode k | Advisory k <- deprecated] `shouldContain` [DeprecatedEventReplayHazard]+            restored <- diffFixtures "test/fixtures/reservation-deprecated.keiro" "test/fixtures/reservation.keiro"+            any isAdvisory restored `shouldBe` True+            [ckCode k | Advisory k <- restored] `shouldContain` [EventUndeprecated]+        it "recognises replay-only deprecation as a replay-safe retirement cutover" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-deprecated-replay-only.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldContain` [EventRetirementInProgress]+            [ckCode k | Advisory k <- cs] `shouldNotContain` [DeprecatedEventReplayHazard]+        it "advises when event retirement starts" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-retiring.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldContain` [EventRetirementInProgress]+        it "does not recommend decode-only deprecation for an event removal" $ do+            old <- specOf "test/fixtures/reservation.keiro"+            let new =+                    old+                        { specNodes =+                            [ case node of+                                NAggregate aggregate ->+                                    NAggregate+                                        aggregate+                                            { aggEvents =+                                                [ event+                                                | event <- aggEvents aggregate+                                                , evName event /= "TransferReservationConfirmed"+                                                ]+                                            }+                                _ -> node+                            | node <- specNodes old+                            ]+                        }+                removals = [change | change@(Breaking kind) <- diffSpecs old new, ckCode kind == EvtRemovedNotDeprecated]+            removals `shouldSatisfy` (not . null)+            [ckDetail kind | Breaking kind <- removals]+                `shouldSatisfy` all (not . T.isInfixOf "so old payloads still decode")+        it "prints a paste-ready replay-only twin when a guard tightens (plan 143)" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-guard-tightened.keiro"+            any isBreaking cs `shouldBe` False+            let advisories = [k | Advisory k <- cs, ckCode k == AggGuardTightened]+            map ckSubject advisories `shouldBe` ["Unrequested -- RequestTransferReservation"]+            detail <- case advisories of+                [k] -> pure (ckDetail k)+                other -> expectationFailure ("expected one advisory, got " <> show other) >> pure ""+            detail `shouldSatisfy` T.isInfixOf "replay-only Unrequested -- RequestTransferReservation"+            -- The printed twin is paste-ready: appended to the new spec it+            -- parses, validates without errors, and silences the advisory.+            tightened <- readTestText "test/fixtures/reservation-guard-tightened.keiro"+            let twinText = snd (T.breakOnEnd "\n\n" detail)+                pasted = tightened <> "\n" <> twinText <> "\n"+            case parseSpec "<pasted-twin>" pasted of+                Left err -> expectationFailure (T.unpack err)+                Right pastedSpec -> do+                    [code d | d <- validateSpec pastedSpec, severity d == Error] `shouldBe` []+                    base <- specOf "test/fixtures/reservation.keiro"+                    [k | Advisory k <- diffSpecs base pastedSpec, ckCode k == AggGuardTightened]+                        `shouldBe` []+        it "omits the twin advisory when the twin is already present (plan 143)" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-guard-tightened-twin.keiro"+            [k | Advisory k <- cs, ckCode k == AggGuardTightened] `shouldBe` []+        it "classifies a removed contract event as ContractEventRemoved" $ do+            cs <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-eventdrop.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [ContractEventRemoved]+        it "classifies contract field type changes and unversioned additions as ContractFieldChanged" $ do+            changed <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-fieldtype.keiro"+            [ckCode k | Breaking k <- changed] `shouldContain` [ContractFieldChanged]+            added <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-fieldadd.keiro"+            [ckCode k | Breaking k <- added] `shouldContain` [ContractFieldChanged]+        it "reports a field addition with a contract version bump as an advisory" $ do+            cs <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-bump-fieldadd.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldContain` [ContractSchemaVersionBumped]+        it "classifies a contract schema version decrease separately" $ do+            cs <- diffFixtures "test/fixtures/contract-bump-fieldadd.keiro" "test/fixtures/contract.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [ContractSchemaVersionDecreased]+        it "classifies contract topic and discriminator changes separately" $ do+            topic <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-topic.keiro"+            [ckCode k | Breaking k <- topic] `shouldContain` [ContractTopicChanged]+            discriminator <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-discriminator.keiro"+            [ckCode k | Breaking k <- discriminator] `shouldContain` [ContractDiscriminatorChanged]+        it "classifies a new contract event as additive" $ do+            cs <- diffFixtures "test/fixtures/contract.keiro" "test/fixtures/contract-eventadd.keiro"+            any isBreaking cs `shouldBe` False+            [ckSubject k | Additive k <- cs] `shouldContain` ["IncidentTransferNeedCancelled"]+        it "classifies workqueue wire names, types, and required additions as WqPayloadFieldChanged" $ do+            wire <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-wirename.keiro"+            [ckCode k | Breaking k <- wire] `shouldContain` [WqPayloadFieldChanged]+            fieldTypeChange <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-fieldtype.keiro"+            [ckCode k | Breaking k <- fieldTypeChange] `shouldContain` [WqPayloadFieldChanged]+            required <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-reqfield.keiro"+            [ckCode k | Breaking k <- required] `shouldContain` [WqPayloadFieldChanged]+        it "classifies a new optional workqueue payload field as additive" $ do+            cs <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-optfield.keiro"+            any isBreaking cs `shouldBe` False+            [ckSubject k | Additive k <- cs] `shouldContain` ["note"]+        it "classifies workqueue ordering changes as breaking delivery-contract changes" $ do+            cs <- diffFixtures "test/fixtures/workqueue-policy-base.keiro" "test/fixtures/workqueue-ordering-change.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [WqOrderingChanged]+            [ckDetail k | Breaking k <- cs, ckCode k == WqOrderingChanged]+                `shouldSatisfy` any (T.isInfixOf "delivery-order contract")+        it "classifies workqueue provision changes as operational migrations" $ do+            cs <- diffFixtures "test/fixtures/workqueue-policy-base.keiro" "test/fixtures/workqueue-provision-change.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [WqProvisionChanged]+            [ckDetail k | Breaking k <- cs, ckCode k == WqProvisionChanged]+                `shouldSatisfy` any (T.isInfixOf "migrate the existing queue operationally")+        it "classifies workqueue group-key changes as breaking repartitioning" $ do+            cs <- diffFixtures "test/fixtures/workqueue-policy-base.keiro" "test/fixtures/workqueue-group-key-change.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [WqGroupKeyChanged]+            [ckDetail k | Breaking k <- cs, ckCode k == WqGroupKeyChanged]+                `shouldSatisfy` any (T.isInfixOf "re-partitioned")+        it "classifies a process input type change as ProcessInputChanged" $ do+            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-inputtype.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [ProcessInputChanged]+        it "classifies workflow input and output changes as WorkflowShapeChanged" $ do+            input <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-inputfield.keiro"+            [ckCode k | Breaking k <- input] `shouldContain` [WorkflowShapeChanged]+            output <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-output.keiro"+            [ckCode k | Breaking k <- output] `shouldContain` [WorkflowShapeChanged]+        it "classifies workflow relabeling and appends as WorkflowBodyChanged" $ do+            relabeled <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-body.keiro"+            [ckCode k | Breaking k <- relabeled] `shouldContain` [WorkflowBodyChanged]+            appended <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-stepadd.keiro"+            [ckCode k | Breaking k <- appended] `shouldContain` [WorkflowBodyChanged]+            [ckDetail k | Breaking k <- appended, ckCode k == WorkflowBodyChanged]+                `shouldSatisfy` any (T.isInfixOf "new patch guard")+        it "classifies a body addition wholly guarded by a new patch as additive" $ do+            cs <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-evolution-diff.keiro"+            any isBreaking cs `shouldBe` False+            [ckSubject k | Additive k <- cs, ckFacet k == "workflow-patch"] `shouldContain` ["fraud-check-v2"]+            [ckSubject k | Additive k <- cs, ckFacet k == "workflow-continue-as-new"] `shouldContain` ["RolloverSeed"]+        it "classifies removing an existing patch as breaking" $ do+            cs <- diffFixtures "test/fixtures/workflow-evolution-diff.keiro" "test/fixtures/workflow-continue.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [WorkflowPatchRemoved]+            [ckDetail k | Breaking k <- cs, ckCode k == WorkflowPatchRemoved]+                `shouldSatisfy` any (T.isInfixOf "cannot prove")+        it "classifies terminal continueAsNew append as additive and seed drift as breaking" $ do+            appended <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-continue.keiro"+            any isBreaking appended `shouldBe` False+            [ckFacet k | Additive k <- appended] `shouldContain` ["workflow-continue-as-new"]+            changed <- diffFixtures "test/fixtures/workflow-continue.keiro" "test/fixtures/workflow-continue-seed-v2.keiro"+            [ckCode k | Breaking k <- changed] `shouldContain` [WorkflowContinueSeedChanged]+            [ckDetail k | Breaking k <- changed, ckCode k == WorkflowContinueSeedChanged]+                `shouldSatisfy` any (T.isInfixOf "restoreSeed")+        it "classifies a workflow stable-name change as WorkflowStableNameChanged" $ do+            cs <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-rename.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [WorkflowStableNameChanged]+        it "classifies workflow id-derivation changes as DerivedIdentityChanged" $ do+            cs <- diffFixtures "test/fixtures/workflow.keiro" "test/fixtures/workflow-idfield.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [DerivedIdentityChanged]+        it "classifies an id prefix change as IdPrefixChanged" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-idprefix.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [IdPrefixChanged]+        it "classifies intake dedupe key and policy changes as DedupeIdentityChanged" $ do+            policy <- diffFixtures "test/fixtures/intake.keiro" "test/fixtures/intake-dedupepolicy.keiro"+            [ckCode k | Breaking k <- policy] `shouldContain` [DedupeIdentityChanged]+            key <- diffFixtures "test/fixtures/intake.keiro" "test/fixtures/intake-dedupekey.keiro"+            [ckCode k | Breaking k <- key] `shouldContain` [DedupeIdentityChanged]+        it "reports intake decode-posture changes as warnings" $ do+            cs <- diffFixtures "test/fixtures/intake.keiro" "test/fixtures/intake-decode.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldContain` [DecodePostureChanged]+            [ckCode k | Advisory k <- cs] `shouldContain` [IntakePersistenceChanged]+        it "classifies process and timer derivation changes as DerivedIdentityChanged" $ do+            processName <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-procname.keiro"+            [ckCode k | Breaking k <- processName] `shouldContain` [DerivedIdentityChanged]+            timerId <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-timerid.keiro"+            [ckCode k | Breaking k <- timerId] `shouldContain` [DerivedIdentityChanged]+            base <- specOf "test/fixtures/hospital-surge.keiro"+            let categoryChange = diffSpecs base (modifyProcess "HospitalSurge" (\process -> process{procSaga = (procSaga process){sagaCategory = "hospitalSurgeV2"}}) base)+            [ckCode k | Breaking k <- categoryChange] `shouldContain` [DerivedIdentityChanged]+        it "classifies router stable names, keys, and targets as identity-bearing" $ do+            base <- specOf "test/fixtures/incident-paging/incident-paging.keiro"+            let stableName = diffSpecs base (modifyRouter "PagingRouter" (\router -> router{rtName = "paging-v2"}) base)+                keyDerivation = diffSpecs base (modifyRouter "PagingRouter" (\router -> router{rtKey = (rtKey router){corrVia = "otherIdText"}}) base)+                target = diffSpecs base (modifyRouter "PagingRouter" (\router -> router{rtTarget = "OtherPage"}) base)+            [ckCode k | Breaking k <- stableName] `shouldContain` [RouterStableNameChanged]+            [ckCode k | Breaking k <- keyDerivation] `shouldContain` [DerivedIdentityChanged]+            [ckCode k | Breaking k <- target] `shouldContain` [DerivedIdentityChanged]+        it "advises on router dispatch-surface changes without making them breaking" $ do+            cs <- diffFixtures "test/fixtures/incident-paging/incident-paging.keiro" "test/fixtures/incident-paging/incident-paging-dispatch.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldBe` [RouterDecideSurfaceChanged]+        it "advises on process dispatch-surface changes without making them breaking" $ do+            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-handle.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldBe` [ProcessDecideSurfaceChanged]+        it "advises on unversioned timer payload changes without making them breaking" $ do+            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-payload.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldBe` [ProcessTimerPayloadChanged]+        it "ignores formatting-only process and timer surface rewrites" $ do+            original <- specOf "test/fixtures/hospital-surge.keiro"+            formatted <- parseInlineSpec "<formatted-process>" (renderSpec original)+            diffSpecs original formatted `shouldBe` []+        it "reports a timer window change as a warning" $ do+            cs <- diffFixtures "test/fixtures/hospital-surge.keiro" "test/fixtures/hospital-surge-window.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldContain` [TimerWindowChanged]+        it "reports emit-map changes as warnings and derive changes as breaking" $ do+            mapping <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-mapchange.keiro"+            any isBreaking mapping `shouldBe` False+            [ckCode k | Advisory k <- mapping] `shouldContain` [EmitMappingChanged]+            derive <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-derive.keiro"+            [ckCode k | Breaking k <- derive] `shouldContain` [DerivedIdentityChanged]+        it "classifies publisher outbox identity and ordering independently" $ do+            outbox <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-outboxfield.keiro"+            [ckCode k | Breaking k <- outbox] `shouldContain` [DerivedIdentityChanged]+            ordering <- diffFixtures "test/fixtures/emit.keiro" "test/fixtures/emit-ordering.keiro"+            any isBreaking ordering `shouldBe` False+            [ckCode k | Advisory k <- ordering] `shouldContain` [PublisherPolicyChanged]+        it "classifies workqueue names as QueueIdentityChanged" $ do+            cs <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-rename.keiro"+            [ckCode k | Breaking k <- cs] `shouldContain` [QueueIdentityChanged]+        it "classifies pgmq dispatch dedupe and retargeting independently" $ do+            dedupe <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-dedupkey.keiro"+            [ckCode k | Breaking k <- dedupe] `shouldContain` [DedupeIdentityChanged]+            retarget <- diffFixtures "test/fixtures/reservation-work.keiro" "test/fixtures/reservation-work-retarget.keiro"+            any isBreaking retarget `shouldBe` False+            [ckCode k | Advisory k <- retarget] `shouldContain` [DispatchRetargeted]+        it "reports aggregate projection changes as warnings" $ do+            cs <- diffFixtures "test/fixtures/reservation.keiro" "test/fixtures/reservation-projection.keiro"+            any isBreaking cs `shouldBe` False+            [ckCode k | Advisory k <- cs] `shouldContain` [ProjectionChanged]+        it "classifies read-model version and unversioned shape changes" $ do+            base <- specOf "test/fixtures/readmodel-runtime.keiro"+            let versionTwo = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmVersion = 2}) base+                changedShape = modifyReadModel "transfer_decisions" changeReadModelShape base+                bumpedShape = modifyReadModel "transfer_decisions" (\readModel -> (changeReadModelShape readModel){rmVersion = 2}) base+                decreased = diffSpecs versionTwo base+                unversioned = diffSpecs base changedShape+                bumped = diffSpecs base bumpedShape+            [ckCode k | Breaking k <- decreased] `shouldContain` [ReadModelVersionDecreased]+            [ckCode k | Breaking k <- unversioned] `shouldContain` [ReadModelShapeChangedWithoutBump]+            any isBreaking bumped `shouldBe` False+            [ckFacet k | Additive k <- bumped] `shouldContain` ["read-model-version"]+        it "classifies read-model registry, table, subscription, and removal identities" $ do+            base <- specOf "test/fixtures/readmodel-runtime.keiro"+            let tableChanged = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmTable = "transfer_decisions_v2"}) base+                subscriptionChanged = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmSubscription = Just "transfer-decisions-v2"}) base+                renamed = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmName = "reservation_decisions"}) base+                removed = removeReadModel "transfer_decisions" base+            mapM_+                (\changes -> [ckCode k | Breaking k <- changes] `shouldContain` [DerivedIdentityChanged])+                [diffSpecs base tableChanged, diffSpecs base subscriptionChanged, diffSpecs base renamed, diffSpecs base removed]+        it "classifies read-model feed flips and consistency/scope weakening as breaking" $ do+            base <- specOf "test/fixtures/readmodel-runtime.keiro"+            let feedChanged = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmFeed = RmInline}) base+                consistencyWeakened = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmConsistency = Eventual}) base+                entireLog = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmScope = Just RmEntireLog}) base+            [ckCode k | Breaking k <- diffSpecs base feedChanged] `shouldContain` [ReadModelFeedChanged]+            [ckCode k | Breaking k <- diffSpecs base consistencyWeakened] `shouldContain` [ReadModelConsistencyWeakened]+            [ckCode k | Breaking k <- diffSpecs entireLog base] `shouldContain` [ReadModelConsistencyWeakened]+        it "classifies Eventual to Strong read-model consistency as additive" $ do+            strong <- specOf "test/fixtures/readmodel-runtime.keiro"+            let eventual = modifyReadModel "transfer_decisions" (\readModel -> readModel{rmConsistency = Eventual}) strong+                changes = diffSpecs eventual strong+            any isBreaking changes `shouldBe` False+            [ckFacet k | Additive k <- changes] `shouldContain` ["read-model-consistency"]++    describe "module placement (M1)" $ do+        it "GeneratedPrefix is today's namespace (Generated.<Ctx>.<Node>, holes at <Ctx>.<Node>)" $ do+            let ctx = defaultContext "hospital-capacity"+            genPrefixFor ctx "Reservation" `shouldBe` "Generated.HospitalCapacity.Reservation"+            holePrefixFor ctx "Reservation" `shouldBe` "HospitalCapacity.Reservation"+        it "module-root prefixes both layers" $ do+            let ctx = (defaultContext "hospital-capacity"){moduleRoot = "Acme"}+            genPrefixFor ctx "Reservation" `shouldBe` "Acme.Generated.HospitalCapacity.Reservation"+            holePrefixFor ctx "Reservation" `shouldBe` "Acme.HospitalCapacity.Reservation"+        it "CollocatedLeaf places the generated layer under the domain leaf" $ do+            let ctx = (defaultContext "hospital-capacity"){moduleRoot = "Acme", placement = CollocatedLeaf}+            genPrefixFor ctx "Reservation" `shouldBe` "Acme.HospitalCapacity.Reservation.Generated"+            holePrefixFor ctx "Reservation" `shouldBe` "Acme.HospitalCapacity.Reservation"+        it "parses and preserves the module/layout clauses through parse . pretty" $ do+            let src = "context hospital-capacity\nmodule Acme.Services\nlayout collocated\n\naggregate Reservation\n  regs\n  states Open\n"+            case parseSpec "<m1>" src of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> do+                    specModuleRoot spec `shouldBe` Just "Acme.Services"+                    specLayout spec `shouldBe` Just CollocatedLeaf+                    parseSpec "<m1>" (renderSpec spec) `shouldBe` Right spec+        it "a spec without the clauses leaves placement at the default" $ do+            input <- readTestText "test/fixtures/reservation.keiro"+            case parseSpec "test/fixtures/reservation.keiro" input of+                Left err -> expectationFailure (T.unpack err)+                Right spec -> do+                    specModuleRoot spec `shouldBe` Nothing+                    specLayout spec `shouldBe` Nothing++    describe "structural scaffold" $ do+        it "emits one private shape module per structural declaration and one context facade" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let modules = scaffoldModules (defaultContext (specContext spec)) spec+                paths = map modulePath modules+            paths+                `shouldContain` [ "Generated/ConsumerDemo/Structural/Shape/ArtifactInfo.hs"+                                , "Generated/ConsumerDemo/Structural/Shape/ArtifactKind.hs"+                                , "Generated/ConsumerDemo/Structural/Shape/ArtifactLocation.hs"+                                , "Generated/ConsumerDemo/StructuralProjections.hs"+                                ]+            paths `shouldNotContain` ["Generated/ConsumerDemo/Structural/Shape/VendorGeometry.hs"]+            firewallBreaches modules `shouldBe` []+        it "emits one create-once binding skeleton per owning module and derives Generic for private shapes" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let modules = scaffoldModules (defaultContext (specContext spec)) spec+                skeletons = [moduleValue | moduleValue <- modules, kind moduleValue == HoleStub, modulePath moduleValue == "Example/Artifact/KeiroBindings.hs"]+                shape = generatedTextEndingIn "Structural/Shape/ArtifactInfo.hs" modules+            case skeletons of+                [skeleton] -> do+                    moduleText skeleton `shouldSatisfy` T.isInfixOf "artifactInfoBinding :: StructuralBinding"+                    moduleText skeleton `shouldSatisfy` T.isInfixOf "artifactKindBinding :: StructuralBinding"+                    moduleText skeleton `shouldSatisfy` T.isInfixOf "artifactLocationBinding :: StructuralBinding"+                    moduleText skeleton `shouldSatisfy` T.isInfixOf "HOLE: fill ArtifactInfo bindingToShape.key"+                _ -> expectationFailure ("expected exactly one shared binding skeleton, got " <> show (map modulePath skeletons))+            shape `shouldSatisfy` T.isInfixOf "deriving stock (Eq, Generic, Show)"+            shape `shouldSatisfy` T.isInfixOf "import GHC.Generics (Generic)"+        it "never overwrites an existing binding skeleton" $+            withTempDirectory "keiro-dsl-binding-create-once" $ \out -> do+                spec <- specOf "test/fixtures/consumer-types.keiro"+                let ctx = defaultContext (specContext spec)+                    bindingPath = out </> "Example/Artifact/KeiroBindings.hs"+                _ <- executePlannedScaffold out "consumer-types.keiro" ctx spec+                TIO.writeFile bindingPath "hand-owned binding\n"+                second <- executePlannedScaffold out "consumer-types.keiro" ctx spec+                TIO.readFile bindingPath `shouldReturn` "hand-owned binding\n"+                reportDispositions second+                    `shouldSatisfy` any (\(moduleValue, disposition) -> modulePath moduleValue == "Example/Artifact/KeiroBindings.hs" && disposition == Skipped)+        it "fresh binding skeletons compile at the application boundary" $+            withTempDirectory "keiro-dsl-binding-compiles" $ \out -> do+                spec <- specOf "test/fixtures/structural-conformance.keiro"+                let ctx = defaultContext (specContext spec)+                    bindingSource = out </> "Conformance/Structural/Bindings.hs"+                    ghcOutput = out </> ".ghc"+                _ <- executePlannedScaffold out "structural-conformance.keiro" ctx spec+                createDirectoryIfMissing True ghcOutput+                (exitCode, standardOutput, standardError) <-+                    readProcessWithExitCode+                        "cabal"+                        [ "exec"+                        , "--"+                        , "ghc"+                        , "-XGHC2024"+                        , "-XOverloadedStrings"+                        , "-fno-code"+                        , "-fforce-recomp"+                        , "-outputdir"+                        , ghcOutput+                        , "-i" <> out+                        , "-itest/conformance-structural"+                        , "-i../keiro-core/src"+                        , bindingSource+                        ]+                        ""+                unless (exitCode == ExitSuccess) $+                    expectationFailure (standardOutput <> standardError)+        it "keeps consumer types in Domain while the generated Codec owns keys, tags, and defaults" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let modules = scaffoldModules (defaultContext (specContext spec)) spec+                domain = generatedTextEndingIn "Catalog/Domain.hs" modules+                codec = generatedTextEndingIn "Catalog/Codec.hs" modules+            domain `shouldSatisfy` T.isInfixOf "Example.Artifact.Domain.ArtifactInfo"+            domain `shouldSatisfy` T.isInfixOf "Vendor.Geometry.Geometry"+            domain `shouldSatisfy` T.isInfixOf "Example.Artifact.KeiroBindings.emptyArtifactInfo"+            codec `shouldSatisfy` T.isInfixOf "\"location\" .= encodeArtifactLocationShape"+            codec `shouldSatisfy` T.isInfixOf "\"local_file\""+            codec `shouldSatisfy` T.isInfixOf "Nothing -> pure Generated.ConsumerDemo.Structural.Shape.ArtifactKind.Guide"+            codec `shouldSatisfy` T.isInfixOf "rejectUnknownFields \"ArtifactInfo\""+            codec `shouldSatisfy` T.isInfixOf "toJSON payload.geometry"+            codec `shouldSatisfy` (not . T.isInfixOf "vendor.geometry.json")+        it "generates shape-only nested types and schema-derived Keiki witnesses" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let modules = scaffoldModules (defaultContext (specContext spec)) spec+                shape = generatedTextEndingIn "Structural/Shape/ArtifactInfo.hs" modules+                facade = generatedTextEndingIn "StructuralProjections.hs" modules+            shape `shouldSatisfy` T.isInfixOf "data ArtifactInfoShape = ArtifactInfo"+            shape `shouldSatisfy` T.isInfixOf "ArtifactKind.ArtifactKindShape"+            shape `shouldSatisfy` (not . T.isInfixOf "KeiroBindings")+            facade `shouldSatisfy` T.isInfixOf "type FieldName"+            facade `shouldSatisfy` T.isInfixOf "= \"/key\""+            facade `shouldSatisfy` T.isInfixOf "fieldShapeId _ = \"example.artifact.ArtifactInfo.v1\""+            facade `shouldSatisfy` T.isInfixOf "bindingToShape Example.Artifact.KeiroBindings.artifactInfoBinding owner"++    describe "structural manifest" $ do+        it "lists consumer packages and every domain, binding, fixture, and initial module" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let modules = scaffoldModules (defaultContext (specContext spec)) spec+                manifest = renderManifest "consumer-types.keiro" modules spec+            mapM_ (\packageName -> manifestDependencies spec `shouldContain` [packageName]) ["artifact-domain", "vendor-geometry"]+            manifest `shouldSatisfy` T.isInfixOf "consumer-packages:\n    artifact-domain\n    vendor-geometry"+            mapM_+                (\moduleName -> manifest `shouldSatisfy` T.isInfixOf moduleName)+                [ "Example.Artifact.Domain"+                , "Example.Artifact.KeiroBindings"+                , "Vendor.Geometry"+                , "Vendor.Geometry.KeiroBindings"+                ]++    describe "structural scaffold record" $ do+        it "round-trips canonical mapping rows and reports binding drift on the next run" $+            withTempDirectory "keiro-dsl-mapping-record" $ \out -> do+                spec <- specOf "test/fixtures/consumer-types.keiro"+                let ctx = defaultContext (specContext spec)+                first <- executePlannedScaffold out "consumer-types.keiro" ctx spec+                length (consumerMappings (reportConsumerPlan first)) `shouldBe` 4+                recordText <- TIO.readFile (out </> recordFileName (specContext spec))+                let mappingRows = filter (T.isPrefixOf "mapping ") (T.lines recordText)+                    bindingRows = filter (T.isPrefixOf "binding ") (T.lines recordText)+                length mappingRows `shouldBe` 4+                bindingRows `shouldSatisfy` (not . null)+                fmap recMappings (parseRecord recordText) `shouldSatisfy` maybe False ((== 4) . length)+                fmap recBindingObligations (parseRecord recordText) `shouldSatisfy` maybe False ((== length bindingRows) . length)+                let bumped = spec{specMapped = map bumpArtifactBindingVersion (specMapped spec)}+                second <- executePlannedScaffold out "consumer-types.keiro" ctx bumped+                reportMappingDrift second+                    `shouldSatisfy` any (\drift -> driftSpecName drift == "ArtifactInfo" && driftPrevious drift /= driftCurrent drift)+                renderScaffoldReport second `shouldSatisfy` any (T.isInfixOf "mapping drift:")+                case mappingRows of+                    row : _ -> parseRecord (recordText <> row <> "\n") `shouldBe` Nothing+                    [] -> expectationFailure "expected mapping rows"+                case bindingRows of+                    row : _ -> parseRecord (recordText <> row <> "\n") `shouldBe` Nothing+                    [] -> expectationFailure "expected binding rows"+        it "reports exactly the newly added binding field without rewriting the shared skeleton" $+            withTempDirectory "keiro-dsl-binding-drift" $ \out -> do+                spec <- specOf "test/fixtures/consumer-types.keiro"+                let ctx = defaultContext (specContext spec)+                _ <- executePlannedScaffold out "consumer-types.keiro" ctx spec+                let extended = spec{specMapped = map addArtifactSummaryField (specMapped spec)}+                second <- executePlannedScaffold out "consumer-types.keiro" ctx extended+                reportNewHoles second+                    `shouldBe` [ BindingHole+                                    { holeMappedName = "ArtifactInfo"+                                    , holeModule = "Example.Artifact.KeiroBindings"+                                    , holeSymbol = "artifactInfoBinding"+                                    , holeKind = BindingValue+                                    , holePath = Just "summary"+                                    , holeSignature = "artifactInfoBinding.summary :: Text"+                                    }+                               ]+                renderScaffoldReport second `shouldSatisfy` any (T.isInfixOf "artifactInfoBinding.summary :: Text")+        it "rejects malformed known mapping JSON while ignoring unrelated future rows" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            withTempDirectory "keiro-dsl-mapping-malformed" $ \out -> do+                report <- executePlannedScaffold out "consumer-types.keiro" (defaultContext (specContext spec)) spec+                recordText <- TIO.readFile (reportRecordPath report)+                parseRecord (recordText <> "mapping {not-json}\n") `shouldBe` Nothing+                parseRecord (recordText <> "future-row retained\n") `shouldBe` parseRecord recordText++    describe "structural import plan" $ do+        it "reports the successful dependency plan in the scaffold report" $+            withTempDirectory "keiro-dsl-dependency-plan" $ \out -> do+                spec <- specOf "test/fixtures/consumer-types.keiro"+                report <- executePlannedScaffold out "consumer-types.keiro" (defaultContext (specContext spec)) spec+                renderScaffoldReport report+                    `shouldSatisfy` any (T.isInfixOf "dependency plan: consumer packages [artifact-domain, vendor-geometry]")+        it "refuses a binding module inside the generated namespace with the exact cycle" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let cyclic = spec{specMapped = map moveArtifactBindingIntoGenerated (specMapped spec)}+            case planScaffold (defaultContext (specContext cyclic)) cyclic of+                Left refusals -> do+                    refusals `shouldSatisfy` any isImportCycle+                    renderRefusals refusals `shouldSatisfy` any (T.isInfixOf "Generated.ConsumerDemo.Bindings")+                Right _ -> expectationFailure "expected an import-cycle refusal"+        it "refuses missing mapped register initials but permits command/event-only use" $ do+            missing <- specOf "test/fixtures/mapped-missing-initial.keiro"+            planScaffold (defaultContext (specContext missing)) missing `shouldSatisfy` isLoweringRefusal+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let commandOnly = removeMappedRegisterRequirements spec+            planScaffold (defaultContext (specContext commandOnly)) commandOnly `shouldSatisfy` isRight++    describe "binding explanations" $ do+        it "lists binding, fixture, and use-site-scoped initial obligations deterministically" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            obligations <- either (\errors -> expectationFailure (show errors) >> pure []) pure (bindingObligations spec)+            length obligations `shouldBe` 7+            obligations+                `shouldSatisfy` any+                    ( \obligation ->+                        obligationKind obligation == BindingValue+                            && obligationSymbol obligation == "artifactInfoBinding"+                            && obligationBindingVersion obligation == Just "1"+                    )+            obligations+                `shouldSatisfy` any+                    ( \obligation ->+                        obligationKind obligation == InitialValue+                            && obligationSymbol obligation == "emptyArtifactInfo"+                            && any (T.isInfixOf "Catalog register currentArtifact") (obligationUseSites obligation)+                    )+            let rendered = renderBindingObligations (specContext spec) obligations+            rendered `shouldSatisfy` T.isInfixOf "binding obligations for context consumer-demo"+            rendered `shouldSatisfy` T.isInfixOf "artifactInfoBinding :: StructuralBinding Example.Artifact.Domain.ArtifactInfo ArtifactInfoShape"+            rendered `shouldSatisfy` T.isInfixOf "provenance: binding-version \"1\""+        it "states explicitly when a spec has no structural obligations" $ do+            spec <- specOf "test/fixtures/reservation.keiro"+            obligations <- either (\errors -> expectationFailure (show errors) >> pure []) pure (bindingObligations spec)+            renderBindingObligations (specContext spec) obligations+                `shouldBe` "no binding obligations for context hospital-capacity"++    describe "exact generic structural bindings" $ do+        forM_+            [ ("renamed-field", "selector mismatch")+            , ("reordered-field", "selector mismatch")+            , ("arity-mismatch", "no exact nominal correspondence")+            , ("incompatible-type", "no exact nominal correspondence")+            ]+            $ \(fixture, diagnostic) ->+                it ("rejects " <> fixture <> " and directs the author to the scaffolded module") $+                    expectGenericCompileFailure fixture diagnostic++    describe "structural harness" $ do+        it "emits every structural, wire-policy, projection, and replay assertion family" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let aggregate = onlyAggregate spec+                ctx = defaultContext (specContext spec)+                harness = generatedTextEndingIn "Harness.hs" (harnessFor ctx spec aggregate)+            mapM_+                (\needle -> harness `shouldSatisfy` T.isInfixOf needle)+                [ "binding domain round-trip: example.artifact.ArtifactInfo.v1/"+                , "binding shape round-trip: example.artifact.ArtifactInfo.v1/"+                , "mapped codec round-trip: ArtifactObserved/artifact/"+                , "fixture coverage: example.artifact.ArtifactLocation.v1"+                , "wire policy missing default: example.artifact.ArtifactInfo.v1/description"+                , "wire policy explicit null: example.artifact.ArtifactInfo.v1/description"+                , "wire policy unknown fields: example.artifact.ArtifactInfo.v1"+                , "wire union arm: example.artifact.ArtifactLocation.v1/local_file"+                , "canonical identity: example.artifact.ArtifactInfo.v1"+                , "projection witness agreement: example.artifact.ArtifactInfo.v1/key"+                , "forward/replay equality: ObserveArtifact from CatalogEmpty -- "+                , "register currentArtifact"+                ]+        it "keeps opaque assertions at the declared codec boundary" $ do+            spec <- specOf "test/fixtures/consumer-types.keiro"+            let aggregate = onlyAggregate spec+                ctx = defaultContext (specContext spec)+                modules = scaffoldAggregate ctx spec aggregate <> harnessFor ctx spec aggregate+                harness = generatedTextEndingIn "Harness.hs" modules+                codec = generatedTextEndingIn "Codec.hs" modules+            harness `shouldSatisfy` T.isInfixOf "opaque codec round-trip: vendor.geometry.json@3/"+            harness `shouldNotSatisfy` T.isInfixOf "wire policy unknown fields: vendor.geometry.json"+            harness `shouldNotSatisfy` T.isInfixOf "fixture coverage: vendor.geometry"+            codec `shouldNotSatisfy` T.isInfixOf "encodeVendorGeometryShape"++    describe "manifest (M2)" $ do+        it "lists exactly the modules the scaffolder produced" $ do+            mods <- scaffoldFixture "test/fixtures/reservation.keiro"+            spec <- specOf "test/fixtures/reservation.keiro"+            let manifest = renderManifest "reservation.keiro" mods spec+                expectedNames = sort (map (moduleNameOf . modulePath) mods)+            -- every produced module name appears in the manifest…+            mapM_ (\m -> (m `T.isInfixOf` manifest) `shouldBe` True) expectedNames+            -- …and the module list is exactly the scaffolder's output set.+            expectedNames+                `shouldBe` sort+                    [ "Generated.HospitalCapacity.Reservation.Codec"+                    , "Generated.HospitalCapacity.Reservation.Domain"+                    , "Generated.HospitalCapacity.Reservation.EventStream"+                    , "Generated.HospitalCapacity.Reservation.Harness"+                    , "Generated.HospitalCapacity.Reservation.Projection"+                    , "HospitalCapacity.Reservation.Holes"+                    ]+        it "derives the dependency set from the node kinds present (aggregate)" $ do+            spec <- specOf "test/fixtures/reservation.keiro"+            manifestDependencies spec `shouldBe` ["aeson", "base", "keiki", "keiro", "text"]+        it "derives the process dependency set, including worker-policy runtime imports" $ do+            spec <- specOf "test/fixtures/hospital-surge.keiro"+            let dependencies = manifestDependencies spec+            mapM_ (\dependency -> dependencies `shouldContain` [dependency]) ["time", "uuid", "shibuya-core", "keiki", "keiro"]+        it "uses the registered shibuya-core package name for router scaffolds" $ do+            spec <- specOf "test/fixtures/incident-paging/incident-paging.keiro"+            let dependencies = manifestDependencies spec+            mapM_ (\dependency -> dependencies `shouldContain` [dependency]) ["effectful-core", "keiro", "shibuya-core"]+            dependencies `shouldNotContain` ["shibuya"]++    describe "new <kind> skeletons (M5)" $ do+        it "every skeleton parses and validates with zero error diagnostics" $+            mapM_ assertSkeletonValid skeletonKinds+        it "every skeleton passes the scaffold refusal gates" $+            mapM_ assertSkeletonScaffoldable skeletonKinds+        it "fresh skeleton scaffolds match the committed compiling modules" $+            mapM_ (uncurry assertSkeletonMatchesCommitted) skeletonModuleRoots+        it "rejects an unknown kind with a helpful message" $+            case skeletonFor "bogus" of+                Left msg -> ("Valid kinds:" `T.isInfixOf` msg) `shouldBe` True+                Right _ -> expectationFailure "expected an error for an unknown kind"++    describe "firewall self-check (M3)" $ do+        it "flags a forbidden operator in a Generated module" $ do+            let m = ScaffoldModule{modulePath = "Gen/Foo.hs", moduleText = "x = a ./= b", kind = Generated, origin = "test"}+            firewallBreaches [m] `shouldBe` [("Gen/Foo.hs", "./=", 1)]+        it "ignores forbidden operators in a HoleStub module (holes own them)" $ do+            let m = ScaffoldModule{modulePath = "Foo/Holes.hs", moduleText = "x = lit 1 .== y", kind = HoleStub, origin = "test"}+            firewallBreaches [m] `shouldBe` []+        it "matches `lit` as a word, not a substring of quality/split" $ do+            let clean = ScaffoldModule{modulePath = "Gen/Q.hs", moduleText = "quality = split facility", kind = Generated, origin = "test"}+                dirty = ScaffoldModule{modulePath = "Gen/L.hs", moduleText = "v = lit foo", kind = Generated, origin = "test"}+            firewallBreaches [clean] `shouldBe` []+            firewallBreaches [dirty] `shouldBe` [("Gen/L.hs", "lit", 1)]+        it "skips strings and comments and maximal-munches symbolic tokens" $ do+            let clean = syntheticGenerated "Gen/Clean.hs" "wire = \"lit .== B.slot\"\n-- x =: y\nx = a .<= b"+                dirty = syntheticGenerated "Gen/Dirty.hs" "x = a .< b\ny = c =: d"+            firewallBreaches [clean] `shouldBe` [("Gen/Clean.hs", ".<=", 3)]+            firewallBreaches [dirty] `shouldBe` [("Gen/Dirty.hs", ".<", 1), ("Gen/Dirty.hs", "=:", 2)]+        it "guards keiki imports while allowing the generated Core allowlist" $ do+            let forbidden = syntheticGenerated "Gen/Builder.hs" "import Keiki.Builder"+                restricted = syntheticGenerated "Gen/CoreBad.hs" "import Keiki.Core (lit)"+                allowed = syntheticGenerated "Gen/CoreGood.hs" "import Keiki.Core (RegFile (..), HsPred, step)"+            firewallBreaches [forbidden] `shouldBe` [("Gen/Builder.hs", "import:Keiki.Builder", 1)]+            firewallBreaches [restricted] `shouldBe` [("Gen/CoreBad.hs", "import:Keiki.Core", 1)]+            firewallBreaches [allowed] `shouldBe` []+        it "finds no breach in real scaffolder output (aggregate + process fixtures)" $ do+            aggMods <- scaffoldFixture "test/fixtures/reservation.keiro"+            procMods <- scaffoldProcessFixture "test/fixtures/hospital-surge.keiro"+            firewallBreaches (aggMods <> procMods) `shouldBe` []++    describe "scaffold gates" $ do+        it "refuses duplicate and case-folded module paths with both origins" $ do+            spec <- specOf "test/fixtures/reservation.keiro"+            case [aggregate | NAggregate aggregate <- specNodes spec] of+                aggregate : _ -> do+                    let duplicate = spec{specNodes = [NAggregate aggregate, NAggregate aggregate]}+                        caseVariant = spec{specNodes = [NAggregate aggregate, NAggregate aggregate{aggName = T.toUpper (aggName aggregate)}]}+                    planScaffold (defaultContext (specContext spec)) duplicate `shouldSatisfy` hasPathCollisionWithTwoOrigins+                    planScaffold (defaultContext (specContext spec)) caseVariant `shouldSatisfy` hasPathCollisionWithTwoOrigins+                [] -> expectationFailure "reservation fixture has no aggregate"+        it "refuses a bannerless Generated target without changing its bytes" $+            withTempDirectory "keiro-dsl-banner" $ \out -> do+                spec <- specOf "test/fixtures/reservation.keiro"+                let ctx = defaultContext (specContext spec)+                case planScaffold ctx spec of+                    Left refusals -> expectationFailure ("unexpected planning refusal: " <> show refusals)+                    Right modules -> case [m | m <- modules, kind m == Generated] of+                        generated : _ -> do+                            let target = out </> modulePath generated+                            createDirectoryIfMissing True (takeDirectory target)+                            TIO.writeFile target "hand owned\n"+                            result <- executeScaffold out False "test/fixtures/reservation.keiro" ctx spec modules+                            result `shouldSatisfy` isMissingBannerRefusal+                            TIO.readFile target `shouldReturn` "hand owned\n"+                            forced <- executeScaffold out True "test/fixtures/reservation.keiro" ctx spec modules+                            forced `shouldSatisfy` isSuccessfulScaffold+                            TIO.readFile target `shouldReturn` moduleText generated+                        [] -> expectationFailure "reservation scaffold has no Generated module"+        it "reports renamed-node modules as stale without deleting them" $+            withTempDirectory "keiro-dsl-stale-rename" $ \out -> do+                spec <- parseInlineSpec "<stale-rename>" loweringAggregateSpec+                first <- executePlannedScaffold out "counter.keiro" (defaultContext (specContext spec)) spec+                let renamed = spec{specNodes = map renameCounter (specNodes spec)}+                second <- executePlannedScaffold out "counter.keiro" (defaultContext (specContext renamed)) renamed+                let oldDomain = onlyPathEndingIn "Counter/Domain.hs" (map fst (reportDispositions first))+                    oldHoles = onlyPathEndingIn "Counter/Holes.hs" (map fst (reportDispositions first))+                reportStale second `shouldSatisfy` \stale -> StaleModule Generated oldDomain `elem` stale && StaleModule HoleStub oldHoles `elem` stale+                doesFileExist (out </> oldDomain) `shouldReturn` True+                doesFileExist (out </> oldHoles) `shouldReturn` True+        it "reports the entire old tree across a module-root flip" $+            withTempDirectory "keiro-dsl-stale-root" $ \out -> do+                spec <- parseInlineSpec "<stale-root>" loweringAggregateSpec+                let initialCtx = defaultContext (specContext spec)+                    rootedCtx = initialCtx{moduleRoot = "Acme"}+                first <- executePlannedScaffold out "counter.keiro" initialCtx spec+                second <- executePlannedScaffold out "moved-counter.keiro" rootedCtx spec+                reportStale second+                    `shouldMatchList` [StaleModule (kind m) (modulePath m) | (m, _) <- reportDispositions first]+                forM_ (reportStale second) $ \stale -> doesFileExist (out </> stalePath stale) `shouldReturn` True+                renderScaffoldReport second `shouldSatisfy` any (T.isInfixOf "previous scaffold record used spec counter.keiro")+        it "reports moved generated modules across a layout flip" $+            withTempDirectory "keiro-dsl-stale-layout" $ \out -> do+                spec <- parseInlineSpec "<stale-layout>" loweringAggregateSpec+                let initialCtx = defaultContext (specContext spec)+                    collocatedCtx = initialCtx{placement = CollocatedLeaf}+                first <- executePlannedScaffold out "counter.keiro" initialCtx spec+                second <- executePlannedScaffold out "counter.keiro" collocatedCtx spec+                let oldGenerated = [StaleModule Generated (modulePath m) | (m, _) <- reportDispositions first, kind m == Generated]+                reportStale second `shouldSatisfy` all (`elem` oldGenerated)+                length (reportStale second) `shouldBe` length oldGenerated+        it "writes a parseable record and no stale section for a fresh output" $+            withTempDirectory "keiro-dsl-record" $ \out -> do+                spec <- parseInlineSpec "<fresh-record>" loweringAggregateSpec+                let ctx = defaultContext (specContext spec)+                report <- executePlannedScaffold out "counter.keiro" ctx spec+                reportStale report `shouldBe` []+                renderScaffoldReport report `shouldSatisfy` all (not . T.isPrefixOf "stale:")+                contents <- TIO.readFile (out </> recordFileName (specContext spec))+                parseRecord contents+                    `shouldBe` Just+                        ScaffoldRecord+                            { recSpecPath = "counter.keiro"+                            , recModuleRoot = ""+                            , recLayout = "prefixed"+                            , recFiles = [(kind m, modulePath m) | (m, _) <- reportDispositions report]+                            , recMappings = []+                            , recBindingObligations = []+                            }+                parseRecord (T.replace "spec: " "future-field: retained\nspec: " contents) `shouldBe` parseRecord contents+                parseRecord (T.replace "record v1" "record v2" contents) `shouldBe` Nothing++    describe "faithful scaffold lowering" $ do+        it "escapes a trailing-backslash payload literal exactly once" $ do+            spec <- specOf "test/fixtures/hospital-surge.keiro"+            case [process | NProcess process <- specNodes spec] of+                process : _ -> do+                    let timer = (procTimer process){tmPayload = [FieldBinding "kind" (Just "\"follow-up\\\"")]}+                        modules = scaffoldProcess (defaultContext (specContext spec)) process{procTimer = timer}+                    generatedTextEndingIn "Process.hs" modules+                        `shouldSatisfy` T.isInfixOf "\"kind\" .= (\"follow-up\\\\\" :: Value)"+                [] -> expectationFailure "hospital-surge fixture has no process"+        it "preserves quoted Text register initials and refuses unsafe register shapes" $ do+            spec <- parseInlineSpec "<register-initials>" loweringAggregateSpec+            let modules = scaffoldAggregate (defaultContext (specContext spec)) spec =<< [aggregate | NAggregate aggregate <- specNodes spec]+                domain = generatedTextEndingIn "Domain.hs" modules+            domain `shouldSatisfy` T.isInfixOf "RCons (Proxy @\"note\") \"hello world\""+            scaffoldRefusals spec `shouldBe` []+            bare <- parseInlineSpec "<bare-text-initial>" (T.replace "\"hello world\"" "hello" loweringAggregateSpec)+            scaffoldRefusals bare `shouldSatisfy` any (T.isInfixOf "RegTextInitialNotQuoted")+            unsupported <- parseInlineSpec "<unsupported-field>" (T.replace "count:Int" "count:Time" loweringAggregateSpec)+            scaffoldRefusals unsupported `shouldSatisfy` any (T.isInfixOf "FieldTypeUnrepresentable")+        it "lowers seconds, minutes, hours, and both backoff constructors faithfully" $ do+            windowSeconds "90s" `shouldBe` Right 90+            windowSeconds "5m" `shouldBe` Right 300+            windowSeconds "2h" `shouldBe` Right 7200+            emitSource <- readTestText "test/fixtures/emit.keiro"+            let exponentialSource = T.replace "backoff constant 2s" "backoff exponential 2s max=60s multiplier=2.0" emitSource+            exponential <- parseInlineSpec "<exponential-backoff>" exponentialSource+            case [publisher | NPublisher publisher <- specNodes exponential] of+                publisher : _ -> do+                    let generated = generatedTextEndingIn "Publisher.hs" (scaffoldPublisher (defaultContext (specContext exponential)) publisher)+                    generated `shouldSatisfy` T.isInfixOf "ExponentialBackoff ExponentialBackoffOptions { initial = 2, maxDelay = 60, multiplier = 2.0 }"+                    parseSpec "<exponential-round-trip>" (renderSpec exponential) `shouldBe` Right exponential+                [] -> expectationFailure "emit fixture has no publisher"+            constant <- parseInlineSpec "<constant-backoff>" (T.replace "backoff constant 2s" "backoff constant 2m" emitSource)+            case [publisher | NPublisher publisher <- specNodes constant] of+                publisher : _ -> generatedTextEndingIn "Publisher.hs" (scaffoldPublisher (defaultContext (specContext constant)) publisher) `shouldSatisfy` T.isInfixOf "ConstantBackoff 120"+                [] -> expectationFailure "emit fixture has no publisher"+        it "refuses incomplete exponential backoff and rejects unknown window units" $ do+            emitSource <- readTestText "test/fixtures/emit.keiro"+            incomplete <- parseInlineSpec "<incomplete-backoff>" (T.replace "backoff constant 2s" "backoff exponential 2s" emitSource)+            scaffoldRefusals incomplete `shouldSatisfy` any (T.isInfixOf "BackoffExponentialIncomplete")+            parseSpec "<bad-window>" (T.replace "backoff constant 2s" "backoff constant 2x" emitSource)+                `shouldSatisfy` leftContains "time unit: s, m, or h"+        it "lowers workqueue retry windows in minutes to seconds" $ do+            queueSource <- readTestText "test/fixtures/reservation-work.keiro"+            queueSpec <- parseInlineSpec "<minute-queue>" (T.replace "5s" "5m" queueSource)+            case [workqueue | NWorkqueue workqueue <- specNodes queueSpec] of+                workqueue : _ -> do+                    let policy = generatedTextEndingIn "QueuePolicy.hs" (scaffoldWorkqueue (defaultContext (specContext queueSpec)) workqueue)+                    policy `shouldSatisfy` T.isInfixOf "defaultRetryDelay = RetryDelay 300"+                    policy `shouldSatisfy` T.isInfixOf "Retry (RetryDelay 300)"+                [] -> expectationFailure "queue fixture has no workqueue"+        it "uses exact status-map keys and emits total Int harness samples" $ do+            statusSpec <- parseInlineSpec "<exact-status>" exactStatusSpec+            case [aggregate | NAggregate aggregate <- specNodes statusSpec] of+                aggregate : _ -> do+                    let ctx = defaultContext (specContext statusSpec)+                        projection = generatedTextEndingIn "Projection.hs" (scaffoldAggregate ctx statusSpec aggregate)+                        harness = generatedTextEndingIn "Harness.hs" (harnessFor ctx statusSpec aggregate)+                    projection `shouldSatisfy` T.isInfixOf "ReservationUnHeld {} -> Just \"available\""+                    harness `shouldSatisfy` T.isInfixOf "CountBumpedData 0"+                    harness `shouldNotSatisfy` T.isInfixOf "sample: unsupported"+                [] -> expectationFailure "exact-status spec has no aggregate"++    describe "scaffold" $ do+        it "synthesizes the exact old wire shape and embeds it in the harness" $ do+            oldSpec <- specOf "test/fixtures/reservation.keiro"+            newSpec <- specOf "test/fixtures/reservation-v2.keiro"+            case goldensForDiff oldSpec newSpec of+                [golden] -> do+                    goldenRelativePath golden+                        `shouldBe` "hospital-capacity/Reservation/TransferReservationCreated.v1.json"+                    goldenJson golden+                        `shouldBe` "{\"commandId\":\"cmd_01hzy3v7q2e8kaw2m5x0d41n9c\",\"divertStatus\":\"open\",\"hospitalId\":\"hosp_01hzy3v7q2e8kaw2m5x0d41n9c\",\"kind\":\"TransferReservationCreated\",\"lifeCriticalOverride\":true,\"patientAcuity\":\"red\",\"reservationId\":\"rsv_01hzy3v7q2e8kaw2m5x0d41n9c\"}\n"+                    goldenEvidence golden `shouldBe` SynthesizedWeakStandIn+                    let aggregate = onlyAggregate newSpec+                        modules =+                            harnessForWithGoldens+                                [golden]+                                (defaultContext (specContext newSpec))+                                newSpec+                                aggregate+                        harness = generatedTextEndingIn "Harness.hs" modules+                    harness `shouldSatisfy` T.isInfixOf "golden TransferReservationCreated.v1 decodes"+                    harness `shouldSatisfy` T.isInfixOf "\\\"reservationId\\\":\\\"rsv_"+                    harness `shouldSatisfy` (not . T.isInfixOf "current-shape stand-in")+                goldens -> expectationFailure ("expected one synthesized golden, got " <> show goldens)+        it "synthesizes complete nested mapped old shapes deterministically and never overwrites captured evidence" $ do+            oldSpec <- specOf "test/fixtures/consumer-types.keiro"+            newSpec <- specOf "test/fixtures/consumer-types-v2.keiro"+            case goldensForDiff oldSpec newSpec of+                [golden] -> do+                    goldenEvidence golden `shouldBe` SynthesizedWeakStandIn+                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"artifact\":{"+                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"location\":{\"contents\":\"sample\",\"tag\":\"local_file\"}"+                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"labels\":[\"sample\"]"+                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"revision\":1"+                    goldenJson golden `shouldSatisfy` T.isInfixOf "\"observedAt\":\"2026-01-01T00:00:00Z\""+                    goldensForDiff oldSpec newSpec `shouldBe` [golden]+                    withTempDirectory "keiro-golden-preserve" $ \root -> do+                        let target = root </> goldenRelativePath golden+                        createDirectoryIfMissing True (takeDirectory target)+                        TIO.writeFile target "hand captured\n"+                        emitGoldenPayloads root oldSpec newSpec `shouldReturn` []+                        TIO.readFile target `shouldReturn` "hand captured\n"+                    withTempDirectory "keiro-golden-write" $ \root -> do+                        let target = root </> goldenRelativePath golden+                        emitGoldenPayloads root oldSpec newSpec `shouldReturn` [target]+                        TIO.readFile target `shouldReturn` goldenJson golden+                goldens -> expectationFailure ("expected one nested synthesized golden, got " <> show goldens)+        it "dispatches shared-version upcasters by wire event type and passes foreign kinds through" $ do+            source <- readTestText "test/fixtures/reservation-dup-upcast-source.keiro"+            spec <- parseInlineSpec "<shared-upcaster-source>" source+            case [aggregate | NAggregate aggregate <- specNodes spec] of+                [aggregate] -> do+                    let modules = scaffoldAggregate (defaultContext (specContext spec)) spec aggregate+                        codec = generatedTextEndingIn "Codec.hs" modules+                        holes = case [moduleText m | m <- modules, "Holes.hs" `T.isSuffixOf` T.pack (modulePath m)] of+                            [text] -> text+                            _ -> ""+                    codec `shouldSatisfy` T.isInfixOf "upcasters = [(1, upcastRungV1)]"+                    codec `shouldSatisfy` T.isInfixOf "upcastRungV1 (EventType \"TransferReservationCreated\") value = upcastTransferReservationCreatedV1 value"+                    codec `shouldSatisfy` T.isInfixOf "upcastRungV1 (EventType \"TransferReservationConfirmed\") value = upcastTransferReservationConfirmedV1 value"+                    codec `shouldSatisfy` T.isInfixOf "upcastRungV1 _ value = Right value"+                    holes `shouldSatisfy` T.isInfixOf "receives ONLY TransferReservationCreated payloads"+                _ -> expectationFailure "expected exactly one aggregate"+        it "keeps foreign payloads byte-for-byte and invokes both same-rung event upcasters" $ do+            let payloadA = object ["kind" .= ("AmountScaled" :: T.Text), "amount" .= (2 :: Int)]+                payloadB = object ["kind" .= ("AmountRenamed" :: T.Text), "amount" .= (3 :: Int)]+                foreignPayload = object ["kind" .= ("AmountObserved" :: T.Text), "amount" .= (7 :: Int)]+                upcastA _ = Right (object ["kind" .= ("AmountScaled" :: T.Text), "amount" .= (200 :: Int)])+                upcastB _ = Right (object ["kind" .= ("AmountRenamed" :: T.Text), "amountInCents" .= (300 :: Int)])+                rung (EventType "AmountScaled") = upcastA+                rung (EventType "AmountRenamed") = upcastB+                rung _ = Right+                codec =+                    Codec+                        { eventTypes = EventType "AmountScaled" :| [EventType "AmountRenamed", EventType "AmountObserved"]+                        , eventType = const (EventType "AmountObserved")+                        , schemaVersion = 2+                        , encode = id+                        , decode = \_ -> Right+                        , upcasters = [(1, rung)]+                        } ::+                        Codec Value+            decodeRaw codec (EventType "AmountObserved") 1 foreignPayload `shouldBe` Right foreignPayload+            decodeRaw codec (EventType "AmountScaled") 1 payloadA+                `shouldBe` Right (object ["kind" .= ("AmountScaled" :: T.Text), "amount" .= (200 :: Int)])+            decodeRaw codec (EventType "AmountRenamed") 1 payloadB+                `shouldBe` Right (object ["kind" .= ("AmountRenamed" :: T.Text), "amountInCents" .= (300 :: Int)])+        it "never emits a keiki symbolic operator into a Generated module (firewall)" $ do+            mods <- scaffoldFixture "test/fixtures/reservation.keiro"+            firewallBreaches mods `shouldBe` []+        it "marks the Holes module HoleStub and the rest Generated" $ do+            mods <- scaffoldFixture "test/fixtures/reservation.keiro"+            let holes = [m | m <- mods, "Holes.hs" `T.isSuffixOf` T.pack (modulePath m)]+            map kind holes `shouldBe` [HoleStub]+            -- Domain, Codec, EventStream, Projection, Harness.+            length [m | m <- mods, kind m == Generated] `shouldBe` 5+        it "is deterministic (re-scaffolding yields byte-identical text)" $ do+            a <- scaffoldFixture "test/fixtures/reservation.keiro"+            b <- scaffoldFixture "test/fixtures/reservation.keiro"+            map moduleText a `shouldBe` map moduleText b+        it "keeps retiring as validator-only metadata in generated modules" $ do+            ordinary <- scaffoldFixture "test/fixtures/reservation.keiro"+            retiring <- scaffoldFixture "test/fixtures/reservation-retiring.keiro"+            map (\m -> (modulePath m, kind m, moduleText m)) retiring+                `shouldBe` map (\m -> (modulePath m, kind m, moduleText m)) ordinary+        it "matches the committed compiling Generated conformance modules (modulo whitespace)" $ do+            mods <- scaffoldFixture "test/fixtures/reservation.keiro"+            mapM_ assertMatchesCommitted [m | m <- mods, kind m == Generated]+        it "matches every committed new-surface Generated module (modulo formatting)" $ do+            spec <- specOf "test/fixtures/transfer-routing.keiro"+            let modules = scaffoldModules (defaultContext (specContext spec)) spec+            forM_ [m | m <- modules, kind m == Generated] $ \m -> do+                committed <- readTestText ("test/conformance-newsurface/" <> modulePath m)+                normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText m)+        it "scaffolds the register-free OrderStream smoke target without error" $ do+            mods <- scaffoldFixture "test/fixtures/order.keiro"+            -- 5 Generated (Domain/Codec/EventStream/Projection/Harness) + 1 Holes.+            length mods `shouldBe` 6+            firewallBreaches mods `shouldBe` []+            let harness = generatedTextEndingIn "Harness.hs" mods+            harness `shouldSatisfy` T.isInfixOf "prefix = \"forward/replay equality: PlaceOrder from OrderNotStarted -- \""+            harness `shouldSatisfy` T.isInfixOf "prefix <> \"final vertex\""+            harness `shouldNotSatisfy` T.isInfixOf "prefix <> \"register "+        it "emits forward/replay checks with field-distinct Text samples" $ do+            spec <- parseInlineSpec "<forward-replay-samples>" (T.replace "command Bump { count:Int }" "command Bump { count:Int noteText:Text echo:Text }" loweringAggregateSpec)+            case [aggregate | NAggregate aggregate <- specNodes spec] of+                aggregate : _ -> do+                    let ctx = defaultContext (specContext spec)+                        harness = generatedTextEndingIn "Harness.hs" (harnessFor ctx spec aggregate)+                    harness `shouldSatisfy` T.isInfixOf "\"sample-noteText\" \"sample-echo\""+                    harness `shouldSatisfy` T.isInfixOf "prefix = \"forward/replay equality: Bump from CounterPending -- \""+                    harness `shouldSatisfy` T.isInfixOf "prefix <> \"register note\""+                [] -> expectationFailure "forward/replay sample spec has no aggregate"+        it "emits the canonical reservation register checks" $ do+            mods <- scaffoldFixture "test/fixtures/reservation.keiro"+            let harness = generatedTextEndingIn "Harness.hs" mods+            harness `shouldSatisfy` T.isInfixOf "prefix = \"forward/replay equality: RequestTransferReservation from ReservationUnrequested -- \""+            harness `shouldSatisfy` T.isInfixOf "prefix <> \"register reservationState\""+        it "lowers a replay-only transition to B.replayOnly in the holes skeleton (plan 143)" $ do+            twinMods <- scaffoldFixture "test/fixtures/reservation-guard-tightened-twin.keiro"+            let twinHoles = [moduleText m | m <- twinMods, kind m == HoleStub]+            twinHoles `shouldSatisfy` any (T.isInfixOf "B.replayOnly")+            let twinHarness = generatedTextEndingIn "Harness.hs" twinMods+            T.count "forwardReplayRequestTransferReservation ::" twinHarness `shouldBe` 1+            plainMods <- scaffoldFixture "test/fixtures/reservation.keiro"+            let plainHoles = [moduleText m | m <- plainMods, kind m == HoleStub]+            plainHoles `shouldSatisfy` all (not . T.isInfixOf "B.replayOnly")++    describe "service workspace (EP-153)" $ do+        describe "manifest grammar" $ do+            it "round-trips the canonical fixture manifest byte-for-byte" $ do+                source <- readTestText canonicalWorkspacePath+                manifest <- shouldParseManifest canonicalWorkspacePath source+                wmfService manifest `shouldBe` "demo-project"+                wmfModuleRoot manifest `shouldBe` Just "Demo.Modules.Project"+                wmfLayout manifest `shouldBe` Just CollocatedLeaf+                map wmrPath (NE.toList (wmfMembers manifest))+                    `shouldBe` [ "domain/project-artifact.keiro"+                               , "domain/project.keiro"+                               , "domain/shared.keiro"+                               ]+                renderWorkspaceManifest manifest+                    `shouldBe` T.intercalate+                        "\n"+                        [ "service demo-project"+                        , "module Demo.Modules.Project"+                        , "layout collocated"+                        , "spec domain/project-artifact.keiro"+                        , "spec domain/project.keiro"+                        , "spec domain/shared.keiro"+                        ]+            it "treats membership as a set: source order changes neither the AST nor the bytes" $ do+                canonical <- readTestText canonicalWorkspacePath >>= shouldParseManifest canonicalWorkspacePath+                reordered <-+                    shouldParseManifest "<reordered>" $+                        T.unlines+                            [ "service demo-project"+                            , "layout collocated"+                            , "spec domain/shared.keiro"+                            , "module Demo.Modules.Project"+                            , "spec domain/project.keiro"+                            , "spec ./domain/project-artifact.keiro"+                            ]+                reordered `shouldBe` canonical+                renderWorkspaceManifest reordered `shouldBe` renderWorkspaceManifest canonical+            it "satisfies parse . render == id and render . parse . render == render" $+                property $+                    forAll genWorkspaceManifest $ \manifest ->+                        let rendered = renderWorkspaceManifest manifest+                         in case parseWorkspaceManifest "<generated>" rendered of+                                Left err -> counterexample (T.unpack err) False+                                Right reparsed ->+                                    counterexample (T.unpack rendered) $+                                        reparsed == manifest && renderWorkspaceManifest reparsed == rendered+            it "recognizes a workspace manifest by extension, case-insensitively" $ do+                map+                    isWorkspacePath+                    [ "service.keiro-workspace"+                    , "a/b/Service.KEIRO-Workspace"+                    , "service.keiro"+                    , ".keiro-workspace"+                    , "keiro-workspace"+                    ]+                    `shouldBe` [True, True, False, False, False]+        describe "manifest refusals" $ do+            let rejects label source expected =+                    it label $ case parseWorkspaceManifest "<manifest>" source of+                        Right _ -> expectationFailure ("expected a refusal, got a manifest for:\n" <> T.unpack source)+                        Left err -> T.unpack err `shouldContain` expected+            rejects+                "an empty manifest"+                "# only a comment\n"+                "must begin with a 'service <name>' clause"+            rejects+                "a manifest with no service clause"+                "spec domain/a.keiro\n"+                "first clause of a workspace manifest must be 'service <name>'"+            rejects+                "a manifest whose first clause is not service"+                "module Demo\nservice demo\nspec domain/a.keiro\n"+                "first clause of a workspace manifest must be 'service <name>'"+            rejects+                "a duplicate service clause"+                "service demo\nservice demo\nspec domain/a.keiro\n"+                "duplicate 'service' clause"+            rejects+                "a duplicate module clause"+                "service demo\nmodule Demo\nmodule Demo\nspec domain/a.keiro\n"+                "duplicate 'module' clause"+            rejects+                "a duplicate layout clause"+                "service demo\nlayout prefixed\nlayout prefixed\nspec domain/a.keiro\n"+                "duplicate 'layout' clause"+            rejects+                "a manifest with no members"+                "service demo\nmodule Demo\n"+                "must list at least one 'spec <path>.keiro' member"+            rejects+                "the same member listed twice"+                "service demo\nspec domain/a.keiro\nspec ./domain/a.keiro\n"+                "duplicate workspace member 'domain/a.keiro'"+            rejects+                "two members that differ only by case"+                "service demo\nspec domain/a.keiro\nspec domain/A.keiro\n"+                "differ only by case"+            rejects+                "an absolute member path"+                "service demo\nspec /etc/a.keiro\n"+                "must be relative, not absolute"+            rejects+                "a member path escaping the manifest directory"+                "service demo\nspec ../escape.keiro\n"+                "must not contain '..' segments"+            rejects+                "a member that is not a .keiro spec"+                "service demo\nspec domain/a.txt\n"+                "must name a .keiro spec"+            rejects+                "a manifest listing another manifest"+                "service demo\nspec domain/other.keiro-workspace\n"+                "must name a .keiro spec"+        describe "line relocation" $ do+            it "shifts every location the AST carries, and only the locations" $ do+                spec <- specOf "test/fixtures/reservation.keiro"+                let shifted = relocateLocs (+ 1000) spec+                collectLocs spec `shouldSatisfy` (not . null)+                collectLocs shifted `shouldBe` map (+ 1000) (collectLocs spec)+                -- Loc's Eq deliberately ignores the line, so relocation cannot+                -- change any equality-based behavior anywhere downstream.+                shifted `shouldBe` spec+            it "leaves the placeholder location alone so it never lands inside a member range" $ do+                spec <- specOf "test/fixtures/reservation.keiro"+                let blanked = relocateLocs (const 0) spec+                    reshifted = relocateLocs (\n -> if n <= 0 then n else n + 500) blanked+                collectLocs reshifted `shouldBe` map (const 0) (collectLocs spec)+        describe "composition" $ do+            it "resolves cross-file ids, enums, mapped types, and read-model feeds" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                wsService workspace `shouldBe` "demo-project"+                wsContext workspace `shouldBe` "demo-project"+                wsModuleRoot workspace `shouldBe` Just "Demo.Modules.Project"+                wsLayout workspace `shouldBe` Just CollocatedLeaf+                map wmPath (wsMembers workspace)+                    `shouldBe` [ "domain/project-artifact.keiro"+                               , "domain/project.keiro"+                               , "domain/shared.keiro"+                               ]+                -- Every member is individually incomplete; together they check.+                checkWorkspace workspace `shouldBe` []+            it "records which member owns each shared declaration and node" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                let ownership = wsOwnership workspace+                fmap fst (declarationOwner ownership "id" "ProjectId")+                    `shouldBe` Just "domain/shared.keiro"+                fmap fst (declarationOwner ownership "enum" "ProjectPhase")+                    `shouldBe` Just "domain/shared.keiro"+                fmap fst (declarationOwner ownership "rule" "phaseIsTerminal")+                    `shouldBe` Just "domain/shared.keiro"+                fmap fst (declarationOwner ownership "mapped" "ProjectSummary")+                    `shouldBe` Just "domain/shared.keiro"+                fmap fst (nodeOwner ownership "aggregate" "Project")+                    `shouldBe` Just "domain/project.keiro"+                fmap fst (nodeOwner ownership "aggregate" "ProjectArtifact")+                    `shouldBe` Just "domain/project-artifact.keiro"+                fmap fst (nodeOwner ownership "readmodel" "project_activity")+                    `shouldBe` Just "domain/project-artifact.keiro"+            it "maps every merged line back to the member that wrote it" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                let bases = [(wmPath m, wmLineBase m, wmLineCount m) | m <- wsMembers workspace]+                -- Ranges are disjoint and contiguous from zero.+                map (\(_, base, _) -> base) bases `shouldBe` scanl (+) 0 (init [c | (_, _, c) <- bases])+                sequence_+                    [ resolveWorkspaceLine workspace (base + offset) `shouldBe` Just (path, offset)+                    | (path, base, memberLines) <- bases+                    , offset <- [1, memberLines]+                    ]+                resolveWorkspaceLine workspace 0 `shouldBe` Nothing+            it "is insensitive to the order members are listed in" $ do+                canonical <- shouldComposeWorkspace canonicalWorkspacePath+                reordered <- shouldComposeWorkspace reorderedWorkspacePath+                reordered{wsManifestPath = wsManifestPath canonical} `shouldBe` canonical+            it "checks a single .keiro file as a one-member workspace, diagnostic for diagnostic" $ do+                let fixtures =+                        [ "test/fixtures/reservation.keiro"+                        , "test/fixtures/consumer-types.keiro"+                        , "test/fixtures/aggregate-bad-refs.keiro"+                        , "test/fixtures/readmodel.keiro"+                        ]+                forM_ fixtures $ \path -> do+                    spec <- specOf path+                    let workspace = oneMemberWorkspace path spec+                        viaWorkspace = map (renderWorkspaceDiagnostic path) (checkWorkspace workspace)+                        direct = map (renderDiagnostic path) (validateSpec spec)+                    viaWorkspace `shouldBe` direct+                -- At least one of those fixtures must actually produce errors,+                -- or the equivalence claim is vacuous.+                badRefs <- specOf "test/fixtures/aggregate-bad-refs.keiro"+                checkWorkspace (oneMemberWorkspace "test/fixtures/aggregate-bad-refs.keiro" badRefs)+                    `shouldSatisfy` any ((== Error) . wdSeverity)+        describe "composition refusals" $ do+            let refusesWith path expectedCode expectedFiles = do+                    diagnostics <- shouldRefuseWorkspace path+                    map wdCode (NE.toList diagnostics) `shouldContain` [expectedCode]+                    let cited =+                            [ wlFile location+                            | diagnostic <- NE.toList diagnostics+                            , wdCode diagnostic == expectedCode+                            , location <- NE.toList (wdLocations diagnostic)+                            ]+                    sort (nubOrd cited) `shouldBe` sort expectedFiles+            it "refuses members that declare different contexts, citing every context clause" $+                refusesWith+                    "test/fixtures/workspace-context-mismatch/service.keiro-workspace"+                    WorkspaceContextMismatch+                    [WorkspaceMemberFile "domain/a.keiro", WorkspaceMemberFile "domain/b.keiro"]+            it "refuses a member layout clause that contradicts the manifest authority" $+                refusesWith+                    "test/fixtures/workspace-authority-conflict/service.keiro-workspace"+                    WorkspaceAuthorityConflict+                    [WorkspaceManifestFile, WorkspaceMemberFile "domain/b.keiro"]+            it "refuses a textually identical shared declaration owned by two members" $+                refusesWith+                    "test/fixtures/workspace-dup-decl/service.keiro-workspace"+                    WorkspaceDuplicateDeclaration+                    [WorkspaceMemberFile "domain/project.keiro", WorkspaceMemberFile "domain/shared.keiro"]+            it "refuses one aggregate defined in two members" $+                refusesWith+                    "test/fixtures/workspace-dup-node/service.keiro-workspace"+                    WorkspaceDuplicateNodeName+                    [WorkspaceMemberFile "domain/a.keiro", WorkspaceMemberFile "domain/b.keiro"]+            it "refuses generated paths that collide across members under case folding" $+                refusesWith+                    "test/fixtures/workspace-path-collision/service.keiro-workspace"+                    WorkspacePathCollision+                    [WorkspaceMemberFile "domain/a.keiro", WorkspaceMemberFile "domain/b.keiro"]+            it "reports a listed member that is missing from disk" $+                refusesWith+                    "test/fixtures/workspace-missing-member/service.keiro-workspace"+                    WorkspaceMemberUnreadable+                    [WorkspaceManifestFile]+            it "reports a member that does not parse" $+                refusesWith+                    "test/fixtures/workspace-member-parse-failed/service.keiro-workspace"+                    WorkspaceMemberParseFailed+                    [WorkspaceManifestFile]+            it "surfaces a cross-file unresolved reference through the merged validator" $ do+                workspace <- shouldComposeWorkspace "test/fixtures/workspace-unresolved/service.keiro-workspace"+                let errors = [d | d <- checkWorkspace workspace, wdSeverity d == Error]+                map wdCode errors `shouldContain` [GuardAtomOutOfScope]+                [wlFile location | d <- errors, location <- NE.toList (wdLocations d)]+                    `shouldContain` [WorkspaceMemberFile "domain/project.keiro"]+        describe "multi-file diagnostic rendering" $ do+            it "puts the primary location in the established shape and every other file on a note line" $ do+                diagnostics <- shouldRefuseWorkspace "test/fixtures/workspace-dup-decl/service.keiro-workspace"+                let manifest = "keiro-dsl/test/fixtures/workspace-dup-decl/service.keiro-workspace"+                map (renderWorkspaceDiagnostic manifest) (NE.toList diagnostics)+                    `shouldBe` [ T.intercalate+                                    "\n"+                                    [ "keiro-dsl/test/fixtures/workspace-dup-decl/domain/project.keiro:3: error[WorkspaceDuplicateDeclaration]: duplicate declaration 'ProjectId': a shared declaration has exactly one owning member (identical duplicates do not merge)"+                                    , "  keiro-dsl/test/fixtures/workspace-dup-decl/domain/shared.keiro:3: note: also declared here, as id 'ProjectId'"+                                    ]+                               ]+        describe "whole-service check through the CLI" $ do+            it "prints OK and exits zero for the composed fixture workspace" $ do+                (exitCode, out, err) <- runKeiroDsl ["check", canonicalWorkspacePath]+                unless (exitCode == ExitSuccess) (expectationFailure (out <> err))+                lines out `shouldBe` ["OK"]+            it "exits non-zero and names every involved file for a cross-file refusal" $ do+                (exitCode, _, err) <-+                    runKeiroDsl ["check", "test/fixtures/workspace-dup-decl/service.keiro-workspace"]+                exitCode `shouldBe` ExitFailure 1+                err `shouldContain` "error[WorkspaceDuplicateDeclaration]"+                err `shouldContain` "workspace-dup-decl/domain/project.keiro:3"+                err `shouldContain` "workspace-dup-decl/domain/shared.keiro:3"+            it "attributes a merged-graph validation error to the member that wrote it" $ do+                (exitCode, _, err) <-+                    runKeiroDsl ["check", "test/fixtures/workspace-unresolved/service.keiro-workspace"]+                exitCode `shouldBe` ExitFailure 1+                err `shouldContain` "workspace-unresolved/domain/project.keiro:12: error[GuardAtomOutOfScope]"+            it "produces byte-identical output for a manifest whose members are listed in reverse" $ do+                (canonicalCode, canonicalOut, _) <- runKeiroDsl ["check", canonicalWorkspacePath, "--emit"]+                (reorderedCode, reorderedOut, _) <- runKeiroDsl ["check", reorderedWorkspacePath, "--emit"]+                canonicalCode `shouldBe` ExitSuccess+                reorderedCode `shouldBe` ExitSuccess+                reorderedOut `shouldBe` canonicalOut+                (_, canonicalParse, _) <- runKeiroDsl ["parse", canonicalWorkspacePath]+                (_, reorderedParse, _) <- runKeiroDsl ["parse", reorderedWorkspacePath]+                reorderedParse `shouldBe` canonicalParse+            it "keeps the single-file path working, byte for byte" $ do+                (exitCode, out, err) <- runKeiroDsl ["check", "test/fixtures/reservation.keiro"]+                unless (exitCode == ExitSuccess) (expectationFailure (out <> err))+                lines out `shouldBe` ["OK"]+            it "explains bindings and reports coverage against the merged graph" $ do+                (bindingsCode, bindingsOut, _) <-+                    runKeiroDsl ["check", canonicalWorkspacePath, "--explain-bindings"]+                bindingsCode `shouldBe` ExitSuccess+                bindingsOut `shouldContain` "binding obligations for context demo-project"+                -- The obligation's use sites span both aggregate members, which+                -- is only possible because the graph was resolved once, merged.+                bindingsOut `shouldContain` "Project register summary : ProjectSummary"+                bindingsOut `shouldContain` "ProjectArtifact command RecordArtifact .artifactSummary : ProjectSummary"+                withTempDirectory "keiro-dsl-workspace-coverage" $ \out -> do+                    let reportPath = out </> "coverage.json"+                    (coverageCode, coverageOut, _) <-+                        runKeiroDsl ["check", canonicalWorkspacePath, "--coverage-report", reportPath]+                    coverageCode `shouldBe` ExitSuccess+                    coverageOut `shouldContain` "structural/opaque boundaries (reporting only)"+                    report <- Aeson.eitherDecodeFileStrict reportPath+                    case report of+                        Left err -> expectationFailure err+                        Right value -> coverageSpecPath value `shouldBe` Just (T.pack canonicalWorkspacePath)++    describe "workspace diff revision loading (EP-155 M1)" $ do+        it "composes added, removed, and renamed members through an in-memory content source" $ do+            project <- readTestText "test/fixtures/workspace/domain/project.keiro"+            artifact <- readTestText "test/fixtures/workspace/domain/project-artifact.keiro"+            shared <- readTestText "test/fixtures/workspace/domain/shared.keiro"+            let extra = "context demo-project\n\nid ExtraId prefix=extra\n"+                manifest members =+                    T.unlines+                        ( ["service demo-project", "module Demo.Modules.Project", "layout collocated"]+                            <> ["spec " <> T.pack member | member <- members]+                        )+                baseFiles =+                    Map.fromList+                        [ ("domain/project.keiro", project)+                        , ("domain/project-artifact.keiro", artifact)+                        , ("domain/shared.keiro", shared)+                        ]+                loadFrom members files =+                    loadWorkspace+                        (memoryContentSource (Map.insert "service.keiro-workspace" (manifest members) files))+                        "service.keiro-workspace"+                baseMembers = ["domain/project.keiro", "domain/project-artifact.keiro", "domain/shared.keiro"]+                expectLoaded result = case result of+                    Left failure -> expectationFailure (show failure) >> error "unreachable"+                    Right workspace -> pure workspace++            oldAdded <- loadFrom baseMembers baseFiles >>= expectLoaded+            newAdded <-+                loadFrom+                    (baseMembers <> ["domain/extra.keiro"])+                    (Map.insert "domain/extra.keiro" extra baseFiles)+                    >>= expectLoaded+            map changeCode (diffSpecs (wsMergedSpec oldAdded) (wsMergedSpec newAdded))+                `shouldContain` [DeclarationAdded]++            oldRemoved <- loadFrom baseMembers baseFiles >>= expectLoaded+            newRemoved <-+                loadFrom+                    ["domain/project.keiro", "domain/shared.keiro"]+                    (Map.delete "domain/project-artifact.keiro" baseFiles)+                    >>= expectLoaded+            map changeCode (diffSpecs (wsMergedSpec oldRemoved) (wsMergedSpec newRemoved))+                `shouldContain` [EvtRemovedNotDeprecated]++            oldRenamed <- loadFrom baseMembers baseFiles >>= expectLoaded+            let renamedMembers = ["domain/project-renamed.keiro", "domain/project-artifact.keiro", "domain/shared.keiro"]+                renamedFiles = Map.insert "domain/project-renamed.keiro" project (Map.delete "domain/project.keiro" baseFiles)+            newRenamed <- loadFrom renamedMembers renamedFiles >>= expectLoaded+            diffSpecs (wsMergedSpec oldRenamed) (wsMergedSpec newRenamed) `shouldBe` []++    describe "workspace diff ownership and unified reports (EP-155 M2)" $ do+        it "classifies shared declarations at use sites across every member with owned citations" $ do+            old <- shouldComposeWorkspace "test/fixtures/workspace-diff-old/service.keiro-workspace"+            new <- shouldComposeWorkspace "test/fixtures/workspace-diff-new/service.keiro-workspace"+            let changes = diffWorkspaces old new+                enumChanges = filter ((== EnumCtorAdded) . changeCode . wcChange) changes+                mappedChanges = filter ((== MappedFieldTypeChanged) . changeCode . wcChange) changes+                citedFiles workspaceChanges =+                    [ osFile site+                    | change <- workspaceChanges+                    , (_, Just site) <- wcUseSites change+                    ]+            enumChanges `shouldSatisfy` (not . null)+            mappedChanges `shouldSatisfy` (not . null)+            let enumWireChanges =+                    [ change+                    | workspaceChange <- enumChanges+                    , let change = wcChange workspaceChange+                    , OldBinaryReadNewEvents `elem` breakingSurfaces change+                    ]+            enumWireChanges `shouldSatisfy` (not . null)+            enumWireChanges `shouldSatisfy` all (not . gatedBreaking defaultGate)+            enumWireChanges `shouldSatisfy` all (gatedBreaking (gateWith [OldBinaryReadNewEvents]))+            map (fmap osFile . wcDeclarationSite) (enumChanges <> mappedChanges)+                `shouldSatisfy` all (== Just "domain/shared.keiro")+            citedFiles enumChanges `shouldContain` ["domain/order.keiro", "domain/shipment.keiro"]+            citedFiles mappedChanges `shouldContain` ["domain/order.keiro", "domain/shipment.keiro"]+            let rendered = T.intercalate "\n" (map renderWorkspaceFinding (enumChanges <> mappedChanges))+            rendered `shouldSatisfy` T.isInfixOf "    declared: domain/shared.keiro:3"+            rendered `shouldSatisfy` T.isInfixOf "    use-site: Order"+            rendered `shouldSatisfy` T.isInfixOf "(domain/order.keiro:"+            rendered `shouldSatisfy` T.isInfixOf "(domain/shipment.keiro:"+            golden <- readTestText "test/fixtures/workspace-diff-new/workspace.diff.golden"+            T.unlines (map renderWorkspaceFinding changes) `shouldBe` golden++        it "emits one additive version-1 report with workspace provenance" $ do+            old <- shouldComposeWorkspace "test/fixtures/workspace-diff-old/service.keiro-workspace"+            new <- shouldComposeWorkspace "test/fixtures/workspace-diff-new/service.keiro-workspace"+            let changes = diffWorkspaces old new+                meta =+                    WorkspaceMeta+                        { wmIdentity = wsService new+                        , wmManifest = "service.keiro-workspace"+                        , wmSince = "HEAD"+                        , wmMembersOld = map wmPath (wsMembers old)+                        , wmMembersNew = map wmPath (wsMembers new)+                        , wmAdoptionBaseline = False+                        }+            case Aeson.toJSON (workspaceDiffReport meta defaultGate changes) of+                Aeson.Object report -> do+                    KeyMap.lookup "schema" report `shouldBe` Just (Aeson.String "keiro-dsl/diff-report/1")+                    case KeyMap.lookup "workspace" report of+                        Just (Aeson.Object workspace) -> do+                            KeyMap.lookup "identity" workspace `shouldBe` Just (Aeson.String "workspace-diff")+                            KeyMap.lookup "adoptionBaseline" workspace `shouldBe` Just (Aeson.Bool False)+                        other -> expectationFailure ("missing workspace report metadata: " <> show other)+                    case KeyMap.lookup "findings" report of+                        Just (Aeson.Array findings) -> do+                            findings `shouldSatisfy` (not . null)+                            let objects = [finding | Aeson.Object finding <- toList findings]+                            objects `shouldSatisfy` any (KeyMap.member "declaration")+                            objects `shouldSatisfy` any (KeyMap.member "useSites")+                        other -> expectationFailure ("missing workspace findings: " <> show other)+                other -> expectationFailure ("workspace report was not an object: " <> show other)++        it "computes one replay-impact value over both aggregates" $ do+            old <- shouldComposeWorkspace "test/fixtures/workspace-diff-old/service.keiro-workspace"+            new <- shouldComposeWorkspace "test/fixtures/workspace-diff-new/service.keiro-workspace"+            case ReplayImpact.replayImpact (wsMergedSpec old) (wsMergedSpec new) of+                ReplayAffected affected -> Map.keysSet affected `shouldBe` Set.fromList ["Order", "Shipment"]+                ReplayNeutral -> expectationFailure "shared mapped evolution unexpectedly reported replay-neutral"++    describe "workspace ownership and authority changes (EP-155 M3)" $ do+        it "reports an unchanged aggregate move once without wire evolution" $ do+            old <- shouldComposeWorkspace "test/fixtures/workspace-diff-old/service.keiro-workspace"+            moved <- shouldComposeWorkspace "test/fixtures/workspace-diff-moved/service.keiro-workspace"+            let changes = diffWorkspaces old moved+            map (changeCode . wcChange) changes `shouldBe` [OwnershipMoved]+            forM_ changes $ \workspaceMove -> do+                let move = wcChange workspaceMove+                move `shouldSatisfy` isAdvisory+                move `shouldSatisfy` (not . gatedBreaking defaultGate)+                move `shouldSatisfy` (not . gatedBreaking (gateWith [minBound .. maxBound]))+                deriveLabel defaultGate (ckVector (workspaceChangeKind move)) `shouldBe` LabelAdvisory+                remediationFor (ckContext (workspaceChangeKind move)) OwnershipMoved+                    `shouldBe` (RemedyRescaffoldWorkspace :| [])+                renderWorkspaceFinding workspaceMove+                    `shouldSatisfy` T.isInfixOf "declaration moved domain/shipment.keiro -> domain/order.keiro"++        it "treats a member rename as the same owner-map change" $ do+            old <- shouldComposeWorkspace "test/fixtures/workspace-diff-old/service.keiro-workspace"+            let ownership = wsOwnership old+                renamed =+                    old+                        { wsOwnership =+                            ownership+                                { oiNodes =+                                    Map.adjust+                                        (\(_, loc) -> ("domain/shipping.keiro", loc))+                                        ("aggregate", "Shipment")+                                        (oiNodes ownership)+                                }+                        }+                moves = filter ((== OwnershipMoved) . changeCode . wcChange) (diffWorkspaces old renamed)+            length moves `shouldBe` 1+            forM_ moves $ \move ->+                renderWorkspaceFinding move `shouldSatisfy` T.isInfixOf "domain/shipment.keiro -> domain/shipping.keiro"++        it "reports ownership motion beside an independently classified wire edit" $ do+            old <- shouldComposeWorkspace "test/fixtures/workspace-diff-old/service.keiro-workspace"+            edited <- shouldComposeWorkspace "test/fixtures/workspace-diff-new/service.keiro-workspace"+            let ownership = wsOwnership edited+                movedAndEdited =+                    edited+                        { wsOwnership =+                            ownership+                                { oiNodes =+                                    Map.adjust+                                        (\(_, loc) -> ("domain/order.keiro", loc))+                                        ("aggregate", "Shipment")+                                        (oiNodes ownership)+                                }+                        }+                codes = map (changeCode . wcChange) (diffWorkspaces old movedAndEdited)+            codes `shouldContain` [OwnershipMoved]+            codes `shouldContain` [MappedFieldTypeChanged]++        it "reports context authority separately from derived read-model identity breaks" $ do+            old <- shouldComposeWorkspace canonicalWorkspacePath+            let newContext = "demo-project-renamed"+                renamed =+                    old+                        { wsContext = newContext+                        , wsMergedSpec = (wsMergedSpec old){specContext = newContext}+                        }+                changes = diffWorkspaces old renamed+                codes = map (changeCode . wcChange) changes+            codes `shouldContain` [WorkspaceAuthorityChanged]+            codes `shouldContain` [DerivedIdentityChanged]+            map wcChange changes `shouldSatisfy` any (gatedBreaking defaultGate)++        it "keeps service, module-root, and layout authority advisories non-blocking" $ do+            old <- shouldComposeWorkspace canonicalWorkspacePath+            let changed =+                    old+                        { wsService = "demo-project-renamed"+                        , wsModuleRoot = Just "Demo.Modules.Renamed"+                        , wsLayout = Just GeneratedPrefix+                        }+                authority = filter ((== WorkspaceAuthorityChanged) . changeCode . wcChange) (diffWorkspaces old changed)+            length authority `shouldBe` 3+            forM_ (map wcChange authority) $ \change -> do+                deriveLabel defaultGate (ckVector (workspaceChangeKind change)) `shouldBe` LabelAdvisory+                change `shouldSatisfy` (not . gatedBreaking (gateWith [minBound .. maxBound]))+                remediationFor (ckContext (workspaceChangeKind change)) WorkspaceAuthorityChanged+                    `shouldBe` (RemedyRescaffoldWorkspace :| [RemedyRecompileConsumers])++    describe "workspace scaffold (EP-154)" $ do+        describe "workspace record" $ do+            it "round-trips modules, owners, members, mappings, obligations, and adoptions" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                let record = sampleWorkspaceRecord workspace+                    rendered = renderWorkspaceRecord record+                parseWorkspaceRecord rendered `shouldBe` Just record+                -- The header pins the schema: a v1 context-keyed record and a+                -- workspace record can never be read as each other.+                T.lines rendered `shouldSatisfy` \case+                    header : _ -> header == "keiro-dsl workspace scaffold record v1"+                    [] -> False+                parseRecord rendered `shouldBe` Nothing+                parseWorkspaceRecord (T.replace "record v1" "record v2" rendered) `shouldBe` Nothing+            it "ignores unknown rows and unknown JSON keys, and keeps context-level rows ownerless" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                let record = sampleWorkspaceRecord workspace+                    rendered = renderWorkspaceRecord record+                parseWorkspaceRecord (T.replace "service: " "future-row: retained\nservice: " rendered)+                    `shouldBe` Just record+                parseWorkspaceRecord (T.replace "\"kind\":\"generated\"" "\"kind\":\"generated\",\"future\":1" rendered)+                    `shouldBe` Just record+                [row | row <- wrModules record, wrmOwner row == Nothing]+                    `shouldSatisfy` (not . null)+            it "rejects unsafe module, owner, member, and adoption paths" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                let rendered = renderWorkspaceRecord (sampleWorkspaceRecord workspace)+                    corrupt from to = parseWorkspaceRecord (T.replace from to rendered)+                corrupt "member domain/shared.keiro" "member /etc/passwd" `shouldBe` Nothing+                corrupt "member domain/shared.keiro" "member ../escape.keiro" `shouldBe` Nothing+                corrupt "\"owner\":\"domain/shared.keiro\"" "\"owner\":\"../shared.keiro\"" `shouldBe` Nothing+                corrupt "\"path\":\"claimed/One.hs\"" "\"path\":\"/tmp/One.hs\"" `shouldBe` Nothing+            it "keys history by service in a slot no context name can reach" $ do+                -- A context name is lexed as letters/digits/_/- and can never+                -- contain a dot, so the workspace slot cannot alias a legacy+                -- record even when the service is named after its context.+                workspaceRecordFileName "demo-project"+                    `shouldBe` "keiro-dsl-scaffold-record.workspace.demo-project.txt"+                workspaceManifestFileName "demo-project"+                    `shouldBe` "keiro-dsl-manifest.workspace.demo-project.txt"+                workspaceRecordFileName "demo-project" `shouldNotBe` recordFileName "demo-project"+                map+                    (T.isInfixOf "." . T.pack)+                    [ workspaceRecordFileName "demo-project"+                    , recordFileName "demo-project"+                    ]+                    `shouldBe` [True, True]+                supersededByLine "demo-project"+                    `shouldBe` "superseded-by: keiro-dsl-scaffold-record.workspace.demo-project.txt"++        describe "workspace plan" $ do+            it "emits the context-level facade and replay-audit exactly once from the merged graph" $ do+                plan <- shouldPlanWorkspace canonicalWorkspacePath+                let modules = map fst (wpModules plan)+                    facades = [m | m <- modules, "StructuralProjections.hs" `isSuffixOfPath` m]+                    audits = [m | m <- modules, "ReplayAudit.hs" `isSuffixOfPath` m]+                    shapes = [m | m <- modules, "Structural/Shape/ProjectSummary.hs" `isSuffixOfPath` m]+                length facades `shouldBe` 1+                length audits `shouldBe` 1+                length shapes `shouldBe` 1+                -- The audit assembles aggregates owned by two different member+                -- files, which is only possible from one merged graph.+                forM_ audits $ \audit -> do+                    moduleText audit `shouldSatisfy` T.isInfixOf "Project.projectEventStream"+                    moduleText audit `shouldSatisfy` T.isInfixOf "ProjectArtifact.projectArtifactEventStream"+            it "attributes every module to its owning member and leaves shared ones context-level" $ do+                plan <- shouldPlanWorkspace canonicalWorkspacePath+                let memberPaths = map wmPath (wsMembers (wpWorkspace plan))+                    ownerOf suffix =+                        case [provenance | (m, provenance) <- wpModules plan, suffix `isSuffixOfPath` m] of+                            [provenance] -> Just provenance+                            _ -> Nothing+                ownerOf "StructuralProjections.hs" `shouldBe` Just ContextLevel+                ownerOf "ReplayAudit.hs" `shouldBe` Just ContextLevel+                ownerOf "Structural/Shape/ProjectSummary.hs"+                    `shouldBe` Just (MemberOwned "domain/shared.keiro")+                ownerOf "Project/Generated/Domain.hs"+                    `shouldBe` Just (MemberOwned "domain/project.keiro")+                ownerOf "ProjectArtifact/Generated/Domain.hs"+                    `shouldBe` Just (MemberOwned "domain/project-artifact.keiro")+                ownerOf "Project_activity/Generated/ReadModel.hs"+                    `shouldBe` Just (MemberOwned "domain/project-artifact.keiro")+                -- No module may claim an owner that is not a member of the+                -- workspace: the record's owner column has to stay resolvable.+                map (provenanceOwner . snd) (wpModules plan)+                    `shouldSatisfy` all (maybe True (`elem` memberPaths))+            it "plans a one-member workspace byte-identically to the single-file path" $ do+                let fixtures =+                        [ "test/fixtures/reservation.keiro"+                        , "test/fixtures/consumer-types.keiro"+                        , "test/fixtures/readmodel.keiro"+                        , "test/fixtures/hospital-surge.keiro"+                        ]+                -- Modules and refusals both: hospital-surge refuses on both+                -- paths, which proves the gates agree as well as the emitters.+                forM_ fixtures $ \path -> do+                    spec <- specOf path+                    let ctx = defaultContext (specContext spec)+                        workspace = oneMemberWorkspace path spec+                    fmap (map fst . wpModules) (planWorkspaceScaffold "goldens" ctx workspace)+                        `shouldBe` planScaffold ctx spec+                -- The equality is not vacuous: at least one fixture plans, and+                -- its per-node modules are attributed to the single member.+                spec <- specOf "test/fixtures/reservation.keiro"+                let workspace = oneMemberWorkspace "test/fixtures/reservation.keiro" spec+                case planWorkspaceScaffold "goldens" (defaultContext (specContext spec)) workspace of+                    Left refusals -> expectationFailure ("reservation should plan: " <> show refusals)+                    Right plan -> do+                        wpModules plan `shouldSatisfy` (not . null)+                        map snd (wpModules plan)+                            `shouldSatisfy` all (`elem` [ContextLevel, MemberOwned "reservation.keiro"])+                        map snd (wpModules plan)+                            `shouldSatisfy` elem (MemberOwned "reservation.keiro")+            it "computes obligations from the complete merged graph, spanning members" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                case bindingObligations (wsMergedSpec workspace) of+                    Left graphErrors -> expectationFailure ("merged graph did not resolve: " <> show graphErrors)+                    Right obligations ->+                        case [o | o <- obligations, obligationMappedName o == "ProjectSummary", obligationKind o == BindingValue] of+                            [obligation] -> do+                                obligationUseSites obligation+                                    `shouldSatisfy` any (T.isInfixOf "Project register summary")+                                obligationUseSites obligation+                                    `shouldSatisfy` any (T.isInfixOf "ProjectArtifact command RecordArtifact")+                            found -> expectationFailure ("expected one ProjectSummary binding obligation, got " <> show (length found))+            it "refuses a case-folded path collision across members, naming both files" $ do+                workspace <- shouldComposeWorkspace canonicalWorkspacePath+                let collided = withCaseVariantAggregate workspace+                case planWorkspaceScaffold "goldens" (workspaceContext collided) collided of+                    Right _ -> expectationFailure "expected a cross-member path collision refusal"+                    Left refusals -> do+                        let origins = concat [os | PathCollision _ os <- refusals]+                        origins `shouldSatisfy` any (T.isInfixOf "domain/project.keiro: ")+                        origins `shouldSatisfy` any (T.isInfixOf "domain/project-artifact.keiro: ")+            it "refuses golden fixtures stranded beside a member instead of under the workspace root" $+                withTempDirectory "keiro-dsl-workspace-goldens" $ \root -> do+                    workspace <- writeGoldenWorkspace root+                    let workspaceGoldens = root </> "golden-payloads"+                        fixture = "hospital-capacity/Reservation/TransferReservationCreated.v1.json"+                        beside = root </> "domain/golden-payloads" </> fixture+                    goldenRootDivergence workspaceGoldens workspace `shouldReturn` []+                    createDirectoryIfMissing True (takeDirectory beside)+                    TIO.writeFile beside "{}\n"+                    refusals <- goldenRootDivergence workspaceGoldens workspace+                    refusals `shouldBe` [GoldenRootDivergence workspaceGoldens [beside]]+                    renderRefusals refusals+                        `shouldSatisfy` any (T.isInfixOf "one golden root per workspace")+                    -- The same fixture under the workspace root is no divergence.+                    let atRoot = workspaceGoldens </> fixture+                    createDirectoryIfMissing True (takeDirectory atRoot)+                    TIO.writeFile atRoot "{}\n"+                    goldenRootDivergence workspaceGoldens workspace `shouldReturn` []++        describe "workspace scaffold" $ do+            it "writes workspace-keyed history and no context-keyed file at all" $+                withWorkspaceFixture "keiro-dsl-workspace-history" id $ \_ out workspace -> do+                    report <- executePlannedWorkspaceScaffold out workspace+                    wsrRecordPath report+                        `shouldBe` out </> "keiro-dsl-scaffold-record.workspace.demo-project.txt"+                    wsrBuildManifestPath report+                        `shouldBe` out </> "keiro-dsl-manifest.workspace.demo-project.txt"+                    doesFileExist (out </> recordFileName "demo-project") `shouldReturn` False+                    doesFileExist (out </> "keiro-dsl-manifest.demo-project.txt") `shouldReturn` False+                    contents <- TIO.readFile (wsrRecordPath report)+                    case parseWorkspaceRecord contents of+                        Nothing -> expectationFailure ("workspace record did not parse:\n" <> T.unpack contents)+                        Just record -> do+                            wrService record `shouldBe` "demo-project"+                            wrManifest record `shouldBe` "service.keiro-workspace"+                            wrMembers record+                                `shouldBe` [ "domain/project-artifact.keiro"+                                           , "domain/project.keiro"+                                           , "domain/shared.keiro"+                                           ]+                            -- Context-level modules are ownerless; everything+                            -- else names the member that produced it.+                            [wrmPath row | row <- wrModules record, wrmOwner row == Nothing]+                                `shouldSatisfy` \ownerless ->+                                    length ownerless == 2+                                        && any (T.isSuffixOf "StructuralProjections.hs" . T.pack) ownerless+                                        && any (T.isSuffixOf "ReplayAudit.hs" . T.pack) ownerless+                            [ wrmOwner row+                              | row <- wrModules record+                              , "Project/Generated/Domain.hs" `T.isSuffixOf` T.pack (wrmPath row)+                              ]+                                `shouldBe` [Just "domain/project.keiro"]+            it "is idempotent: an unchanged second run rewrites nothing and reports nothing" $+                withWorkspaceFixture "keiro-dsl-workspace-idempotent" id $ \_ out workspace -> do+                    first <- executePlannedWorkspaceScaffold out workspace+                    before <- treeSnapshot out+                    second <- executePlannedWorkspaceScaffold out workspace+                    after <- treeSnapshot out+                    after `shouldBe` before+                    map thd3 (wsrDispositions second)+                        `shouldSatisfy` all (`elem` [Unchanged, Skipped])+                    wsrStale second `shouldBe` []+                    wsrOwnershipMoves second `shouldBe` []+                    wsrMappingDrift second `shouldBe` []+                    wsrNewHoles second `shouldBe` []+                    -- The first run had to write; the claim is not vacuous.+                    map thd3 (wsrDispositions first) `shouldSatisfy` any (== Overwritten)+                    renderWorkspaceScaffoldReport second+                        `shouldSatisfy` all (not . T.isPrefixOf "stale:")+            it "produces byte-identical output for members listed in reverse order" $+                withWorkspaceFixture "keiro-dsl-workspace-order-a" id $ \_ outA workspaceA ->+                    withWorkspaceFixture "keiro-dsl-workspace-order-b" reverse $ \_ outB workspaceB -> do+                        _ <- executePlannedWorkspaceScaffold outA workspaceA+                        _ <- executePlannedWorkspaceScaffold outB workspaceB+                        treeB <- treeSnapshot outB+                        treeA <- treeSnapshot outA+                        treeB `shouldBe` treeA+                        map fst treeA `shouldSatisfy` elem "keiro-dsl-scaffold-record.workspace.demo-project.txt"+            it "reports stale files only for the member that changed" $+                withWorkspaceFixture "keiro-dsl-workspace-stale" id $ \root out workspace -> do+                    first <- executePlannedWorkspaceScaffold out workspace+                    let siblingPaths =+                            [ modulePath m+                            | (m, provenance, _) <- wsrDispositions first+                            , provenance == MemberOwned "domain/project-artifact.keiro"+                            ]+                    siblingsBefore <- traverse (TIO.readFile . (out </>)) siblingPaths+                    renamed <- renameMemberAggregate root "domain/project.keiro" "Project" "Ledger"+                    second <- executePlannedWorkspaceScaffold out renamed+                    let stalePaths = map stalePath (wsrStale second)+                    stalePaths `shouldSatisfy` (not . null)+                    stalePaths `shouldSatisfy` all (T.isInfixOf "/Project/" . T.pack)+                    -- Nothing the sibling member owns is stale, and nothing it+                    -- owns changed on disk: no cross-member false positives.+                    stalePaths `shouldSatisfy` all (`notElem` siblingPaths)+                    siblingsAfter <- traverse (TIO.readFile . (out </>)) siblingPaths+                    siblingsAfter `shouldBe` siblingsBefore+                    forM_ stalePaths $ \path -> doesFileExist (out </> path) `shouldReturn` True+                    renderWorkspaceScaffoldReport second+                        `shouldSatisfy` any (T.isInfixOf "keiro-dsl never deletes files.")+            it "reports an aggregate moved between members as an ownership move, not stale churn" $+                withWorkspaceFixture "keiro-dsl-workspace-move" id $ \root out workspace -> do+                    _ <- executePlannedWorkspaceScaffold out workspace+                    before <- treeSnapshot out+                    moved <- moveArtifactAggregate root+                    second <- executePlannedWorkspaceScaffold out moved+                    wsrStale second `shouldBe` []+                    let moves = wsrOwnershipMoves second+                    moves `shouldSatisfy` (not . null)+                    moves+                        `shouldSatisfy` all+                            ( \move ->+                                omPrevious move == Just "domain/project-artifact.keiro"+                                    && omCurrent move == Just "domain/project.keiro"+                            )+                    map omPath moves+                        `shouldSatisfy` any (T.isInfixOf "ProjectArtifact" . T.pack)+                    -- An ownership move is not a content change: every module's+                    -- bytes, and the build manifest, are untouched.+                    map thd3 (wsrDispositions second)+                        `shouldSatisfy` all (`elem` [Unchanged, Skipped])+                    after <- treeSnapshot out+                    map fst after `shouldBe` map fst before+                    [(path, text) | (path, text) <- after, not ("scaffold-record" `T.isInfixOf` T.pack path)]+                        `shouldBe` [(path, text) | (path, text) <- before, not ("scaffold-record" `T.isInfixOf` T.pack path)]+                    renderWorkspaceScaffoldReport second+                        `shouldSatisfy` any (T.isInfixOf "changed owning member")+            it "leaves the tree, record, and manifest untouched when any member refuses" $+                withWorkspaceFixture "keiro-dsl-workspace-atomic" id $ \_ out workspace -> do+                    _ <- executePlannedWorkspaceScaffold out workspace+                    before <- treeSnapshot out+                    let broken = withCaseVariantAggregate workspace+                    case planWorkspaceScaffold "goldens" (workspaceContext broken) broken of+                        Right _ -> expectationFailure "expected the broken workspace to refuse"+                        Left refusals -> refusals `shouldSatisfy` any isPathCollision+                    treeSnapshot out `shouldReturn` before+                    -- A fresh output directory is never even created.+                    withTempDirectory "keiro-dsl-workspace-atomic-fresh" $ \fresh -> do+                        let target = fresh </> "out"+                        case planWorkspaceScaffold "goldens" (workspaceContext broken) broken of+                            Right _ -> expectationFailure "expected the broken workspace to refuse"+                            Left _ -> doesDirectoryExist target `shouldReturn` False+            it "leaves prior workspace output byte-identical for parse, validation, and collision failures" $+                withWorkspaceFixture "keiro-dsl-workspace-atomic-cli" id $ \root out workspace -> do+                    _ <- executePlannedWorkspaceScaffold out workspace+                    before <- treeSnapshot out+                    let member = root </> "domain/project-artifact.keiro"+                        manifest = root </> "service.keiro-workspace"+                    original <- TIO.readFile member+                    let failures =+                            [ ("parse", "context demo-project\naggregate !!!\n")+                            , ("validation", T.replace "ProjectId" "MissingProjectId" original)+                            , ("collision", T.replace "aggregate ProjectArtifact" "aggregate PROJECT" original)+                            ]+                    forM_ failures $ \(failureKind, brokenSource) -> do+                        TIO.writeFile member brokenSource+                        (exitCode, stdoutText, stderrText) <-+                            runKeiroDsl ["scaffold", manifest, "--out", out]+                        unless (exitCode == ExitFailure 1) $+                            expectationFailure+                                (failureKind <> " failure unexpectedly scaffolded:\n" <> stdoutText <> stderrText)+                        treeSnapshot out `shouldReturn` before+                        TIO.writeFile member original+            it "refuses the whole workspace for one bannerless Generated target, changing nothing" $+                withWorkspaceFixture "keiro-dsl-workspace-banner" id $ \_ out workspace -> do+                    plan <- shouldPlanWorkspaceSpec workspace+                    let generated = [m | (m, _) <- wpModules plan, kind m == Generated]+                    case generated of+                        [] -> expectationFailure "workspace fixture has no Generated module"+                        target : _ -> do+                            let path = out </> modulePath target+                            createDirectoryIfMissing True (takeDirectory path)+                            TIO.writeFile path "hand owned\n"+                            before <- treeSnapshot out+                            refused <- executeWorkspaceScaffold out False plan+                            refused `shouldSatisfy` isMissingBannerRefusal+                            treeSnapshot out `shouldReturn` before+                            forced <- executeWorkspaceScaffold out True plan+                            forced `shouldSatisfy` isSuccessfulScaffold+                            TIO.readFile path `shouldReturn` moduleText target+            it "scaffolds a whole workspace through the CLI" $+                withTempDirectory "keiro-dsl-workspace-cli" $ \out -> do+                    (exitCode, stdoutText, stderrText) <-+                        runKeiroDsl ["scaffold", canonicalWorkspacePath, "--out", out]+                    unless (exitCode == ExitSuccess) (expectationFailure (stdoutText <> stderrText))+                    stderrText `shouldContain` "workspace: demo-project"+                    doesFileExist (out </> "keiro-dsl-scaffold-record.workspace.demo-project.txt")+                        `shouldReturn` True+                    tree <- treeSnapshot out+                    length [path | (path, _) <- tree, "StructuralProjections.hs" `T.isSuffixOf` T.pack path]+                        `shouldBe` 1+                    length [path | (path, _) <- tree, "ReplayAudit.hs" `T.isSuffixOf` T.pack path]+                        `shouldBe` 1+                    (secondCode, _, secondErr) <-+                        runKeiroDsl ["scaffold", canonicalWorkspacePath, "--out", out]+                    secondCode `shouldBe` ExitSuccess+                    secondErr `shouldSatisfy` (not . isInfixOfString "(overwritten)")+                    treeSnapshot out `shouldReturn` tree++        describe "workspace adoption" $ do+            it "adopts an overwritten same-context record pair by record and by banner" $+                withInlineWorkspace "keiro-dsl-workspace-adopt" adoptionMembers $ \_ out workspace -> do+                    -- Reproduce today's defect first: two same-context specs+                    -- scaffolded independently into one directory, the second+                    -- replacing the first's record and calling its files stale.+                    specA <- parseInlineSpec "domain/a.keiro" adoptionMemberA+                    specB <- parseInlineSpec "domain/b.keiro" adoptionMemberB+                    let ctx = defaultContext "adoption-demo"+                    legacyA <- executePlannedScaffold out "domain/a.keiro" ctx specA+                    legacyB <- executePlannedScaffold out "domain/b.keiro" ctx specB+                    reportStale legacyB `shouldSatisfy` (not . null)+                    legacyBefore <- TIO.readFile (out </> recordFileName "adoption-demo")++                    report <- executePlannedWorkspaceScaffold out workspace+                    wsrStale report `shouldBe` []+                    case wsrMigration report of+                        Nothing -> expectationFailure "expected the first workspace run to adopt"+                        Just migration -> do+                            let generatedOf run = sort [modulePath m | (m, _) <- reportDispositions run, kind m == Generated]+                                claimedBy evidence = sort [cfPath entry | entry <- mrClaimed migration, cfEvidence entry == evidence]+                            -- The surviving record attributes B's files; A's+                            -- files survived only as banners, which is exactly+                            -- the orphan case the overwrite created.+                            claimedBy ClaimedFromRecord `shouldBe` generatedOf legacyB+                            claimedBy ClaimedFromBanner `shouldBe` sort (generatedOf legacyA \\ generatedOf legacyB)+                            claimedBy ClaimedFromBanner `shouldSatisfy` (not . null)+                            mrLikelyStale migration `shouldBe` []+                            mrLegacyRecord migration+                                `shouldBe` Just (recordFileName "adoption-demo", "domain/b.keiro")+                            -- Provenance is persisted, not merely printed.+                            recorded <- parseWorkspaceRecord <$> TIO.readFile (wsrRecordPath report)+                            fmap (sort . map adPath . wrAdopted) recorded+                                `shouldBe` Just (sort (map cfPath (mrClaimed migration)))+                            fmap (sort . nubOrd . map adEvidence . wrAdopted) recorded+                                `shouldBe` Just ["banner", "record"]+                            persisted <- TIO.readFile (out </> "keiro-dsl-migration-report.workspace.adoption-demo.txt")+                            persisted `shouldBe` T.unlines (renderMigrationReport migration)+                            renderWorkspaceScaffoldReport report+                                `shouldSatisfy` any (T.isInfixOf "adopting pre-workspace scaffold output")++                    -- The legacy record gained one line and nothing else: it+                    -- still parses to the same value for an old binary.+                    legacyAfter <- TIO.readFile (out </> recordFileName "adoption-demo")+                    T.lines legacyAfter `shouldSatisfy` elem (supersededByLine "adoption-demo")+                    parseRecord legacyAfter `shouldBe` parseRecord legacyBefore+                    T.lines legacyAfter+                        `shouldBe` T.lines legacyBefore <> [supersededByLine "adoption-demo"]++                    -- Adoption is not a content change: the generated tree is+                    -- what a fresh workspace scaffold of the same members emits.+                    withInlineWorkspace "keiro-dsl-workspace-adopt-fresh" adoptionMembers $ \_ fresh freshWorkspace -> do+                        freshReport <- executePlannedWorkspaceScaffold fresh freshWorkspace+                        wsrMigration freshReport `shouldBe` Nothing+                        adoptedTree <- treeSnapshot out+                        freshTree <- treeSnapshot fresh+                        haskellOnly adoptedTree `shouldBe` haskellOnly freshTree+            it "lists hand-written files as unclaimed and leaves their bytes alone" $+                withInlineWorkspace "keiro-dsl-workspace-unclaimed" adoptionMembers $ \_ out workspace -> do+                    plan <- shouldPlanWorkspaceSpec workspace+                    case [modulePath m | (m, _) <- wpModules plan, kind m == HoleStub] of+                        [] -> expectationFailure "adoption fixture emits no hole module"+                        holePath : _ -> do+                            writeFileWithParents (out </> holePath) "-- hand filled\n"+                            writeFileWithParents (out </> "Notes.hs") "module Notes where\n"+                            report <- executePlannedWorkspaceScaffold out workspace+                            case wsrMigration report of+                                Nothing -> expectationFailure "expected a report for a directory holding hand-written files"+                                Just migration -> do+                                    mrLegacyRecord migration `shouldBe` Nothing+                                    mrClaimed migration `shouldBe` []+                                    mrUnclaimed migration `shouldBe` sort [holePath, "Notes.hs"]+                            TIO.readFile (out </> holePath) `shouldReturn` "-- hand filled\n"+                            TIO.readFile (out </> "Notes.hs") `shouldReturn` "module Notes where\n"+            it "never claims a bannerless file at a planned Generated path" $+                withInlineWorkspace "keiro-dsl-workspace-unattributable" adoptionMembers $ \_ out workspace -> do+                    plan <- shouldPlanWorkspaceSpec workspace+                    case [modulePath m | (m, _) <- wpModules plan, kind m == Generated] of+                        [] -> expectationFailure "adoption fixture emits no Generated module"+                        target : _ -> do+                            writeFileWithParents (out </> target) "hand owned\n"+                            refused <- executeWorkspaceScaffold out False plan+                            refused `shouldSatisfy` isMissingBannerRefusal+                            TIO.readFile (out </> target) `shouldReturn` "hand owned\n"+                            doesFileExist (out </> "keiro-dsl-migration-report.workspace.adoption-demo.txt")+                                `shouldReturn` False+            it "adopts at most once, and the second run is an ordinary idempotent run" $+                withInlineWorkspace "keiro-dsl-workspace-adopt-once" adoptionMembers $ \_ out workspace -> do+                    specA <- parseInlineSpec "domain/a.keiro" adoptionMemberA+                    _ <- executePlannedScaffold out "domain/a.keiro" (defaultContext "adoption-demo") specA+                    first <- executePlannedWorkspaceScaffold out workspace+                    wsrMigration first `shouldSatisfy` \case Just _ -> True; Nothing -> False+                    before <- treeSnapshot out+                    reportBefore <- TIO.readFile (out </> "keiro-dsl-migration-report.workspace.adoption-demo.txt")+                    legacyBefore <- TIO.readFile (out </> recordFileName "adoption-demo")++                    second <- executePlannedWorkspaceScaffold out workspace+                    wsrMigration second `shouldBe` Nothing+                    wsrStale second `shouldBe` []+                    map thd3 (wsrDispositions second) `shouldSatisfy` all (`elem` [Unchanged, Skipped])+                    treeSnapshot out `shouldReturn` before+                    TIO.readFile (out </> "keiro-dsl-migration-report.workspace.adoption-demo.txt")+                        `shouldReturn` reportBefore+                    legacyAfter <- TIO.readFile (out </> recordFileName "adoption-demo")+                    legacyAfter `shouldBe` legacyBefore+                    length (filter (== supersededByLine "adoption-demo") (T.lines legacyAfter))+                        `shouldBe` 1++comparisonProvenance :: CompareProvenance+comparisonProvenance =+    CompareProvenance+        { cpHistoricalCodecIdentity = "example.historical"+        , cpHistoricalCodecVersion = "legacy-v1"+        , cpCanonicalType = CanonicalTypeId "example.Artifact.v1"+        , cpBindingSymbol = QualifiedValueName "Example.Bindings.artifactBinding"+        , cpBindingVersion = BindingVersion "1"+        , cpWireFingerprint = "deadbeef"+        }++syntheticGenerated :: FilePath -> T.Text -> ScaffoldModule+syntheticGenerated path contents =+    ScaffoldModule{modulePath = path, moduleText = contents, kind = Generated, origin = "test"}++generatedTextEndingIn :: T.Text -> [ScaffoldModule] -> T.Text+generatedTextEndingIn suffix modules = case [moduleText m | m <- modules, kind m == Generated, suffix `T.isSuffixOf` T.pack (modulePath m)] of+    contents : _ -> contents+    [] -> ""++onlyAggregate :: Spec -> Aggregate+onlyAggregate spec = case [aggregate | NAggregate aggregate <- specNodes spec] of+    [aggregate] -> aggregate+    aggregates -> error ("expected one aggregate, got " <> show (length aggregates))++loweringAggregateSpec :: T.Text+loweringAggregateSpec =+    T.unlines+        [ "context samples"+        , ""+        , "aggregate Counter"+        , "  regs"+        , "    note Text = \"hello world\""+        , "    count Int = 0"+        , "    state CounterVertex = Pending"+        , "  states Pending Done!"+        , "  command Bump { count:Int }"+        , "  event CountBumped { count:Int }"+        , "  Pending -- Bump --> emit CountBumped ; goto Done"+        ]++exactStatusSpec :: T.Text+exactStatusSpec =+    T.unlines+        [ "context samples"+        , ""+        , "aggregate Reservation"+        , "  regs"+        , "    state ReservationVertex = Open"+        , "  states Open Closed!"+        , "  command Bump { count:Int }"+        , "  event ReservationHeld { count:Int }"+        , "  event ReservationUnHeld { count:Int }"+        , "  event CountBumped { count:Int }"+        , "  Open -- Bump --> emit CountBumped ; goto Closed"+        , "  projection reservation_status consistency=Eventual key=count"+        , "    status-map { ReservationHeld=>held ReservationUnHeld=>available CountBumped=>bumped }"+        ]++hasPathCollisionWithTwoOrigins :: Either [Refusal] [ScaffoldModule] -> Bool+hasPathCollisionWithTwoOrigins = \case+    Left refusals -> any hasTwo refusals+    Right _ -> False+  where+    hasTwo (PathCollision _ origins) = length origins == 2+    hasTwo _ = False++isMissingBannerRefusal :: Either [Refusal] a -> Bool+isMissingBannerRefusal = \case+    Left [MissingGeneratedBanner paths] -> not (null paths)+    _ -> False++isSuccessfulScaffold :: Either [Refusal] a -> Bool+isSuccessfulScaffold = \case+    Right _ -> True+    Left _ -> False++executePlannedScaffold :: FilePath -> FilePath -> Context -> Spec -> IO ScaffoldReport+executePlannedScaffold out specPath ctx spec = case planScaffold ctx spec of+    Left refusals -> expectationFailure ("unexpected scaffold refusal: " <> show refusals) >> error "unreachable"+    Right modules -> do+        result <- executeScaffold out False specPath ctx spec modules+        case result of+            Left refusals -> expectationFailure ("unexpected execution refusal: " <> show refusals) >> error "unreachable"+            Right report -> pure report++renameCounter :: Node -> Node+renameCounter (NAggregate aggregate) =+    NAggregate+        aggregate+            { aggName = "Widget"+            , aggRegs = [reg{regType = if regType reg == "CounterVertex" then "WidgetVertex" else regType reg} | reg <- aggRegs aggregate]+            }+renameCounter node = node++onlyPathEndingIn :: FilePath -> [ScaffoldModule] -> FilePath+onlyPathEndingIn suffix modules = case [modulePath m | m <- modules, T.pack suffix `T.isSuffixOf` T.pack (modulePath m)] of+    [path] -> path+    paths -> error ("expected one path ending in " <> suffix <> ", got " <> show paths)++withTempDirectory :: String -> (FilePath -> IO a) -> IO a+withTempDirectory template = bracket acquire removePathForcibly+  where+    acquire = do+        base <- getTemporaryDirectory+        (path, handle) <- openTempFile base template+        hClose handle+        removeFile path+        createDirectory path+        pure path++{- | Parse a fixture and return the validator's diagnostic codes (failing the+test on a parse error).+-}+diagnosticCodesOf :: FilePath -> IO [DiagnosticCode]+diagnosticCodesOf path = do+    map code <$> diagnosticsOf path++-- | Parse a fixture and return all validator diagnostics.+diagnosticsOf :: FilePath -> IO [Diagnostic]+diagnosticsOf path = do+    input <- readTestText path+    case parseSpec path input of+        Left err -> expectationFailure (T.unpack err) >> pure []+        Right spec -> pure (validateSpec spec)++{- | Like 'diagnosticCodesOf' but only the Error-severity codes (warnings, e.g.+the benign-inversion notices, are excluded).+-}+errorCodesOf :: FilePath -> IO [DiagnosticCode]+errorCodesOf path = do+    diagnostics <- diagnosticsOf path+    pure [code d | d <- diagnostics, severity d == Error]++{- | Parse two fixtures and diff them (old, new).+| Plan 143: render an Expr in concrete guard syntax by printing a dummy+transition through the real pretty-printer and slicing its guard clause,+so the test exercises the exact printer the diff advisory uses.+-}+renderExprText :: Expr -> T.Text+renderExprText e =+    case [T.strip l | l <- T.lines rendered, "guard " `T.isPrefixOf` T.strip l] of+        [guardLine] -> T.strip (T.drop (T.length "guard ") guardLine)+        _ -> error ("renderExprText: unexpected printer output: " <> T.unpack rendered)+  where+    rendered =+        renderTransition+            Transition+                { tSource = "S"+                , tCommand = "C"+                , tGuard = Just e+                , tWrites = []+                , tEmits = []+                , tGoto = "S"+                , tMode = TmLive+                , tLoc = noLoc+                }++{- | Plan 143: a minimal spec whose only transition is replay-only, with the+supplied clause lines spliced into its body.+-}+replayOnlySpecWith :: [T.Text] -> T.Text+replayOnlySpecWith clauseLines =+    T.unlines $+        [ "context hospital-capacity"+        , ""+        , "id TransferReservationId prefix=rsv"+        , ""+        , "aggregate Reservation"+        , "  regs"+        , "    reservationId    TransferReservationId = placeholder"+        , "    reservationState ReservationVertex     = Unrequested"+        , "  states Unrequested Held"+        , ""+        , "  command RequestTransferReservation { reservationId }"+        , ""+        , "  event TransferReservationCreated = fields(RequestTransferReservation)"+        , ""+        , "  replay-only Unrequested -- RequestTransferReservation -->"+        ]+            ++ clauseLines++diffFixtures :: FilePath -> FilePath -> IO [Change]+diffFixtures oldP newP = do+    old <- readTestText oldP+    new <- readTestText newP+    case (,) <$> parseSpec oldP old <*> parseSpec newP new of+        Left err -> expectationFailure (T.unpack err) >> pure []+        Right (o, n) -> pure (diffSpecs o n)++kindOfChange :: Change -> ChangeKind+kindOfChange (Additive kind) = kind+kindOfChange (Advisory kind) = kind+kindOfChange (Breaking kind) = kind++labelOfChange :: Change -> Label+labelOfChange Additive{} = LabelAdditive+labelOfChange Advisory{} = LabelAdvisory+labelOfChange Breaking{} = LabelBreaking++genSurfaceSet :: Gen (Set.Set CompatibilitySurface)+genSurfaceSet = Set.fromList <$> listOf (elements [minBound .. maxBound])++genCompatibilityVector :: Gen CompatibilityVector+genCompatibilityVector =+    CompatibilityVector+        <$> genVerdict+        <*> genVerdict+        <*> genVerdict+        <*> genVerdict+        <*> genVerdict+        <*> genVerdict+        <*> (Set.fromList <$> listOf (elements rolloutConstraints))+  where+    genVerdict = elements [VCompatible, VAdvisory, VBreaking, VNotApplicable]+    rolloutConstraints =+        [ RolloutStopTheWorld+        , RolloutWorkersFirst+        , RolloutDrainRequired+        , RolloutProducerLast+        ]++replayImpactFixtures :: FilePath -> FilePath -> IO ReplayImpact+replayImpactFixtures oldPath newPath = do+    old <- specOf oldPath+    new <- specOf newPath+    pure (ReplayImpact.replayImpact old new)++modifyAggregate :: Name -> (Aggregate -> Aggregate) -> Spec -> Spec+modifyAggregate target update spec =+    spec+        { specNodes =+            [ case node of+                NAggregate aggregate | aggName aggregate == target -> NAggregate (update aggregate)+                _ -> node+            | node <- specNodes spec+            ]+        }++modifyReadModel :: Name -> (ReadModelNode -> ReadModelNode) -> Spec -> Spec+modifyReadModel target update spec =+    spec+        { specNodes =+            [ case node of+                NReadModel readModel | rmName readModel == target -> NReadModel (update readModel)+                _ -> node+            | node <- specNodes spec+            ]+        }++removeReadModel :: Name -> Spec -> Spec+removeReadModel target spec =+    spec{specNodes = [node | node <- specNodes spec, not (isTarget node)]}+  where+    isTarget (NReadModel readModel) = rmName readModel == target+    isTarget _ = False++modifyRouter :: Name -> (RouterNode -> RouterNode) -> Spec -> Spec+modifyRouter target update spec =+    spec+        { specNodes =+            [ case node of+                NRouter router | rtId router == target -> NRouter (update router)+                _ -> node+            | node <- specNodes spec+            ]+        }++routerErrorCodes :: (RouterNode -> RouterNode) -> Spec -> [DiagnosticCode]+routerErrorCodes update = errorCodes . modifyRouter "PagingRouter" update++modifyProcess :: Name -> (ProcessNode -> ProcessNode) -> Spec -> Spec+modifyProcess target update spec =+    spec+        { specNodes =+            [ case node of+                NProcess process | procId process == target -> NProcess (update process)+                _ -> node+            | node <- specNodes spec+            ]+        }++processErrorCodes :: (ProcessNode -> ProcessNode) -> Spec -> [DiagnosticCode]+processErrorCodes update = errorCodes . modifyProcess "HospitalSurge" update++errorCodes :: Spec -> [DiagnosticCode]+errorCodes spec = [code diagnostic | diagnostic <- validateSpec spec, severity diagnostic == Error]++changeReadModelShape :: ReadModelNode -> ReadModelNode+changeReadModelShape readModel =+    readModel+        { rmColumns = rmColumns readModel <> [RmColumn "reviewed_by" "text" False]+        , rmShape = "fnv1a:0000000000000000"+        }++{- | Assert a @new \<kind\>@ skeleton parses and validates with zero+error-severity diagnostics.+-}+assertSkeletonValid :: T.Text -> IO ()+assertSkeletonValid kind = case skeletonFor kind of+    Left err -> expectationFailure (T.unpack ("skeleton for " <> kind <> ": " <> err))+    Right src -> case parseSpec ("new:" <> T.unpack kind) src of+        Left perr -> expectationFailure (T.unpack ("skeleton for " <> kind <> " failed to parse: " <> perr))+        Right spec ->+            [code d | d <- validateSpec spec, severity d == Error]+                `shouldBe` ([] :: [DiagnosticCode])++assertSkeletonScaffoldable :: T.Text -> IO ()+assertSkeletonScaffoldable kind = case skeletonFor kind of+    Left err -> expectationFailure (T.unpack ("skeleton for " <> kind <> ": " <> err))+    Right src -> case parseSpec ("new:" <> T.unpack kind) src of+        Left perr -> expectationFailure (T.unpack perr)+        Right spec -> planScaffold (defaultContext (specContext spec)) spec `shouldSatisfy` isSuccessfulScaffold++skeletonModuleRoots :: [(T.Text, T.Text)]+skeletonModuleRoots =+    [ ("aggregate", "SkelAggregate")+    , ("process", "SkelProcess")+    , ("router", "SkelRouter")+    , ("contract", "SkelContract")+    , ("intake", "SkelIntake")+    , ("emit", "SkelEmit")+    , ("workqueue", "SkelQueue")+    , ("workflow", "SkelWorkflow")+    ]++assertSkeletonMatchesCommitted :: T.Text -> T.Text -> IO ()+assertSkeletonMatchesCommitted kind root = case skeletonFor kind of+    Left err -> expectationFailure (T.unpack err)+    Right source -> case parseSpec ("new:" <> T.unpack kind) source of+        Left err -> expectationFailure (T.unpack err)+        Right spec -> do+            let ctx = (defaultContext (specContext spec)){moduleRoot = root}+            forM_ [m | m <- scaffoldModules ctx spec, kindOf m == Generated] $ \m -> do+                committed <- readTestText ("test/conformance-skeletons/" <> modulePath m)+                normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText m)+  where+    kindOf = Keiro.Dsl.Scaffold.kind++bumpArtifactBindingVersion :: MappedDecl -> MappedDecl+bumpArtifactBindingVersion declaration@MappedStructural{msName = "ArtifactInfo"} =+    declaration{msBindingVersion = Just "2"}+bumpArtifactBindingVersion declaration = declaration++addArtifactSummaryField :: MappedDecl -> MappedDecl+addArtifactSummaryField declaration@MappedStructural{msName = "ArtifactInfo", msShape = ShapeRecord constructor unknownFields fields} =+    declaration+        { msShape =+            ShapeRecord+                constructor+                unknownFields+                ( fields+                    <> [ WireField+                            { wfHaskell = "summary"+                            , wfKey = "summary"+                            , wfType = TText+                            , wfPresence = PRequired+                            , wfOnMissing = Nothing+                            , wfLoc = Loc 0+                            }+                       ]+                )+        }+addArtifactSummaryField declaration = declaration++expectGenericCompileFailure :: FilePath -> String -> Expectation+expectGenericCompileFailure fixture expectedDiagnostic = do+    let fixtureDir = "../keiro-core/test/compile-fail" </> fixture+        fixtureSource = fixtureDir </> "Fixture.hs"+    (exitCode, standardOutput, standardError) <-+        readProcessWithExitCode+            "cabal"+            [ "exec"+            , "--"+            , "ghc"+            , "-XGHC2024"+            , "-fno-code"+            , "-fforce-recomp"+            , "-i../keiro-core/src"+            , "-i" <> fixtureDir+            , fixtureSource+            ]+            ""+    exitCode `shouldSatisfy` (/= ExitSuccess)+    let compilerOutput = standardOutput <> standardError+    compilerOutput `shouldContain` expectedDiagnostic+    compilerOutput `shouldContain` "Run keiro-dsl scaffold and fill the binding by hand at this error location in the scaffolded module."+    compilerOutput `shouldContain` fixtureSource++moveArtifactBindingIntoGenerated :: MappedDecl -> MappedDecl+moveArtifactBindingIntoGenerated declaration@MappedStructural{msName = "ArtifactInfo"} =+    declaration{msBinding = Just "Generated.ConsumerDemo.Bindings.artifactInfoBinding"}+moveArtifactBindingIntoGenerated declaration = declaration++removeMappedRegisterRequirements :: Spec -> Spec+removeMappedRegisterRequirements spec =+    spec+        { specMapped = map removeInitial (specMapped spec)+        , specNodes = map removeRegisters (specNodes spec)+        }+  where+    removeInitial declaration@MappedStructural{} = declaration{msInitial = Nothing}+    removeInitial declaration@MappedOpaque{} = declaration{moInitial = Nothing}+    removeRegisters (NAggregate aggregate) =+        NAggregate+            aggregate+                { aggRegs = []+                , aggTransitions = [transition{tWrites = []} | transition <- aggTransitions aggregate]+                }+    removeRegisters node = node++isImportCycle :: Refusal -> Bool+isImportCycle ImportCycle{} = True+isImportCycle _ = False++isLoweringRefusal :: Either [Refusal] modules -> Bool+isLoweringRefusal (Left refusals) = any isLowering refusals+  where+    isLowering LoweringRefusal{} = True+    isLowering _ = False+isLoweringRefusal (Right _) = False++-- | The canonical positive workspace fixture: three members under one context.+canonicalWorkspacePath :: FilePath+canonicalWorkspacePath = "test/fixtures/workspace/service.keiro-workspace"++-- | Deterministic workspace source used to model git blobs without invoking git.+memoryContentSource :: Map.Map FilePath T.Text -> ContentSource+memoryContentSource files =+    ContentSource+        { csRead = \path ->+            pure $ maybe (Left ("missing in-memory content: " <> T.pack path)) Right (Map.lookup path files)+        }++changeCode :: Change -> DiagnosticCode+changeCode (Additive kind) = ckCode kind+changeCode (Advisory kind) = ckCode kind+changeCode (Breaking kind) = ckCode kind++breakingSurfaces :: Change -> [CompatibilitySurface]+breakingSurfaces change =+    [ surface+    | surface <- [minBound .. maxBound]+    , verdictFor surface (ckVector kind) == VBreaking+    ]+  where+    kind = case change of+        Additive value -> value+        Advisory value -> value+        Breaking value -> value++workspaceChangeKind :: Change -> ChangeKind+workspaceChangeKind (Additive kind) = kind+workspaceChangeKind (Advisory kind) = kind+workspaceChangeKind (Breaking kind) = kind++-- | The same members as 'canonicalWorkspacePath', listed in reverse order.+reorderedWorkspacePath :: FilePath+reorderedWorkspacePath = "test/fixtures/workspace/service-reordered.keiro-workspace"++{- | Load and compose a workspace fixture, failing the test on a refusal. The+fixture path is package-relative; the loader is rooted at the manifest's own+directory, exactly as the CLI roots it.+-}+shouldComposeWorkspace :: FilePath -> IO WorkspaceSpec+shouldComposeWorkspace path = do+    resolved <- resolveTestPath path+    loaded <- loadWorkspace (fileContentSource (takeDirectory resolved)) resolved+    case loaded of+        Left failure ->+            expectationFailure (T.unpack (T.intercalate "\n" (renderWorkspaceFailure resolved failure)))+                >> error "unreachable"+        Right workspace -> pure workspace{wsManifestPath = path}++{- | The 'Context' a workspace scaffolds under, with no CLI overrides: the+members' unanimous context name, the manifest's module-root and layout+authority, and the built-in defaults where the manifest is silent.+-}+workspaceContext :: WorkspaceSpec -> Context+workspaceContext workspace =+    Context+        { contextName = wsContext workspace+        , moduleRoot = maybe "" id (wsModuleRoot workspace)+        , placement = maybe GeneratedPrefix id (wsLayout workspace)+        }++-- | Compose and plan a workspace fixture, failing the test on any refusal.+shouldPlanWorkspace :: FilePath -> IO WorkspacePlan+shouldPlanWorkspace path = do+    workspace <- shouldComposeWorkspace path+    case planWorkspaceScaffold "goldens" (workspaceContext workspace) workspace of+        Left refusals -> expectationFailure ("unexpected workspace plan refusal: " <> show refusals) >> error "unreachable"+        Right plan -> pure plan++-- | Does a scaffolded module's path end in this suffix?+isSuffixOfPath :: FilePath -> ScaffoldModule -> Bool+isSuffixOfPath suffix m = T.pack suffix `T.isSuffixOf` T.pack (modulePath m)++{- | A workspace record built from real composed data plus two synthetic+adoption rows, so the round-trip test exercises every row kind including the+JSON encodings shared with the v1 record.+-}+sampleWorkspaceRecord :: WorkspaceSpec -> WorkspaceRecord+sampleWorkspaceRecord workspace =+    WorkspaceRecord+        { wrService = wsService workspace+        , wrManifest = "service.keiro-workspace"+        , wrContext = wsContext workspace+        , wrModuleRoot = maybe "" id (wsModuleRoot workspace)+        , wrLayout = "collocated"+        , wrMembers = map wmPath (wsMembers workspace)+        , wrModules =+            [ WorkspaceModuleRow Generated "Demo/Generated/StructuralProjections.hs" Nothing+            , WorkspaceModuleRow Generated "Demo/Project/Generated/Domain.hs" (Just "domain/project.keiro")+            , WorkspaceModuleRow HoleStub "Demo/Project/Holes.hs" (Just "domain/shared.keiro")+            ]+        , wrMappings = consumerMappings (consumerPlan (wsMergedSpec workspace))+        , wrBindingObligations = either (const []) id (bindingHoles (wsMergedSpec workspace))+        , wrAdopted =+            [ AdoptedRow "claimed/One.hs" "record" (Just "keiro-dsl-scaffold-record.demo-project.txt") (Just "project.keiro")+            , AdoptedRow "claimed/Two.hs" "banner" Nothing Nothing+            ]+        }++{- | The canonical workspace with a case-variant copy of one member's aggregate+grafted onto another member. Composition refuses this shape (EP-153 catches it+at the earliest boundary), so the planner's own cross-member collision gate can+only be exercised by constructing the graph directly — which is exactly what+this does, mirroring the single-file @caseVariant@ construction.+-}+withCaseVariantAggregate :: WorkspaceSpec -> WorkspaceSpec+withCaseVariantAggregate workspace = case [aggregate | NAggregate aggregate <- specNodes merged, aggName aggregate == "Project"] of+    [] -> error "canonical workspace fixture has no Project aggregate"+    aggregate : _ ->+        let shouted = aggregate{aggName = T.toUpper (aggName aggregate)}+            ownership = wsOwnership workspace+         in workspace+                { wsMergedSpec = merged{specNodes = specNodes merged <> [NAggregate shouted]}+                , wsOwnership =+                    ownership+                        { oiNodes =+                            Map.insert+                                ("aggregate", aggName shouted)+                                ("domain/project-artifact.keiro", Loc 1)+                                (oiNodes ownership)+                        }+                }+  where+    merged = wsMergedSpec workspace++{- | Write a one-member workspace whose member declares an upcaster, so its+golden payload fixture has a canonical location. Returns the composed+workspace; the caller decides where the fixture lives.+-}+writeGoldenWorkspace :: FilePath -> IO WorkspaceSpec+writeGoldenWorkspace root = do+    source <- readTestText "test/fixtures/reservation-v2.keiro"+    createDirectoryIfMissing True (root </> "domain")+    TIO.writeFile (root </> "domain/reservation.keiro") source+    let manifestPath = root </> "service.keiro-workspace"+    TIO.writeFile manifestPath "service gold-demo\nspec domain/reservation.keiro\n"+    loaded <- loadWorkspace (fileContentSource root) manifestPath+    case loaded of+        Left failure ->+            expectationFailure (T.unpack (T.intercalate "\n" (renderWorkspaceFailure manifestPath failure)))+                >> error "unreachable"+        Right workspace -> pure workspace++{- | Materialize the canonical fixture workspace in a fresh temporary directory+and hand the callback its root, a sibling output directory, and the composed+workspace. Working on a copy is what lets a test edit a member and re-scaffold.++The manifest's @spec@ lines are passed through the given function first, so a+caller can list the same members in a different order; the manifest __file+name__ stays the same, which is what makes two runs comparable byte for byte.+-}+withWorkspaceFixture ::+    String ->+    ([FilePath] -> [FilePath]) ->+    (FilePath -> FilePath -> WorkspaceSpec -> IO a) ->+    IO a+withWorkspaceFixture template orderMembers act =+    withTempDirectory template $ \base -> do+        let root = base </> "workspace"+            out = base </> "out"+            members =+                [ "domain/project-artifact.keiro"+                , "domain/project.keiro"+                , "domain/shared.keiro"+                ]+        createDirectoryIfMissing True (root </> "domain")+        forM_ members $ \relative -> do+            source <- readTestText ("test/fixtures/workspace" </> relative)+            TIO.writeFile (root </> relative) source+        TIO.writeFile+            (root </> "service.keiro-workspace")+            ( T.unlines+                ( ["service demo-project", "module Demo.Modules.Project", "layout collocated"]+                    <> ["spec " <> T.pack relative | relative <- orderMembers members]+                )+            )+        workspace <- loadTempWorkspace root+        act root out workspace++{- | Materialize an inline workspace — a manifest plus literal member sources —+in a fresh temporary directory, and hand the callback its root, a sibling output+directory, and the composed workspace.+-}+withInlineWorkspace ::+    String ->+    (T.Text, [(FilePath, T.Text)]) ->+    (FilePath -> FilePath -> WorkspaceSpec -> IO a) ->+    IO a+withInlineWorkspace template (service, members) act =+    withTempDirectory template $ \base -> do+        let root = base </> "workspace"+            out = base </> "out"+        forM_ members $ \(relative, source) -> writeFileWithParents (root </> relative) source+        TIO.writeFile+            (root </> "service.keiro-workspace")+            ( T.unlines+                (("service " <> service) : ["spec " <> T.pack relative | (relative, _) <- members])+            )+        workspace <- loadTempWorkspace root+        act root out workspace++{- | Two independently valid members under one context. Each is a complete spec+that the pre-workspace single-file scaffolder accepts, which is what lets a test+reproduce the overwritten-record defect before adopting.+-}+adoptionMembers :: (T.Text, [(FilePath, T.Text)])+adoptionMembers = ("adoption-demo", [("domain/a.keiro", adoptionMemberA), ("domain/b.keiro", adoptionMemberB)])++adoptionMemberA :: T.Text+adoptionMemberA =+    T.unlines+        [ "context adoption-demo"+        , ""+        , "aggregate Counter"+        , "  regs"+        , "    count Int = 0"+        , "    state CounterVertex = Pending"+        , "  states Pending Done!"+        , "  command Bump { count:Int }"+        , "  event CountBumped { count:Int }"+        , "  Pending -- Bump --> emit CountBumped ; goto Done"+        ]++adoptionMemberB :: T.Text+adoptionMemberB =+    T.unlines+        [ "context adoption-demo"+        , ""+        , "aggregate Widget"+        , "  regs"+        , "    size Int = 0"+        , "    state WidgetVertex = Draft"+        , "  states Draft Shipped!"+        , "  command Ship { size:Int }"+        , "  event WidgetShipped { size:Int }"+        , "  Draft -- Ship --> emit WidgetShipped ; goto Shipped"+        ]++writeFileWithParents :: FilePath -> T.Text -> IO ()+writeFileWithParents path contents = do+    createDirectoryIfMissing True (takeDirectory path)+    TIO.writeFile path contents++-- | Only the Haskell sources of a tree snapshot, dropping bookkeeping files.+haskellOnly :: [(FilePath, T.Text)] -> [(FilePath, T.Text)]+haskellOnly entries = [entry | entry@(path, _) <- entries, ".hs" `T.isSuffixOf` T.pack path]++-- | Compose a workspace that a test just wrote to disk.+loadTempWorkspace :: FilePath -> IO WorkspaceSpec+loadTempWorkspace root = do+    let manifestPath = root </> "service.keiro-workspace"+    loaded <- loadWorkspace (fileContentSource root) manifestPath+    case loaded of+        Left failure ->+            expectationFailure (T.unpack (T.intercalate "\n" (renderWorkspaceFailure manifestPath failure)))+                >> error "unreachable"+        Right workspace -> pure workspace++-- | Plan an already-composed workspace, failing the test on a refusal.+shouldPlanWorkspaceSpec :: WorkspaceSpec -> IO WorkspacePlan+shouldPlanWorkspaceSpec workspace =+    case planWorkspaceScaffold "goldens" (workspaceContext workspace) workspace of+        Left refusals -> expectationFailure ("unexpected workspace plan refusal: " <> show refusals) >> error "unreachable"+        Right plan -> pure plan++-- | Plan then execute a whole-workspace scaffold, failing loudly on either.+executePlannedWorkspaceScaffold :: FilePath -> WorkspaceSpec -> IO WorkspaceScaffoldReport+executePlannedWorkspaceScaffold out workspace = do+    plan <- shouldPlanWorkspaceSpec workspace+    result <- executeWorkspaceScaffold out False plan+    case result of+        Left refusals -> expectationFailure ("unexpected workspace execution refusal: " <> show refusals) >> error "unreachable"+        Right report -> pure report++{- | Rename one member's aggregate in place and recompose. Only the+@aggregate \<Name\>@ header is rewritten, so declarations that merely share the+prefix (@ProjectId@, @ProjectSummary@) are untouched.+-}+renameMemberAggregate :: FilePath -> FilePath -> T.Text -> T.Text -> IO WorkspaceSpec+renameMemberAggregate root member from to = do+    source <- TIO.readFile (root </> member)+    TIO.writeFile (root </> member) (T.replace ("aggregate " <> from <> "\n") ("aggregate " <> to <> "\n") source)+    loadTempWorkspace root++{- | Move the @ProjectArtifact@ aggregate from the artifact member into the+project member, and recompose.++It is prepended, so the merged spec's node order — and therefore every emitted+byte, including the replay-audit assembly's aggregate list — is exactly what it+was. That isolates the change to ownership, which is the point of the test.+-}+moveArtifactAggregate :: FilePath -> IO WorkspaceSpec+moveArtifactAggregate root = do+    artifact <- TIO.readFile (root </> "domain/project-artifact.keiro")+    project <- TIO.readFile (root </> "domain/project.keiro")+    case T.breakOn "aggregate ProjectArtifact" artifact of+        (kept, moved) | not (T.null moved) -> do+            TIO.writeFile (root </> "domain/project-artifact.keiro") kept+            TIO.writeFile+                (root </> "domain/project.keiro")+                (T.replace "aggregate Project\n" (moved <> "\naggregate Project\n") project)+            loadTempWorkspace root+        _ -> expectationFailure "artifact member has no ProjectArtifact aggregate" >> error "unreachable"++{- | Every regular file under a directory, as @(relative path, contents)@ sorted+by path — the comparison unit for "byte-identical output".+-}+treeSnapshot :: FilePath -> IO [(FilePath, T.Text)]+treeSnapshot root = do+    exists <- doesDirectoryExist root+    if not exists then pure [] else sort <$> walk ""+  where+    walk relative = do+        entries <- listDirectory (root </> relative)+        fmap concat . forM (sort entries) $ \entry -> do+            let child = if null relative then entry else relative </> entry+            isDirectory <- doesDirectoryExist (root </> child)+            if isDirectory+                then walk child+                else do+                    contents <- TIO.readFile (root </> child)+                    pure [(child, contents)]++thd3 :: (a, b, c) -> c+thd3 (_, _, value) = value++isPathCollision :: Refusal -> Bool+isPathCollision PathCollision{} = True+isPathCollision _ = False++isInfixOfString :: String -> String -> Bool+isInfixOfString needle haystack = T.isInfixOf (T.pack needle) (T.pack haystack)++-- | Load a workspace fixture expecting a compose refusal, and return it.+shouldRefuseWorkspace :: FilePath -> IO (NonEmpty WorkspaceDiagnostic)+shouldRefuseWorkspace path = do+    resolved <- resolveTestPath path+    loaded <- loadWorkspace (fileContentSource (takeDirectory resolved)) resolved+    case loaded of+        Left (WorkspaceRefused diagnostics) -> pure diagnostics+        Left other ->+            expectationFailure+                ("expected compose refusals, got:\n" <> T.unpack (T.intercalate "\n" (renderWorkspaceFailure resolved other)))+                >> error "unreachable"+        Right _ -> expectationFailure ("expected " <> path <> " to be refused") >> error "unreachable"++{- | Invoke the built @keiro-dsl@ executable. Fixture paths are resolved first,+so the test works whether it runs from the package directory or the repository+root.+-}+runKeiroDsl :: [String] -> IO (ExitCode, String, String)+runKeiroDsl arguments = do+    resolved <- traverse resolveArgument arguments+    readProcessWithExitCode "cabal" (["run", "-v0", "keiro-dsl", "--"] <> resolved) ""+  where+    resolveArgument argument+        | "test/fixtures/" `isPrefixOfString` argument = resolveTestPath argument+        | otherwise = pure argument+    isPrefixOfString prefix value = take (length prefix) value == prefix++-- | The @spec@ field of a coverage report, i.e. what the report says it covers.+coverageSpecPath :: Value -> Maybe T.Text+coverageSpecPath value = case value of+    Aeson.Object fields -> case KeyMap.lookup "spec" fields of+        Just (Aeson.String path) -> Just path+        _ -> Nothing+    _ -> Nothing++-- | Order-preserving deduplication for comparing cited file sets.+nubOrd :: (Eq a) => [a] -> [a]+nubOrd = go []+  where+    go seen [] = reverse seen+    go seen (x : xs) = if x `elem` seen then go seen xs else go (x : seen) xs++-- | Parse a workspace manifest, failing the test on a refusal.+shouldParseManifest :: FilePath -> T.Text -> IO WorkspaceManifest+shouldParseManifest path source = case parseWorkspaceManifest path source of+    Left err -> expectationFailure (T.unpack err) >> error "unreachable"+    Right manifest -> pure manifest++{- | Generate a canonical workspace manifest. Members are drawn from a pool of+paths that are distinct even under case folding and are held sorted, which is+the invariant every parsed manifest satisfies.+-}+genWorkspaceManifest :: Gen WorkspaceManifest+genWorkspaceManifest = do+    service <- elements ["demo-project", "mori", "kotei", "a1", "svc-2"]+    moduleRoot <- elements [Nothing, Just "Demo", Just "Demo.Modules.Project"]+    layout <- elements [Nothing, Just GeneratedPrefix, Just CollocatedLeaf]+    chosen <-+        sublistOf+            [ "a.keiro"+            , "d-e_f.keiro"+            , "domain/b.keiro"+            , "domain/sub/c.keiro"+            , "x1.keiro"+            ]+            `suchThat` (not . null)+    pure+        WorkspaceManifest+            { wmfService = service+            , wmfServiceLoc = Loc 1+            , wmfModuleRoot = moduleRoot+            , wmfModuleRootLoc = Loc 2+            , wmfLayout = layout+            , wmfLayoutLoc = Loc 3+            , wmfMembers = NE.fromList [WorkspaceMemberRef path (Loc 4) | path <- sort chosen]+            }  -- | Parse a fixture into a 'Spec', failing the test on a parse error. specOf :: FilePath -> IO Spec