keiro-dsl 0.5.0.0 → 0.6.0.0
raw patch · 162 files changed
+31287/−24024 lines, 162 filesdep +mmzk-typeiddep ~aesondep ~containersdep ~keikiPVP ok
version bump matches the API change (PVP)
Dependencies added: mmzk-typeid
Dependency ranges changed: aeson, containers, keiki, time
API changes (from Hackage documentation)
+ Keiro.Dsl.AggregateType: AggregateBool :: ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateInt :: ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateInteger :: ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateMapped :: !MappedKey -> ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateNatural :: ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateNominal :: !ResolvedNominalType -> ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateText :: ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateTime :: ResolvedAggregateType
+ Keiro.Dsl.AggregateType: AggregateTypeError :: !Loc -> !AggregateUseSite -> !AggregateTypeErrorReason -> AggregateTypeError
+ Keiro.Dsl.AggregateType: AggregateVertex :: !Name -> ResolvedAggregateType
+ Keiro.Dsl.AggregateType: CodecUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: CommandFieldUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: EqualityGuardUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: EventFieldUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: HarnessSampleUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: HaskellLoweringUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: InitialBool :: !Bool -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialId :: !Name -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialInt :: !Int -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialInteger :: !Integer -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialMapped :: !MappedKey -> !QualifiedValueName -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialNamed :: !ResolvedAggregateType -> !Name -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialNatural :: !Natural -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialNominal :: !Name -> !QualifiedValueName -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialText :: !Text -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InitialTime :: !UTCTime -> ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: InvalidRegisterInitial :: !ResolvedAggregateType -> !Text -> AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: OpaqueOnly :: AggregateCapability
+ Keiro.Dsl.AggregateType: OrderingGuardUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: RegisterUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: SnapshotUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: SolverVisible :: AggregateCapability
+ Keiro.Dsl.AggregateType: UnknownAggregateType :: !Name -> AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: Unsupported :: AggregateCapability
+ Keiro.Dsl.AggregateType: UnsupportedAggregateCapability :: !ResolvedAggregateType -> AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: UnsupportedAggregateShape :: !TypeExpr -> AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: WholeValueWriteUse :: AggregateUseSite
+ Keiro.Dsl.AggregateType: [aggregateTypeErrorLoc] :: AggregateTypeError -> !Loc
+ Keiro.Dsl.AggregateType: [aggregateTypeErrorReason] :: AggregateTypeError -> !AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: [aggregateTypeErrorUseSite] :: AggregateTypeError -> !AggregateUseSite
+ Keiro.Dsl.AggregateType: aggregateCanonicalName :: ResolvedAggregateType -> Text
+ Keiro.Dsl.AggregateType: aggregateCapability :: AggregateUseSite -> ResolvedAggregateType -> AggregateCapability
+ Keiro.Dsl.AggregateType: aggregateHaskellType :: AggregateSymbols -> ResolvedAggregateType -> Text
+ Keiro.Dsl.AggregateType: aggregateImports :: AggregateSymbols -> ResolvedAggregateType -> Set Text
+ Keiro.Dsl.AggregateType: aggregatePackages :: AggregateSymbols -> ResolvedAggregateType -> Set Text
+ Keiro.Dsl.AggregateType: aggregateSampleHaskell :: AggregateSymbols -> Text -> ResolvedAggregateType -> Text
+ Keiro.Dsl.AggregateType: aggregateSymbols :: Spec -> AggregateSymbols
+ Keiro.Dsl.AggregateType: data AggregateCapability
+ Keiro.Dsl.AggregateType: data AggregateSymbols
+ Keiro.Dsl.AggregateType: data AggregateTypeError
+ Keiro.Dsl.AggregateType: data AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: data AggregateUseSite
+ Keiro.Dsl.AggregateType: data ResolvedAggregateType
+ Keiro.Dsl.AggregateType: data ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: inferAggregateFieldType :: AggregateSymbols -> Aggregate -> AggregateUseSite -> AggregateField -> Either AggregateTypeError ResolvedAggregateType
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Eq Keiro.Dsl.AggregateType.AggregateCapability
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Eq Keiro.Dsl.AggregateType.AggregateTypeError
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Eq Keiro.Dsl.AggregateType.AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Eq Keiro.Dsl.AggregateType.AggregateUseSite
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Eq Keiro.Dsl.AggregateType.ResolvedAggregateType
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Eq Keiro.Dsl.AggregateType.ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Ord Keiro.Dsl.AggregateType.AggregateCapability
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Ord Keiro.Dsl.AggregateType.AggregateUseSite
+ Keiro.Dsl.AggregateType: instance GHC.Classes.Ord Keiro.Dsl.AggregateType.ResolvedAggregateType
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Enum.Bounded Keiro.Dsl.AggregateType.AggregateCapability
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Enum.Bounded Keiro.Dsl.AggregateType.AggregateUseSite
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Enum.Enum Keiro.Dsl.AggregateType.AggregateCapability
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Enum.Enum Keiro.Dsl.AggregateType.AggregateUseSite
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Show.Show Keiro.Dsl.AggregateType.AggregateCapability
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Show.Show Keiro.Dsl.AggregateType.AggregateTypeError
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Show.Show Keiro.Dsl.AggregateType.AggregateTypeErrorReason
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Show.Show Keiro.Dsl.AggregateType.AggregateUseSite
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Show.Show Keiro.Dsl.AggregateType.ResolvedAggregateType
+ Keiro.Dsl.AggregateType: instance GHC.Internal.Show.Show Keiro.Dsl.AggregateType.ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: registerInitialCanonicalName :: ResolvedRegisterInitial -> Text
+ Keiro.Dsl.AggregateType: renderRegisterInitial :: ResolvedRegisterInitial -> Text
+ Keiro.Dsl.AggregateType: resolveAggregateType :: AggregateSymbols -> Loc -> AggregateUseSite -> TypeExpr -> Either AggregateTypeError ResolvedAggregateType
+ Keiro.Dsl.AggregateType: resolveRegisterInitial :: AggregateSymbols -> Loc -> ResolvedAggregateType -> RegInitial -> Either AggregateTypeError ResolvedRegisterInitial
+ Keiro.Dsl.AggregateType: typeExprCanonicalName :: TypeExpr -> Text
+ Keiro.Dsl.Diff: diffSources :: ParsedSource -> ParsedSource -> [Change]
+ Keiro.Dsl.Diff: sourceLanguageChange :: Name -> Text -> SourceLanguage -> SourceLanguage -> [Change]
+ Keiro.Dsl.DiffReport: RemedyNoSemanticAction :: Remedy
+ Keiro.Dsl.ExplainBindings: BindingNominalTypeError :: !NominalTypeError -> BindingResolutionError
+ Keiro.Dsl.ExplainBindings: BindingTypeGraphError :: !TypeGraphError -> BindingResolutionError
+ Keiro.Dsl.ExplainBindings: [obligationCanonicalType] :: BindingObligation -> !Maybe Text
+ Keiro.Dsl.ExplainBindings: [obligationCategory] :: BindingObligation -> !Text
+ Keiro.Dsl.ExplainBindings: data BindingResolutionError
+ Keiro.Dsl.ExplainBindings: instance GHC.Classes.Eq Keiro.Dsl.ExplainBindings.BindingResolutionError
+ Keiro.Dsl.ExplainBindings: instance GHC.Internal.Show.Show Keiro.Dsl.ExplainBindings.BindingResolutionError
+ Keiro.Dsl.Expression: ExactIntegerArithmetic :: ArithmeticEvidence
+ Keiro.Dsl.Expression: ExpectScalarType :: !ResolvedAggregateType -> ExpectedScalarType
+ Keiro.Dsl.Expression: ExpressionDiagnostic :: !Loc -> !ExpressionDiagnosticCode -> !Text -> ExpressionDiagnostic
+ Keiro.Dsl.Expression: InferScalarType :: ExpectedScalarType
+ Keiro.Dsl.Expression: ResolvedScalarProjection :: !MappedKey -> !Text -> ![Name] -> ResolvedScalarProjection
+ Keiro.Dsl.Expression: ScalarBoolValue :: !Bool -> ScalarValue
+ Keiro.Dsl.Expression: ScalarBooleanOperandRequired :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarCommandRoot :: !Name -> !ResolvedAggregateType -> ScalarRootProvenance
+ Keiro.Dsl.Expression: ScalarEnumValue :: !Name -> !Name -> ScalarValue
+ Keiro.Dsl.Expression: ScalarGuardBoolRequired :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarIdValue :: !Name -> !Text -> ScalarValue
+ Keiro.Dsl.Expression: ScalarIntValue :: !Int -> ScalarValue
+ Keiro.Dsl.Expression: ScalarIntegerValue :: !Integer -> ScalarValue
+ Keiro.Dsl.Expression: ScalarLiteralInvalid :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarLiteralNeedsType :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarNaturalValue :: !Integer -> ScalarValue
+ Keiro.Dsl.Expression: ScalarOperandTypeMismatch :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarOperatorUnsupported :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarPathInvalid :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarPathUnsupported :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarRegisterRoot :: !Name -> !ResolvedAggregateType -> ScalarRootProvenance
+ Keiro.Dsl.Expression: ScalarRootAmbiguous :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarRootUnknown :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarTextValue :: !Text -> ScalarValue
+ Keiro.Dsl.Expression: ScalarTimeValue :: !UTCTime -> ScalarValue
+ Keiro.Dsl.Expression: ScalarWriteTargetUnknown :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: ScalarWriteTypeMismatch :: ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: TotalNaturalArithmetic :: ArithmeticEvidence
+ Keiro.Dsl.Expression: TypedAdd :: !ArithmeticEvidence -> !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedAnd :: !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedCompare :: !CmpOp -> !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedEqual :: !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedLiteral :: !ScalarValue -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedMultiply :: !ArithmeticEvidence -> !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedNotEqual :: !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedOr :: !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedProject :: !ScalarRootProvenance -> !ResolvedScalarProjection -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedRoot :: !ScalarRootProvenance -> TypedScalarNode
+ Keiro.Dsl.Expression: TypedScalarExpr :: !ResolvedAggregateType -> !Loc -> !TypedScalarNode -> TypedScalarExpr
+ Keiro.Dsl.Expression: TypedSubtract :: !ArithmeticEvidence -> !TypedScalarExpr -> !TypedScalarExpr -> TypedScalarNode
+ Keiro.Dsl.Expression: [expressionDiagnosticCode] :: ExpressionDiagnostic -> !ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: [expressionDiagnosticLoc] :: ExpressionDiagnostic -> !Loc
+ Keiro.Dsl.Expression: [expressionDiagnosticMessage] :: ExpressionDiagnostic -> !Text
+ Keiro.Dsl.Expression: [scalarProjectionFields] :: ResolvedScalarProjection -> ![Name]
+ Keiro.Dsl.Expression: [scalarProjectionOwner] :: ResolvedScalarProjection -> !MappedKey
+ Keiro.Dsl.Expression: [scalarProjectionPointer] :: ResolvedScalarProjection -> !Text
+ Keiro.Dsl.Expression: [typedScalarLoc] :: TypedScalarExpr -> !Loc
+ Keiro.Dsl.Expression: [typedScalarNode] :: TypedScalarExpr -> !TypedScalarNode
+ Keiro.Dsl.Expression: [typedScalarType] :: TypedScalarExpr -> !ResolvedAggregateType
+ Keiro.Dsl.Expression: data ArithmeticEvidence
+ Keiro.Dsl.Expression: data ExpectedScalarType
+ Keiro.Dsl.Expression: data ExpressionDiagnostic
+ Keiro.Dsl.Expression: data ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: data ExpressionEnvironment
+ Keiro.Dsl.Expression: data ResolvedScalarProjection
+ Keiro.Dsl.Expression: data ScalarRootProvenance
+ Keiro.Dsl.Expression: data ScalarValue
+ Keiro.Dsl.Expression: data TypedScalarExpr
+ Keiro.Dsl.Expression: data TypedScalarNode
+ Keiro.Dsl.Expression: expressionEnvironment :: Spec -> Aggregate -> Transition -> ExpressionEnvironment
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.ArithmeticEvidence
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.ExpectedScalarType
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.ExpressionDiagnostic
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.ResolvedScalarProjection
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.ScalarRootProvenance
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.ScalarValue
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.TypedScalarExpr
+ Keiro.Dsl.Expression: instance GHC.Classes.Eq Keiro.Dsl.Expression.TypedScalarNode
+ Keiro.Dsl.Expression: instance GHC.Classes.Ord Keiro.Dsl.Expression.ArithmeticEvidence
+ Keiro.Dsl.Expression: instance GHC.Classes.Ord Keiro.Dsl.Expression.ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.ArithmeticEvidence
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.ExpectedScalarType
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.ExpressionDiagnostic
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.ExpressionDiagnosticCode
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.ResolvedScalarProjection
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.ScalarRootProvenance
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.ScalarValue
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.TypedScalarExpr
+ Keiro.Dsl.Expression: instance GHC.Internal.Show.Show Keiro.Dsl.Expression.TypedScalarNode
+ Keiro.Dsl.Expression: resolveGuardExpr :: ExpressionEnvironment -> Expr -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr
+ Keiro.Dsl.Expression: resolveScalarExpr :: ExpressionEnvironment -> ExpectedScalarType -> Expr -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr
+ Keiro.Dsl.Expression: resolveWriteExpr :: ExpressionEnvironment -> Name -> Expr -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr
+ Keiro.Dsl.Grammar: AggregateField :: !Name -> !Maybe TypeExpr -> !Loc -> AggregateField
+ Keiro.Dsl.Grammar: CommandRoot :: ExprRoot
+ Keiro.Dsl.Grammar: EAdd :: !Loc -> !Expr -> !Expr -> Expr
+ Keiro.Dsl.Grammar: ELiteral :: !Loc -> !ScalarLiteral -> Expr
+ Keiro.Dsl.Grammar: EMultiply :: !Loc -> !Expr -> !Expr -> Expr
+ Keiro.Dsl.Grammar: EPath :: !Loc -> !ExprRoot -> ![Name] -> Expr
+ Keiro.Dsl.Grammar: ESubtract :: !Loc -> !Expr -> !Expr -> Expr
+ Keiro.Dsl.Grammar: GeneratedImplementation :: TransitionImplementation
+ Keiro.Dsl.Grammar: HoleImplementation :: TransitionImplementation
+ Keiro.Dsl.Grammar: LegacyHoleImplementation :: TransitionImplementation
+ Keiro.Dsl.Grammar: LiteralBool :: !Bool -> ScalarLiteral
+ Keiro.Dsl.Grammar: LiteralId :: !Name -> !Text -> ScalarLiteral
+ Keiro.Dsl.Grammar: LiteralIntegral :: !Integer -> ScalarLiteral
+ Keiro.Dsl.Grammar: LiteralQualified :: !Name -> !Name -> ScalarLiteral
+ Keiro.Dsl.Grammar: LiteralText :: !Text -> ScalarLiteral
+ Keiro.Dsl.Grammar: NominalBindingDecl :: !Maybe HaskellSource -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Loc -> NominalBindingDecl
+ Keiro.Dsl.Grammar: NominalScalarDecl :: !Name -> !Name -> !NominalBindingDecl -> !Loc -> NominalScalarDecl
+ Keiro.Dsl.Grammar: RegisterRoot :: ExprRoot
+ Keiro.Dsl.Grammar: TInteger :: TypeExpr
+ Keiro.Dsl.Grammar: UnqualifiedRoot :: ExprRoot
+ Keiro.Dsl.Grammar: [aggregateFieldLoc] :: AggregateField -> !Loc
+ Keiro.Dsl.Grammar: [aggregateFieldName] :: AggregateField -> !Name
+ Keiro.Dsl.Grammar: [aggregateFieldType] :: AggregateField -> !Maybe TypeExpr
+ Keiro.Dsl.Grammar: [enumBinding] :: EnumDecl -> !Maybe NominalBindingDecl
+ Keiro.Dsl.Grammar: [idBinding] :: IdDecl -> !Maybe NominalBindingDecl
+ Keiro.Dsl.Grammar: [nominalBindingVersion] :: NominalBindingDecl -> !Maybe Text
+ Keiro.Dsl.Grammar: [nominalBinding] :: NominalBindingDecl -> !Maybe Text
+ Keiro.Dsl.Grammar: [nominalCanonicalType] :: NominalBindingDecl -> !Maybe Text
+ Keiro.Dsl.Grammar: [nominalFixtures] :: NominalBindingDecl -> !Maybe Text
+ Keiro.Dsl.Grammar: [nominalHaskell] :: NominalBindingDecl -> !Maybe HaskellSource
+ Keiro.Dsl.Grammar: [nominalInitial] :: NominalBindingDecl -> !Maybe Text
+ Keiro.Dsl.Grammar: [nominalLoc] :: NominalBindingDecl -> !Loc
+ Keiro.Dsl.Grammar: [nominalScalarBinding] :: NominalScalarDecl -> !NominalBindingDecl
+ Keiro.Dsl.Grammar: [nominalScalarLoc] :: NominalScalarDecl -> !Loc
+ Keiro.Dsl.Grammar: [nominalScalarName] :: NominalScalarDecl -> !Name
+ Keiro.Dsl.Grammar: [nominalScalarRepresentation] :: NominalScalarDecl -> !Name
+ Keiro.Dsl.Grammar: [specNominalScalars] :: Spec -> ![NominalScalarDecl]
+ Keiro.Dsl.Grammar: [tImplementation] :: Transition -> !TransitionImplementation
+ Keiro.Dsl.Grammar: data AggregateField
+ Keiro.Dsl.Grammar: data ExprRoot
+ Keiro.Dsl.Grammar: data NominalBindingDecl
+ Keiro.Dsl.Grammar: data NominalScalarDecl
+ Keiro.Dsl.Grammar: data ScalarLiteral
+ Keiro.Dsl.Grammar: data TransitionImplementation
+ Keiro.Dsl.Grammar: exprLoc :: Expr -> Loc
+ Keiro.Dsl.Grammar: instance GHC.Classes.Eq Keiro.Dsl.Grammar.AggregateField
+ Keiro.Dsl.Grammar: instance GHC.Classes.Eq Keiro.Dsl.Grammar.ExprRoot
+ Keiro.Dsl.Grammar: instance GHC.Classes.Eq Keiro.Dsl.Grammar.NominalBindingDecl
+ Keiro.Dsl.Grammar: instance GHC.Classes.Eq Keiro.Dsl.Grammar.NominalScalarDecl
+ Keiro.Dsl.Grammar: instance GHC.Classes.Eq Keiro.Dsl.Grammar.ScalarLiteral
+ Keiro.Dsl.Grammar: instance GHC.Classes.Eq Keiro.Dsl.Grammar.TransitionImplementation
+ Keiro.Dsl.Grammar: instance GHC.Classes.Ord Keiro.Dsl.Grammar.ExprRoot
+ Keiro.Dsl.Grammar: instance GHC.Classes.Ord Keiro.Dsl.Grammar.HaskellSource
+ Keiro.Dsl.Grammar: instance GHC.Classes.Ord Keiro.Dsl.Grammar.TransitionImplementation
+ Keiro.Dsl.Grammar: instance GHC.Internal.Generics.Generic Keiro.Dsl.Grammar.AggregateField
+ Keiro.Dsl.Grammar: instance GHC.Internal.Generics.Generic Keiro.Dsl.Grammar.ExprRoot
+ Keiro.Dsl.Grammar: instance GHC.Internal.Generics.Generic Keiro.Dsl.Grammar.NominalBindingDecl
+ Keiro.Dsl.Grammar: instance GHC.Internal.Generics.Generic Keiro.Dsl.Grammar.NominalScalarDecl
+ Keiro.Dsl.Grammar: instance GHC.Internal.Generics.Generic Keiro.Dsl.Grammar.ScalarLiteral
+ Keiro.Dsl.Grammar: instance GHC.Internal.Generics.Generic Keiro.Dsl.Grammar.TransitionImplementation
+ Keiro.Dsl.Grammar: instance GHC.Internal.Show.Show Keiro.Dsl.Grammar.AggregateField
+ Keiro.Dsl.Grammar: instance GHC.Internal.Show.Show Keiro.Dsl.Grammar.ExprRoot
+ Keiro.Dsl.Grammar: instance GHC.Internal.Show.Show Keiro.Dsl.Grammar.NominalBindingDecl
+ Keiro.Dsl.Grammar: instance GHC.Internal.Show.Show Keiro.Dsl.Grammar.NominalScalarDecl
+ Keiro.Dsl.Grammar: instance GHC.Internal.Show.Show Keiro.Dsl.Grammar.ScalarLiteral
+ Keiro.Dsl.Grammar: instance GHC.Internal.Show.Show Keiro.Dsl.Grammar.TransitionImplementation
+ Keiro.Dsl.LanguageVersion: BodyGrammarFailure :: !Text -> ParseFailure
+ Keiro.Dsl.LanguageVersion: DeclaredLanguage :: !LanguageVersion -> !Loc -> SourceLanguage
+ Keiro.Dsl.LanguageVersion: DuplicateLanguagePreamble :: SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: InvalidLanguageVersion :: SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: LanguageBodyParserV1 :: LanguageBodyParser
+ Keiro.Dsl.LanguageVersion: LanguageBodyParserV2 :: LanguageBodyParser
+ Keiro.Dsl.LanguageVersion: LanguageDefinition :: !LanguageVersion -> !Maybe LanguageVersion -> !LanguageBodyParser -> LanguageDefinition
+ Keiro.Dsl.LanguageVersion: LanguageFeatureRequiresVersion :: SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: LegacyUnversioned :: SourceLanguage
+ Keiro.Dsl.LanguageVersion: MisplacedLanguagePreamble :: SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: ParsedSource :: !SourceLanguage -> !Spec -> ParsedSource
+ Keiro.Dsl.LanguageVersion: SourceLanguageDiagnostic :: !SourceLanguageErrorCode -> !FilePath -> !Loc -> !Maybe Text -> !Maybe LanguageVersion -> !NonEmpty LanguageVersion -> SourceLanguageDiagnostic
+ Keiro.Dsl.LanguageVersion: SourceLanguageFailure :: !SourceLanguageDiagnostic -> ParseFailure
+ Keiro.Dsl.LanguageVersion: UnsupportedLanguageVersion :: SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: [declaredLanguageVersion] :: SourceLanguage -> !LanguageVersion
+ Keiro.Dsl.LanguageVersion: [definitionBodyParser] :: LanguageDefinition -> !LanguageBodyParser
+ Keiro.Dsl.LanguageVersion: [definitionPredecessor] :: LanguageDefinition -> !Maybe LanguageVersion
+ Keiro.Dsl.LanguageVersion: [definitionVersion] :: LanguageDefinition -> !LanguageVersion
+ Keiro.Dsl.LanguageVersion: [languageVersionLoc] :: SourceLanguage -> !Loc
+ Keiro.Dsl.LanguageVersion: [parsedSourceLanguage] :: ParsedSource -> !SourceLanguage
+ Keiro.Dsl.LanguageVersion: [parsedSpec] :: ParsedSource -> !Spec
+ Keiro.Dsl.LanguageVersion: [sourceLanguageDeclaredVersion] :: SourceLanguageDiagnostic -> !Maybe LanguageVersion
+ Keiro.Dsl.LanguageVersion: [sourceLanguageErrorCode] :: SourceLanguageDiagnostic -> !SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: [sourceLanguageLoc] :: SourceLanguageDiagnostic -> !Loc
+ Keiro.Dsl.LanguageVersion: [sourceLanguageSource] :: SourceLanguageDiagnostic -> !FilePath
+ Keiro.Dsl.LanguageVersion: [sourceLanguageSupportedVersions] :: SourceLanguageDiagnostic -> !NonEmpty LanguageVersion
+ Keiro.Dsl.LanguageVersion: [sourceLanguageToken] :: SourceLanguageDiagnostic -> !Maybe Text
+ Keiro.Dsl.LanguageVersion: data LanguageBodyParser
+ Keiro.Dsl.LanguageVersion: data LanguageDefinition
+ Keiro.Dsl.LanguageVersion: data LanguageVersion
+ Keiro.Dsl.LanguageVersion: data ParseFailure
+ Keiro.Dsl.LanguageVersion: data ParsedSource
+ Keiro.Dsl.LanguageVersion: data SourceLanguage
+ Keiro.Dsl.LanguageVersion: data SourceLanguageDiagnostic
+ Keiro.Dsl.LanguageVersion: data SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: declaredLanguageVersionMaybe :: SourceLanguage -> Maybe LanguageVersion
+ Keiro.Dsl.LanguageVersion: effectiveLanguageVersion :: SourceLanguage -> LanguageVersion
+ Keiro.Dsl.LanguageVersion: instance Data.Aeson.Types.FromJSON.FromJSON Keiro.Dsl.LanguageVersion.LanguageVersion
+ Keiro.Dsl.LanguageVersion: instance Data.Aeson.Types.FromJSON.FromJSON Keiro.Dsl.LanguageVersion.SourceLanguage
+ Keiro.Dsl.LanguageVersion: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Dsl.LanguageVersion.LanguageVersion
+ Keiro.Dsl.LanguageVersion: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Dsl.LanguageVersion.SourceLanguage
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.LanguageBodyParser
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.LanguageDefinition
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.LanguageVersion
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.ParseFailure
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.ParsedSource
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.SourceLanguage
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.SourceLanguageDiagnostic
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Eq Keiro.Dsl.LanguageVersion.SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Ord Keiro.Dsl.LanguageVersion.LanguageVersion
+ Keiro.Dsl.LanguageVersion: instance GHC.Classes.Ord Keiro.Dsl.LanguageVersion.SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.LanguageBodyParser
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.LanguageDefinition
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.LanguageVersion
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.ParseFailure
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.ParsedSource
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.SourceLanguage
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.SourceLanguageDiagnostic
+ Keiro.Dsl.LanguageVersion: instance GHC.Internal.Show.Show Keiro.Dsl.LanguageVersion.SourceLanguageErrorCode
+ Keiro.Dsl.LanguageVersion: languageRegistry :: NonEmpty LanguageDefinition
+ Keiro.Dsl.LanguageVersion: languageVersion :: Natural -> Maybe LanguageVersion
+ Keiro.Dsl.LanguageVersion: languageVersionNumber :: LanguageVersion -> Natural
+ Keiro.Dsl.LanguageVersion: languageVersionText :: LanguageVersion -> Text
+ Keiro.Dsl.LanguageVersion: lookupLanguageDefinition :: LanguageVersion -> Maybe LanguageDefinition
+ Keiro.Dsl.LanguageVersion: renderParseFailure :: ParseFailure -> Text
+ Keiro.Dsl.LanguageVersion: renderSourceLanguageDiagnostic :: SourceLanguageDiagnostic -> Text
+ Keiro.Dsl.LanguageVersion: sourceFormText :: SourceLanguage -> Text
+ Keiro.Dsl.LanguageVersion: sourceLanguageErrorCodeText :: SourceLanguageErrorCode -> Text
+ Keiro.Dsl.LanguageVersion: supportedLanguageVersions :: NonEmpty LanguageVersion
+ Keiro.Dsl.MappedConsumer: NominalMapping :: !Text -> !Text -> !Text -> !Text -> !Text -> !Text -> !Text -> !Text -> !Text -> !Text -> !Maybe Text -> MappingIdentity
+ Keiro.Dsl.MappedConsumer: [mappingFixtureSymbol] :: MappingIdentity -> !Text
+ Keiro.Dsl.MappedConsumer: [mappingInitialSymbol] :: MappingIdentity -> !Maybe Text
+ Keiro.Dsl.MappedConsumer: [mappingNominalCategory] :: MappingIdentity -> !Text
+ Keiro.Dsl.MappedConsumer: [mappingNominalRepresentation] :: MappingIdentity -> !Text
+ Keiro.Dsl.NominalType: ConsumerNominal :: !ConsumerNominalBinding -> NominalOwnership
+ Keiro.Dsl.NominalType: ConsumerNominalBinding :: !HaskellSource -> !QualifiedValueName -> !BindingVersion -> !CanonicalTypeId -> !QualifiedValueName -> !Maybe QualifiedValueName -> ConsumerNominalBinding
+ Keiro.Dsl.NominalType: EnumRepresentation :: !NonEmpty (Name, Text) -> NominalRepresentation
+ Keiro.Dsl.NominalType: GeneratedNominal :: NominalOwnership
+ Keiro.Dsl.NominalType: IdRepresentation :: !Text -> NominalRepresentation
+ Keiro.Dsl.NominalType: NominalBool :: NominalScalarRepresentation
+ Keiro.Dsl.NominalType: NominalDeclarationCollision :: !Name -> !Loc -> ![Text] -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalEmptyEnum :: !Name -> !Loc -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalInt :: NominalScalarRepresentation
+ Keiro.Dsl.NominalType: NominalInvalidHaskellSource :: !Name -> !Loc -> !Text -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalInvalidIdPrefix :: !Name -> !Loc -> !Text -> !Text -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalInvalidIdentity :: !Name -> !Loc -> !Text -> !Text -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalInvalidQualifiedValue :: !Name -> !Loc -> !Text -> !Text -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalMissingIngredient :: !Name -> !Loc -> !Text -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalMissingRegisterInitial :: !Name -> !Loc -> !Name -> NominalTypeError
+ Keiro.Dsl.NominalType: NominalNatural :: NominalScalarRepresentation
+ Keiro.Dsl.NominalType: NominalText :: NominalScalarRepresentation
+ Keiro.Dsl.NominalType: NominalTime :: NominalScalarRepresentation
+ Keiro.Dsl.NominalType: NominalUnsupportedScalar :: !Name -> !Loc -> !Name -> NominalTypeError
+ Keiro.Dsl.NominalType: ResolvedNominalType :: !Name -> !NominalRepresentation -> !NominalOwnership -> !Loc -> ResolvedNominalType
+ Keiro.Dsl.NominalType: ScalarRepresentation :: !NominalScalarRepresentation -> NominalRepresentation
+ Keiro.Dsl.NominalType: [consumerNominalBindingVersion] :: ConsumerNominalBinding -> !BindingVersion
+ Keiro.Dsl.NominalType: [consumerNominalBinding] :: ConsumerNominalBinding -> !QualifiedValueName
+ Keiro.Dsl.NominalType: [consumerNominalCanonical] :: ConsumerNominalBinding -> !CanonicalTypeId
+ Keiro.Dsl.NominalType: [consumerNominalFixtures] :: ConsumerNominalBinding -> !QualifiedValueName
+ Keiro.Dsl.NominalType: [consumerNominalHaskell] :: ConsumerNominalBinding -> !HaskellSource
+ Keiro.Dsl.NominalType: [consumerNominalInitial] :: ConsumerNominalBinding -> !Maybe QualifiedValueName
+ Keiro.Dsl.NominalType: [resolvedNominalLoc] :: ResolvedNominalType -> !Loc
+ Keiro.Dsl.NominalType: [resolvedNominalName] :: ResolvedNominalType -> !Name
+ Keiro.Dsl.NominalType: [resolvedNominalOwnership] :: ResolvedNominalType -> !NominalOwnership
+ Keiro.Dsl.NominalType: [resolvedNominalRepresentation] :: ResolvedNominalType -> !NominalRepresentation
+ Keiro.Dsl.NominalType: data ConsumerNominalBinding
+ Keiro.Dsl.NominalType: data NominalOwnership
+ Keiro.Dsl.NominalType: data NominalRepresentation
+ Keiro.Dsl.NominalType: data NominalScalarRepresentation
+ Keiro.Dsl.NominalType: data NominalTypeError
+ Keiro.Dsl.NominalType: data NominalTypeRegistry
+ Keiro.Dsl.NominalType: data ResolvedNominalType
+ Keiro.Dsl.NominalType: instance GHC.Classes.Eq Keiro.Dsl.NominalType.ConsumerNominalBinding
+ Keiro.Dsl.NominalType: instance GHC.Classes.Eq Keiro.Dsl.NominalType.NominalOwnership
+ Keiro.Dsl.NominalType: instance GHC.Classes.Eq Keiro.Dsl.NominalType.NominalRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Classes.Eq Keiro.Dsl.NominalType.NominalScalarRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Classes.Eq Keiro.Dsl.NominalType.NominalTypeError
+ Keiro.Dsl.NominalType: instance GHC.Classes.Eq Keiro.Dsl.NominalType.NominalTypeRegistry
+ Keiro.Dsl.NominalType: instance GHC.Classes.Eq Keiro.Dsl.NominalType.ResolvedNominalType
+ Keiro.Dsl.NominalType: instance GHC.Classes.Ord Keiro.Dsl.NominalType.ConsumerNominalBinding
+ Keiro.Dsl.NominalType: instance GHC.Classes.Ord Keiro.Dsl.NominalType.NominalOwnership
+ Keiro.Dsl.NominalType: instance GHC.Classes.Ord Keiro.Dsl.NominalType.NominalRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Classes.Ord Keiro.Dsl.NominalType.NominalScalarRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Classes.Ord Keiro.Dsl.NominalType.ResolvedNominalType
+ Keiro.Dsl.NominalType: instance GHC.Internal.Generics.Generic Keiro.Dsl.NominalType.ConsumerNominalBinding
+ Keiro.Dsl.NominalType: instance GHC.Internal.Generics.Generic Keiro.Dsl.NominalType.NominalOwnership
+ Keiro.Dsl.NominalType: instance GHC.Internal.Generics.Generic Keiro.Dsl.NominalType.NominalRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Internal.Generics.Generic Keiro.Dsl.NominalType.NominalScalarRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Internal.Generics.Generic Keiro.Dsl.NominalType.NominalTypeError
+ Keiro.Dsl.NominalType: instance GHC.Internal.Generics.Generic Keiro.Dsl.NominalType.NominalTypeRegistry
+ Keiro.Dsl.NominalType: instance GHC.Internal.Generics.Generic Keiro.Dsl.NominalType.ResolvedNominalType
+ Keiro.Dsl.NominalType: instance GHC.Internal.Show.Show Keiro.Dsl.NominalType.ConsumerNominalBinding
+ Keiro.Dsl.NominalType: instance GHC.Internal.Show.Show Keiro.Dsl.NominalType.NominalOwnership
+ Keiro.Dsl.NominalType: instance GHC.Internal.Show.Show Keiro.Dsl.NominalType.NominalRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Internal.Show.Show Keiro.Dsl.NominalType.NominalScalarRepresentation
+ Keiro.Dsl.NominalType: instance GHC.Internal.Show.Show Keiro.Dsl.NominalType.NominalTypeError
+ Keiro.Dsl.NominalType: instance GHC.Internal.Show.Show Keiro.Dsl.NominalType.NominalTypeRegistry
+ Keiro.Dsl.NominalType: instance GHC.Internal.Show.Show Keiro.Dsl.NominalType.ResolvedNominalType
+ Keiro.Dsl.NominalType: lookupNominalType :: Name -> NominalTypeRegistry -> Maybe ResolvedNominalType
+ Keiro.Dsl.NominalType: nominalTypes :: NominalTypeRegistry -> Map Name ResolvedNominalType
+ Keiro.Dsl.NominalType: resolveNominalTypes :: Spec -> Either (NonEmpty NominalTypeError) NominalTypeRegistry
+ Keiro.Dsl.Parser: BodyGrammarFailure :: !Text -> ParseFailure
+ Keiro.Dsl.Parser: ParsedSource :: !SourceLanguage -> !Spec -> ParsedSource
+ Keiro.Dsl.Parser: SourceLanguageFailure :: !SourceLanguageDiagnostic -> ParseFailure
+ Keiro.Dsl.Parser: [parsedSourceLanguage] :: ParsedSource -> !SourceLanguage
+ Keiro.Dsl.Parser: [parsedSpec] :: ParsedSource -> !Spec
+ Keiro.Dsl.Parser: data ParseFailure
+ Keiro.Dsl.Parser: data ParsedSource
+ Keiro.Dsl.Parser: parseSource :: FilePath -> Text -> Either ParseFailure ParsedSource
+ Keiro.Dsl.Parser: renderParseFailure :: ParseFailure -> Text
+ Keiro.Dsl.PrettyPrint: renderSource :: ParsedSource -> Text
+ Keiro.Dsl.Scaffold: ResolvedRegister :: !Name -> !ResolvedAggregateType -> !ResolvedRegisterInitial -> !Loc -> ResolvedRegister
+ Keiro.Dsl.Scaffold: [aAggregate] :: Agg -> !Aggregate
+ Keiro.Dsl.Scaffold: [aSpec] :: Agg -> !Spec
+ Keiro.Dsl.Scaffold: [aSymbols] :: Agg -> !AggregateSymbols
+ Keiro.Dsl.Scaffold: [rrInitial] :: ResolvedRegister -> !ResolvedRegisterInitial
+ Keiro.Dsl.Scaffold: [rrLoc] :: ResolvedRegister -> !Loc
+ Keiro.Dsl.Scaffold: [rrName] :: ResolvedRegister -> !Name
+ Keiro.Dsl.Scaffold: [rrType] :: ResolvedRegister -> !ResolvedAggregateType
+ Keiro.Dsl.Scaffold: data ResolvedRegister
+ Keiro.Dsl.Scaffold: instance GHC.Classes.Eq Keiro.Dsl.Scaffold.ResolvedRegister
+ Keiro.Dsl.Scaffold: instance GHC.Internal.Show.Show Keiro.Dsl.Scaffold.ResolvedRegister
+ Keiro.Dsl.Scaffold: nominalProjectionModule :: Context -> Text
+ Keiro.Dsl.ScaffoldRecord: [recSourceLanguage] :: ScaffoldRecord -> !SourceLanguage
+ Keiro.Dsl.ScaffoldRun: SourceLanguageDrift :: !SourceLanguage -> !SourceLanguage -> SourceLanguageDrift
+ Keiro.Dsl.ScaffoldRun: [languageDriftCurrent] :: SourceLanguageDrift -> !SourceLanguage
+ Keiro.Dsl.ScaffoldRun: [languageDriftPrevious] :: SourceLanguageDrift -> !SourceLanguage
+ Keiro.Dsl.ScaffoldRun: [reportSourceLanguageDrift] :: ScaffoldReport -> !Maybe SourceLanguageDrift
+ Keiro.Dsl.ScaffoldRun: data SourceLanguageDrift
+ Keiro.Dsl.ScaffoldRun: executeScaffoldWithLanguage :: FilePath -> Bool -> FilePath -> SourceLanguage -> Context -> Spec -> [ScaffoldModule] -> IO (Either [Refusal] ScaffoldReport)
+ Keiro.Dsl.ScaffoldRun: instance GHC.Classes.Eq Keiro.Dsl.ScaffoldRun.SourceLanguageDrift
+ Keiro.Dsl.ScaffoldRun: instance GHC.Internal.Show.Show Keiro.Dsl.ScaffoldRun.SourceLanguageDrift
+ Keiro.Dsl.TypeGraph: RInteger :: ResolvedTypeExpr
+ Keiro.Dsl.TypeGraph: [onInteger] :: TypeExprAlgebra a -> a
+ Keiro.Dsl.Validate: AggregateExpressionBooleanRequired :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionGuardBoolRequired :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionLiteralInvalid :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionLiteralNeedsType :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionOperandTypeMismatch :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionOperatorUnsupported :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionPathInvalid :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionPathUnsupported :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionRootAmbiguous :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionRootUnknown :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionWriteTargetUnknown :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateExpressionWriteTypeMismatch :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateGuardCapabilityUnsupported :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateGuardTypeMismatch :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateRegisterInitialInvalid :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateTransitionOwnershipConflict :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateTypeUnknown :: DiagnosticCode
+ Keiro.Dsl.Validate: AggregateTypeUnsupportedAtUse :: DiagnosticCode
+ Keiro.Dsl.Validate: CollectionExpressionUnsupported :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalBindingChanged :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalCanonicalTypeChanged :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalEmptyEnumRepresentation :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalFixturesChanged :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalIdDecoderTightened :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalInitialChanged :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalInvalidHaskellSource :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalInvalidIdPrefix :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalInvalidIdentity :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalInvalidQualifiedName :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalMissingIngredient :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalMissingInitialValue :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalNameCollision :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalRepresentationChanged :: DiagnosticCode
+ Keiro.Dsl.Validate: NominalUnsupportedRepresentation :: DiagnosticCode
+ Keiro.Dsl.Validate: SourceLanguageDeclarationChanged :: DiagnosticCode
+ Keiro.Dsl.Validate: WorkspaceLanguageVersionMismatch :: DiagnosticCode
+ Keiro.Dsl.Workspace: [wdSourceLanguageCause] :: WorkspaceDiagnostic -> !Maybe SourceLanguageDiagnostic
+ Keiro.Dsl.Workspace: [wmSourceLanguage] :: WorkspaceMember -> !SourceLanguage
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.AggregateField
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ExprRoot
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.NominalBindingDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.NominalScalarDecl
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.ScalarLiteral
+ Keiro.Dsl.Workspace: instance Keiro.Dsl.Workspace.HasLocs Keiro.Dsl.Grammar.TransitionImplementation
+ Keiro.Dsl.Workspace: oneMemberParsedWorkspace :: FilePath -> ParsedSource -> WorkspaceSpec
+ Keiro.Dsl.WorkspaceRecord: WorkspaceSourceLanguageRow :: !FilePath -> !SourceLanguage -> WorkspaceSourceLanguageRow
+ Keiro.Dsl.WorkspaceRecord: [wrSourceLanguages] :: WorkspaceRecord -> ![WorkspaceSourceLanguageRow]
+ Keiro.Dsl.WorkspaceRecord: [wrslPath] :: WorkspaceSourceLanguageRow -> !FilePath
+ Keiro.Dsl.WorkspaceRecord: [wrslSourceLanguage] :: WorkspaceSourceLanguageRow -> !SourceLanguage
+ Keiro.Dsl.WorkspaceRecord: data WorkspaceSourceLanguageRow
+ Keiro.Dsl.WorkspaceRecord: instance Data.Aeson.Types.FromJSON.FromJSON Keiro.Dsl.WorkspaceRecord.WorkspaceSourceLanguageRow
+ Keiro.Dsl.WorkspaceRecord: instance Data.Aeson.Types.ToJSON.ToJSON Keiro.Dsl.WorkspaceRecord.WorkspaceSourceLanguageRow
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceRecord.WorkspaceSourceLanguageRow
+ Keiro.Dsl.WorkspaceRecord: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceRecord.WorkspaceSourceLanguageRow
+ Keiro.Dsl.WorkspaceScaffold: WorkspaceSourceLanguageDrift :: !FilePath -> !SourceLanguage -> !SourceLanguage -> WorkspaceSourceLanguageDrift
+ Keiro.Dsl.WorkspaceScaffold: [wsldCurrent] :: WorkspaceSourceLanguageDrift -> !SourceLanguage
+ Keiro.Dsl.WorkspaceScaffold: [wsldPath] :: WorkspaceSourceLanguageDrift -> !FilePath
+ Keiro.Dsl.WorkspaceScaffold: [wsldPrevious] :: WorkspaceSourceLanguageDrift -> !SourceLanguage
+ Keiro.Dsl.WorkspaceScaffold: [wsrSourceLanguageDrift] :: WorkspaceScaffoldReport -> ![WorkspaceSourceLanguageDrift]
+ Keiro.Dsl.WorkspaceScaffold: data WorkspaceSourceLanguageDrift
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Classes.Eq Keiro.Dsl.WorkspaceScaffold.WorkspaceSourceLanguageDrift
+ Keiro.Dsl.WorkspaceScaffold: instance GHC.Internal.Show.Show Keiro.Dsl.WorkspaceScaffold.WorkspaceSourceLanguageDrift
- Keiro.Dsl.ExplainBindings: BindingObligation :: !Name -> !Text -> !Text -> !Text -> !BindingObligationKind -> !Text -> ![Text] -> !Maybe Text -> BindingObligation
+ Keiro.Dsl.ExplainBindings: BindingObligation :: !Name -> !Text -> !Text -> !Text -> !BindingObligationKind -> !Text -> ![Text] -> !Maybe Text -> !Maybe Text -> !Text -> BindingObligation
- Keiro.Dsl.ExplainBindings: bindingHoles :: Spec -> Either (NonEmpty TypeGraphError) [BindingHole]
+ Keiro.Dsl.ExplainBindings: bindingHoles :: Spec -> Either (NonEmpty BindingResolutionError) [BindingHole]
- Keiro.Dsl.ExplainBindings: bindingObligations :: Spec -> Either (NonEmpty TypeGraphError) [BindingObligation]
+ Keiro.Dsl.ExplainBindings: bindingObligations :: Spec -> Either (NonEmpty BindingResolutionError) [BindingObligation]
- Keiro.Dsl.Grammar: Command :: !Name -> ![Field] -> !Loc -> Command
+ Keiro.Dsl.Grammar: Command :: !Name -> ![AggregateField] -> !Loc -> Command
- Keiro.Dsl.Grammar: EnumDecl :: !Name -> ![(Name, Text)] -> !Loc -> EnumDecl
+ Keiro.Dsl.Grammar: EnumDecl :: !Name -> ![(Name, Text)] -> !Maybe NominalBindingDecl -> !Loc -> EnumDecl
- Keiro.Dsl.Grammar: EventFields :: ![Field] -> EventBody
+ Keiro.Dsl.Grammar: EventFields :: ![AggregateField] -> EventBody
- Keiro.Dsl.Grammar: IdDecl :: !Name -> !Text -> !Loc -> IdDecl
+ Keiro.Dsl.Grammar: IdDecl :: !Name -> !Text -> !Maybe NominalBindingDecl -> !Loc -> IdDecl
- Keiro.Dsl.Grammar: RegDecl :: !Name -> !Name -> !RegInitial -> !Loc -> RegDecl
+ Keiro.Dsl.Grammar: RegDecl :: !Name -> !TypeExpr -> !RegInitial -> !Loc -> RegDecl
- Keiro.Dsl.Grammar: Spec :: !Name -> !Maybe Text -> !Maybe Placement -> ![IdDecl] -> ![EnumDecl] -> ![RuleDecl] -> ![MappedDecl] -> ![Node] -> Spec
+ Keiro.Dsl.Grammar: Spec :: !Name -> !Maybe Text -> !Maybe Placement -> ![IdDecl] -> ![EnumDecl] -> ![RuleDecl] -> ![NominalScalarDecl] -> ![MappedDecl] -> ![Node] -> Spec
- Keiro.Dsl.Grammar: Transition :: !Name -> !Name -> !Maybe Expr -> ![(Name, Expr)] -> ![Name] -> !Name -> !TransitionMode -> !Loc -> Transition
+ Keiro.Dsl.Grammar: Transition :: !Name -> !Name -> !TransitionImplementation -> !Maybe Expr -> ![(Name, Expr)] -> ![Name] -> !Name -> !TransitionMode -> !Loc -> Transition
- Keiro.Dsl.Grammar: [cmdFields] :: Command -> ![Field]
+ Keiro.Dsl.Grammar: [cmdFields] :: Command -> ![AggregateField]
- Keiro.Dsl.Grammar: [regType] :: RegDecl -> !Name
+ Keiro.Dsl.Grammar: [regType] :: RegDecl -> !TypeExpr
- Keiro.Dsl.Scaffold: Agg :: !Context -> !Text -> !Text -> !Loc -> !Text -> ![IdDecl] -> ![EnumDecl] -> ![RegDecl] -> ![StateDecl] -> ![ResolvedCtor] -> ![ResolvedCtor] -> ![Transition] -> !WireSpec -> !Maybe ProjectionSpec -> !Maybe SnapshotSpec -> !Text -> ![ReadModelNode] -> !Maybe TypeGraph -> !Text -> !Text -> Agg
+ Keiro.Dsl.Scaffold: Agg :: !Context -> !Spec -> !Aggregate -> !Text -> !Text -> !Loc -> !Text -> ![IdDecl] -> ![EnumDecl] -> ![ResolvedRegister] -> ![StateDecl] -> ![ResolvedCtor] -> ![ResolvedCtor] -> ![Transition] -> !WireSpec -> !Maybe ProjectionSpec -> !Maybe SnapshotSpec -> !Text -> ![ReadModelNode] -> !Maybe TypeGraph -> !AggregateSymbols -> !Text -> !Text -> Agg
- Keiro.Dsl.Scaffold: ResolvedCtor :: !Text -> ![(Text, Text)] -> !Int -> !Maybe Int -> ResolvedCtor
+ Keiro.Dsl.Scaffold: ResolvedCtor :: !Text -> ![(Text, ResolvedAggregateType)] -> !Int -> !Maybe Int -> ResolvedCtor
- Keiro.Dsl.Scaffold: [aRegs] :: Agg -> ![RegDecl]
+ Keiro.Dsl.Scaffold: [aRegs] :: Agg -> ![ResolvedRegister]
- Keiro.Dsl.Scaffold: [rcFields] :: ResolvedCtor -> ![(Text, Text)]
+ Keiro.Dsl.Scaffold: [rcFields] :: ResolvedCtor -> ![(Text, ResolvedAggregateType)]
- Keiro.Dsl.Scaffold: fieldCat :: Agg -> Text -> FieldCat
+ Keiro.Dsl.Scaffold: fieldCat :: Agg -> ResolvedAggregateType -> FieldCat
- Keiro.Dsl.ScaffoldRecord: ScaffoldRecord :: !Text -> !Text -> !Text -> ![(ModuleKind, FilePath)] -> ![MappingIdentity] -> ![BindingHole] -> ScaffoldRecord
+ Keiro.Dsl.ScaffoldRecord: ScaffoldRecord :: !Text -> !Text -> !Text -> !SourceLanguage -> ![(ModuleKind, FilePath)] -> ![MappingIdentity] -> ![BindingHole] -> ScaffoldRecord
- Keiro.Dsl.ScaffoldRun: ScaffoldReport :: !FilePath -> !FilePath -> !Context -> ![(ScaffoldModule, WriteDisposition)] -> !FilePath -> !FilePath -> !Maybe Text -> ![StaleModule] -> !ConsumerPlan -> ![Text] -> ![MappingDrift] -> ![BindingHole] -> ScaffoldReport
+ Keiro.Dsl.ScaffoldRun: ScaffoldReport :: !FilePath -> !FilePath -> !Context -> ![(ScaffoldModule, WriteDisposition)] -> !FilePath -> !FilePath -> !Maybe Text -> ![StaleModule] -> !ConsumerPlan -> ![Text] -> ![MappingDrift] -> !Maybe SourceLanguageDrift -> ![BindingHole] -> ScaffoldReport
- Keiro.Dsl.TypeGraph: TypeExprAlgebra :: a -> a -> a -> a -> a -> a -> (a -> a) -> (a -> a) -> (a -> a) -> (MappedKey -> a) -> TypeExprAlgebra a
+ Keiro.Dsl.TypeGraph: TypeExprAlgebra :: a -> a -> a -> a -> a -> a -> a -> (a -> a) -> (a -> a) -> (a -> a) -> (MappedKey -> a) -> TypeExprAlgebra a
- Keiro.Dsl.Workspace: WorkspaceDiagnostic :: !NonEmpty WorkspaceLocation -> !Severity -> !DiagnosticCode -> !Text -> WorkspaceDiagnostic
+ Keiro.Dsl.Workspace: WorkspaceDiagnostic :: !NonEmpty WorkspaceLocation -> !Severity -> !DiagnosticCode -> !Maybe SourceLanguageDiagnostic -> !Text -> WorkspaceDiagnostic
- Keiro.Dsl.Workspace: WorkspaceMember :: !FilePath -> !Spec -> !Int -> !Int -> WorkspaceMember
+ Keiro.Dsl.Workspace: WorkspaceMember :: !FilePath -> !Spec -> !SourceLanguage -> !Int -> !Int -> WorkspaceMember
- Keiro.Dsl.Workspace: composeWorkspace :: FilePath -> WorkspaceManifest -> [(FilePath, Text, Spec)] -> Either (NonEmpty WorkspaceDiagnostic) WorkspaceSpec
+ Keiro.Dsl.Workspace: composeWorkspace :: FilePath -> WorkspaceManifest -> [(FilePath, Text, ParsedSource)] -> Either (NonEmpty WorkspaceDiagnostic) WorkspaceSpec
- Keiro.Dsl.WorkspaceRecord: WorkspaceRecord :: !Text -> !Text -> !Text -> !Text -> !Text -> ![FilePath] -> ![WorkspaceModuleRow] -> ![MappingIdentity] -> ![BindingHole] -> ![AdoptedRow] -> WorkspaceRecord
+ Keiro.Dsl.WorkspaceRecord: WorkspaceRecord :: !Text -> !Text -> !Text -> !Text -> !Text -> ![FilePath] -> ![WorkspaceSourceLanguageRow] -> ![WorkspaceModuleRow] -> ![MappingIdentity] -> ![BindingHole] -> ![AdoptedRow] -> WorkspaceRecord
- 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: WorkspaceScaffoldReport :: !FilePath -> !FilePath -> !Text -> !Context -> ![FilePath] -> ![(ScaffoldModule, ModuleProvenance, WriteDisposition)] -> !FilePath -> !FilePath -> !Maybe Text -> ![StaleModule] -> ![OwnershipMove] -> !ConsumerPlan -> ![Text] -> ![MappingDrift] -> ![WorkspaceSourceLanguageDrift] -> ![BindingHole] -> !Maybe MigrationReport -> WorkspaceScaffoldReport
Files
- CHANGELOG.md +112/−0
- app/Main.hs +528/−460
- bench/structural-codec/Main.hs +59/−59
- keiro-dsl.cabal +102/−13
- src/Keiro/Dsl/AggregateType.hs +413/−0
- src/Keiro/Dsl/CodecCompare.hs +461/−463
- src/Keiro/Dsl/Coverage.hs +465/−464
- src/Keiro/Dsl/Diff.hs +2172/−1989
- src/Keiro/Dsl/DiffReport.hs +280/−277
- src/Keiro/Dsl/ExplainBindings.hs +311/−192
- src/Keiro/Dsl/Expression.hs +584/−0
- src/Keiro/Dsl/FoldFingerprint.hs +174/−102
- src/Keiro/Dsl/Goldens.hs +170/−148
- src/Keiro/Dsl/Grammar.hs +1228/−1170
- src/Keiro/Dsl/Harness.hs +1188/−1077
- src/Keiro/Dsl/LanguageVersion.hs +218/−0
- src/Keiro/Dsl/Manifest.hs +114/−80
- src/Keiro/Dsl/MappedConsumer.hs +213/−118
- src/Keiro/Dsl/MappedDiff.hs +377/−376
- src/Keiro/Dsl/NominalType.hs +344/−0
- src/Keiro/Dsl/Parser.hs +2105/−1792
- src/Keiro/Dsl/PrettyPrint.hs +483/−403
- src/Keiro/Dsl/ReadModelShape.hs +36/−37
- src/Keiro/Dsl/ReplayImpact.hs +178/−134
- src/Keiro/Dsl/Scaffold.hs +4417/−3437
- src/Keiro/Dsl/ScaffoldRecord.hs +91/−75
- src/Keiro/Dsl/ScaffoldRun.hs +362/−319
- src/Keiro/Dsl/Skeleton.hs +308/−307
- src/Keiro/Dsl/TypeGraph.hs +404/−395
- src/Keiro/Dsl/Validate.hs +2493/−2242
- src/Keiro/Dsl/Workspace.hs +1426/−1296
- src/Keiro/Dsl/WorkspaceAdoption.hs +190/−194
- src/Keiro/Dsl/WorkspaceDiff.hs +105/−86
- src/Keiro/Dsl/WorkspaceRecord.hs +255/−210
- src/Keiro/Dsl/WorkspaceScaffold.hs +441/−410
- test/Main.hs +6350/−5598
- test/conformance-aggregate-scalars/AggregateScalars/ScalarLedger/Holes.hs +72/−0
- test/conformance-aggregate-scalars/Generated/AggregateScalars/ReplayAudit.hs +24/−0
- test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Codec.hs +49/−0
- test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Domain.hs +58/−0
- test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/EventStream.hs +58/−0
- test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Harness.hs +58/−0
- test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Projection.hs +2/−0
- test/conformance-aggregate-scalars/Main.hs +114/−0
- test/conformance-codec-compare/Conformance/CodecCompare/Historical.hs +0/−1
- test/conformance-codec-compare/Main.hs +0/−1
- test/conformance-coldstart/Generated/Billing/Subscription/Codec.hs +0/−1
- test/conformance-coldstart/Generated/Billing/Subscription/Domain.hs +0/−1
- test/conformance-coldstart/Generated/Billing/Subscription/Harness.hs +0/−1
- test/conformance-coldstart/Generated/Billing/Subscription/Projection.hs +0/−1
- test/conformance-contract/Generated/HospitalCapacity/Emergency/Contract.hs +0/−1
- test/conformance-contract/Main.hs +0/−1
- test/conformance-dispatch-full/Generated/HospitalCapacity/Reservation_work/Queue.hs +0/−1
- test/conformance-dispatch-full/Generated/HospitalCapacity/Reservation_work/QueueCodec.hs +0/−1
- test/conformance-dispatch-full/Generated/HospitalCapacity/Reservation_work/QueuePolicy.hs +0/−1
- test/conformance-dispatch-full/HospitalCapacity/ReservationWork/WorkqueueJob.hs +0/−1
- test/conformance-dispatch-full/Main.hs +0/−1
- test/conformance-intake-full/Main.hs +0/−1
- test/conformance-newsurface/Generated/TransferRouting/Hospital/Codec.hs +0/−1
- test/conformance-newsurface/Generated/TransferRouting/Hospital/Domain.hs +0/−1
- test/conformance-newsurface/Generated/TransferRouting/Hospital/Harness.hs +0/−1
- test/conformance-newsurface/Generated/TransferRouting/HospitalTransferRouter/Router.hs +0/−1
- test/conformance-newsurface/Generated/TransferRouting/Hospital_load/ReadModel.hs +0/−1
- test/conformance-newsurface/Generated/TransferRouting/Hospital_load/ReadModelTable.hs +0/−1
- test/conformance-newsurface/Main.hs +0/−1
- test/conformance-newsurface/TransferRouting/HospitalTransferRouter/RouterValue.hs +0/−1
- test/conformance-nominal-scalars/Generated/NominalScalars/Nominal/Shape/OrderStatus.hs +19/−0
- test/conformance-nominal-scalars/Generated/NominalScalars/NominalLedger/Codec.hs +80/−0
- test/conformance-nominal-scalars/Generated/NominalScalars/NominalLedger/Domain.hs +71/−0
- test/conformance-nominal-scalars/Generated/NominalScalars/NominalProjections.hs +69/−0
- test/conformance-nominal-scalars/Main.hs +281/−0
- test/conformance-nominal-scalars/NominalConformance/Bindings.hs +109/−0
- test/conformance-nominal-scalars/NominalConformance/Domain.hs +64/−0
- test/conformance-process-full/Generated/SurgeDemo/Hospital/Codec.hs +0/−1
- test/conformance-process-full/Generated/SurgeDemo/Hospital/Domain.hs +0/−1
- test/conformance-process-full/Generated/SurgeDemo/Hospital/Projection.hs +0/−1
- test/conformance-process-full/Generated/SurgeDemo/Surge/Codec.hs +0/−1
- test/conformance-process-full/Generated/SurgeDemo/Surge/Domain.hs +0/−1
- test/conformance-process-full/Generated/SurgeDemo/Surge/Projection.hs +0/−1
- test/conformance-process-full/Generated/SurgeDemo/SurgeFlow/Process.hs +0/−1
- test/conformance-process-full/Main.hs +0/−1
- test/conformance-process-full/SurgeDemo/SurgeFlow/Manager.hs +0/−1
- test/conformance-process-runtime/Generated/HospitalCapacity/HospitalSurge/Process.hs +0/−1
- test/conformance-process/Generated/HospitalCapacity/HospitalSurge/ProcessHarness.hs +0/−1
- test/conformance-queue-runtime/Generated/HospitalCapacity/Reservation_work/Queue.hs +0/−1
- test/conformance-queue-runtime/Generated/HospitalCapacity/Reservation_work/QueueCodec.hs +0/−1
- test/conformance-queue-runtime/Generated/HospitalCapacity/Reservation_work/QueuePolicy.hs +0/−1
- test/conformance-queue/Generated/HospitalCapacity/Reservation_work/Queue.hs +0/−1
- test/conformance-queue/Generated/HospitalCapacity/Reservation_work/QueueCodec.hs +0/−1
- test/conformance-readmodel-runtime/Generated/HospitalCapacity/Transfer_decisions/ReadModel.hs +0/−1
- test/conformance-readmodel-runtime/Generated/HospitalCapacity/Transfer_decisions/ReadModelTable.hs +0/−1
- test/conformance-readmodel-runtime/Main.hs +0/−1
- test/conformance-replay/Generated/ReplayDivergence/Note/Codec.hs +0/−1
- test/conformance-replay/Generated/ReplayDivergence/Note/Domain.hs +0/−1
- test/conformance-replay/Generated/ReplayDivergence/Note/Harness.hs +0/−1
- test/conformance-router-full/Generated/IncidentPaging/Page/Codec.hs +0/−1
- test/conformance-router-full/Generated/IncidentPaging/Page/Domain.hs +0/−1
- test/conformance-router-full/Generated/IncidentPaging/PagingRouter/Router.hs +0/−1
- test/conformance-router-runtime/Generated/IncidentPaging/PagingRouter/Router.hs +0/−1
- test/conformance-scalar-expressions/AggregateScalarExpressions/ScalarAccount/Holes.hs +53/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ReplayAudit.hs +24/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Codec.hs +110/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Domain.hs +121/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/EventStream.hs +58/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Expressions.hs +58/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Harness.hs +147/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Projection.hs +2/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Transducer.hs +77/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/Structural/Shape/Limits.hs +13/−0
- test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/StructuralProjections.hs +43/−0
- test/conformance-scalar-expressions/Main.hs +235/−0
- test/conformance-scalar-expressions/ScalarExpressions/Bindings.hs +39/−0
- test/conformance-scalar-expressions/ScalarExpressions/Domain.hs +22/−0
- test/conformance-skeletons/SkelAggregate/Generated/MyService/Thing/Codec.hs +0/−1
- test/conformance-skeletons/SkelAggregate/Generated/MyService/Thing/Domain.hs +0/−1
- test/conformance-skeletons/SkelAggregate/Generated/MyService/Thing/Harness.hs +0/−1
- test/conformance-skeletons/SkelContract/Generated/MyService/MyContract/Contract.hs +0/−1
- test/conformance-skeletons/SkelEmit/Generated/MyService/MyContract/Contract.hs +0/−1
- test/conformance-skeletons/SkelIntake/Generated/MyService/MyContract/Contract.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/Hospital/Codec.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/Hospital/Domain.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/Hospital/Harness.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/HospitalSurge/Process.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/HospitalSurge/ProcessHarness.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/Surge/Codec.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/Surge/Domain.hs +0/−1
- test/conformance-skeletons/SkelProcess/Generated/MyService/Surge/Harness.hs +0/−1
- test/conformance-skeletons/SkelQueue/Generated/MyService/Accepted_transfer_needs/ReadModel.hs +0/−1
- test/conformance-skeletons/SkelQueue/Generated/MyService/Accepted_transfer_needs/ReadModelTable.hs +0/−1
- test/conformance-skeletons/SkelQueue/Generated/MyService/Reservation_work/Queue.hs +0/−1
- test/conformance-skeletons/SkelQueue/Generated/MyService/Reservation_work/QueueCodec.hs +0/−1
- test/conformance-skeletons/SkelQueue/Generated/MyService/Reservation_work/QueuePolicy.hs +0/−1
- test/conformance-skeletons/SkelQueue/Generated/MyService/Transfer_decisions/ReadModel.hs +0/−1
- test/conformance-skeletons/SkelQueue/Generated/MyService/Transfer_decisions/ReadModelTable.hs +0/−1
- test/conformance-skeletons/SkelRouter/Generated/MyService/Page/Codec.hs +0/−1
- test/conformance-skeletons/SkelRouter/Generated/MyService/Page/Domain.hs +0/−1
- test/conformance-skeletons/SkelRouter/Generated/MyService/Page/Harness.hs +0/−1
- test/conformance-skeletons/SkelRouter/Generated/MyService/PagingRouter/Router.hs +0/−1
- test/conformance-skeletons/SkelWorkflow/Generated/MyService/HospitalTransferReservation/WorkflowFacts.hs +0/−1
- test/conformance-skeletons/SkelWorkflow/Generated/MyService/HospitalTransferReservation/WorkflowRuntime.hs +0/−2
- test/conformance-snapshot/Generated/HospitalCapacity/Reservation/Codec.hs +0/−1
- test/conformance-snapshot/Generated/HospitalCapacity/Reservation/Domain.hs +0/−1
- test/conformance-snapshot/Main.hs +0/−1
- test/conformance-structural/Conformance/Structural/Bindings.hs +0/−1
- test/conformance-structural/Generated/StructuralConformance/ArtifactCatalog/Codec.hs +0/−1
- test/conformance-structural/Generated/StructuralConformance/ArtifactCatalog/Domain.hs +0/−1
- test/conformance-structural/Generated/StructuralConformance/ArtifactCatalog/Harness.hs +0/−1
- test/conformance-structural/Generated/StructuralConformance/Structural/CodecCompare/ArtifactInfo.hs +0/−2
- test/conformance-v2/Generated/HospitalCapacity/Reservation/Codec.hs +0/−1
- test/conformance-v2/Generated/HospitalCapacity/Reservation/Domain.hs +0/−1
- test/conformance-v2/Generated/HospitalCapacity/Reservation/Harness.hs +0/−1
- test/conformance-v2/Generated/HospitalCapacity/Reservation/Projection.hs +0/−1
- test/conformance-v2/HospitalCapacity/Reservation/Holes.hs +0/−1
- test/conformance-workflow-full/Generated/HospitalCapacity/HospitalTransferReservation/WorkflowRuntime.hs +0/−2
- test/conformance-workflow-full/HospitalCapacity/HospitalTransferReservation/WorkflowBody.hs +0/−1
- test/conformance-workflow-runtime/Generated/HospitalCapacity/HospitalTransferReservation/WorkflowRuntime.hs +0/−2
- test/conformance-workflow-runtime/Main.hs +0/−1
- test/conformance-workflow/Generated/HospitalCapacity/HospitalTransferReservation/WorkflowFacts.hs +0/−1
- test/conformance/Generated/HospitalCapacity/Reservation/Codec.hs +0/−1
- test/conformance/Generated/HospitalCapacity/Reservation/Domain.hs +0/−1
- test/conformance/Generated/HospitalCapacity/Reservation/Harness.hs +0/−1
- test/conformance/Generated/HospitalCapacity/Reservation/Projection.hs +0/−1
CHANGELOG.md view
@@ -6,6 +6,118 @@ ## [Unreleased] +## 0.6.0.0 — 2026-07-31++### Breaking Changes++- `DiagnosticCode` gains `AggregateTypeUnknown`,+ `AggregateTypeUnsupportedAtUse`, `AggregateRegisterInitialInvalid`,+ `AggregateGuardTypeMismatch`, `AggregateGuardCapabilityUnsupported`,+ `WorkspaceLanguageVersionMismatch`, and `SourceLanguageDeclarationChanged`.+ The additions are append-only, but exhaustive matches must be extended.+- `DiagnosticCode` also gains the nominal-check codes+ `NominalMissingIngredient`, `NominalInvalidHaskellSource`,+ `NominalInvalidQualifiedName`, `NominalInvalidIdentity`,+ `NominalInvalidIdPrefix`, `NominalUnsupportedRepresentation`,+ `NominalEmptyEnumRepresentation`, `NominalMissingInitialValue`, and+ `NominalNameCollision`, plus the nominal diff codes+ `NominalBindingChanged`, `NominalFixturesChanged`,+ `NominalCanonicalTypeChanged`, `NominalInitialChanged`,+ `NominalRepresentationChanged`, and `NominalIdDecoderTightened`.+- `DiagnosticCode` gains `AggregateExpressionRootUnknown`,+ `AggregateExpressionRootAmbiguous`, `AggregateExpressionPathInvalid`,+ `AggregateExpressionPathUnsupported`, `AggregateExpressionLiteralNeedsType`,+ `AggregateExpressionLiteralInvalid`,+ `AggregateExpressionOperandTypeMismatch`,+ `AggregateExpressionOperatorUnsupported`,+ `AggregateExpressionBooleanRequired`,+ `AggregateExpressionGuardBoolRequired`,+ `AggregateExpressionWriteTargetUnknown`,+ `AggregateExpressionWriteTypeMismatch`,+ `AggregateTransitionOwnershipConflict`, and+ `CollectionExpressionUnsupported`. The additions are append-only, but+ exhaustive matches must be extended.+- The public expression AST gains located arithmetic, scalar literals, explicit+ roots/paths, and transition implementation ownership. Exhaustive matches over+ `Expr`, `TypeExpr`, or transition implementation must be extended.+- Aggregate command/event fields now use the located `AggregateField` type and+ aggregate register types use `TypeExpr` instead of a raw `Name`. The library+ requires the exact-Integer/total-Natural `keiki >=0.6 && <0.7` and+ `keiki-codec-json >=0.6 && <0.7` releases.++### New Features++- Adds language version 2 syntax for binding direct aggregate IDs, enums, and+ nominal scalar wrappers to consumer-owned Haskell types. Version 1 and+ legacy-unversioned sources reject these declarations at source-language+ dispatch. The `pretty` command is an explicit alias for canonical parsing and+ rendering.+- Adds the public total `Keiro.Codec.Nominal` binding/fixture API, a checked+ nominal registry, generated prefix-safe `KindID` codecs, closed private enum+ representations, built-in scalar codecs, create-once binding skeletons, and+ context-level nominal scalar projections. Consumer-owned registers retain+ the existing consumer-JSON snapshot cache boundary.+- Nominal consumer provenance is fingerprinted and diff-visible, persisted in+ additive `nominal-mapping` scaffold/workspace rows, and included in generated+ package requirements. Bound-ID adoption at an existing event use reports a+ named decoder-tightening historical-read advisory and targeted replay audit.+- Adds a compiled nominal conformance ring with binding laws, pinned wire bytes,+ malformed/wrong-prefix ID and unknown-enum rejection, snapshot and canonical+ identity checks, projection agreement, forward/replay parity, mutation gates,+ and a compile-fail partial-inverse fixture.+- Adds authoritative version-2 scalar aggregate expressions. Guards and writes+ accept typed `reg.`/`cmd.` roots, required structural scalar paths, all scalar+ literal families, exact `Integer` `+`/`-`/`*`, and total `Natural`+ `+`/monus/`*`. Machine-`Int` arithmetic, coercion, division/remainder, Time+ arithmetic, predicate-valued Bool writes, and collection expressions fail+ before scaffolding.+- Adds generated per-aggregate `Expressions` and `Transducer` modules. Every+ version-2 transition is exclusively generated-owned or explicitly+ `implementation hole`; Hole transitions retain a generated structural+ envelope, require a per-transition `FoldVersion`, and expose conservative+ ownership/predicate-verification reports where opaque terms remain+ unverified. Version-1 generated output remains frozen.+- Adds a compiled scalar-expression conformance service with a 360-case oracle,+ concrete/symbolic/replay/snapshot agreement, all scalar literal families,+ Natural `2 - 5 = 0`, required structural projection identity, and eight+ mutation sentinels for arithmetic, authority, envelope, fold-version, and+ verification drift.++- Adds an explicit source-language contract. A first-significant-clause+ `language keiro-dsl 1` preamble selects the frozen released v1 parser before+ body parsing; unsupported future versions fail at that boundary. Unversioned+ input remains readable as `legacy-unversioned` with effective version 1 and+ is never silently rewritten by parse/pretty.+- Adds `Keiro.Dsl.LanguageVersion`, provenance-preserving parsing/rendering,+ `keiro-dsl inspect FILE --format=json`, and canonical per-member workspace+ inspection. Workspace composition compares effective versions before graph+ merge while keeping the merged `Spec` semantic.+- Single-file and workspace scaffold records now carry additive source-language+ rows, report provenance drift, and interpret missing rows from older records+ as legacy. Source-aware diff reports declaration-only changes with an+ all-compatible vector and no-semantic-action remedy; generated bytes, fold+ fingerprints, and replay impact remain unchanged.+- Every `new <kind>` skeleton and ordinary checked-in `.keiro` fixture now+ declares version 1. Dedicated legacy, future, malformed, zero, duplicate, and+ misplaced fixtures preserve compatibility and rejection coverage. Automated+ upgrades remain deferred to IR-5.+- Adds `Keiro.Dsl.AggregateType`, the single resolution and capability policy+ used by aggregate validation, Haskell lowering, imports, packages, JSON and+ snapshot samples, fold fingerprints, diffs, replay impact, and scaffold+ refusals.+- Direct aggregate `Time` and `Natural` fields and registers now check,+ scaffold, compile, encode, snapshot, and replay. `Time`/`UTCTime` normalize+ to `Time` and lower to exact `UTCTime` constructors; Natural accepts only+ non-negative integral initials. Equality and ordering are checked against+ Keiki's released symbolic capabilities. Version 1 retains the original+ no-arithmetic grammar; version 2 adds only the exact/total arithmetic listed+ above.+- Direct aggregate `Json` and container shapes now parse far enough to receive+ a located remediation toward `mapped structural`, and malformed scalar+ initials, mismatched comparisons, and unsupported ordering fail during+ `check`. Generated imports and Cabal dependencies remain type-directed and+ minimal.+ ## 0.5.0.0 — 2026-07-31 ### Breaking Changes
app/Main.hs view
@@ -1,26 +1,29 @@-{- | The @keiro-dsl@ command-line tool. EP-1 ships the @parse@ and @check@-subcommands; a later milestone adds @scaffold@ to the same-optparse-applicative command tree.--}+-- | The @keiro-dsl@ command-line tool. EP-1 ships the @parse@ and @check@+-- subcommands; a later milestone adds @scaffold@ to the same+-- optparse-applicative command tree. module Main (main) where import Control.Monad (when)+import Data.Aeson ((.=)) import Data.Aeson qualified as Aeson+import Data.Aeson.Text qualified as AesonText import Data.List.NonEmpty qualified as NE import Data.Maybe (fromMaybe) import Data.Text qualified as T import Data.Text.IO qualified as TIO+import Data.Text.Lazy.IO qualified as TLIO import Keiro.Dsl.Coverage qualified as Coverage-import Keiro.Dsl.Diff (Change (..), CompatibilitySurface, diffSpecs, gateWith, gatedBreaking)+import Keiro.Dsl.Diff (Change (..), CompatibilitySurface, diffSources, gateWith, gatedBreaking) import Keiro.Dsl.DiffReport (diffReport, parseSurfaceName, renderExplainBlock, renderFinding) import Keiro.Dsl.ExplainBindings (bindingObligations, renderBindingObligations) import Keiro.Dsl.Goldens (emitGoldenPayloads, loadGoldenPayloads) import Keiro.Dsl.Grammar (Placement (..), Spec (..))-import Keiro.Dsl.Parser (parseSpec)-import Keiro.Dsl.PrettyPrint (renderSpec)+import Keiro.Dsl.LanguageVersion (ParsedSource (..), SourceLanguage, declaredLanguageVersionMaybe, effectiveLanguageVersion, sourceFormText)+import Keiro.Dsl.Parser (parseSource, renderParseFailure)+import Keiro.Dsl.PrettyPrint (renderSource, renderSpec) import Keiro.Dsl.ReplayImpact (renderReplayImpact, replayImpact) import Keiro.Dsl.Scaffold (Context (..), ScaffoldModule (..), codecComparisonBanner, codecComparisonModule)-import Keiro.Dsl.ScaffoldRun (executeScaffold, planScaffoldWithGoldens, renderRefusals, renderScaffoldReport)+import Keiro.Dsl.ScaffoldRun (executeScaffoldWithLanguage, 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)@@ -34,49 +37,59 @@ import System.Process (readProcessWithExitCode) data Command- = Parse FilePath- | Check FilePath Bool Bool (Maybe CheckCoverageOptions)- | Scaffold FilePath FilePath (Maybe String) Bool Bool (Maybe FilePath) (Maybe (String, FilePath))- | Diff FilePath String (Maybe FilePath) (Maybe FilePath) [CompatibilitySurface] Bool (Maybe FilePath) (Maybe DiffCoverageOptions)- | New String+ = Parse FilePath+ | Pretty FilePath+ | Check FilePath Bool Bool (Maybe CheckCoverageOptions)+ | Inspect FilePath InspectionFormat+ | Scaffold FilePath FilePath (Maybe String) Bool Bool (Maybe FilePath) (Maybe (String, FilePath))+ | Diff FilePath String (Maybe FilePath) (Maybe FilePath) [CompatibilitySurface] Bool (Maybe FilePath) (Maybe DiffCoverageOptions)+ | New String +data InspectionFormat = InspectionJson+ data CheckCoverageOptions = CheckCoverageOptions- { checkCoveragePath :: !FilePath- , checkFailOnOpaque :: !Bool- }+ { checkCoveragePath :: !FilePath,+ checkFailOnOpaque :: !Bool+ } data DiffCoverageOptions = DiffCoverageOptions- { diffCoveragePath :: !FilePath- , diffFailOnOpaqueIncrease :: !Bool- }+ { diffCoveragePath :: !FilePath,+ diffFailOnOpaqueIncrease :: !Bool+ } main :: IO () main = run =<< execParser opts where opts =- info- (commands <**> helper)- (fullDesc <> progDesc "keiro-dsl: a typed-specification toolchain for keiro services")+ info+ (commands <**> helper)+ (fullDesc <> progDesc "keiro-dsl: a typed-specification toolchain for keiro services") commands :: Parser Command commands =- subparser- ( command- "parse"- (info (Parse <$> fileArg <**> helper) (progDesc "Parse a .keiro file and pretty-print it back"))- <> command- "check"- (info (Check <$> fileArg <*> emitSwitch <*> explainBindingsSwitch <*> checkCoverageOptions <**> helper) (progDesc "Validate a .keiro file; print diagnostics and exit non-zero on any error"))- <> command- "scaffold"- (info (Scaffold <$> fileArg <*> outOpt <*> optional moduleRootOpt <*> collocateSwitch <*> forceGeneratedOverwriteSwitch <*> optional goldensOpt <*> codecComparisonOpts <**> helper) (progDesc "Emit the generated layer + typed holes from a .keiro file"))- <> command- "diff"- (info (Diff <$> fileArg <*> sinceOpt <*> optional emitGoldensOpt <*> optional replayImpactOutOpt <*> many gateOpt <*> explainSwitch <*> optional reportOutOpt <*> diffCoverageOptions <**> helper) (progDesc "Classify spec changes since a git ref as per-surface compatibility vectors; exit non-zero on any gated BREAKING surface"))- <> command- "new"- (info (New <$> kindArg <**> helper) (progDesc "Print a minimal valid .keiro skeleton for a node kind (aggregate, process, router, contract, intake, emit, publisher, workqueue, dispatch, workflow, operation)"))- )+ subparser+ ( command+ "parse"+ (info (Parse <$> fileArg <**> helper) (progDesc "Parse a .keiro file and pretty-print it back"))+ <> command+ "pretty"+ (info (Pretty <$> fileArg <**> helper) (progDesc "Parse a .keiro file and print its canonical source form"))+ <> command+ "check"+ (info (Check <$> fileArg <*> emitSwitch <*> explainBindingsSwitch <*> checkCoverageOptions <**> helper) (progDesc "Validate a .keiro file; print diagnostics and exit non-zero on any error"))+ <> command+ "inspect"+ (info (Inspect <$> fileArg <*> inspectionFormatOpt <**> helper) (progDesc "Inspect source-language provenance for a .keiro file or workspace as JSON"))+ <> command+ "scaffold"+ (info (Scaffold <$> fileArg <*> outOpt <*> optional moduleRootOpt <*> collocateSwitch <*> forceGeneratedOverwriteSwitch <*> optional goldensOpt <*> codecComparisonOpts <**> helper) (progDesc "Emit the generated layer + typed holes from a .keiro file"))+ <> command+ "diff"+ (info (Diff <$> fileArg <*> sinceOpt <*> optional emitGoldensOpt <*> optional replayImpactOutOpt <*> many gateOpt <*> explainSwitch <*> optional reportOutOpt <*> diffCoverageOptions <**> helper) (progDesc "Classify spec changes since a git ref as per-surface compatibility vectors; exit non-zero on any gated BREAKING surface"))+ <> command+ "new"+ (info (New <$> kindArg <**> helper) (progDesc "Print a minimal valid .keiro skeleton for a node kind (aggregate, process, router, contract, intake, emit, publisher, workqueue, dispatch, workflow, operation)"))+ ) outOpt :: Parser FilePath outOpt = strOption (long "out" <> metavar "DIR" <> help "Output directory for the scaffolded modules")@@ -95,11 +108,11 @@ codecComparisonOpts :: Parser (Maybe (String, FilePath)) codecComparisonOpts =- optional- ( (,)- <$> strOption (long "codec-comparison" <> metavar "MAPPED-NAME" <> help "Emit a non-production historical-codec comparison module for one structural mapped type (requires --comparison-out)")- <*> strOption (long "comparison-out" <> metavar "FILE" <> help "Exact generated comparison-module path under --out (requires --codec-comparison)")- )+ optional+ ( (,)+ <$> strOption (long "codec-comparison" <> metavar "MAPPED-NAME" <> help "Emit a non-production historical-codec comparison module for one structural mapped type (requires --comparison-out)")+ <*> strOption (long "comparison-out" <> metavar "FILE" <> help "Exact generated comparison-module path under --out (requires --codec-comparison)")+ ) emitGoldensOpt :: Parser FilePath emitGoldensOpt = strOption (long "emit-goldens" <> metavar "DIR" <> help "Write old-shape payload fixtures for event version bumps without overwriting existing files")@@ -121,19 +134,19 @@ checkCoverageOptions :: Parser (Maybe CheckCoverageOptions) checkCoverageOptions =- optional- ( CheckCoverageOptions- <$> coverageReportOpt- <*> switch (long "fail-on-opaque" <> help "Fail when a private persisted root contains an opaque boundary (requires --coverage-report)")- )+ optional+ ( CheckCoverageOptions+ <$> coverageReportOpt+ <*> switch (long "fail-on-opaque" <> help "Fail when a private persisted root contains an opaque boundary (requires --coverage-report)")+ ) diffCoverageOptions :: Parser (Maybe DiffCoverageOptions) diffCoverageOptions =- optional- ( DiffCoverageOptions- <$> coverageReportOpt- <*> switch (long "fail-on-opaque-increase" <> help "Fail when diff adds a named opaque boundary (requires --coverage-report)")- )+ optional+ ( DiffCoverageOptions+ <$> coverageReportOpt+ <*> switch (long "fail-on-opaque-increase" <> help "Fail when diff adds a named opaque boundary (requires --coverage-report)")+ ) emitSwitch :: Parser Bool emitSwitch = switch (long "emit" <> help "On success, pretty-print the parsed spec to stdout (folds parse + check into one call)")@@ -150,380 +163,437 @@ kindArg :: Parser String kindArg = argument str (metavar "KIND" <> help "Node kind to scaffold a starter spec for") +inspectionFormatOpt :: Parser InspectionFormat+inspectionFormatOpt =+ option+ (eitherReader parseFormat)+ (long "format" <> metavar "json" <> value InspectionJson <> help "Inspection output format (json)")+ where+ parseFormat "json" = Right InspectionJson+ parseFormat other = Left ("unsupported inspection format: " <> other <> " (expected json)")+ run :: Command -> IO ()+run (Pretty fp) = run (Parse fp) -- 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+ | isWorkspacePath fp = runWorkspaceCheck fp emit explainBindings coverageOptions+run (Inspect fp format)+ | isWorkspacePath fp = runWorkspaceInspect fp format run (Scaffold fp out cliRoot cliCollocate forceGeneratedOverwrite cliGoldens comparisonRequest)- | isWorkspacePath fp = runWorkspaceScaffold 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+ | isWorkspacePath fp = runWorkspaceDiff fp ref emitGoldensRoot replayImpactOut gatedSurfaces explain reportOut coverageOptions run (Parse fp) = do- input <- TIO.readFile fp- case parseSpec fp input of- Left err -> do- hPutStrLn stderr (T.unpack err)- exitFailure- Right spec -> TIO.putStrLn (renderSpec spec)+ input <- TIO.readFile fp+ case parseSource fp input of+ Left failure -> do+ hPutStrLn stderr (T.unpack (renderParseFailure failure))+ exitFailure+ Right parsedSource -> TIO.putStrLn (renderSource parsedSource) run (Check fp emit explainBindings coverageOptions) = do- input <- TIO.readFile fp- case parseSpec fp input of- Left err -> do- hPutStrLn stderr (T.unpack err)- exitFailure- Right spec -> do- let diags = validateSpec spec- mapM_ (TIO.hPutStrLn stderr . renderDiagnostic fp) diags- if any ((== Error) . severity) diags- then exitFailure- else do- when emit (TIO.putStrLn (renderSpec spec))- if explainBindings- then case bindingObligations spec of- Left graphErrors -> do- hPutStrLn stderr ("validated spec did not resolve its mapped type graph: " <> show graphErrors)- exitFailure- Right obligations -> TIO.putStrLn (renderBindingObligations (specContext spec) obligations)- else pure ()- coverageOk <- runCheckCoverage fp spec coverageOptions- when (coverageOk && not emit && not explainBindings) (putStrLn "OK")- when (not coverageOk) exitFailure+ input <- TIO.readFile fp+ case parseSource fp input of+ Left failure -> do+ hPutStrLn stderr (T.unpack (renderParseFailure failure))+ exitFailure+ Right parsedSource -> do+ let spec = parsedSpec parsedSource+ let diags = validateSpec spec+ mapM_ (TIO.hPutStrLn stderr . renderDiagnostic fp) diags+ if any ((== Error) . severity) diags+ then exitFailure+ else do+ when emit (TIO.putStrLn (renderSource parsedSource))+ if explainBindings+ then case bindingObligations spec of+ Left graphErrors -> do+ hPutStrLn stderr ("validated spec did not resolve its mapped type graph: " <> show graphErrors)+ exitFailure+ Right obligations -> TIO.putStrLn (renderBindingObligations (specContext spec) obligations)+ else pure ()+ coverageOk <- runCheckCoverage fp spec coverageOptions+ when (coverageOk && not emit && not explainBindings) (putStrLn "OK")+ when (not coverageOk) exitFailure run (Scaffold fp out cliRoot cliCollocate forceGeneratedOverwrite cliGoldens comparisonRequest) = do- input <- TIO.readFile fp- case parseSpec fp input of- Left err -> do- hPutStrLn stderr (T.unpack err)- exitFailure- Right spec -> do- -- Validation gate: never scaffold an invalid spec. Abort on any- -- error-severity diagnostic before writing a single module.- let diags = validateSpec spec- mapM_ (TIO.hPutStrLn stderr . renderDiagnostic fp) diags- when (any ((== Error) . severity) diags) exitFailure- let ctx = mkContext cliRoot cliCollocate spec- goldenRoot = fromMaybe (takeDirectory fp </> "golden-payloads") cliGoldens- goldens <- loadGoldenPayloads goldenRoot spec- case (planScaffoldWithGoldens goldens ctx spec, 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 modules, Right comparisonModule) -> do- comparisonReady <- preflightComparison out comparisonRequest comparisonModule- case comparisonReady of- Left comparisonError -> TIO.hPutStrLn stderr comparisonError >> exitFailure- Right () -> do- result <- executeScaffold out forceGeneratedOverwrite fp ctx spec modules- case result of- Left refusals -> do- mapM_ (TIO.hPutStrLn stderr) (renderRefusals refusals)- exitFailure- Right report -> do- mapM_ (TIO.hPutStrLn stderr) (renderScaffoldReport report)- writeComparison comparisonRequest comparisonModule+ input <- TIO.readFile fp+ case parseSource fp input of+ Left failure -> do+ hPutStrLn stderr (T.unpack (renderParseFailure failure))+ exitFailure+ Right parsedSource -> do+ let spec = parsedSpec parsedSource+ -- Validation gate: never scaffold an invalid spec. Abort on any+ -- error-severity diagnostic before writing a single module.+ let diags = validateSpec spec+ mapM_ (TIO.hPutStrLn stderr . renderDiagnostic fp) diags+ when (any ((== Error) . severity) diags) exitFailure+ let ctx = mkContext cliRoot cliCollocate spec+ goldenRoot = fromMaybe (takeDirectory fp </> "golden-payloads") cliGoldens+ goldens <- loadGoldenPayloads goldenRoot spec+ case (planScaffoldWithGoldens goldens ctx spec, 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 modules, Right comparisonModule) -> do+ comparisonReady <- preflightComparison out comparisonRequest comparisonModule+ case comparisonReady of+ Left comparisonError -> TIO.hPutStrLn stderr comparisonError >> exitFailure+ Right () -> do+ result <- executeScaffoldWithLanguage out forceGeneratedOverwrite fp (parsedSourceLanguage parsedSource) ctx spec modules+ case result of+ Left refusals -> do+ mapM_ (TIO.hPutStrLn stderr) (renderRefusals refusals)+ exitFailure+ Right report -> do+ mapM_ (TIO.hPutStrLn stderr) (renderScaffoldReport report)+ writeComparison comparisonRequest comparisonModule run (New kind) =- case skeletonFor (T.pack kind) of- Left err -> hPutStrLn stderr (T.unpack err) >> exitFailure- Right skel -> TIO.putStr skel+ case skeletonFor (T.pack kind) of+ Left err -> hPutStrLn stderr (T.unpack err) >> exitFailure+ Right skel -> TIO.putStr skel+run (Inspect fp InspectionJson) = do+ input <- TIO.readFile fp+ case parseSource fp input of+ Left failure -> hPutStrLn stderr (T.unpack (renderParseFailure failure)) >> exitFailure+ Right parsedSource -> TLIO.putStrLn (AesonText.encodeToLazyText (sourceInspection fp (parsedSourceLanguage parsedSource))) run (Diff fp ref emitGoldensRoot replayImpactOut gatedSurfaces explain reportOut coverageOptions) = do- -- Resolve the spec to a repo-relative path so `git show <ref>:<relpath>` works.- 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 relPath = makeRelative repoRoot absFp- oldRes <- git repoRoot ["show", ref <> ":" <> relPath]- case oldRes of- Left err -> hPutStrLn stderr ("git show " <> ref <> ":" <> relPath <> " failed:\n" <> err) >> exitFailure- Right oldText -> do- newText <- TIO.readFile fp- case (,) <$> parseSpec (ref <> ":" <> relPath) (T.pack oldText) <*> parseSpec fp newText of- Left perr -> hPutStrLn stderr (T.unpack perr) >> exitFailure- Right (oldSpec, newSpec) -> do- written <- maybe (pure []) (\root -> emitGoldenPayloads root oldSpec newSpec) emitGoldensRoot- mapM_ (putStrLn . ("golden: wrote synthesized weak stand-in " <>)) written- let changes = diffSpecs oldSpec newSpec- impact = replayImpact oldSpec newSpec- effectiveGate = gateWith gatedSurfaces- mapM_ (TIO.putStrLn . renderFinding) changes- when explain $- mapM_ (TIO.putStrLn . renderExplainBlock) (filter shouldExplain changes)- TIO.putStrLn (renderReplayImpact impact)- mapM_ (`Aeson.encodeFile` impact) replayImpactOut- 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 ()+ -- Resolve the spec to a repo-relative path so `git show <ref>:<relpath>` works.+ 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 relPath = makeRelative repoRoot absFp+ oldRes <- git repoRoot ["show", ref <> ":" <> relPath]+ case oldRes of+ Left err -> hPutStrLn stderr ("git show " <> ref <> ":" <> relPath <> " failed:\n" <> err) >> exitFailure+ Right oldText -> do+ newText <- TIO.readFile fp+ case (,) <$> parseSource (ref <> ":" <> relPath) (T.pack oldText) <*> parseSource fp newText of+ Left failure -> hPutStrLn stderr (T.unpack (renderParseFailure failure)) >> exitFailure+ Right (oldSource, newSource) -> do+ let oldSpec = parsedSpec oldSource+ newSpec = parsedSpec newSource+ written <- maybe (pure []) (\root -> emitGoldenPayloads root oldSpec newSpec) emitGoldensRoot+ mapM_ (putStrLn . ("golden: wrote synthesized weak stand-in " <>)) written+ let changes = diffSources oldSource newSource+ impact = replayImpact oldSpec newSpec+ effectiveGate = gateWith gatedSurfaces+ mapM_ (TIO.putStrLn . renderFinding) changes+ when explain $+ mapM_ (TIO.putStrLn . renderExplainBlock) (filter shouldExplain changes)+ TIO.putStrLn (renderReplayImpact impact)+ mapM_ (`Aeson.encodeFile` impact) replayImpactOut+ 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).--}+-- | @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)+ 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.+sourceInspection :: FilePath -> SourceLanguage -> Aeson.Value+sourceInspection path sourceLanguage =+ Aeson.object+ [ "schema" .= ("keiro-dsl/source-inspection/1" :: T.Text),+ "kind" .= ("source" :: T.Text),+ "path" .= path,+ "sourceForm" .= sourceFormText sourceLanguage,+ "declaredLanguageVersion" .= declaredLanguageVersionMaybe sourceLanguage,+ "effectiveLanguageVersion" .= effectiveLanguageVersion sourceLanguage+ ] -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.--}+runWorkspaceInspect :: FilePath -> InspectionFormat -> IO ()+runWorkspaceInspect fp InspectionJson = do+ loaded <- loadWorkspace (fileContentSource (takeDirectory fp)) fp+ case loaded of+ Left failure -> do+ mapM_ (TIO.hPutStrLn stderr) (renderWorkspaceFailure fp failure)+ exitFailure+ Right workspace ->+ TLIO.putStrLn+ ( AesonText.encodeToLazyText+ ( Aeson.object+ [ "schema" .= ("keiro-dsl/source-inspection/1" :: T.Text),+ "kind" .= ("workspace" :: T.Text),+ "path" .= fp,+ "service" .= wsService workspace,+ "members" .= map memberInspection (wsMembers workspace)+ ]+ )+ )+ where+ memberInspection member =+ Aeson.object+ [ "path" .= wmPath member,+ "sourceForm" .= sourceFormText sourceLanguage,+ "declaredLanguageVersion" .= declaredLanguageVersionMaybe sourceLanguage,+ "effectiveLanguageVersion" .= effectiveLanguageVersion sourceLanguage+ ]+ where+ sourceLanguage = wmSourceLanguage member++-- | @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.+ 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 -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.--}+-- | @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 ()+ 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.+ 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 -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.--}+-- | @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 ()+ 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+ 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 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 ()+ 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)- }+ 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.--}+-- | 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)+ 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+ 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)+ 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- }+ 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)+ | isAbsolute requested = requested+ | otherwise = normalise (takeDirectory manifestPath </> requested) printWorkspaceFailure :: FilePath -> WorkspaceFailure -> IO a printWorkspaceFailure fp failure = printWorkspaceFailureLines fp failure >> exitFailure@@ -531,35 +601,34 @@ 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.--}+-- | 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)- }+ 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-shouldExplain Advisory{} = True-shouldExplain Breaking{} = True+shouldExplain Additive {} = False+shouldExplain Advisory {} = True+shouldExplain Breaking {} = True -- | Run git in a directory, returning trimmed stdout or stderr. git :: FilePath -> [String] -> IO (Either String String) git dir args = do- (ec, out, err) <- readProcessWithExitCode "git" (["-C", dir] <> args) ""- pure $ case ec of- ExitSuccess -> Right out- ExitFailure _ -> Left (if null err then out else err)+ (ec, out, err) <- readProcessWithExitCode "git" (["-C", dir] <> args) ""+ pure $ case ec of+ ExitSuccess -> Right out+ ExitFailure _ -> Left (if null err then out else err) trim :: String -> String trim = f . f where f = reverse . dropWhile (`elem` (" \t\r\n" :: String))@@ -567,77 +636,76 @@ preflightComparison :: FilePath -> Maybe (String, FilePath) -> Maybe ScaffoldModule -> IO (Either T.Text ()) preflightComparison _ Nothing Nothing = pure (Right ()) preflightComparison out (Just (_, requestedPath)) (Just comparisonModule) = do- let expectedPath = normalise (out </> modulePath comparisonModule)- actualPath = normalise requestedPath- if actualPath /= expectedPath- then- pure- ( Left- ( "--comparison-out must match the generated module path under --out: expected "- <> T.pack expectedPath- )- )+ let expectedPath = normalise (out </> modulePath comparisonModule)+ actualPath = normalise requestedPath+ if actualPath /= expectedPath+ then+ pure+ ( Left+ ( "--comparison-out must match the generated module path under --out: expected "+ <> T.pack expectedPath+ )+ )+ else do+ exists <- doesFileExist actualPath+ if not exists+ then pure (Right ()) else do- exists <- doesFileExist actualPath- if not exists- then pure (Right ())- else do- existing <- TIO.readFile actualPath- pure- ( if codecComparisonBanner `elem` T.lines existing- then Right ()- else Left ("refusing to overwrite non-comparison output: " <> T.pack actualPath)- )+ existing <- TIO.readFile actualPath+ pure+ ( if codecComparisonBanner `elem` T.lines existing+ then Right ()+ else Left ("refusing to overwrite non-comparison output: " <> T.pack actualPath)+ ) preflightComparison _ _ _ = pure (Left "internal error: incomplete codec-comparison option pair") writeComparison :: Maybe (String, FilePath) -> Maybe ScaffoldModule -> IO () writeComparison Nothing Nothing = pure () writeComparison (Just (_, path)) (Just comparisonModule) = do- createDirectoryIfMissing True (takeDirectory path)- TIO.writeFile path (moduleText comparisonModule)- TIO.hPutStrLn stderr ("comparison generated " <> T.pack path <> " (migration evidence only)")+ createDirectoryIfMissing True (takeDirectory path)+ TIO.writeFile path (moduleText comparisonModule)+ TIO.hPutStrLn stderr ("comparison generated " <> T.pack path <> " (migration evidence only)") writeComparison _ _ = hPutStrLn stderr "internal error: incomplete codec-comparison output" >> exitFailure runCheckCoverage :: FilePath -> Spec -> Maybe CheckCoverageOptions -> IO Bool runCheckCoverage _ _ Nothing = pure True runCheckCoverage specPath spec (Just options) =- case Coverage.coverageReport specPath spec of- Left graphErrors -> do- hPutStrLn stderr ("validated spec did not resolve its mapped type graph for coverage: " <> show graphErrors)- pure False- Right baseReport -> do- let report = if checkFailOnOpaque options then Coverage.failOnOpaque baseReport else baseReport- emitCoverageReport (checkCoveragePath options) report+ case Coverage.coverageReport specPath spec of+ Left graphErrors -> do+ hPutStrLn stderr ("validated spec did not resolve its mapped type graph for coverage: " <> show graphErrors)+ pure False+ Right baseReport -> do+ let report = if checkFailOnOpaque options then Coverage.failOnOpaque baseReport else baseReport+ emitCoverageReport (checkCoveragePath options) report runDiffCoverage :: FilePath -> T.Text -> Spec -> Spec -> Maybe DiffCoverageOptions -> IO Bool runDiffCoverage _ _ _ _ Nothing = pure True runDiffCoverage specPath reference oldSpec newSpec (Just options) =- case Coverage.coverageDiffReport specPath reference oldSpec newSpec of- Left graphErrors -> do- hPutStrLn stderr ("diff specs did not resolve their mapped type graph for coverage: " <> show graphErrors)- pure False- Right baseReport -> do- let report = if diffFailOnOpaqueIncrease options then Coverage.failOnOpaqueIncrease baseReport else baseReport- emitCoverageReport (diffCoveragePath options) report+ case Coverage.coverageDiffReport specPath reference oldSpec newSpec of+ Left graphErrors -> do+ hPutStrLn stderr ("diff specs did not resolve their mapped type graph for coverage: " <> show graphErrors)+ pure False+ Right baseReport -> do+ let report = if diffFailOnOpaqueIncrease options then Coverage.failOnOpaqueIncrease baseReport else baseReport+ emitCoverageReport (diffCoveragePath options) report emitCoverageReport :: FilePath -> Coverage.CoverageReport -> IO Bool emitCoverageReport path report = do- mapM_ (TIO.hPutStrLn stderr . Coverage.renderCoverageFinding (Coverage.coverageSpec report)) (Coverage.coverageFindings report)- TIO.putStr (Coverage.renderCoverageSummary report)- Coverage.writeCoverageReport path report- putStrLn ("coverage report written to " <> path)- pure (Coverage.coverageSucceeded report)+ mapM_ (TIO.hPutStrLn stderr . Coverage.renderCoverageFinding (Coverage.coverageSpec report)) (Coverage.coverageFindings report)+ TIO.putStr (Coverage.renderCoverageSummary report)+ Coverage.writeCoverageReport path report+ putStrLn ("coverage report written to " <> path)+ pure (Coverage.coverageSucceeded report) -{- | Fold the spec's @module@/@layout@ clauses with the CLI overrides to a-'Context'. Precedence is CLI flag > spec clause > built-in default.--}+-- | Fold the spec's @module@/@layout@ clauses with the CLI overrides to a+-- 'Context'. Precedence is CLI flag > spec clause > built-in default. mkContext :: Maybe String -> Bool -> Spec -> Context mkContext cliRoot cliCollocate spec =- Context- { contextName = specContext spec- , moduleRoot = maybe (fromMaybe "" (specModuleRoot spec)) T.pack cliRoot- , placement =- if cliCollocate- then CollocatedLeaf- else fromMaybe GeneratedPrefix (specLayout spec)- }+ Context+ { contextName = specContext spec,+ moduleRoot = maybe (fromMaybe "" (specModuleRoot spec)) T.pack cliRoot,+ placement =+ if cliCollocate+ then CollocatedLeaf+ else fromMaybe GeneratedPrefix (specLayout spec)+ }
bench/structural-codec/Main.hs view
@@ -23,27 +23,27 @@ benchmarks :: [Benchmark] benchmarks =- [ bgroup- "encode"- [ comparison "encode-small-record" baselineEncodeArtifact encodeArtifactInfoMapped smallArtifact- , comparison "encode-nested-union" (map baselineEncodeArtifact) (map encodeArtifactInfoMapped) unionArtifacts- , comparison "encode-large-list" (map baselineEncodeArtifact) (map encodeArtifactInfoMapped) largeArtifacts- ]- , bgroup- "decode"- [ comparison "decode-small-record" baselineDecodeArtifact decodeArtifactInfoMapped smallEncoded- , comparison "decode-nested-union" baselineDecodeArtifacts generatedDecodeArtifacts unionEncoded- , comparison "decode-large-list" baselineDecodeArtifacts generatedDecodeArtifacts largeEncoded- ]- ]+ [ bgroup+ "encode"+ [ comparison "encode-small-record" baselineEncodeArtifact encodeArtifactInfoMapped smallArtifact,+ comparison "encode-nested-union" (map baselineEncodeArtifact) (map encodeArtifactInfoMapped) unionArtifacts,+ comparison "encode-large-list" (map baselineEncodeArtifact) (map encodeArtifactInfoMapped) largeArtifacts+ ],+ bgroup+ "decode"+ [ comparison "decode-small-record" baselineDecodeArtifact decodeArtifactInfoMapped smallEncoded,+ comparison "decode-nested-union" baselineDecodeArtifacts generatedDecodeArtifacts unionEncoded,+ comparison "decode-large-list" baselineDecodeArtifacts generatedDecodeArtifacts largeEncoded+ ]+ ] comparison :: (NFData result) => String -> (input -> result) -> (input -> result) -> input -> Benchmark comparison label baseline generated input =- bgroup- label- [ bench ("baseline-" <> label) (nf baseline input)- , bcompareWithin 0 2 ("baseline-" <> label) $ bench ("generated-" <> label) (nf generated input)- ]+ bgroup+ label+ [ bench ("baseline-" <> label) (nf baseline input),+ bcompareWithin 0 2 ("baseline-" <> label) $ bench ("generated-" <> label) (nf generated input)+ ] allArtifacts :: [Domain.ArtifactInfo] allArtifacts = map snd (NonEmpty.toList (fixtureCases Bindings.artifactInfoCases))@@ -68,8 +68,8 @@ generatedDecodeArtifacts :: Value -> Either Text [Domain.ArtifactInfo] generatedDecodeArtifacts value = do- values <- firstText (parseEither Aeson.parseJSON value)- traverse decodeArtifactInfoMapped values+ values <- firstText (parseEither Aeson.parseJSON value)+ traverse decodeArtifactInfoMapped values baselineDecodeArtifacts :: Value -> Either Text [Domain.ArtifactInfo] baselineDecodeArtifacts = firstText . parseEither (Aeson.parseJSON >=> traverse baselineParseArtifact)@@ -82,65 +82,65 @@ baselineEncodeArtifact :: Domain.ArtifactInfo -> Value baselineEncodeArtifact value =- object- [ "artifact_key" .= value.artifactKey- , "display_name" .= value.displayName- , "artifact_hash" .= value.artifactHash- , "artifact_kind" .= encodeKind value.artifactKind- , "location" .= encodeLocation value.location- , "metadata" .= object ["note" .= value.metadata.note]- , "active" .= value.active- , "tags" .= value.tags- ]+ object+ [ "artifact_key" .= value.artifactKey,+ "display_name" .= value.displayName,+ "artifact_hash" .= value.artifactHash,+ "artifact_kind" .= encodeKind value.artifactKind,+ "location" .= encodeLocation value.location,+ "metadata" .= object ["note" .= value.metadata.note],+ "active" .= value.active,+ "tags" .= value.tags+ ] baselineParseArtifact :: Value -> Parser Domain.ArtifactInfo baselineParseArtifact = withObject "ArtifactInfo" $ \value -> do- rejectUnknownFields "ArtifactInfo" ["artifact_key", "display_name", "artifact_hash", "artifact_kind", "location", "metadata", "active", "tags"] value- Domain.ArtifactInfo- <$> value .: "artifact_key"- <*> value .: "display_name"- <*> value .:? "artifact_hash"- <*> (value .:? "artifact_kind" .!= String "guide" >>= parseKind)- <*> (value .: "location" >>= parseLocation)- <*> (value .: "metadata" >>= withObject "ArtifactMetadata" (\metadata -> Domain.ArtifactMetadata <$> metadata .: "note"))- <*> (value .:? "active" .!= False)- <*> (value .:? "tags" .!= [])+ rejectUnknownFields "ArtifactInfo" ["artifact_key", "display_name", "artifact_hash", "artifact_kind", "location", "metadata", "active", "tags"] value+ Domain.ArtifactInfo+ <$> value .: "artifact_key"+ <*> value .: "display_name"+ <*> value .:? "artifact_hash"+ <*> (value .:? "artifact_kind" .!= String "guide" >>= parseKind)+ <*> (value .: "location" >>= parseLocation)+ <*> (value .: "metadata" >>= withObject "ArtifactMetadata" (\metadata -> Domain.ArtifactMetadata <$> metadata .: "note"))+ <*> (value .:? "active" .!= False)+ <*> (value .:? "tags" .!= []) encodeKind :: Domain.ArtifactKind -> Value encodeKind = \case- Domain.Guide -> String "guide"- Domain.Reference -> String "reference"+ Domain.Guide -> String "guide"+ Domain.Reference -> String "reference" parseKind :: Value -> Parser Domain.ArtifactKind parseKind = \case- String "guide" -> pure Domain.Guide- String "reference" -> pure Domain.Reference- _ -> fail "unknown ArtifactKind"+ String "guide" -> pure Domain.Guide+ String "reference" -> pure Domain.Reference+ _ -> fail "unknown ArtifactKind" encodeLocation :: Domain.ArtifactLocation -> Value encodeLocation = \case- Domain.LocalFile path -> tagged "local_file" (Just path)- Domain.LocalDir path -> tagged "local_dir" (Just path)- Domain.RepoPath path -> tagged "repo_path" (Just path)- Domain.LocUrl url -> tagged "url" (Just url)- Domain.Canonical -> tagged "canonical" Nothing+ Domain.LocalFile path -> tagged "local_file" (Just path)+ Domain.LocalDir path -> tagged "local_dir" (Just path)+ Domain.RepoPath path -> tagged "repo_path" (Just path)+ Domain.LocUrl url -> tagged "url" (Just url)+ Domain.Canonical -> tagged "canonical" Nothing where tagged :: Text -> Maybe Text -> Value tagged tag contents = object (["tag" .= tag] <> maybe [] (pure . ("contents" .=)) contents) parseLocation :: Value -> Parser Domain.ArtifactLocation parseLocation = withObject "ArtifactLocation" $ \value -> do- tag <- value .: "tag" :: Parser Text- case tag of- "local_file" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.LocalFile <$> value .: "contents")- "local_dir" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.LocalDir <$> value .: "contents")- "repo_path" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.RepoPath <$> value .: "contents")- "url" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.LocUrl <$> value .: "contents")- "canonical" -> rejectUnknownFields "ArtifactLocation" ["tag"] value >> pure Domain.Canonical- _ -> fail "unknown ArtifactLocation"+ tag <- value .: "tag" :: Parser Text+ case tag of+ "local_file" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.LocalFile <$> value .: "contents")+ "local_dir" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.LocalDir <$> value .: "contents")+ "repo_path" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.RepoPath <$> value .: "contents")+ "url" -> rejectUnknownFields "ArtifactLocation" ["tag", "contents"] value >> (Domain.LocUrl <$> value .: "contents")+ "canonical" -> rejectUnknownFields "ArtifactLocation" ["tag"] value >> pure Domain.Canonical+ _ -> fail "unknown ArtifactLocation" rejectUnknownFields :: String -> [Text] -> KeyMap.KeyMap Value -> Parser () rejectUnknownFields label allowed value =- unless (null extras) (fail (label <> " contains unknown fields: " <> show extras))+ unless (null extras) (fail (label <> " contains unknown fields: " <> show extras)) where extras = filter (`notElem` allowed) (map Key.toText (KeyMap.keys value))
keiro-dsl.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: keiro-dsl-version: 0.5.0.0+version: 0.6.0.0 synopsis: Typed specification toolchain for keiro services description: keiro-dsl is the toolchain over a typed `.keiro` specification of a keiro@@ -24,6 +24,7 @@ default-language: GHC2024 default-extensions: DuplicateRecordFields+ ImportQualifiedPost LambdaCase OverloadedStrings @@ -31,18 +32,22 @@ import: warnings, shared hs-source-dirs: src exposed-modules:+ Keiro.Dsl.AggregateType Keiro.Dsl.CodecCompare Keiro.Dsl.Coverage Keiro.Dsl.Diff Keiro.Dsl.DiffReport Keiro.Dsl.ExplainBindings+ Keiro.Dsl.Expression Keiro.Dsl.FoldFingerprint Keiro.Dsl.Goldens Keiro.Dsl.Grammar Keiro.Dsl.Harness+ Keiro.Dsl.LanguageVersion Keiro.Dsl.Manifest Keiro.Dsl.MappedConsumer Keiro.Dsl.MappedDiff+ Keiro.Dsl.NominalType Keiro.Dsl.Parser Keiro.Dsl.PrettyPrint Keiro.Dsl.ReadModelShape@@ -67,9 +72,11 @@ , directory >=1.3 && <1.4 , filepath >=1.4 && <1.6 , megaparsec >=9.6 && <9.9+ , mmzk-typeid >=0.7 && <0.8 , parser-combinators >=1.3 && <1.4 , prettyprinter >=1.7 && <1.8 , text >=2.1 && <2.2+ , time >=1.12 && <1.15 executable keiro-dsl import: warnings, shared@@ -125,10 +132,92 @@ build-depends: , aeson >=2.2 && <2.3 , base >=4.21 && <5- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , text >=2.1 && <2.2 +-- Plan 157: compiled direct Time/Natural aggregate fields and registers,+-- symbolic equality/ordering, codecs, snapshots, forward/replay equality,+-- Natural JSON boundaries, canonical identity, and opaque arithmetic audit.+test-suite keiro-dsl-conformance-aggregate-scalars+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: test/conformance-aggregate-scalars+ main-is: Main.hs+ other-modules:+ AggregateScalars.ScalarLedger.Holes+ Generated.AggregateScalars.ReplayAudit+ Generated.AggregateScalars.ScalarLedger.Codec+ Generated.AggregateScalars.ScalarLedger.Domain+ Generated.AggregateScalars.ScalarLedger.EventStream+ Generated.AggregateScalars.ScalarLedger.Harness+ Generated.AggregateScalars.ScalarLedger.Projection++ build-depends:+ , aeson >=2.2 && <2.3+ , base >=4.21 && <5+ , keiki >=0.6 && <0.7+ , keiro+ , text >=2.1 && <2.2+ , time >=1.12 && <1.15++-- Plan 161: compiled authoritative version-2 scalar expressions, generated+-- transition ownership, explicit Hole opacity/fold identity, and concrete,+-- symbolic, codec-replay, and snapshot-invalidation agreement.+test-suite keiro-dsl-conformance-aggregate-scalar-expressions+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: test/conformance-scalar-expressions+ main-is: Main.hs+ other-modules:+ AggregateScalarExpressions.ScalarAccount.Holes+ Generated.AggregateScalarExpressions.ReplayAudit+ Generated.AggregateScalarExpressions.ScalarAccount.Codec+ Generated.AggregateScalarExpressions.ScalarAccount.Domain+ Generated.AggregateScalarExpressions.ScalarAccount.EventStream+ Generated.AggregateScalarExpressions.ScalarAccount.Expressions+ Generated.AggregateScalarExpressions.ScalarAccount.Harness+ Generated.AggregateScalarExpressions.ScalarAccount.Projection+ Generated.AggregateScalarExpressions.ScalarAccount.Transducer+ Generated.AggregateScalarExpressions.Structural.Shape.Limits+ Generated.AggregateScalarExpressions.StructuralProjections+ ScalarExpressions.Bindings+ ScalarExpressions.Domain++ build-depends:+ , aeson >=2.2 && <2.3+ , base >=4.21 && <5+ , containers >=0.6 && <0.8+ , keiki >=0.6 && <0.7+ , keiro+ , keiro-dsl+ , text >=2.1 && <2.2+ , time >=1.12 && <1.15++-- EP-158: consumer-owned direct IDs, enums, and nominal scalar wrappers,+-- checked KindID decoding, total bindings, projections, and snapshot caches.+test-suite keiro-dsl-conformance-nominal-scalars+ import: warnings, shared+ type: exitcode-stdio-1.0+ hs-source-dirs: test/conformance-nominal-scalars+ main-is: Main.hs+ other-modules:+ Generated.NominalScalars.Nominal.Shape.OrderStatus+ Generated.NominalScalars.NominalLedger.Codec+ Generated.NominalScalars.NominalLedger.Domain+ Generated.NominalScalars.NominalProjections+ NominalConformance.Bindings+ NominalConformance.Domain++ build-depends:+ , aeson >=2.2 && <2.3+ , base >=4.21 && <5+ , keiki >=0.6 && <0.7+ , keiro+ , mmzk-typeid >=0.7 && <0.8+ , text >=2.1 && <2.2+ , time >=1.12 && <1.15+ -- Plan 150 / IR-1: compiled structural consumer bindings, declared-wire -- codecs, generated projection witnesses, opaque boundaries, fixture branch -- coverage, current payload goldens, and mapped-register replay equality.@@ -159,7 +248,7 @@ , bytestring >=0.12 && <0.13 , containers >=0.6 && <0.8 , deepseq >=1.5 && <1.6- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , text >=2.1 && <2.2 , time >=1.12 && <1.15@@ -193,7 +282,7 @@ , deepseq >=1.5 && <1.6 , directory >=1.3 && <1.4 , filepath >=1.4 && <1.6- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , keiro-dsl , text >=2.1 && <2.2@@ -219,7 +308,7 @@ build-depends: , aeson >=2.2 && <2.3 , base >=4.21 && <5- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , text >=2.1 && <2.2 @@ -243,7 +332,7 @@ , base >=4.21 && <5 , containers >=0.6 && <0.8 , deepseq >=1.5 && <1.6- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , tasty-bench >=0.5 && <0.6 , text >=2.1 && <2.2@@ -267,7 +356,7 @@ build-depends: , aeson >=2.2 && <2.3 , base >=4.21 && <5- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , text >=2.1 && <2.2 @@ -338,7 +427,7 @@ , containers , effectful-core , hasql-transaction- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , keiro-core , keiro-pgmq@@ -368,7 +457,7 @@ build-depends: , aeson >=2.2 && <2.3 , base >=4.21 && <5- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , text >=2.1 && <2.2 @@ -567,7 +656,7 @@ build-depends: , aeson >=2.2 && <2.3 , base >=4.21 && <5- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , shibuya-core , text >=2.1 && <2.2@@ -662,7 +751,7 @@ , aeson >=2.2 && <2.3 , base >=4.21 && <5 , effectful-core- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , shibuya-core , text >=2.1 && <2.2@@ -698,7 +787,7 @@ , base >=4.21 && <5 , effectful-core , hasql-transaction- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , kiroku-store , shibuya-core@@ -742,6 +831,6 @@ , aeson >=2.2 && <2.3 , base >=4.21 && <5 , directory >=1.3 && <1.4- , keiki >=0.4 && <0.5+ , keiki >=0.6 && <0.7 , keiro , text >=2.1 && <2.2
+ src/Keiro/Dsl/AggregateType.hs view
@@ -0,0 +1,413 @@+{-# OPTIONS_GHC -Werror=incomplete-patterns #-}++-- | Canonical aggregate type resolution and capability policy.+--+-- Parsing a 'TypeExpr' only establishes syntax. This module decides whether that+-- type is legal at an aggregate use site, canonicalizes aliases, validates+-- register initials, and supplies total Haskell lowering for admitted values.+module Keiro.Dsl.AggregateType+ ( AggregateUseSite (..),+ AggregateCapability (..),+ ResolvedAggregateType (..),+ AggregateSymbols,+ aggregateSymbols,+ AggregateTypeErrorReason (..),+ AggregateTypeError (..),+ resolveAggregateType,+ inferAggregateFieldType,+ aggregateCapability,+ aggregateCanonicalName,+ typeExprCanonicalName,+ aggregateHaskellType,+ aggregateImports,+ aggregatePackages,+ aggregateSampleHaskell,+ ResolvedRegisterInitial (..),+ resolveRegisterInitial,+ renderRegisterInitial,+ registerInitialCanonicalName,+ )+where++import Data.Char (toUpper)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time.Calendar (toGregorian)+import Data.Time.Clock (UTCTime (..), diffTimeToPicoseconds)+import Data.Time.Format.ISO8601 (iso8601ParseM)+import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType+import Keiro.Dsl.TypeGraph+import Numeric.Natural (Natural)+import Text.Read (readMaybe)++data AggregateUseSite+ = CommandFieldUse+ | EventFieldUse+ | RegisterUse+ | EqualityGuardUse+ | OrderingGuardUse+ | WholeValueWriteUse+ | CodecUse+ | SnapshotUse+ | HarnessSampleUse+ | HaskellLoweringUse+ deriving stock (Eq, Ord, Show, Enum, Bounded)++-- | Whether a use is solver-visible, legal but opaque, or unsupported.+data AggregateCapability = SolverVisible | OpaqueOnly | Unsupported+ deriving stock (Eq, Ord, Show, Enum, Bounded)++data ResolvedAggregateType+ = AggregateText+ | AggregateInt+ | AggregateInteger+ | AggregateBool+ | AggregateTime+ | AggregateNatural+ | AggregateNominal !ResolvedNominalType+ | AggregateVertex !Name+ | AggregateMapped !MappedKey+ deriving stock (Eq, Ord, Show)++data AggregateSymbols = AggregateSymbols+ { symbolNominals :: !(Map Name ResolvedNominalType),+ symbolVertices :: !(Map Name [Name]),+ symbolMapped :: !(Map MappedKey ResolvedMappedDecl)+ }++aggregateSymbols :: Spec -> AggregateSymbols+aggregateSymbols spec =+ AggregateSymbols+ { symbolNominals = either (const Map.empty) nominalTypes (resolveNominalTypes spec),+ symbolVertices =+ Map.fromList+ [ (aggName aggregate <> "Vertex", map stName (aggStates aggregate))+ | NAggregate aggregate <- specNodes spec+ ],+ symbolMapped = either (const Map.empty) tgDeclarations (resolveTypeGraph spec)+ }++data AggregateTypeErrorReason+ = UnknownAggregateType !Name+ | UnsupportedAggregateShape !TypeExpr+ | UnsupportedAggregateCapability !ResolvedAggregateType+ | InvalidRegisterInitial !ResolvedAggregateType !Text+ deriving stock (Eq, Show)++data AggregateTypeError = AggregateTypeError+ { aggregateTypeErrorLoc :: !Loc,+ aggregateTypeErrorUseSite :: !AggregateUseSite,+ aggregateTypeErrorReason :: !AggregateTypeErrorReason+ }+ deriving stock (Eq, Show)++resolveAggregateType :: AggregateSymbols -> Loc -> AggregateUseSite -> TypeExpr -> Either AggregateTypeError ResolvedAggregateType+resolveAggregateType symbols loc useSite expression = do+ resolved <- case expression of+ TText -> pure AggregateText+ TInt -> pure AggregateInt+ TInteger -> pure AggregateInteger+ TBool -> pure AggregateBool+ TNatural -> pure AggregateNatural+ TTime -> pure AggregateTime+ TJson -> unsupportedShape+ TOptional {} -> unsupportedShape+ TList {} -> unsupportedShape+ TMap {} -> unsupportedShape+ TRef name+ | Just nominal <- Map.lookup name (symbolNominals symbols) -> pure (AggregateNominal nominal)+ | Map.member name (symbolVertices symbols) -> pure (AggregateVertex name)+ | Map.member (MappedKey name) (symbolMapped symbols) -> pure (AggregateMapped (MappedKey name))+ | otherwise -> Left (AggregateTypeError loc useSite (UnknownAggregateType name))+ case aggregateCapability useSite resolved of+ Unsupported -> Left (AggregateTypeError loc useSite (UnsupportedAggregateCapability resolved))+ SolverVisible -> pure resolved+ OpaqueOnly -> pure resolved+ where+ unsupportedShape = Left (AggregateTypeError loc useSite (UnsupportedAggregateShape expression))++inferAggregateFieldType :: AggregateSymbols -> Aggregate -> AggregateUseSite -> AggregateField -> Either AggregateTypeError ResolvedAggregateType+inferAggregateFieldType symbols aggregate useSite field =+ resolveAggregateType symbols (aggregateFieldLoc field) useSite inferred+ where+ inferred = case aggregateFieldType field of+ Just expression -> expression+ Nothing -> case [regType register | register <- aggRegs aggregate, regName register == aggregateFieldName field] of+ expression : _ -> expression+ [] ->+ let candidate = pascal (aggregateFieldName field)+ in if Map.member candidate (symbolNominals symbols)+ || Map.member candidate (symbolVertices symbols)+ || Map.member (MappedKey candidate) (symbolMapped symbols)+ then TRef candidate+ else TText++aggregateCapability :: AggregateUseSite -> ResolvedAggregateType -> AggregateCapability+aggregateCapability useSite resolved = case useSite of+ EqualityGuardUse -> case resolved of+ AggregateMapped {} -> Unsupported+ _ -> solverVisibility resolved+ OrderingGuardUse -> case resolved of+ AggregateInt -> SolverVisible+ AggregateInteger -> SolverVisible+ AggregateTime -> SolverVisible+ AggregateNatural -> SolverVisible+ AggregateNominal nominal -> nominalOrderingCapability nominal+ AggregateText -> Unsupported+ AggregateBool -> Unsupported+ AggregateVertex {} -> Unsupported+ AggregateMapped {} -> Unsupported+ CommandFieldUse -> solverVisibility resolved+ EventFieldUse -> solverVisibility resolved+ RegisterUse -> solverVisibility resolved+ WholeValueWriteUse -> solverVisibility resolved+ CodecUse -> solverVisibility resolved+ SnapshotUse -> solverVisibility resolved+ HarnessSampleUse -> solverVisibility resolved+ HaskellLoweringUse -> solverVisibility resolved++solverVisibility :: ResolvedAggregateType -> AggregateCapability+solverVisibility resolved = case resolved of+ AggregateText -> SolverVisible+ AggregateInt -> SolverVisible+ AggregateInteger -> SolverVisible+ AggregateBool -> SolverVisible+ AggregateTime -> SolverVisible+ AggregateNatural -> SolverVisible+ AggregateNominal nominal -> nominalSolverVisibility nominal+ AggregateVertex {} -> OpaqueOnly+ AggregateMapped {} -> OpaqueOnly++nominalSolverVisibility :: ResolvedNominalType -> AggregateCapability+nominalSolverVisibility nominal = case resolvedNominalRepresentation nominal of+ ScalarRepresentation {} -> SolverVisible+ IdRepresentation {} -> OpaqueOnly+ EnumRepresentation {} -> OpaqueOnly++nominalOrderingCapability :: ResolvedNominalType -> AggregateCapability+nominalOrderingCapability nominal = case resolvedNominalRepresentation nominal of+ ScalarRepresentation NominalInt -> SolverVisible+ ScalarRepresentation NominalNatural -> SolverVisible+ ScalarRepresentation NominalTime -> SolverVisible+ ScalarRepresentation NominalText -> Unsupported+ ScalarRepresentation NominalBool -> Unsupported+ IdRepresentation {} -> Unsupported+ EnumRepresentation {} -> Unsupported++aggregateCanonicalName :: ResolvedAggregateType -> Text+aggregateCanonicalName resolved = case resolved of+ AggregateText -> "Text"+ AggregateInt -> "Int"+ AggregateInteger -> "Integer"+ AggregateBool -> "Bool"+ AggregateTime -> "Time"+ AggregateNatural -> "Natural"+ AggregateNominal nominal -> resolvedNominalName nominal+ AggregateVertex name -> name+ AggregateMapped key -> unMappedKey key++typeExprCanonicalName :: TypeExpr -> Text+typeExprCanonicalName expression = case expression of+ TText -> "Text"+ TInt -> "Int"+ TInteger -> "Integer"+ TBool -> "Bool"+ TNatural -> "Natural"+ TTime -> "Time"+ TJson -> "Json"+ TOptional value -> "Optional(" <> typeExprCanonicalName value <> ")"+ TList value -> "List(" <> typeExprCanonicalName value <> ")"+ TMap value -> "Map(" <> typeExprCanonicalName value <> ")"+ TRef name -> name++aggregateHaskellType :: AggregateSymbols -> ResolvedAggregateType -> Text+aggregateHaskellType symbols resolved = case resolved of+ AggregateTime -> "UTCTime"+ AggregateNominal nominal -> case resolvedNominalOwnership nominal of+ GeneratedNominal -> resolvedNominalName nominal+ ConsumerNominal binding -> renderHaskellSource (consumerNominalHaskell binding)+ AggregateMapped key -> case Map.lookup key (symbolMapped symbols) of+ Just declaration -> renderHaskellSource (mappedHaskell declaration)+ Nothing -> unMappedKey key+ _ -> aggregateCanonicalName resolved+ where+ mappedHaskell (ResolvedStructural declaration _) = sdHaskell declaration+ mappedHaskell (ResolvedOpaque declaration) = odHaskell declaration+ renderHaskellSource source = hsModule source <> "." <> hsType source++aggregateImports :: AggregateSymbols -> ResolvedAggregateType -> Set Text+aggregateImports symbols resolved = case resolved of+ AggregateTime ->+ Set.fromList+ [ "Data.Time.Calendar (fromGregorian)",+ "Data.Time.Clock (UTCTime(..), picosecondsToDiffTime)"+ ]+ AggregateNatural -> Set.singleton "Numeric.Natural (Natural)"+ AggregateNominal nominal -> case resolvedNominalOwnership nominal of+ GeneratedNominal -> Set.empty+ ConsumerNominal binding -> Set.singleton (hsModule (consumerNominalHaskell binding) <> " qualified")+ AggregateMapped key -> case Map.lookup key (symbolMapped symbols) of+ Just declaration -> Set.singleton (hsModule (mappedHaskell declaration) <> " qualified")+ Nothing -> Set.empty+ _ -> Set.empty+ where+ mappedHaskell (ResolvedStructural declaration _) = sdHaskell declaration+ mappedHaskell (ResolvedOpaque declaration) = odHaskell declaration++aggregatePackages :: AggregateSymbols -> ResolvedAggregateType -> Set Text+aggregatePackages symbols resolved = case resolved of+ AggregateTime -> Set.singleton "time"+ AggregateNominal nominal -> case resolvedNominalOwnership nominal of+ GeneratedNominal -> Set.empty+ ConsumerNominal binding -> Set.singleton (hsPackage (consumerNominalHaskell binding))+ AggregateMapped key -> case Map.lookup key (symbolMapped symbols) of+ Just declaration -> Set.singleton (hsPackage (mappedHaskell declaration))+ Nothing -> Set.empty+ _ -> Set.empty+ where+ mappedHaskell (ResolvedStructural declaration _) = sdHaskell declaration+ mappedHaskell (ResolvedOpaque declaration) = odHaskell declaration++aggregateSampleHaskell :: AggregateSymbols -> Text -> ResolvedAggregateType -> Text+aggregateSampleHaskell symbols fieldName resolved = case resolved of+ AggregateText -> tshow ("sample-" <> fieldName)+ AggregateInt -> "0"+ AggregateInteger -> "0"+ AggregateBool -> "False"+ AggregateTime -> "(UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012))"+ AggregateNatural -> "0"+ AggregateNominal nominal -> nominalSample nominal+ AggregateVertex name -> firstConstructor name+ AggregateMapped key -> case Map.lookup key (symbolMapped symbols) of+ Just declaration -> "(snd (NonEmpty.head (fixtureCases " <> unQualifiedValueName (mappedFixtures declaration) <> ")))"+ Nothing -> unMappedKey key <> ".sample"+ where+ firstConstructor name = case Map.lookup name (symbolVertices symbols) of+ Just (constructor : _) -> constructor+ _ -> name+ nominalSample nominal = case resolvedNominalOwnership nominal of+ ConsumerNominal binding ->+ "(nominalFixtureDomain (NonEmpty.head (nominalFixtureCases "+ <> unQualifiedValueName (consumerNominalFixtures binding)+ <> ")))"+ GeneratedNominal -> case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> "(" <> resolvedNominalName nominal <> " \"sample\")"+ EnumRepresentation constructors -> fst (NE.head constructors)+ ScalarRepresentation {} -> resolvedNominalName nominal <> ".sample"+ mappedFixtures (ResolvedStructural declaration _) = sdFixtures declaration+ mappedFixtures (ResolvedOpaque declaration) = odFixtures declaration++data ResolvedRegisterInitial+ = InitialText !Text+ | InitialInt !Int+ | InitialInteger !Integer+ | InitialBool !Bool+ | InitialTime !UTCTime+ | InitialNatural !Natural+ | InitialId !Name+ | InitialNamed !ResolvedAggregateType !Name+ | InitialNominal !Name !QualifiedValueName+ | InitialMapped !MappedKey !QualifiedValueName+ deriving stock (Eq, Show)++resolveRegisterInitial :: AggregateSymbols -> Loc -> ResolvedAggregateType -> RegInitial -> Either AggregateTypeError ResolvedRegisterInitial+resolveRegisterInitial symbols loc resolved syntax = case resolved of+ AggregateText -> case syntax of+ RegInitText value -> pure (InitialText value)+ RegInitBare _ -> invalid "Text initials must be quoted"+ AggregateInt -> case syntax of+ RegInitBare value -> maybe (invalid "Int initials must be integral literals in the Haskell Int range") (pure . InitialInt) (readMaybe (T.unpack value))+ RegInitText _ -> invalid "Int initials must be unquoted integral literals"+ AggregateInteger -> case syntax of+ RegInitBare value -> maybe (invalid "Integer initials must be integral literals") (pure . InitialInteger) (readMaybe (T.unpack value))+ RegInitText _ -> invalid "Integer initials must be unquoted integral literals"+ AggregateBool -> case syntax of+ RegInitBare "True" -> pure (InitialBool True)+ RegInitBare "False" -> pure (InitialBool False)+ _ -> invalid "Bool initials must be True or False"+ AggregateTime -> case syntax of+ RegInitText value -> maybe (invalid "Time initials must be valid quoted ISO-8601 UTC timestamps") (pure . InitialTime) (iso8601ParseM (T.unpack value))+ RegInitBare _ -> invalid "Time initials must be quoted ISO-8601 UTC timestamps"+ AggregateNatural -> case syntax of+ RegInitBare value -> case readMaybe (T.unpack value) :: Maybe Integer of+ Just number | number >= 0 -> pure (InitialNatural (fromInteger number))+ _ -> invalid "Natural initials must be non-negative integral literals"+ RegInitText _ -> invalid "Natural initials must be unquoted non-negative integral literals"+ AggregateNominal nominal -> case resolvedNominalOwnership nominal of+ ConsumerNominal binding -> case syntax of+ RegInitBare "initial" -> case consumerNominalInitial binding of+ Just value -> pure (InitialNominal (resolvedNominalName nominal) value)+ Nothing -> invalid "consumer-owned nominal register type must declare an initial symbol"+ _ -> invalid "consumer-owned nominal register initials must use the bare initial token"+ GeneratedNominal -> case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> case syntax of+ RegInitBare "placeholder" -> pure (InitialId (resolvedNominalName nominal))+ _ -> invalid "ID initials must use placeholder"+ EnumRepresentation constructors -> namedInitial (resolvedNominalName nominal) (Just (map fst (NE.toList constructors)))+ ScalarRepresentation {} -> invalid "generated nominal scalars are unsupported"+ AggregateVertex name -> namedInitial name (Map.lookup name (symbolVertices symbols))+ AggregateMapped key -> case syntax of+ RegInitBare "initial" -> case Map.lookup key (symbolMapped symbols) >>= mappedInitial of+ Just value -> pure (InitialMapped key value)+ Nothing -> invalid "mapped register type must declare an initial symbol"+ _ -> invalid "mapped register initials must use the bare initial token"+ where+ invalid detail = Left (AggregateTypeError loc RegisterUse (InvalidRegisterInitial resolved detail))+ namedInitial name constructors = case syntax of+ RegInitBare constructor | maybe False (constructor `elem`) constructors -> pure (InitialNamed resolved constructor)+ _ -> invalid ("initial must name a constructor of " <> name)+ mappedInitial (ResolvedStructural declaration _) = sdInitial declaration+ mappedInitial (ResolvedOpaque declaration) = odInitial declaration++renderRegisterInitial :: ResolvedRegisterInitial -> Text+renderRegisterInitial initial = case initial of+ InitialText value -> tshow value+ InitialInt value -> T.pack (show value)+ InitialInteger value -> T.pack (show value)+ InitialBool value -> if value then "True" else "False"+ InitialTime value ->+ let (year, month, day) = toGregorian (utctDay value)+ picoseconds = diffTimeToPicoseconds (utctDayTime value)+ in "(UTCTime (fromGregorian "+ <> T.pack (show year)+ <> " "+ <> T.pack (show month)+ <> " "+ <> T.pack (show day)+ <> ") (picosecondsToDiffTime "+ <> T.pack (show picoseconds)+ <> "))"+ InitialNatural value -> T.pack (show value)+ InitialId name -> "(" <> name <> " \"\")"+ InitialNamed resolved constructor -> case resolved of+ AggregateVertex vertexType -> T.dropEnd (T.length ("Vertex" :: Text)) vertexType <> constructor+ _ -> constructor+ InitialNominal _ value -> unQualifiedValueName value+ InitialMapped _ value -> unQualifiedValueName value++registerInitialCanonicalName :: ResolvedRegisterInitial -> Text+registerInitialCanonicalName initial = case initial of+ InitialText value -> tshow value+ InitialInt value -> T.pack (show value)+ InitialInteger value -> T.pack (show value)+ InitialBool value -> if value then "True" else "False"+ InitialTime {} -> renderRegisterInitial initial+ InitialNatural value -> T.pack (show value)+ InitialId {} -> "placeholder"+ InitialNamed _ constructor -> constructor+ InitialNominal {} -> "initial"+ InitialMapped {} -> "initial"++pascal :: Text -> Text+pascal value = case T.uncons value of+ Just (first, rest) -> T.cons (toUpper first) rest+ Nothing -> value++tshow :: Text -> Text+tshow = T.pack . show
src/Keiro/Dsl/CodecCompare.hs view
@@ -1,13 +1,12 @@ {-# OPTIONS_GHC -Werror=incomplete-patterns #-} -{- | Pure historical-codec comparison and migration-evidence reports.--The comparison is deliberately finite evidence over typed fixture cases and-historical JSON goldens. It never changes which codec owns the wire schema and-never upgrades an opaque declaration to a structural claim.--}-module Keiro.Dsl.CodecCompare (- FixtureOrigin (..),+-- | Pure historical-codec comparison and migration-evidence reports.+--+-- The comparison is deliberately finite evidence over typed fixture cases and+-- historical JSON goldens. It never changes which codec owns the wire schema and+-- never upgrades an opaque declaration to a structural claim.+module Keiro.Dsl.CodecCompare+ ( FixtureOrigin (..), DecodeOutcome (..), JsonPointer (..), ComparisonDifference (..),@@ -35,7 +34,8 @@ renderCompareReport, reportSucceeded, writeCompareReportAtomic,-) where+ )+where import Control.Exception (IOException, bracketOnError, displayException, try) import Control.Monad (when)@@ -59,146 +59,144 @@ import System.IO (Handle, hClose, openBinaryTempFile) data FixtureOrigin = HistoricalGolden | FromBinding- deriving stock (Eq, Ord, Show)+ deriving stock (Eq, Ord, Show) data DecodeOutcome- = DecodedShape !Value- | DecodeFailed !Text- deriving stock (Eq, Show)+ = DecodedShape !Value+ | DecodeFailed !Text+ deriving stock (Eq, Show) newtype JsonPointer = JsonPointer {unJsonPointer :: Text}- deriving stock (Eq, Ord, Show)+ deriving stock (Eq, Ord, Show) data ComparisonDifference- = EncodedValueDifference !JsonPointer !Value !Value- | DecodedValueDifference !JsonPointer !Value !Value- | GeneratedDecodeRejected !Text- deriving stock (Eq, Show)+ = EncodedValueDifference !JsonPointer !Value !Value+ | DecodedValueDifference !JsonPointer !Value !Value+ | GeneratedDecodeRejected !Text+ deriving stock (Eq, Show) -{- | A historical codec is an explicit value supplied by consumer-owned test-code. Its identity and version are report provenance, not dispatch keys.--}+-- | A historical codec is an explicit value supplied by consumer-owned test+-- code. Its identity and version are report provenance, not dispatch keys. data HistoricalCodec a = HistoricalCodec- { hcIdentity :: !Text- , hcVersion :: !Text- , hcEncode :: !(a -> Value)- , hcDecode :: !(Value -> Either Text a)- }+ { hcIdentity :: !Text,+ hcVersion :: !Text,+ hcEncode :: !(a -> Value),+ hcDecode :: !(Value -> Either Text a)+ } data CompareObservation- = EncodeObservation- { coCaseName :: !Text- , coHistoricalValue :: !Value- , coGeneratedValue :: !Value- }- | DecodeObservation- { coFixturePath :: !FilePath- , coInputValue :: !Value- , coHistoricalDecode :: !DecodeOutcome- , coGeneratedDecode :: !DecodeOutcome- }- deriving stock (Eq, Show)+ = EncodeObservation+ { coCaseName :: !Text,+ coHistoricalValue :: !Value,+ coGeneratedValue :: !Value+ }+ | DecodeObservation+ { coFixturePath :: !FilePath,+ coInputValue :: !Value,+ coHistoricalDecode :: !DecodeOutcome,+ coGeneratedDecode :: !DecodeOutcome+ }+ deriving stock (Eq, Show) data FixtureVerdict- = JsonParity- | RequiresVersionWork !ComparisonDifference- deriving stock (Eq, Show)+ = JsonParity+ | RequiresVersionWork !ComparisonDifference+ deriving stock (Eq, Show) data CompareInputIssue- = HistoricalGoldenUnreadable !FilePath !Text- | HistoricalCodecRejected !FilePath !Text- | HistoricalCodecProvenanceInvalid !Text- deriving stock (Eq, Show)+ = HistoricalGoldenUnreadable !FilePath !Text+ | HistoricalCodecRejected !FilePath !Text+ | HistoricalCodecProvenanceInvalid !Text+ deriving stock (Eq, Show) data BranchKind- = UnionArm !Text- | OptionalPresent- | OptionalMissing- | ExplicitNull- deriving stock (Eq, Ord, Show)+ = UnionArm !Text+ | OptionalPresent+ | OptionalMissing+ | ExplicitNull+ deriving stock (Eq, Ord, Show) data DeclaredBranch = DeclaredBranch- { dbOrigin :: !FixtureOrigin- , dbPointer :: !JsonPointer- , dbKind :: !BranchKind- }- deriving stock (Eq, Ord, Show)+ { dbOrigin :: !FixtureOrigin,+ dbPointer :: !JsonPointer,+ dbKind :: !BranchKind+ }+ deriving stock (Eq, Ord, Show) data ObservedBranch = ObservedBranch- { obOrigin :: !FixtureOrigin- , obPointer :: !JsonPointer- , obKind :: !BranchKind- }- deriving stock (Eq, Ord, Show)+ { obOrigin :: !FixtureOrigin,+ obPointer :: !JsonPointer,+ obKind :: !BranchKind+ }+ deriving stock (Eq, Ord, Show) data CoverageGap = CoverageGap- { cgOrigin :: !FixtureOrigin- , cgPointer :: !JsonPointer- , cgKind :: !BranchKind- }- deriving stock (Eq, Ord, Show)+ { cgOrigin :: !FixtureOrigin,+ cgPointer :: !JsonPointer,+ cgKind :: !BranchKind+ }+ deriving stock (Eq, Ord, Show) -{- | A codec-independent branch description embedded into generated-comparison runners. The generator constructs it through the checked type-graph's total algebras, so this module never has to interpret a consumer type.--}+-- | A codec-independent branch description embedded into generated+-- comparison runners. The generator constructs it through the checked type+-- graph's total algebras, so this module never has to interpret a consumer type. data BranchSchema- = BranchScalar- | BranchOptional !BranchSchema- | BranchList !BranchSchema- | BranchMap !BranchSchema- | BranchRecord ![BranchField]- | BranchUnion !Text !Text ![BranchArm]- deriving stock (Eq, Show)+ = BranchScalar+ | BranchOptional !BranchSchema+ | BranchList !BranchSchema+ | BranchMap !BranchSchema+ | BranchRecord ![BranchField]+ | BranchUnion !Text !Text ![BranchArm]+ deriving stock (Eq, Show) data BranchField = BranchField- { bfWireKey :: !Text- , bfPresenceOptional :: !Bool- , bfSchema :: !BranchSchema- }- deriving stock (Eq, Show)+ { bfWireKey :: !Text,+ bfPresenceOptional :: !Bool,+ bfSchema :: !BranchSchema+ }+ deriving stock (Eq, Show) data BranchArm = BranchArm- { baWireTag :: !Text- , baPayloadSchema :: !(Maybe BranchSchema)- }- deriving stock (Eq, Show)+ { baWireTag :: !Text,+ baPayloadSchema :: !(Maybe BranchSchema)+ }+ deriving stock (Eq, Show) data CompareProvenance = CompareProvenance- { cpHistoricalCodecIdentity :: !Text- , cpHistoricalCodecVersion :: !Text- , cpCanonicalType :: !CanonicalTypeId- , cpBindingSymbol :: !QualifiedValueName- , cpBindingVersion :: !BindingVersion- , cpWireFingerprint :: !Text- }- deriving stock (Eq, Show)+ { cpHistoricalCodecIdentity :: !Text,+ cpHistoricalCodecVersion :: !Text,+ cpCanonicalType :: !CanonicalTypeId,+ cpBindingSymbol :: !QualifiedValueName,+ cpBindingVersion :: !BindingVersion,+ cpWireFingerprint :: !Text+ }+ deriving stock (Eq, Show) data ClassifiedObservation = ClassifiedObservation- { classifiedOrigin :: !FixtureOrigin- , classifiedName :: !Text- , classifiedVerdict :: !FixtureVerdict- }- deriving stock (Eq, Show)+ { classifiedOrigin :: !FixtureOrigin,+ classifiedName :: !Text,+ classifiedVerdict :: !FixtureVerdict+ }+ deriving stock (Eq, Show) data CompareReport = CompareReport- { crProvenance :: !CompareProvenance- , crObservations :: ![ClassifiedObservation]- , crInputIssues :: ![CompareInputIssue]- , crCoverageGaps :: ![CoverageGap]- , crAuthority :: !Text- }- deriving stock (Eq, Show)+ { crProvenance :: !CompareProvenance,+ crObservations :: ![ClassifiedObservation],+ crInputIssues :: ![CompareInputIssue],+ crCoverageGaps :: ![CoverageGap],+ crAuthority :: !Text+ }+ deriving stock (Eq, Show) data ReportWriteError = ReportWriteError- { reportWritePath :: !FilePath- , reportWriteMessage :: !Text- }- deriving stock (Eq, Show)+ { reportWritePath :: !FilePath,+ reportWriteMessage :: !Text+ }+ deriving stock (Eq, Show) authorityStatement :: Text authorityStatement =- "This comparison is MIGRATION EVIDENCE ONLY. After cutover the generated structural codec is the sole wire authority. This runner is never a runtime fallback and never upgrades an opaque declaration to structural. Resolve each difference with an explicit version bump and upcaster, or correct the declaration to match the historical wire contract; \"close enough\" is not an outcome."+ "This comparison is MIGRATION EVIDENCE ONLY. After cutover the generated structural codec is the sole wire authority. This runner is never a runtime fallback and never upgrades an opaque declaration to structural. Resolve each difference with an explicit version bump and upcaster, or correct the declaration to match the historical wire contract; \"close enough\" is not an outcome." -- | Render a JSON value in RFC 8785 canonical form. canonicalJsonBytes :: Value -> ByteString@@ -206,73 +204,73 @@ classifyObservation :: CompareObservation -> Either CompareInputIssue FixtureVerdict classifyObservation observation = case observation of- EncodeObservation _ historical generated ->- Right (classifyValues EncodedValueDifference historical generated)- DecodeObservation fixturePath _ historical generated -> case historical of- DecodeFailed reason -> Left (HistoricalCodecRejected fixturePath reason)- DecodedShape historicalValue -> case generated of- DecodeFailed reason -> Right (RequiresVersionWork (GeneratedDecodeRejected reason))- DecodedShape generatedValue ->- Right (classifyValues DecodedValueDifference historicalValue generatedValue)+ EncodeObservation _ historical generated ->+ Right (classifyValues EncodedValueDifference historical generated)+ DecodeObservation fixturePath _ historical generated -> case historical of+ DecodeFailed reason -> Left (HistoricalCodecRejected fixturePath reason)+ DecodedShape historicalValue -> case generated of+ DecodeFailed reason -> Right (RequiresVersionWork (GeneratedDecodeRejected reason))+ DecodedShape generatedValue ->+ Right (classifyValues DecodedValueDifference historicalValue generatedValue) classifyValues :: (JsonPointer -> Value -> Value -> ComparisonDifference) -> Value -> Value -> FixtureVerdict classifyValues difference historical generated- | canonicalJsonBytes historical == canonicalJsonBytes generated = JsonParity- | otherwise = RequiresVersionWork (difference (firstDivergentPointer historical generated) historical generated)+ | canonicalJsonBytes historical == canonicalJsonBytes generated = JsonParity+ | otherwise = RequiresVersionWork (difference (firstDivergentPointer historical generated) historical generated) compareReport ::- CompareProvenance ->- [CompareInputIssue] ->- [CompareObservation] ->- [DeclaredBranch] ->- [ObservedBranch] ->- CompareReport+ CompareProvenance ->+ [CompareInputIssue] ->+ [CompareObservation] ->+ [DeclaredBranch] ->+ [ObservedBranch] ->+ CompareReport compareReport provenance suppliedIssues observations declaredBranches observedBranches =- CompareReport- { crProvenance = provenance- , crObservations = classified- , crInputIssues = provenanceIssues provenance <> suppliedIssues <> classificationIssues- , crCoverageGaps = coverageGaps declaredBranches observedBranches- , crAuthority = authorityStatement- }+ CompareReport+ { crProvenance = provenance,+ crObservations = classified,+ crInputIssues = provenanceIssues provenance <> suppliedIssues <> classificationIssues,+ crCoverageGaps = coverageGaps declaredBranches observedBranches,+ crAuthority = authorityStatement+ } where outcomes = map classify observations classified = [value | Right value <- outcomes] classificationIssues = [issue | Left issue <- outcomes] classify observation = case classifyObservation observation of- Left issue -> Left issue- Right verdict ->- Right- ClassifiedObservation- { classifiedOrigin = observationOrigin observation- , classifiedName = observationName observation- , classifiedVerdict = verdict- }+ Left issue -> Left issue+ Right verdict ->+ Right+ ClassifiedObservation+ { classifiedOrigin = observationOrigin observation,+ classifiedName = observationName observation,+ classifiedVerdict = verdict+ } observationOrigin :: CompareObservation -> FixtureOrigin-observationOrigin EncodeObservation{} = FromBinding-observationOrigin DecodeObservation{} = HistoricalGolden+observationOrigin EncodeObservation {} = FromBinding+observationOrigin DecodeObservation {} = HistoricalGolden observationName :: CompareObservation -> Text-observationName EncodeObservation{coCaseName = name} = name-observationName DecodeObservation{coFixturePath = path} = T.pack path+observationName EncodeObservation {coCaseName = name} = name+observationName DecodeObservation {coFixturePath = path} = T.pack path provenanceIssues :: CompareProvenance -> [CompareInputIssue] provenanceIssues provenance =- [ HistoricalCodecProvenanceInvalid "historical codec identity must not be blank"- | T.null (T.strip (cpHistoricalCodecIdentity provenance))- ]- <> [ HistoricalCodecProvenanceInvalid "historical codec version must not be blank"- | T.null (T.strip (cpHistoricalCodecVersion provenance))- ]+ [ HistoricalCodecProvenanceInvalid "historical codec identity must not be blank"+ | T.null (T.strip (cpHistoricalCodecIdentity provenance))+ ]+ <> [ HistoricalCodecProvenanceInvalid "historical codec version must not be blank"+ | T.null (T.strip (cpHistoricalCodecVersion provenance))+ ] coverageGaps :: [DeclaredBranch] -> [ObservedBranch] -> [CoverageGap] coverageGaps declared observed =- [ CoverageGap (dbOrigin branch) (dbPointer branch) (dbKind branch)- | branch <- declared- , branchKey branch `Set.notMember` observedKeys- ]+ [ CoverageGap (dbOrigin branch) (dbPointer branch) (dbKind branch)+ | branch <- declared,+ branchKey branch `Set.notMember` observedKeys+ ] where observedKeys = Set.fromList (map observedBranchKey observed) branchKey branch = (dbOrigin branch, dbPointer branch, dbKind branch)@@ -283,234 +281,234 @@ where declared pointer kind = Set.singleton (DeclaredBranch origin (JsonPointer pointer) kind) go pointer schema = case schema of- BranchScalar -> Set.empty- BranchOptional nested ->- declared pointer OptionalPresent- <> declared pointer ExplicitNull- <> go pointer nested- BranchList nested -> go (appendPointer pointer "*") nested- BranchMap nested -> go (appendPointer pointer "*") nested- BranchRecord fields ->- Set.unions- [ presenceBranches pointer field <> go (appendPointer pointer (bfWireKey field)) (bfSchema field)- | field <- fields- ]- BranchUnion _tagField contentsField arms ->- Set.unions- [ declared pointer (UnionArm (baWireTag arm))- <> maybe Set.empty (go (appendPointer pointer contentsField)) (baPayloadSchema arm)- | arm <- arms- ]+ BranchScalar -> Set.empty+ BranchOptional nested ->+ declared pointer OptionalPresent+ <> declared pointer ExplicitNull+ <> go pointer nested+ BranchList nested -> go (appendPointer pointer "*") nested+ BranchMap nested -> go (appendPointer pointer "*") nested+ BranchRecord fields ->+ Set.unions+ [ presenceBranches pointer field <> go (appendPointer pointer (bfWireKey field)) (bfSchema field)+ | field <- fields+ ]+ BranchUnion _tagField contentsField arms ->+ Set.unions+ [ declared pointer (UnionArm (baWireTag arm))+ <> maybe Set.empty (go (appendPointer pointer contentsField)) (baPayloadSchema arm)+ | arm <- arms+ ] presenceBranches pointer field- | bfPresenceOptional field =- let fieldPointer = appendPointer pointer (bfWireKey field)- in case origin of- HistoricalGolden -> declared fieldPointer OptionalMissing <> declared fieldPointer OptionalPresent- FromBinding -> declared fieldPointer OptionalPresent- | otherwise = Set.empty+ | bfPresenceOptional field =+ let fieldPointer = appendPointer pointer (bfWireKey field)+ in case origin of+ HistoricalGolden -> declared fieldPointer OptionalMissing <> declared fieldPointer OptionalPresent+ FromBinding -> declared fieldPointer OptionalPresent+ | otherwise = Set.empty observedBranchesFor :: FixtureOrigin -> BranchSchema -> Value -> [ObservedBranch] observedBranchesFor origin schema = Set.toAscList . go "" schema where observed pointer kind = Set.singleton (ObservedBranch origin (JsonPointer pointer) kind) go pointer branchSchema value = case branchSchema of- BranchScalar -> Set.empty- BranchOptional nested -> case value of- Null -> observed pointer ExplicitNull- _ -> observed pointer OptionalPresent <> go pointer nested value- BranchList nested -> case value of- Array values -> Set.unions [go (appendPointer pointer "*") nested item | item <- toList values]- _ -> Set.empty- BranchMap nested -> case value of- Object values -> Set.unions [go (appendPointer pointer "*") nested item | item <- KeyMap.elems values]- _ -> Set.empty- BranchRecord fields -> case value of- Object values -> Set.unions (map (observeField pointer values) fields)- _ -> Set.empty- BranchUnion tagField contentsField arms -> case value of- Object values -> case KeyMap.lookup (Key.fromText tagField) values of- Just (String tag) -> case filter ((== tag) . baWireTag) arms of- arm : _ ->- observed pointer (UnionArm tag)- <> case (baPayloadSchema arm, KeyMap.lookup (Key.fromText contentsField) values) of- (Just nested, Just payload) -> go (appendPointer pointer contentsField) nested payload- _ -> Set.empty- [] -> Set.empty- _ -> Set.empty- _ -> Set.empty+ BranchScalar -> Set.empty+ BranchOptional nested -> case value of+ Null -> observed pointer ExplicitNull+ _ -> observed pointer OptionalPresent <> go pointer nested value+ BranchList nested -> case value of+ Array values -> Set.unions [go (appendPointer pointer "*") nested item | item <- toList values]+ _ -> Set.empty+ BranchMap nested -> case value of+ Object values -> Set.unions [go (appendPointer pointer "*") nested item | item <- KeyMap.elems values]+ _ -> Set.empty+ BranchRecord fields -> case value of+ Object values -> Set.unions (map (observeField pointer values) fields)+ _ -> Set.empty+ BranchUnion tagField contentsField arms -> case value of+ Object values -> case KeyMap.lookup (Key.fromText tagField) values of+ Just (String tag) -> case filter ((== tag) . baWireTag) arms of+ arm : _ ->+ observed pointer (UnionArm tag)+ <> case (baPayloadSchema arm, KeyMap.lookup (Key.fromText contentsField) values) of+ (Just nested, Just payload) -> go (appendPointer pointer contentsField) nested payload+ _ -> Set.empty+ [] -> Set.empty+ _ -> Set.empty+ _ -> Set.empty observeField pointer values field =- let fieldPointer = appendPointer pointer (bfWireKey field)- in case KeyMap.lookup (Key.fromText (bfWireKey field)) values of- Nothing- | bfPresenceOptional field -> observed fieldPointer OptionalMissing- | otherwise -> Set.empty- Just fieldValue ->- (if bfPresenceOptional field then observed fieldPointer OptionalPresent else Set.empty)- <> go fieldPointer (bfSchema field) fieldValue+ let fieldPointer = appendPointer pointer (bfWireKey field)+ in case KeyMap.lookup (Key.fromText (bfWireKey field)) values of+ Nothing+ | bfPresenceOptional field -> observed fieldPointer OptionalMissing+ | otherwise -> Set.empty+ Just fieldValue ->+ (if bfPresenceOptional field then observed fieldPointer OptionalPresent else Set.empty)+ <> go fieldPointer (bfSchema field) fieldValue reportSucceeded :: CompareReport -> Bool reportSucceeded report =- null (crInputIssues report)- && null (crCoverageGaps report)- && all ((== JsonParity) . classifiedVerdict) (crObservations report)+ null (crInputIssues report)+ && null (crCoverageGaps report)+ && all ((== JsonParity) . classifiedVerdict) (crObservations report) renderCompareReport :: CompareReport -> Text renderCompareReport report =- T.unlines- ( [ "codec comparison: "- <> unCanonicalTypeId (cpCanonicalType provenance)- <> " (binding-version \""- <> unBindingVersion (cpBindingVersion provenance)- <> "\")"- , "historical codec: \""- <> cpHistoricalCodecIdentity provenance- <> "\" version \""- <> cpHistoricalCodecVersion provenance- <> "\""- , "observations: " <> tshow (length observations)- , " encode parity: " <> ratio FromBinding- , " structural decode agreement: " <> ratio HistoricalGolden- , "requires explicit version/upcaster work: " <> tshow (length differences) <> " observations [" <> codeText CodecCompareDifference <> "]"- ]- <> concatMap renderDifference differences- <> [ "input issues: " <> tshow (length (crInputIssues report)) <> " [" <> codeText CodecCompareInvalidInput <> "]"- ]- <> map (" " <>) (map renderInputIssue (crInputIssues report))- <> [ "coverage gaps: " <> tshow (length (crCoverageGaps report)) <> " [" <> codeText CodecCompareCoverageGap <> "]"- ]- <> map (" " <>) (map renderCoverageGap (crCoverageGaps report))- <> [ if reportSucceeded report- then "result: PARITY"- else "result: NOT PARITY — " <> tshow (length differences) <> " differences"- , crAuthority report- ]- )+ T.unlines+ ( [ "codec comparison: "+ <> unCanonicalTypeId (cpCanonicalType provenance)+ <> " (binding-version \""+ <> unBindingVersion (cpBindingVersion provenance)+ <> "\")",+ "historical codec: \""+ <> cpHistoricalCodecIdentity provenance+ <> "\" version \""+ <> cpHistoricalCodecVersion provenance+ <> "\"",+ "observations: " <> tshow (length observations),+ " encode parity: " <> ratio FromBinding,+ " structural decode agreement: " <> ratio HistoricalGolden,+ "requires explicit version/upcaster work: " <> tshow (length differences) <> " observations [" <> codeText CodecCompareDifference <> "]"+ ]+ <> concatMap renderDifference differences+ <> [ "input issues: " <> tshow (length (crInputIssues report)) <> " [" <> codeText CodecCompareInvalidInput <> "]"+ ]+ <> map (" " <>) (map renderInputIssue (crInputIssues report))+ <> [ "coverage gaps: " <> tshow (length (crCoverageGaps report)) <> " [" <> codeText CodecCompareCoverageGap <> "]"+ ]+ <> map (" " <>) (map renderCoverageGap (crCoverageGaps report))+ <> [ if reportSucceeded report+ then "result: PARITY"+ else "result: NOT PARITY — " <> tshow (length differences) <> " differences",+ crAuthority report+ ]+ ) where provenance = crProvenance report observations = crObservations report differences = filter ((/= JsonParity) . classifiedVerdict) observations ratio origin =- let matching = filter ((== origin) . classifiedOrigin) observations- parityCount = length (filter ((== JsonParity) . classifiedVerdict) matching)- in tshow parityCount <> "/" <> tshow (length matching) <> suffix origin+ let matching = filter ((== origin) . classifiedOrigin) observations+ parityCount = length (filter ((== JsonParity) . classifiedVerdict) matching)+ in tshow parityCount <> "/" <> tshow (length matching) <> suffix origin suffix FromBinding = " (RFC 8785 canonical form)" suffix HistoricalGolden = "" renderDifference :: ClassifiedObservation -> [Text] renderDifference observation = case classifiedVerdict observation of- JsonParity -> []- RequiresVersionWork difference ->- [ " " <> classifiedName observation <> " [" <> direction <> "] at " <> pointerOf difference- , " " <> reasonOf difference- ]+ JsonParity -> []+ RequiresVersionWork difference ->+ [ " " <> classifiedName observation <> " [" <> direction <> "] at " <> pointerOf difference,+ " " <> reasonOf difference+ ] where direction = case classifiedOrigin observation of- FromBinding -> "encode"- HistoricalGolden -> "decode"+ FromBinding -> "encode"+ HistoricalGolden -> "decode" renderInputIssue :: CompareInputIssue -> Text renderInputIssue issue = case issue of- HistoricalGoldenUnreadable path reason -> T.pack path <> ": unreadable historical golden: " <> reason- HistoricalCodecRejected path reason -> T.pack path <> ": historical codec rejected its alleged golden: " <> reason- HistoricalCodecProvenanceInvalid reason -> reason+ HistoricalGoldenUnreadable path reason -> T.pack path <> ": unreadable historical golden: " <> reason+ HistoricalCodecRejected path reason -> T.pack path <> ": historical codec rejected its alleged golden: " <> reason+ HistoricalCodecProvenanceInvalid reason -> reason renderCoverageGap :: CoverageGap -> Text renderCoverageGap gap =- originName (cgOrigin gap)- <> " "- <> renderPointer (cgPointer gap)- <> ": "- <> branchKindName (cgKind gap)+ originName (cgOrigin gap)+ <> " "+ <> renderPointer (cgPointer gap)+ <> ": "+ <> branchKindName (cgKind gap) pointerOf :: ComparisonDifference -> Text pointerOf difference = case difference of- EncodedValueDifference pointer _ _ -> renderPointer pointer- DecodedValueDifference pointer _ _ -> renderPointer pointer- GeneratedDecodeRejected _ -> "<root>"+ EncodedValueDifference pointer _ _ -> renderPointer pointer+ DecodedValueDifference pointer _ _ -> renderPointer pointer+ GeneratedDecodeRejected _ -> "<root>" reasonOf :: ComparisonDifference -> Text reasonOf difference = case difference of- EncodedValueDifference _ historical generated ->- "historical and generated encoders produced different JSON values: " <> valuePair historical generated- DecodedValueDifference _ historical generated ->- "historical and generated decoders normalized to different structural values: " <> valuePair historical generated- GeneratedDecodeRejected reason -> "generated structural decoder rejected historical JSON: " <> reason+ EncodedValueDifference _ historical generated ->+ "historical and generated encoders produced different JSON values: " <> valuePair historical generated+ DecodedValueDifference _ historical generated ->+ "historical and generated decoders normalized to different structural values: " <> valuePair historical generated+ GeneratedDecodeRejected reason -> "generated structural decoder rejected historical JSON: " <> reason valuePair :: Value -> Value -> Text valuePair historical generated = "historical=" <> tshow historical <> "; generated=" <> tshow generated renderPointer :: JsonPointer -> Text renderPointer (JsonPointer pointer)- | T.null pointer = "<root>"- | otherwise = pointer+ | T.null pointer = "<root>"+ | otherwise = pointer writeCompareReportAtomic :: FilePath -> CompareReport -> IO (Either ReportWriteError ()) writeCompareReportAtomic path report = do- let directory = takeDirectory path- template = takeFileName path <> ".tmp"- result <- try $ do- createDirectoryIfMissing True directory- bracketOnError- (openBinaryTempFile directory template)- cleanupTemporary- ( \(temporary, handle) -> do- LazyByteString.hPut handle (Aeson.encode report)- hClose handle- renameFile temporary path- )- pure $ case result of- Left err -> Left (ReportWriteError path (T.pack (displayException (err :: IOException))))- Right () -> Right ()+ let directory = takeDirectory path+ template = takeFileName path <> ".tmp"+ result <- try $ do+ createDirectoryIfMissing True directory+ bracketOnError+ (openBinaryTempFile directory template)+ cleanupTemporary+ ( \(temporary, handle) -> do+ LazyByteString.hPut handle (Aeson.encode report)+ hClose handle+ renameFile temporary path+ )+ pure $ case result of+ Left err -> Left (ReportWriteError path (T.pack (displayException (err :: IOException))))+ Right () -> Right () cleanupTemporary :: (FilePath, Handle) -> IO () cleanupTemporary (temporary, handle) = do- _ <- try (hClose handle) :: IO (Either IOException ())- exists <- doesFileExist temporary- when exists (removeFile temporary)+ _ <- try (hClose handle) :: IO (Either IOException ())+ exists <- doesFileExist temporary+ when exists (removeFile temporary) firstDivergentPointer :: Value -> Value -> JsonPointer firstDivergentPointer = go "" where go pointer (Object historical) (Object generated) =- case firstDifferentKey historical generated of- Nothing -> JsonPointer pointer- Just key -> case (KeyMap.lookup (Key.fromText key) historical, KeyMap.lookup (Key.fromText key) generated) of- (Just historicalValue, Just generatedValue) -> go (appendPointer pointer key) historicalValue generatedValue- _ -> JsonPointer (appendPointer pointer key)+ case firstDifferentKey historical generated of+ Nothing -> JsonPointer pointer+ Just key -> case (KeyMap.lookup (Key.fromText key) historical, KeyMap.lookup (Key.fromText key) generated) of+ (Just historicalValue, Just generatedValue) -> go (appendPointer pointer key) historicalValue generatedValue+ _ -> JsonPointer (appendPointer pointer key) go pointer (Array historical) (Array generated) =- let historicalValues = toList historical- generatedValues = toList generated- in case firstDifferentIndex historicalValues generatedValues of- Nothing -> JsonPointer pointer- Just index -> case (indexMaybe index historicalValues, indexMaybe index generatedValues) of- (Just historicalValue, Just generatedValue) -> go (appendPointer pointer (tshow index)) historicalValue generatedValue- _ -> JsonPointer (appendPointer pointer (tshow index))+ let historicalValues = toList historical+ generatedValues = toList generated+ in case firstDifferentIndex historicalValues generatedValues of+ Nothing -> JsonPointer pointer+ Just index -> case (indexMaybe index historicalValues, indexMaybe index generatedValues) of+ (Just historicalValue, Just generatedValue) -> go (appendPointer pointer (tshow index)) historicalValue generatedValue+ _ -> JsonPointer (appendPointer pointer (tshow index)) go pointer _ _ = JsonPointer pointer firstDifferentKey :: KeyMap.KeyMap Value -> KeyMap.KeyMap Value -> Maybe Text firstDifferentKey historical generated =- firstMatch differs allKeys+ firstMatch differs allKeys where allKeys = sort (map Key.toText (KeyMap.keys historical <> KeyMap.keys generated)) differs key = KeyMap.lookup (Key.fromText key) historical /= KeyMap.lookup (Key.fromText key) generated firstDifferentIndex :: [Value] -> [Value] -> Maybe Int firstDifferentIndex historical generated =- firstMatch differs [0 .. max (length historical) (length generated) - 1]+ firstMatch differs [0 .. max (length historical) (length generated) - 1] where differs index = indexMaybe index historical /= indexMaybe index generated indexMaybe :: Int -> [a] -> Maybe a indexMaybe index values = case drop index values of- value : _ -> Just value- [] -> Nothing+ value : _ -> Just value+ [] -> Nothing firstMatch :: (a -> Bool) -> [a] -> Maybe a firstMatch predicate = \case- [] -> Nothing- value : rest- | predicate value -> Just value- | otherwise -> firstMatch predicate rest+ [] -> Nothing+ value : rest+ | predicate value -> Just value+ | otherwise -> firstMatch predicate rest appendPointer :: Text -> Text -> Text appendPointer base segment = base <> "/" <> escapePointerSegment segment@@ -535,208 +533,208 @@ branchKindName :: BranchKind -> Text branchKindName kind = case kind of- UnionArm arm -> "union-arm:" <> arm- OptionalPresent -> "optional-present"- OptionalMissing -> "optional-missing"- ExplicitNull -> "explicit-null"+ UnionArm arm -> "union-arm:" <> arm+ OptionalPresent -> "optional-present"+ OptionalMissing -> "optional-missing"+ ExplicitNull -> "explicit-null" parseBranchKind :: Text -> Parser BranchKind parseBranchKind value- | Just arm <- T.stripPrefix "union-arm:" value = pure (UnionArm arm)- | value == "optional-present" = pure OptionalPresent- | value == "optional-missing" = pure OptionalMissing- | value == "explicit-null" = pure ExplicitNull- | otherwise = fail ("unknown branch kind: " <> T.unpack value)+ | Just arm <- T.stripPrefix "union-arm:" value = pure (UnionArm arm)+ | value == "optional-present" = pure OptionalPresent+ | value == "optional-missing" = pure OptionalMissing+ | value == "explicit-null" = pure ExplicitNull+ | otherwise = fail ("unknown branch kind: " <> T.unpack value) instance ToJSON FixtureOrigin where- toJSON = String . originName+ toJSON = String . originName instance FromJSON FixtureOrigin where- parseJSON = withText "FixtureOrigin" parseOrigin+ parseJSON = withText "FixtureOrigin" parseOrigin instance ToJSON JsonPointer where- toJSON = String . unJsonPointer+ toJSON = String . unJsonPointer instance FromJSON JsonPointer where- parseJSON = withText "JsonPointer" (pure . JsonPointer)+ parseJSON = withText "JsonPointer" (pure . JsonPointer) instance ToJSON BranchKind where- toJSON = String . branchKindName+ toJSON = String . branchKindName instance FromJSON BranchKind where- parseJSON = withText "BranchKind" parseBranchKind+ parseJSON = withText "BranchKind" parseBranchKind instance ToJSON ComparisonDifference where- toJSON difference = case difference of- EncodedValueDifference pointer historical generated ->- differenceObject "encoded-value-difference" pointer "encoder outputs differ" historical generated- DecodedValueDifference pointer historical generated ->- differenceObject "decoded-value-difference" pointer "normalized decoder outputs differ" historical generated- GeneratedDecodeRejected reason ->- object- [ "kind" .= ("generated-decode-rejected" :: Text)- , "pointer" .= JsonPointer ""- , "reason" .= reason- ]- where- differenceObject kind pointer reason historical generated =- object- [ "kind" .= (kind :: Text)- , "pointer" .= pointer- , "reason" .= (reason :: Text)- , "historical" .= historical- , "generated" .= generated- ]+ toJSON difference = case difference of+ EncodedValueDifference pointer historical generated ->+ differenceObject "encoded-value-difference" pointer "encoder outputs differ" historical generated+ DecodedValueDifference pointer historical generated ->+ differenceObject "decoded-value-difference" pointer "normalized decoder outputs differ" historical generated+ GeneratedDecodeRejected reason ->+ object+ [ "kind" .= ("generated-decode-rejected" :: Text),+ "pointer" .= JsonPointer "",+ "reason" .= reason+ ]+ where+ differenceObject kind pointer reason historical generated =+ object+ [ "kind" .= (kind :: Text),+ "pointer" .= pointer,+ "reason" .= (reason :: Text),+ "historical" .= historical,+ "generated" .= generated+ ] instance FromJSON ComparisonDifference where- parseJSON = withObject "ComparisonDifference" $ \value -> do- kind <- value .: "kind" :: Parser Text- case kind of- "encoded-value-difference" -> EncodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"- "decoded-value-difference" -> DecodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"- "generated-decode-rejected" -> GeneratedDecodeRejected <$> value .: "reason"- _ -> fail ("unknown comparison difference: " <> T.unpack kind)+ parseJSON = withObject "ComparisonDifference" $ \value -> do+ kind <- value .: "kind" :: Parser Text+ case kind of+ "encoded-value-difference" -> EncodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"+ "decoded-value-difference" -> DecodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"+ "generated-decode-rejected" -> GeneratedDecodeRejected <$> value .: "reason"+ _ -> fail ("unknown comparison difference: " <> T.unpack kind) instance ToJSON FixtureVerdict where- toJSON JsonParity = object ["verdict" .= ("json-parity" :: Text)]- toJSON (RequiresVersionWork difference) =- object- [ "verdict" .= ("requires-version-work" :: Text)- , "code" .= codeText CodecCompareDifference- , "difference" .= difference- ]+ toJSON JsonParity = object ["verdict" .= ("json-parity" :: Text)]+ toJSON (RequiresVersionWork difference) =+ object+ [ "verdict" .= ("requires-version-work" :: Text),+ "code" .= codeText CodecCompareDifference,+ "difference" .= difference+ ] instance FromJSON FixtureVerdict where- parseJSON = withObject "FixtureVerdict" $ \value -> do- verdict <- value .: "verdict" :: Parser Text- case verdict of- "json-parity" -> pure JsonParity- "requires-version-work" -> RequiresVersionWork <$> value .: "difference"- _ -> fail ("unknown fixture verdict: " <> T.unpack verdict)+ parseJSON = withObject "FixtureVerdict" $ \value -> do+ verdict <- value .: "verdict" :: Parser Text+ case verdict of+ "json-parity" -> pure JsonParity+ "requires-version-work" -> RequiresVersionWork <$> value .: "difference"+ _ -> fail ("unknown fixture verdict: " <> T.unpack verdict) instance ToJSON CompareInputIssue where- toJSON issue = case issue of- HistoricalGoldenUnreadable path reason -> issueObject "historical-golden-unreadable" path reason- HistoricalCodecRejected path reason -> issueObject "historical-codec-rejected" path reason- HistoricalCodecProvenanceInvalid reason ->- object- [ "code" .= codeText CodecCompareInvalidInput- , "kind" .= ("historical-codec-provenance-invalid" :: Text)- , "reason" .= reason- ]- where- issueObject kind path reason =- object- [ "code" .= codeText CodecCompareInvalidInput- , "kind" .= (kind :: Text)- , "path" .= path- , "reason" .= reason- ]+ toJSON issue = case issue of+ HistoricalGoldenUnreadable path reason -> issueObject "historical-golden-unreadable" path reason+ HistoricalCodecRejected path reason -> issueObject "historical-codec-rejected" path reason+ HistoricalCodecProvenanceInvalid reason ->+ object+ [ "code" .= codeText CodecCompareInvalidInput,+ "kind" .= ("historical-codec-provenance-invalid" :: Text),+ "reason" .= reason+ ]+ where+ issueObject kind path reason =+ object+ [ "code" .= codeText CodecCompareInvalidInput,+ "kind" .= (kind :: Text),+ "path" .= path,+ "reason" .= reason+ ] instance FromJSON CompareInputIssue where- parseJSON = withObject "CompareInputIssue" $ \value -> do- kind <- value .: "kind" :: Parser Text- case kind of- "historical-golden-unreadable" -> HistoricalGoldenUnreadable <$> value .: "path" <*> value .: "reason"- "historical-codec-rejected" -> HistoricalCodecRejected <$> value .: "path" <*> value .: "reason"- "historical-codec-provenance-invalid" -> HistoricalCodecProvenanceInvalid <$> value .: "reason"- _ -> fail ("unknown comparison input issue: " <> T.unpack kind)+ parseJSON = withObject "CompareInputIssue" $ \value -> do+ kind <- value .: "kind" :: Parser Text+ case kind of+ "historical-golden-unreadable" -> HistoricalGoldenUnreadable <$> value .: "path" <*> value .: "reason"+ "historical-codec-rejected" -> HistoricalCodecRejected <$> value .: "path" <*> value .: "reason"+ "historical-codec-provenance-invalid" -> HistoricalCodecProvenanceInvalid <$> value .: "reason"+ _ -> fail ("unknown comparison input issue: " <> T.unpack kind) instance ToJSON DeclaredBranch where- toJSON branch =- object- [ "origin" .= dbOrigin branch- , "pointer" .= dbPointer branch- , "branch" .= dbKind branch- ]+ toJSON branch =+ object+ [ "origin" .= dbOrigin branch,+ "pointer" .= dbPointer branch,+ "branch" .= dbKind branch+ ] instance FromJSON DeclaredBranch where- parseJSON = withObject "DeclaredBranch" $ \value ->- DeclaredBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"+ parseJSON = withObject "DeclaredBranch" $ \value ->+ DeclaredBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch" instance ToJSON ObservedBranch where- toJSON branch =- object- [ "origin" .= obOrigin branch- , "pointer" .= obPointer branch- , "branch" .= obKind branch- ]+ toJSON branch =+ object+ [ "origin" .= obOrigin branch,+ "pointer" .= obPointer branch,+ "branch" .= obKind branch+ ] instance FromJSON ObservedBranch where- parseJSON = withObject "ObservedBranch" $ \value ->- ObservedBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"+ parseJSON = withObject "ObservedBranch" $ \value ->+ ObservedBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch" instance ToJSON CoverageGap where- toJSON gap =- object- [ "code" .= codeText CodecCompareCoverageGap- , "origin" .= cgOrigin gap- , "pointer" .= cgPointer gap- , "branch" .= cgKind gap- ]+ toJSON gap =+ object+ [ "code" .= codeText CodecCompareCoverageGap,+ "origin" .= cgOrigin gap,+ "pointer" .= cgPointer gap,+ "branch" .= cgKind gap+ ] instance FromJSON CoverageGap where- parseJSON = withObject "CoverageGap" $ \value ->- CoverageGap <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"+ parseJSON = withObject "CoverageGap" $ \value ->+ CoverageGap <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch" instance ToJSON CompareProvenance where- toJSON provenance =- object- [ "historicalCodecIdentity" .= cpHistoricalCodecIdentity provenance- , "historicalCodecVersion" .= cpHistoricalCodecVersion provenance- , "canonicalType" .= unCanonicalTypeId (cpCanonicalType provenance)- , "bindingSymbol" .= unQualifiedValueName (cpBindingSymbol provenance)- , "bindingVersion" .= unBindingVersion (cpBindingVersion provenance)- , "wireFingerprint" .= cpWireFingerprint provenance- ]+ toJSON provenance =+ object+ [ "historicalCodecIdentity" .= cpHistoricalCodecIdentity provenance,+ "historicalCodecVersion" .= cpHistoricalCodecVersion provenance,+ "canonicalType" .= unCanonicalTypeId (cpCanonicalType provenance),+ "bindingSymbol" .= unQualifiedValueName (cpBindingSymbol provenance),+ "bindingVersion" .= unBindingVersion (cpBindingVersion provenance),+ "wireFingerprint" .= cpWireFingerprint provenance+ ] instance FromJSON CompareProvenance where- parseJSON = withObject "CompareProvenance" $ \value ->- CompareProvenance- <$> value .: "historicalCodecIdentity"- <*> value .: "historicalCodecVersion"- <*> (CanonicalTypeId <$> value .: "canonicalType")- <*> (QualifiedValueName <$> value .: "bindingSymbol")- <*> (BindingVersion <$> value .: "bindingVersion")- <*> value .: "wireFingerprint"+ parseJSON = withObject "CompareProvenance" $ \value ->+ CompareProvenance+ <$> value .: "historicalCodecIdentity"+ <*> value .: "historicalCodecVersion"+ <*> (CanonicalTypeId <$> value .: "canonicalType")+ <*> (QualifiedValueName <$> value .: "bindingSymbol")+ <*> (BindingVersion <$> value .: "bindingVersion")+ <*> value .: "wireFingerprint" instance ToJSON ClassifiedObservation where- toJSON observation =- object- [ "origin" .= classifiedOrigin observation- , "name" .= classifiedName observation- , "result" .= classifiedVerdict observation- ]+ toJSON observation =+ object+ [ "origin" .= classifiedOrigin observation,+ "name" .= classifiedName observation,+ "result" .= classifiedVerdict observation+ ] instance FromJSON ClassifiedObservation where- parseJSON = withObject "ClassifiedObservation" $ \value ->- ClassifiedObservation <$> value .: "origin" <*> value .: "name" <*> value .: "result"+ parseJSON = withObject "ClassifiedObservation" $ \value ->+ ClassifiedObservation <$> value .: "origin" <*> value .: "name" <*> value .: "result" instance ToJSON CompareReport where- toJSON report =- object- [ "schema" .= ("keiro-dsl/codec-compare-report/1" :: Text)- , "authority" .= crAuthority report- , "provenance" .= crProvenance report- , "success" .= reportSucceeded report- , "summary"- .= object- [ "observations" .= length (crObservations report)- , "parity" .= length (filter ((== JsonParity) . classifiedVerdict) (crObservations report))- , "differences" .= length (filter ((/= JsonParity) . classifiedVerdict) (crObservations report))- , "inputIssues" .= length (crInputIssues report)- , "coverageGaps" .= length (crCoverageGaps report)- ]- , "observations" .= crObservations report- , "inputIssues" .= crInputIssues report- , "coverageGaps" .= crCoverageGaps report- ]+ toJSON report =+ object+ [ "schema" .= ("keiro-dsl/codec-compare-report/1" :: Text),+ "authority" .= crAuthority report,+ "provenance" .= crProvenance report,+ "success" .= reportSucceeded report,+ "summary"+ .= object+ [ "observations" .= length (crObservations report),+ "parity" .= length (filter ((== JsonParity) . classifiedVerdict) (crObservations report)),+ "differences" .= length (filter ((/= JsonParity) . classifiedVerdict) (crObservations report)),+ "inputIssues" .= length (crInputIssues report),+ "coverageGaps" .= length (crCoverageGaps report)+ ],+ "observations" .= crObservations report,+ "inputIssues" .= crInputIssues report,+ "coverageGaps" .= crCoverageGaps report+ ] instance FromJSON CompareReport where- parseJSON = withObject "CompareReport" $ \value ->- CompareReport- <$> value .: "provenance"- <*> value .: "observations"- <*> value .: "inputIssues"- <*> value .: "coverageGaps"- <*> value .: "authority"+ parseJSON = withObject "CompareReport" $ \value ->+ CompareReport+ <$> value .: "provenance"+ <*> value .: "observations"+ <*> value .: "inputIssues"+ <*> value .: "coverageGaps"+ <*> value .: "authority"
src/Keiro/Dsl/Coverage.hs view
@@ -1,13 +1,12 @@ {-# OPTIONS_GHC -Werror=incomplete-patterns #-} -{- | Reporting-only structural coverage over the checked mapped-type graph.--The report intentionally has no aggregate percentage. Private persisted event-payloads and mapped register cache boundaries have different authorities, and-queue/public-contract payloads are not represented by this graph at all.--}-module Keiro.Dsl.Coverage (- CoverageSurface (..),+-- | Reporting-only structural coverage over the checked mapped-type graph.+--+-- The report intentionally has no aggregate percentage. Private persisted event+-- payloads and mapped register cache boundaries have different authorities, and+-- queue/public-contract payloads are not represented by this graph at all.+module Keiro.Dsl.Coverage+ ( CoverageSurface (..), CoverageMode (..), CoverageRoot (..), StructuralBoundary (..),@@ -29,7 +28,8 @@ renderCoverageSummary, renderCoverageFinding, writeCoverageReport,-) where+ )+where import Data.Aeson (ToJSON (..), object, (.=)) import Data.Aeson qualified as Aeson@@ -46,453 +46,454 @@ import System.FilePath (takeDirectory) data CoverageSurface = PrivateEventPayload | SnapshotRegister- deriving stock (Eq, Ord, Show)+ deriving stock (Eq, Ord, Show) data CoverageMode = StructuralCoverage | OpaqueCoverage- deriving stock (Eq, Ord, Show)+ deriving stock (Eq, Ord, Show) data CoverageRoot = CoverageRoot- { rootSurface :: !CoverageSurface- , rootPath :: !Text- , rootMappedType :: !Text- , rootMode :: !CoverageMode- , rootCanonicalType :: !(Maybe Text)- , rootCodecIdentity :: !(Maybe Text)- , rootCodecVersion :: !(Maybe Text)- , rootWireFingerprint :: !Text- }- deriving stock (Eq, Ord, Show)+ { rootSurface :: !CoverageSurface,+ rootPath :: !Text,+ rootMappedType :: !Text,+ rootMode :: !CoverageMode,+ rootCanonicalType :: !(Maybe Text),+ rootCodecIdentity :: !(Maybe Text),+ rootCodecVersion :: !(Maybe Text),+ rootWireFingerprint :: !Text+ }+ deriving stock (Eq, Ord, Show) data StructuralBoundary = StructuralBoundary- { structuralRoot :: !Text- , structuralPath :: !Text- , structuralMappedType :: !Text- , structuralCanonicalType :: !Text- , structuralWireFingerprint :: !Text- }- deriving stock (Eq, Ord, Show)+ { structuralRoot :: !Text,+ structuralPath :: !Text,+ structuralMappedType :: !Text,+ structuralCanonicalType :: !Text,+ structuralWireFingerprint :: !Text+ }+ deriving stock (Eq, Ord, Show) data OpaqueBoundary = OpaqueBoundary- { opaqueRoot :: !Text- , opaquePath :: !Text- , opaqueMappedType :: !Text- , opaqueCodecIdentity :: !Text- , opaqueCodecVersion :: !Text- }- deriving stock (Eq, Ord, Show)+ { opaqueRoot :: !Text,+ opaquePath :: !Text,+ opaqueMappedType :: !Text,+ opaqueCodecIdentity :: !Text,+ opaqueCodecVersion :: !Text+ }+ deriving stock (Eq, Ord, Show) data JsonBoundary = JsonBoundary- { jsonRoot :: !Text- , jsonPath :: !Text- }- deriving stock (Eq, Ord, Show)+ { jsonRoot :: !Text,+ jsonPath :: !Text+ }+ deriving stock (Eq, Ord, Show) data SnapshotBoundary = SnapshotBoundary- { snapshotRoot :: !Text- , snapshotAggregate :: !Text- , snapshotRegister :: !Text- , snapshotMappedType :: !Text- , snapshotMode :: !CoverageMode- , snapshotEncoding :: !Text- , snapshotInvalidation :: !Text- , snapshotWireFingerprint :: !Text- , snapshotEnabled :: !Bool- }- deriving stock (Eq, Ord, Show)+ { snapshotRoot :: !Text,+ snapshotAggregate :: !Text,+ snapshotRegister :: !Text,+ snapshotMappedType :: !Text,+ snapshotMode :: !CoverageMode,+ snapshotEncoding :: !Text,+ snapshotInvalidation :: !Text,+ snapshotWireFingerprint :: !Text,+ snapshotEnabled :: !Bool+ }+ deriving stock (Eq, Ord, Show) data UnsupportedSurface = UnsupportedSurface- { unsupportedSurface :: !Text- , unsupportedSupport :: !Text- , unsupportedReason :: !Text- }- deriving stock (Eq, Ord, Show)+ { unsupportedSurface :: !Text,+ unsupportedSupport :: !Text,+ unsupportedReason :: !Text+ }+ deriving stock (Eq, Ord, Show) data CoverageCounts = CoverageCounts- { totalRoots :: !Int- , structuralRoots :: !Int- , opaqueRoots :: !Int- , jsonBoundaries :: !Int- }- deriving stock (Eq, Show)+ { totalRoots :: !Int,+ structuralRoots :: !Int,+ opaqueRoots :: !Int,+ jsonBoundaries :: !Int+ }+ deriving stock (Eq, Show) data CoverageSummary = CoverageSummary- { privateEventPayloads :: !CoverageCounts- , snapshotRegisters :: !CoverageCounts- }- deriving stock (Eq, Show)+ { privateEventPayloads :: !CoverageCounts,+ snapshotRegisters :: !CoverageCounts+ }+ deriving stock (Eq, Show) data CoverageFinding = CoverageFinding- { findingSeverity :: !Severity- , findingCode :: !DiagnosticCode- , findingRoots :: ![Text]- , findingMessage :: !Text- }- deriving stock (Eq, Show)+ { findingSeverity :: !Severity,+ findingCode :: !DiagnosticCode,+ findingRoots :: ![Text],+ findingMessage :: !Text+ }+ deriving stock (Eq, Show) data CoveragePrevious = CoveragePrevious- { previousReference :: !Text- , previousSummary :: !CoverageSummary- , previousOpaqueBoundaries :: ![OpaqueBoundary]- }- deriving stock (Eq, Show)+ { previousReference :: !Text,+ previousSummary :: !CoverageSummary,+ previousOpaqueBoundaries :: ![OpaqueBoundary]+ }+ deriving stock (Eq, Show) data CoverageDelta = CoverageDelta- { privateEventRootDelta :: !Int- , snapshotRegisterRootDelta :: !Int- , opaqueBoundaryDelta :: !Int- , addedOpaqueBoundaries :: ![OpaqueBoundary]- , removedOpaqueBoundaries :: ![OpaqueBoundary]- }- deriving stock (Eq, Show)+ { privateEventRootDelta :: !Int,+ snapshotRegisterRootDelta :: !Int,+ opaqueBoundaryDelta :: !Int,+ addedOpaqueBoundaries :: ![OpaqueBoundary],+ removedOpaqueBoundaries :: ![OpaqueBoundary]+ }+ deriving stock (Eq, Show) data CoverageReport = CoverageReport- { coverageSpec :: !FilePath- , coverageRoots :: ![CoverageRoot]- , coverageStructuralBoundaries :: ![StructuralBoundary]- , coverageOpaqueBoundaries :: ![OpaqueBoundary]- , coverageJsonBoundaries :: ![JsonBoundary]- , coverageSnapshotBoundaries :: ![SnapshotBoundary]- , coverageUnsupportedSurfaces :: ![UnsupportedSurface]- , coverageSummary :: !CoverageSummary- , coverageFindings :: ![CoverageFinding]- , coveragePrevious :: !(Maybe CoveragePrevious)- , coverageDelta :: !(Maybe CoverageDelta)- }- deriving stock (Eq, Show)+ { coverageSpec :: !FilePath,+ coverageRoots :: ![CoverageRoot],+ coverageStructuralBoundaries :: ![StructuralBoundary],+ coverageOpaqueBoundaries :: ![OpaqueBoundary],+ coverageJsonBoundaries :: ![JsonBoundary],+ coverageSnapshotBoundaries :: ![SnapshotBoundary],+ coverageUnsupportedSurfaces :: ![UnsupportedSurface],+ coverageSummary :: !CoverageSummary,+ coverageFindings :: ![CoverageFinding],+ coveragePrevious :: !(Maybe CoveragePrevious),+ coverageDelta :: !(Maybe CoverageDelta)+ }+ deriving stock (Eq, Show) coverageReport :: FilePath -> Spec -> Either (NonEmpty TypeGraphError) CoverageReport coverageReport specPath spec = do- graph <- resolveTypeGraph spec- let roots = sortOn rootPath (map (coverageRoot graph) (persistedSites graph))- structural = structuralBoundaryInventory graph- opaque = opaqueBoundaryInventory graph- json = jsonBoundaryInventory graph- snapshots = snapshotBoundaryInventory spec graph- summary = summarize roots json- findings = opaqueSurfaceFindings opaque- pure- CoverageReport- { coverageSpec = specPath- , coverageRoots = roots- , coverageStructuralBoundaries = structural- , coverageOpaqueBoundaries = opaque- , coverageJsonBoundaries = json- , coverageSnapshotBoundaries = snapshots- , coverageUnsupportedSurfaces = unsupportedInventory- , coverageSummary = summary- , coverageFindings = findings- , coveragePrevious = Nothing- , coverageDelta = Nothing- }+ graph <- resolveTypeGraph spec+ let roots = sortOn rootPath (map (coverageRoot graph) (persistedSites graph))+ structural = structuralBoundaryInventory graph+ opaque = opaqueBoundaryInventory graph+ json = jsonBoundaryInventory graph+ snapshots = snapshotBoundaryInventory spec graph+ summary = summarize roots json+ findings = opaqueSurfaceFindings opaque+ pure+ CoverageReport+ { coverageSpec = specPath,+ coverageRoots = roots,+ coverageStructuralBoundaries = structural,+ coverageOpaqueBoundaries = opaque,+ coverageJsonBoundaries = json,+ coverageSnapshotBoundaries = snapshots,+ coverageUnsupportedSurfaces = unsupportedInventory,+ coverageSummary = summary,+ coverageFindings = findings,+ coveragePrevious = Nothing,+ coverageDelta = Nothing+ } coverageDiffReport :: FilePath -> Text -> Spec -> Spec -> Either (NonEmpty TypeGraphError) CoverageReport coverageDiffReport specPath reference oldSpec newSpec = do- oldReport <- coverageReport (T.unpack reference <> ":" <> specPath) oldSpec- newReport <- coverageReport specPath newSpec- let oldOpaque = Set.fromList (coverageOpaqueBoundaries oldReport)- newOpaque = Set.fromList (coverageOpaqueBoundaries newReport)- added = Set.toAscList (newOpaque `Set.difference` oldOpaque)- removed = Set.toAscList (oldOpaque `Set.difference` newOpaque)- oldSummary = coverageSummary oldReport- newSummary = coverageSummary newReport- delta =- CoverageDelta- { privateEventRootDelta = totalRoots (privateEventPayloads newSummary) - totalRoots (privateEventPayloads oldSummary)- , snapshotRegisterRootDelta = totalRoots (snapshotRegisters newSummary) - totalRoots (snapshotRegisters oldSummary)- , opaqueBoundaryDelta = length added - length removed- , addedOpaqueBoundaries = added- , removedOpaqueBoundaries = removed- }- addedFindings =- [ CoverageFinding- { findingSeverity = Warning- , findingCode = CoverageOpaqueBoundaryAdded- , findingRoots = [opaqueRoot boundary]- , findingMessage = "opaque boundary added at " <> opaquePath boundary- }- | boundary <- added- ]- pure- newReport- { coverageFindings = coverageFindings newReport <> addedFindings- , coveragePrevious =- Just- CoveragePrevious- { previousReference = reference- , previousSummary = oldSummary- , previousOpaqueBoundaries = coverageOpaqueBoundaries oldReport- }- , coverageDelta = Just delta+ oldReport <- coverageReport (T.unpack reference <> ":" <> specPath) oldSpec+ newReport <- coverageReport specPath newSpec+ let oldOpaque = Set.fromList (coverageOpaqueBoundaries oldReport)+ newOpaque = Set.fromList (coverageOpaqueBoundaries newReport)+ added = Set.toAscList (newOpaque `Set.difference` oldOpaque)+ removed = Set.toAscList (oldOpaque `Set.difference` newOpaque)+ oldSummary = coverageSummary oldReport+ newSummary = coverageSummary newReport+ delta =+ CoverageDelta+ { privateEventRootDelta = totalRoots (privateEventPayloads newSummary) - totalRoots (privateEventPayloads oldSummary),+ snapshotRegisterRootDelta = totalRoots (snapshotRegisters newSummary) - totalRoots (snapshotRegisters oldSummary),+ opaqueBoundaryDelta = length added - length removed,+ addedOpaqueBoundaries = added,+ removedOpaqueBoundaries = removed+ }+ addedFindings =+ [ CoverageFinding+ { findingSeverity = Warning,+ findingCode = CoverageOpaqueBoundaryAdded,+ findingRoots = [opaqueRoot boundary],+ findingMessage = "opaque boundary added at " <> opaquePath boundary }+ | boundary <- added+ ]+ pure+ newReport+ { coverageFindings = coverageFindings newReport <> addedFindings,+ coveragePrevious =+ Just+ CoveragePrevious+ { previousReference = reference,+ previousSummary = oldSummary,+ previousOpaqueBoundaries = coverageOpaqueBoundaries oldReport+ },+ coverageDelta = Just delta+ } failOnOpaque :: CoverageReport -> CoverageReport failOnOpaque report- | null boundaries = report- | otherwise = report{coverageFindings = coverageFindings report <> [gateFinding "opaque persisted boundaries are forbidden by --fail-on-opaque" boundaries]}+ | null boundaries = report+ | otherwise = report {coverageFindings = coverageFindings report <> [gateFinding "opaque persisted boundaries are forbidden by --fail-on-opaque" boundaries]} where boundaries = coverageOpaqueBoundaries report failOnOpaqueIncrease :: CoverageReport -> CoverageReport failOnOpaqueIncrease report = case coverageDelta report of- Just delta- | not (null (addedOpaqueBoundaries delta)) ->- report- { coverageFindings =- coverageFindings report- <> [gateFinding "new opaque persisted boundaries are forbidden by --fail-on-opaque-increase" (addedOpaqueBoundaries delta)]- }- _ -> report+ Just delta+ | not (null (addedOpaqueBoundaries delta)) ->+ report+ { coverageFindings =+ coverageFindings report+ <> [gateFinding "new opaque persisted boundaries are forbidden by --fail-on-opaque-increase" (addedOpaqueBoundaries delta)]+ }+ _ -> report coverageSucceeded :: CoverageReport -> Bool coverageSucceeded = all ((/= Error) . findingSeverity) . coverageFindings renderCoverageSummary :: CoverageReport -> Text renderCoverageSummary report =- T.unlines- [ "structural/opaque boundaries (reporting only):"- , " private-event-payloads: " <> renderCounts (privateEventPayloads summary)- , " snapshot-registers: " <> renderCounts (snapshotRegisters summary) <> "; encoding=consumer-json-cache; invalidation=tracked"- , " queue-payloads: unsupported"- , " public-contracts: not-applicable (separately owned grammar)"- ]+ T.unlines+ [ "structural/opaque boundaries (reporting only):",+ " private-event-payloads: " <> renderCounts (privateEventPayloads summary),+ " snapshot-registers: " <> renderCounts (snapshotRegisters summary) <> "; encoding=consumer-json-cache; invalidation=tracked",+ " queue-payloads: unsupported",+ " public-contracts: not-applicable (separately owned grammar)"+ ] where summary = coverageSummary report renderCounts counts =- T.pack (show (totalRoots counts))- <> " mapped roots ("- <> T.pack (show (structuralRoots counts))- <> " structural, "- <> T.pack (show (opaqueRoots counts))- <> " opaque, "- <> T.pack (show (jsonBoundaries counts))- <> " Json boundaries)"+ T.pack (show (totalRoots counts))+ <> " mapped roots ("+ <> T.pack (show (structuralRoots counts))+ <> " structural, "+ <> T.pack (show (opaqueRoots counts))+ <> " opaque, "+ <> T.pack (show (jsonBoundaries counts))+ <> " Json boundaries)" renderCoverageFinding :: FilePath -> CoverageFinding -> Text renderCoverageFinding specPath finding =- T.pack specPath- <> ":0: "- <> severityText (findingSeverity finding)- <> "["- <> T.pack (show (findingCode finding))- <> "]: "- <> findingMessage finding- <> rootsSuffix+ T.pack specPath+ <> ":0: "+ <> severityText (findingSeverity finding)+ <> "["+ <> T.pack (show (findingCode finding))+ <> "]: "+ <> findingMessage finding+ <> rootsSuffix where severityText Error = "error" severityText Warning = "warning" rootsSuffix = case findingRoots finding of- [] -> ""- roots -> " (roots: " <> T.intercalate ", " roots <> ")"+ [] -> ""+ roots -> " (roots: " <> T.intercalate ", " roots <> ")" writeCoverageReport :: FilePath -> CoverageReport -> IO () writeCoverageReport path report = do- createDirectoryIfMissing True (takeDirectory path)- Aeson.encodeFile path report+ createDirectoryIfMissing True (takeDirectory path)+ Aeson.encodeFile path report persistedSites :: TypeGraph -> [UseSite] persistedSites = filter isPersisted . tgUseSites where- isPersisted RootEventField{} = True- isPersisted RootRegister{} = True- isPersisted RootCommandField{} = False+ isPersisted RootEventField {} = True+ isPersisted RootRegister {} = True+ isPersisted RootCommandField {} = False coverageRoot :: TypeGraph -> UseSite -> CoverageRoot coverageRoot graph site =- let key = useSiteKey site- path = renderUsePath (UsePath site [])- fingerprint = wireFingerprint graph (unMappedKey key)- in case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural declaration _) ->- CoverageRoot- { rootSurface = useSiteSurface site- , rootPath = path- , rootMappedType = unMappedKey key- , rootMode = StructuralCoverage- , rootCanonicalType = Just (unCanonicalTypeId (sdCanonical declaration))- , rootCodecIdentity = Nothing- , rootCodecVersion = Nothing- , rootWireFingerprint = fingerprint- }- Just (ResolvedOpaque declaration) ->- CoverageRoot- { rootSurface = useSiteSurface site- , rootPath = path- , rootMappedType = unMappedKey key- , rootMode = OpaqueCoverage- , rootCanonicalType = Nothing- , rootCodecIdentity = Just (unCodecIdentity (odCodecIdentity declaration))- , rootCodecVersion = Just (unCodecVersion (odCodecVersion declaration))- , rootWireFingerprint = fingerprint- }- Nothing -> error "coverageRoot: resolved use-site key missing from graph"+ let key = useSiteKey site+ path = renderUsePath (UsePath site [])+ fingerprint = wireFingerprint graph (unMappedKey key)+ in case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural declaration _) ->+ CoverageRoot+ { rootSurface = useSiteSurface site,+ rootPath = path,+ rootMappedType = unMappedKey key,+ rootMode = StructuralCoverage,+ rootCanonicalType = Just (unCanonicalTypeId (sdCanonical declaration)),+ rootCodecIdentity = Nothing,+ rootCodecVersion = Nothing,+ rootWireFingerprint = fingerprint+ }+ Just (ResolvedOpaque declaration) ->+ CoverageRoot+ { rootSurface = useSiteSurface site,+ rootPath = path,+ rootMappedType = unMappedKey key,+ rootMode = OpaqueCoverage,+ rootCanonicalType = Nothing,+ rootCodecIdentity = Just (unCodecIdentity (odCodecIdentity declaration)),+ rootCodecVersion = Just (unCodecVersion (odCodecVersion declaration)),+ rootWireFingerprint = fingerprint+ }+ Nothing -> error "coverageRoot: resolved use-site key missing from graph" structuralBoundaryInventory :: TypeGraph -> [StructuralBoundary] structuralBoundaryInventory graph =- sortOn- structuralPath- [ StructuralBoundary- { structuralRoot = rootText (upRoot path)- , structuralPath = renderUsePath path- , structuralMappedType = sdName declaration- , structuralCanonicalType = unCanonicalTypeId (sdCanonical declaration)- , structuralWireFingerprint = wireFingerprint graph (sdName declaration)- }- | ResolvedStructural declaration _ <- Map.elems (tgDeclarations graph)- , path <- usePaths graph (sdName declaration)- , isEventSite (upRoot path)- ]+ sortOn+ structuralPath+ [ StructuralBoundary+ { structuralRoot = rootText (upRoot path),+ structuralPath = renderUsePath path,+ structuralMappedType = sdName declaration,+ structuralCanonicalType = unCanonicalTypeId (sdCanonical declaration),+ structuralWireFingerprint = wireFingerprint graph (sdName declaration)+ }+ | ResolvedStructural declaration _ <- Map.elems (tgDeclarations graph),+ path <- usePaths graph (sdName declaration),+ isEventSite (upRoot path)+ ] opaqueBoundaryInventory :: TypeGraph -> [OpaqueBoundary] opaqueBoundaryInventory graph =- sortOn- opaquePath- [ OpaqueBoundary- { opaqueRoot = rootText (upRoot path)- , opaquePath = renderUsePath path- , opaqueMappedType = odName declaration- , opaqueCodecIdentity = unCodecIdentity (odCodecIdentity declaration)- , opaqueCodecVersion = unCodecVersion (odCodecVersion declaration)- }- | ResolvedOpaque declaration <- Map.elems (tgDeclarations graph)- , path <- usePaths graph (odName declaration)- , isEventSite (upRoot path)- ]+ sortOn+ opaquePath+ [ OpaqueBoundary+ { opaqueRoot = rootText (upRoot path),+ opaquePath = renderUsePath path,+ opaqueMappedType = odName declaration,+ opaqueCodecIdentity = unCodecIdentity (odCodecIdentity declaration),+ opaqueCodecVersion = unCodecVersion (odCodecVersion declaration)+ }+ | ResolvedOpaque declaration <- Map.elems (tgDeclarations graph),+ path <- usePaths graph (odName declaration),+ isEventSite (upRoot path)+ ] jsonBoundaryInventory :: TypeGraph -> [JsonBoundary] jsonBoundaryInventory graph =- sortOn- jsonPath- [ JsonBoundary- { jsonRoot = rootText site- , jsonPath = renderUsePath (UsePath site segments)- }- | site <- persistedSites graph- , isEventSite site- , segments <- jsonPathsFromDecl graph Set.empty (useSiteKey site)- ]+ sortOn+ jsonPath+ [ JsonBoundary+ { jsonRoot = rootText site,+ jsonPath = renderUsePath (UsePath site segments)+ }+ | site <- persistedSites graph,+ isEventSite site,+ segments <- jsonPathsFromDecl graph Set.empty (useSiteKey site)+ ] snapshotBoundaryInventory :: Spec -> TypeGraph -> [SnapshotBoundary] snapshotBoundaryInventory spec graph =- sortOn- snapshotRoot- [ SnapshotBoundary- { snapshotRoot = renderUsePath (UsePath site [])- , snapshotAggregate = aggregate- , snapshotRegister = register- , snapshotMappedType = unMappedKey key- , snapshotMode = declarationMode declaration- , snapshotEncoding = "consumer-json-cache"- , snapshotInvalidation = "tracked-by-mapped-wire-fingerprint"- , snapshotWireFingerprint = wireFingerprint graph (unMappedKey key)- , snapshotEnabled = aggregateHasSnapshot aggregate- }- | site@(RootRegister aggregate register key) <- persistedSites graph- , Just declaration <- [Map.lookup key (tgDeclarations graph)]- ]+ sortOn+ snapshotRoot+ [ SnapshotBoundary+ { snapshotRoot = renderUsePath (UsePath site []),+ snapshotAggregate = aggregate,+ snapshotRegister = register,+ snapshotMappedType = unMappedKey key,+ snapshotMode = declarationMode declaration,+ snapshotEncoding = "consumer-json-cache",+ snapshotInvalidation = "tracked-by-mapped-wire-fingerprint",+ snapshotWireFingerprint = wireFingerprint graph (unMappedKey key),+ snapshotEnabled = aggregateHasSnapshot aggregate+ }+ | site@(RootRegister aggregate register key) <- persistedSites graph,+ Just declaration <- [Map.lookup key (tgDeclarations graph)]+ ] where aggregateHasSnapshot name =- any- (\case NAggregate aggregate -> aggName aggregate == name && maybe False (const True) (aggSnapshot aggregate); _ -> False)- (specNodes spec)+ any+ (\case NAggregate aggregate -> aggName aggregate == name && maybe False (const True) (aggSnapshot aggregate); _ -> False)+ (specNodes spec) jsonPathsFromDecl :: TypeGraph -> Set.Set MappedKey -> MappedKey -> [[PathSeg]] jsonPathsFromDecl graph visited key- | key `Set.member` visited = []- | otherwise = case Map.lookup key (tgDeclarations graph) of- Nothing -> []- Just declaration ->- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ shape -> jsonPathsFromShape graph (Set.insert key visited) shape- , onOpaqueDecl = const []- }- declaration+ | key `Set.member` visited = []+ | otherwise = case Map.lookup key (tgDeclarations graph) of+ Nothing -> []+ Just declaration ->+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ shape -> jsonPathsFromShape graph (Set.insert key visited) shape,+ onOpaqueDecl = const []+ }+ declaration jsonPathsFromShape :: TypeGraph -> Set.Set MappedKey -> ResolvedMappedShape -> [[PathSeg]] jsonPathsFromShape graph visited =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields ->- concat- [ map (SegField (rwfHaskell field) (rwfKey field) :) (jsonPathsFromExpr graph visited (rwfType field))- | field <- fields- ]- , onEnum = const []- , onUnion = \_ arms ->- concat- [ map (SegArm (rwaCtor arm) (rwaTag arm) :) (maybe [] (jsonPathsFromExpr graph visited) (rwaPayload arm))- | arm <- arms- ]- }+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields ->+ concat+ [ map (SegField (rwfHaskell field) (rwfKey field) :) (jsonPathsFromExpr graph visited (rwfType field))+ | field <- fields+ ],+ onEnum = const [],+ onUnion = \_ arms ->+ concat+ [ map (SegArm (rwaCtor arm) (rwaTag arm) :) (maybe [] (jsonPathsFromExpr graph visited) (rwaPayload arm))+ | arm <- arms+ ]+ } jsonPathsFromExpr :: TypeGraph -> Set.Set MappedKey -> ResolvedTypeExpr -> [[PathSeg]] jsonPathsFromExpr graph visited =- foldTypeExpr- TypeExprAlgebra- { onText = []- , onInt = []- , onBool = []- , onNatural = []- , onTime = []- , onJson = [[]]- , onOptional = map (SegOptional :)- , onList = map (SegElem :)- , onMap = map (SegMapValue :)- , onRef = \key -> map (SegDecl (unMappedKey key) :) (jsonPathsFromDecl graph visited key)- }+ foldTypeExpr+ TypeExprAlgebra+ { onText = [],+ onInt = [],+ onInteger = [],+ onBool = [],+ onNatural = [],+ onTime = [],+ onJson = [[]],+ onOptional = map (SegOptional :),+ onList = map (SegElem :),+ onMap = map (SegMapValue :),+ onRef = \key -> map (SegDecl (unMappedKey key) :) (jsonPathsFromDecl graph visited key)+ } summarize :: [CoverageRoot] -> [JsonBoundary] -> CoverageSummary summarize roots json =- CoverageSummary- { privateEventPayloads = countsFor PrivateEventPayload- , snapshotRegisters = countsFor SnapshotRegister- }+ CoverageSummary+ { privateEventPayloads = countsFor PrivateEventPayload,+ snapshotRegisters = countsFor SnapshotRegister+ } where countsFor surface =- let matching = filter ((== surface) . rootSurface) roots- jsonCount = case surface of- PrivateEventPayload -> length json- SnapshotRegister -> 0- in CoverageCounts- { totalRoots = length matching- , structuralRoots = length (filter ((== StructuralCoverage) . rootMode) matching)- , opaqueRoots = length (filter ((== OpaqueCoverage) . rootMode) matching)- , jsonBoundaries = jsonCount- }+ let matching = filter ((== surface) . rootSurface) roots+ jsonCount = case surface of+ PrivateEventPayload -> length json+ SnapshotRegister -> 0+ in CoverageCounts+ { totalRoots = length matching,+ structuralRoots = length (filter ((== StructuralCoverage) . rootMode) matching),+ opaqueRoots = length (filter ((== OpaqueCoverage) . rootMode) matching),+ jsonBoundaries = jsonCount+ } opaqueSurfaceFindings :: [OpaqueBoundary] -> [CoverageFinding] opaqueSurfaceFindings boundaries =- [ CoverageFinding- { findingSeverity = Warning- , findingCode = CoverageOpaqueSurface- , findingRoots = [root]- , findingMessage = "persisted private-event root contains opaque mapped boundaries"- }- | root <- Set.toAscList (Set.fromList (map opaqueRoot boundaries))- ]+ [ CoverageFinding+ { findingSeverity = Warning,+ findingCode = CoverageOpaqueSurface,+ findingRoots = [root],+ findingMessage = "persisted private-event root contains opaque mapped boundaries"+ }+ | root <- Set.toAscList (Set.fromList (map opaqueRoot boundaries))+ ] gateFinding :: Text -> [OpaqueBoundary] -> CoverageFinding gateFinding message boundaries =- CoverageFinding- { findingSeverity = Error- , findingCode = CoverageOpaqueGateExceeded- , findingRoots = Set.toAscList (Set.fromList (map opaqueRoot boundaries))- , findingMessage = message- }+ CoverageFinding+ { findingSeverity = Error,+ findingCode = CoverageOpaqueGateExceeded,+ findingRoots = Set.toAscList (Set.fromList (map opaqueRoot boundaries)),+ findingMessage = message+ } unsupportedInventory :: [UnsupportedSurface] unsupportedInventory =- [ UnsupportedSurface- { unsupportedSurface = "queue-payloads"- , unsupportedSupport = "unsupported"- , unsupportedReason = "queue payloads are not roots in the mapped-type graph"- }- , UnsupportedSurface- { unsupportedSurface = "public-contracts"- , unsupportedSupport = "not-applicable"- , unsupportedReason = "public contracts have a separately owned grammar and compatibility surface"- }- ]+ [ UnsupportedSurface+ { unsupportedSurface = "queue-payloads",+ unsupportedSupport = "unsupported",+ unsupportedReason = "queue payloads are not roots in the mapped-type graph"+ },+ UnsupportedSurface+ { unsupportedSurface = "public-contracts",+ unsupportedSupport = "not-applicable",+ unsupportedReason = "public contracts have a separately owned grammar and compatibility surface"+ }+ ] useSiteKey :: UseSite -> MappedKey useSiteKey (RootCommandField _ _ _ key) = key@@ -500,151 +501,151 @@ useSiteKey (RootRegister _ _ key) = key useSiteSurface :: UseSite -> CoverageSurface-useSiteSurface RootEventField{} = PrivateEventPayload-useSiteSurface RootRegister{} = SnapshotRegister-useSiteSurface RootCommandField{} = error "command fields are not persisted coverage roots"+useSiteSurface RootEventField {} = PrivateEventPayload+useSiteSurface RootRegister {} = SnapshotRegister+useSiteSurface RootCommandField {} = error "command fields are not persisted coverage roots" isEventSite :: UseSite -> Bool-isEventSite RootEventField{} = True-isEventSite RootRegister{} = False-isEventSite RootCommandField{} = False+isEventSite RootEventField {} = True+isEventSite RootRegister {} = False+isEventSite RootCommandField {} = False rootText :: UseSite -> Text rootText site = renderUsePath (UsePath site []) declarationMode :: ResolvedMappedDecl -> CoverageMode declarationMode =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ _ -> StructuralCoverage- , onOpaqueDecl = const OpaqueCoverage- }+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ _ -> StructuralCoverage,+ onOpaqueDecl = const OpaqueCoverage+ } instance ToJSON CoverageSurface where- toJSON PrivateEventPayload = toJSON ("private-event-payload" :: Text)- toJSON SnapshotRegister = toJSON ("snapshot-register" :: Text)+ toJSON PrivateEventPayload = toJSON ("private-event-payload" :: Text)+ toJSON SnapshotRegister = toJSON ("snapshot-register" :: Text) instance ToJSON CoverageMode where- toJSON StructuralCoverage = toJSON ("structural" :: Text)- toJSON OpaqueCoverage = toJSON ("opaque" :: Text)+ toJSON StructuralCoverage = toJSON ("structural" :: Text)+ toJSON OpaqueCoverage = toJSON ("opaque" :: Text) instance ToJSON CoverageRoot where- toJSON root =- object- [ "surface" .= rootSurface root- , "path" .= rootPath root- , "mappedType" .= rootMappedType root- , "mode" .= rootMode root- , "canonicalType" .= rootCanonicalType root- , "codecIdentity" .= rootCodecIdentity root- , "codecVersion" .= rootCodecVersion root- , "wireFingerprint" .= rootWireFingerprint root- ]+ toJSON root =+ object+ [ "surface" .= rootSurface root,+ "path" .= rootPath root,+ "mappedType" .= rootMappedType root,+ "mode" .= rootMode root,+ "canonicalType" .= rootCanonicalType root,+ "codecIdentity" .= rootCodecIdentity root,+ "codecVersion" .= rootCodecVersion root,+ "wireFingerprint" .= rootWireFingerprint root+ ] instance ToJSON StructuralBoundary where- toJSON boundary =- object- [ "root" .= structuralRoot boundary- , "path" .= structuralPath boundary- , "mappedType" .= structuralMappedType boundary- , "canonicalType" .= structuralCanonicalType boundary- , "wireFingerprint" .= structuralWireFingerprint boundary- ]+ toJSON boundary =+ object+ [ "root" .= structuralRoot boundary,+ "path" .= structuralPath boundary,+ "mappedType" .= structuralMappedType boundary,+ "canonicalType" .= structuralCanonicalType boundary,+ "wireFingerprint" .= structuralWireFingerprint boundary+ ] instance ToJSON OpaqueBoundary where- toJSON boundary =- object- [ "root" .= opaqueRoot boundary- , "path" .= opaquePath boundary- , "mappedType" .= opaqueMappedType boundary- , "codecIdentity" .= opaqueCodecIdentity boundary- , "codecVersion" .= opaqueCodecVersion boundary- ]+ toJSON boundary =+ object+ [ "root" .= opaqueRoot boundary,+ "path" .= opaquePath boundary,+ "mappedType" .= opaqueMappedType boundary,+ "codecIdentity" .= opaqueCodecIdentity boundary,+ "codecVersion" .= opaqueCodecVersion boundary+ ] instance ToJSON JsonBoundary where- toJSON boundary = object ["root" .= jsonRoot boundary, "path" .= jsonPath boundary]+ toJSON boundary = object ["root" .= jsonRoot boundary, "path" .= jsonPath boundary] instance ToJSON SnapshotBoundary where- toJSON boundary =- object- [ "root" .= snapshotRoot boundary- , "aggregate" .= snapshotAggregate boundary- , "register" .= snapshotRegister boundary- , "mappedType" .= snapshotMappedType boundary- , "mode" .= snapshotMode boundary- , "snapshotEncoding" .= snapshotEncoding boundary- , "invalidation" .= snapshotInvalidation boundary- , "wireFingerprint" .= snapshotWireFingerprint boundary- , "snapshotEnabled" .= snapshotEnabled boundary- ]+ toJSON boundary =+ object+ [ "root" .= snapshotRoot boundary,+ "aggregate" .= snapshotAggregate boundary,+ "register" .= snapshotRegister boundary,+ "mappedType" .= snapshotMappedType boundary,+ "mode" .= snapshotMode boundary,+ "snapshotEncoding" .= snapshotEncoding boundary,+ "invalidation" .= snapshotInvalidation boundary,+ "wireFingerprint" .= snapshotWireFingerprint boundary,+ "snapshotEnabled" .= snapshotEnabled boundary+ ] instance ToJSON UnsupportedSurface where- toJSON surface =- object- [ "surface" .= unsupportedSurface surface- , "support" .= unsupportedSupport surface- , "reason" .= unsupportedReason surface- ]+ toJSON surface =+ object+ [ "surface" .= unsupportedSurface surface,+ "support" .= unsupportedSupport surface,+ "reason" .= unsupportedReason surface+ ] instance ToJSON CoverageCounts where- toJSON counts =- object- [ "totalRoots" .= totalRoots counts- , "structuralRoots" .= structuralRoots counts- , "opaqueRoots" .= opaqueRoots counts- , "jsonBoundaries" .= jsonBoundaries counts- ]+ toJSON counts =+ object+ [ "totalRoots" .= totalRoots counts,+ "structuralRoots" .= structuralRoots counts,+ "opaqueRoots" .= opaqueRoots counts,+ "jsonBoundaries" .= jsonBoundaries counts+ ] instance ToJSON CoverageSummary where- toJSON summary =- object- [ "privateEventPayloads" .= privateEventPayloads summary- , "snapshotRegisters" .= snapshotRegisters summary- ]+ toJSON summary =+ object+ [ "privateEventPayloads" .= privateEventPayloads summary,+ "snapshotRegisters" .= snapshotRegisters summary+ ] instance ToJSON CoverageFinding where- toJSON finding =- object- [ "severity" .= severityValue (findingSeverity finding)- , "code" .= show (findingCode finding)- , "roots" .= findingRoots finding- , "message" .= findingMessage finding- ]- where- severityValue Error = "error" :: Text- severityValue Warning = "advisory"+ toJSON finding =+ object+ [ "severity" .= severityValue (findingSeverity finding),+ "code" .= show (findingCode finding),+ "roots" .= findingRoots finding,+ "message" .= findingMessage finding+ ]+ where+ severityValue Error = "error" :: Text+ severityValue Warning = "advisory" instance ToJSON CoveragePrevious where- toJSON previous =- object- [ "reference" .= previousReference previous- , "summary" .= previousSummary previous- , "opaqueBoundaries" .= previousOpaqueBoundaries previous- ]+ toJSON previous =+ object+ [ "reference" .= previousReference previous,+ "summary" .= previousSummary previous,+ "opaqueBoundaries" .= previousOpaqueBoundaries previous+ ] instance ToJSON CoverageDelta where- toJSON delta =- object- [ "privateEventRootDelta" .= privateEventRootDelta delta- , "snapshotRegisterRootDelta" .= snapshotRegisterRootDelta delta- , "opaqueBoundaryDelta" .= opaqueBoundaryDelta delta- , "addedOpaqueBoundaries" .= addedOpaqueBoundaries delta- , "removedOpaqueBoundaries" .= removedOpaqueBoundaries delta- ]+ toJSON delta =+ object+ [ "privateEventRootDelta" .= privateEventRootDelta delta,+ "snapshotRegisterRootDelta" .= snapshotRegisterRootDelta delta,+ "opaqueBoundaryDelta" .= opaqueBoundaryDelta delta,+ "addedOpaqueBoundaries" .= addedOpaqueBoundaries delta,+ "removedOpaqueBoundaries" .= removedOpaqueBoundaries delta+ ] instance ToJSON CoverageReport where- toJSON report =- object- [ "schema" .= ("keiro-dsl/coverage-report/1" :: Text)- , "spec" .= coverageSpec report- , "roots" .= coverageRoots report- , "structuralBoundaries" .= coverageStructuralBoundaries report- , "opaqueBoundaries" .= coverageOpaqueBoundaries report- , "jsonBoundaries" .= coverageJsonBoundaries report- , "snapshotBoundaries" .= coverageSnapshotBoundaries report- , "unsupportedSurfaces" .= coverageUnsupportedSurfaces report- , "summary" .= coverageSummary report- , "findings" .= coverageFindings report- , "previous" .= coveragePrevious report- , "delta" .= coverageDelta report- ]+ toJSON report =+ object+ [ "schema" .= ("keiro-dsl/coverage-report/1" :: Text),+ "spec" .= coverageSpec report,+ "roots" .= coverageRoots report,+ "structuralBoundaries" .= coverageStructuralBoundaries report,+ "opaqueBoundaries" .= coverageOpaqueBoundaries report,+ "jsonBoundaries" .= coverageJsonBoundaries report,+ "snapshotBoundaries" .= coverageSnapshotBoundaries report,+ "unsupportedSurfaces" .= coverageUnsupportedSurfaces report,+ "summary" .= coverageSummary report,+ "findings" .= coverageFindings report,+ "previous" .= coveragePrevious report,+ "delta" .= coverageDelta report+ ]
src/Keiro/Dsl/Diff.hs view
@@ -1,1992 +1,2175 @@-{- | The spec evolution differ. 'diffSpecs' compares an /old/ and a /new/ 'Spec'-and classifies changes over the persisted decode and identity surfaces.--Changes are __ADDITIVE__ when they preserve stored data, __WARNING__ when they-change forward behaviour without invalidating persisted data, and __BREAKING__-when stored payloads may stop decoding or persisted identities may be re-keyed.-The @diff --since@ CLI exits non-zero only when a breaking change is present.--Every 'Node' constructor maps to a 'NodeFamily', and 'familyRegistry' contains-exactly one entry for each family. A family is either handled by an explicit-differ or carries a non-empty out-of-scope rationale. This makes omissions-visible when the grammar grows instead of silently treating new node kinds as-safe.--}-module Keiro.Dsl.Diff (- Change (..),- ChangeKind (..),- Label (..),- CompatibilitySurface (..),- SurfaceVerdict (..),- RolloutConstraint (..),- CompatibilityVector (..),- ChangeContext,- privateEventContext,- privateEventAdditionContext,- snapshotContext,- queueContext,- publicContractContext,- persistedIdentityContext,- consumerBuildContext,- advisoryAt,- changeContextRoot,- changeContextPaths,- classifyCompatibility,- verdictFor,- defaultGate,- gateWith,- deriveLabel,- gatedBreaking,- isBreaking,- isAdvisory,- diffSpecs,- DiffEnv (..),- NodeFamily (..),- familyOf,- FamilyDiff (..),- familyRegistry,- Paired (..),- pairByName,- readModelDiff,- classifyWorkflowBody,-) where--import Data.List (find, (\\))-import Data.Maybe (isJust, isNothing, mapMaybe)-import Data.Set (Set)-import Data.Set qualified as Set-import Data.Text (Text)-import Data.Text qualified as T-import Keiro.Dsl.FoldFingerprint (aggregateFoldSurface)-import Keiro.Dsl.Grammar-import Keiro.Dsl.MappedDiff (MappedFinding (..), diffMapped, renderMappedSubject)-import Keiro.Dsl.PrettyPrint (- renderHandleSurface,- renderResolveSurface,- renderRouterDispatchSurface,- renderTimerPayloadSurface,- renderTransition,- )-import Keiro.Dsl.ReadModelShape (registryNameFor, subscriptionNameFor)-import Keiro.Dsl.TypeGraph (UsePath (..), UseSite (..))-import Keiro.Dsl.Validate (DiagnosticCode (..))---- | A classified spec change.-data Change- = Additive ChangeKind- | Advisory ChangeKind- | Breaking ChangeKind- deriving stock (Eq, Show)---- | The stable headline classification retained by the text interface.-data Label = LabelAdditive | LabelAdvisory | LabelBreaking- deriving stock (Eq, Show)---- | Independently gateable compatibility questions for one finding.-data CompatibilitySurface- = PrivateHistoryRead- | OldBinaryReadNewEvents- | SnapshotHydration- | PublicConsumer- | PersistedIdentity- | ConsumerBuild- deriving stock (Eq, Ord, Show, Enum, Bounded)---- | A verdict on one surface. Constructor order is deliberately not policy.-data SurfaceVerdict = VCompatible | VAdvisory | VBreaking | VNotApplicable- deriving stock (Eq, Show)---- | Deployment ordering that remains after byte compatibility is classified.-data RolloutConstraint- = RolloutStopTheWorld- | RolloutWorkersFirst- | RolloutDrainRequired- | RolloutProducerLast- deriving stock (Eq, Ord, Show)---- | The explicit, compile-forcing compatibility result for one finding.-data CompatibilityVector = CompatibilityVector- { cvPrivateHistoryRead :: !SurfaceVerdict- , cvOldBinaryReadNewEvents :: !SurfaceVerdict- , cvSnapshotHydration :: !SurfaceVerdict- , cvPublicConsumer :: !SurfaceVerdict- , cvPersistedIdentity :: !SurfaceVerdict- , cvConsumerBuild :: !SurfaceVerdict- , cvRollout :: !(Set RolloutConstraint)- }- deriving stock (Eq, Show)--data ContextKind- = ContextGeneral- | ContextPrivateEvent- | ContextPrivateEventAddition- | ContextSnapshot- | ContextQueue- | ContextPublicContract- | ContextPersistedIdentity- | ContextConsumerBuild- deriving stock (Eq, Show)--{- | Facts that select a compatibility row. The constructor stays private so-callers cannot manufacture contradictory ownership and surface claims.--}-data ChangeContext = ChangeContext- { changeContextRoot :: !Name- , changeContextPaths :: ![Text]- , contextKind :: !ContextKind- , contextOriginalLabel :: !Label- }- deriving stock (Eq, Show)--data ChangeKind = ChangeKind- { ckNode :: !Name- , ckFacet :: !Text- , ckSubject :: !Text- , ckCode :: !DiagnosticCode- , ckContext :: !ChangeContext- , ckVector :: !CompatibilityVector- , ckPaths :: ![Text]- , ckDetail :: !Text- }- deriving stock (Eq, Show)--privateEventContext :: Name -> [Text] -> ChangeContext-privateEventContext root paths = ChangeContext root paths ContextPrivateEvent LabelBreaking--privateEventAdditionContext :: Name -> [Text] -> ChangeContext-privateEventAdditionContext root paths = ChangeContext root paths ContextPrivateEventAddition LabelAdvisory--snapshotContext :: Name -> [Text] -> ChangeContext-snapshotContext root paths = ChangeContext root paths ContextSnapshot LabelAdvisory--queueContext :: Name -> [Text] -> ChangeContext-queueContext root paths = ChangeContext root paths ContextQueue LabelBreaking--publicContractContext :: Name -> [Text] -> ChangeContext-publicContractContext root paths = ChangeContext root paths ContextPublicContract LabelBreaking--persistedIdentityContext :: Name -> [Text] -> ChangeContext-persistedIdentityContext root paths = ChangeContext root paths ContextPersistedIdentity LabelBreaking--consumerBuildContext :: Name -> [Text] -> ChangeContext-consumerBuildContext root paths = ChangeContext root paths ContextConsumerBuild LabelAdvisory--compatibleVector :: CompatibilityVector-compatibleVector =- CompatibilityVector- VCompatible- VCompatible- VNotApplicable- VNotApplicable- VNotApplicable- VNotApplicable- Set.empty--privateDecodeBreakingVector :: CompatibilityVector-privateDecodeBreakingVector =- CompatibilityVector- VBreaking- VBreaking- VAdvisory- VNotApplicable- VNotApplicable- VNotApplicable- (Set.singleton RolloutStopTheWorld)--persistedIdentityBreakingVector :: CompatibilityVector-persistedIdentityBreakingVector =- CompatibilityVector- VNotApplicable- VNotApplicable- VNotApplicable- VNotApplicable- VBreaking- VNotApplicable- Set.empty--publicBreakingVector :: CompatibilityVector-publicBreakingVector =- CompatibilityVector- VNotApplicable- VNotApplicable- VNotApplicable- VBreaking- VNotApplicable- VNotApplicable- (Set.singleton RolloutProducerLast)--queueBreakingVector :: CompatibilityVector-queueBreakingVector =- CompatibilityVector- VBreaking- VBreaking- VNotApplicable- VNotApplicable- VAdvisory- VNotApplicable- (Set.singleton RolloutWorkersFirst)--advisoryVector :: CompatibilitySurface -> Set RolloutConstraint -> CompatibilityVector-advisoryVector surface rollout =- compatibleVector- { cvPrivateHistoryRead = verdict PrivateHistoryRead- , cvOldBinaryReadNewEvents = verdict OldBinaryReadNewEvents- , cvSnapshotHydration = verdict SnapshotHydration- , cvPublicConsumer = verdict PublicConsumer- , cvPersistedIdentity = verdict PersistedIdentity- , cvConsumerBuild = verdict ConsumerBuild- , cvRollout = rollout- }- where- verdict candidate- | candidate == surface = VAdvisory- | otherwise = verdictFor candidate compatibleVector--{- | Classify one code at an explicitly owned use site. Codes emitted by the-differ are grouped by their actual persisted/public surface; the context is-load-bearing for codes such as 'EnumCtorAdded' that vary by use site.--}-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- | code `elem` [MappedHaskellSourceChanged, MappedRecordConstructorChanged, MappedFixturesChanged] = mappedBuildVector- | code == MappedBindingChanged = mappedBindingVector context- | code `elem` [MappedInitialChanged, MappedCanonicalTypeChanged] = mappedSnapshotBuildVector context- | code == MappedDeclAdded = compatibleVector- | code `elem` privateDecodeCodes = privateDecodeBreakingVector- | code `elem` identityCodes = persistedIdentityBreakingVector- | code `elem` publicBreakingCodes = publicBreakingVector- | code `elem` queueBreakingCodes = queueBreakingVector- | code `elem` readModelBreakingCodes = persistedIdentityBreakingVector- | code == ContractSchemaVersionBumped = advisoryVector PublicConsumer (Set.singleton RolloutProducerLast)- | code == AggFoldSurfaceChanged =- (advisoryVector PrivateHistoryRead Set.empty){cvSnapshotHydration = VAdvisory}- | code == AggGuardTightened = advisoryVector PrivateHistoryRead Set.empty- | code `elem` [RouterDecideSurfaceChanged, ProcessDecideSurfaceChanged] =- compatibleVector{cvRollout = Set.singleton RolloutDrainRequired}- | code == ProcessTimerPayloadChanged = advisoryVector PrivateHistoryRead (Set.singleton RolloutProducerLast)- | code == TimerWindowChanged = advisoryVector PrivateHistoryRead Set.empty- | code == ProjectionChanged = advisoryVector PersistedIdentity Set.empty- | code == EmitMappingChanged = advisoryVector PublicConsumer (Set.singleton RolloutProducerLast)- | code == DecodePostureChanged = advisoryVector PublicConsumer Set.empty- | code == IntakePersistenceChanged = advisoryVector PrivateHistoryRead Set.empty- | code `elem` [PublisherPolicyChanged, DispatchRetargeted] = advisoryVector PersistedIdentity Set.empty- | code `elem` [DeprecatedEventReplayHazard, EventRetirementInProgress] = advisoryVector PrivateHistoryRead Set.empty- | code == EventUndeprecated = advisoryVector OldBinaryReadNewEvents (Set.singleton RolloutProducerLast)- | code == EnumCtorAdded = case contextKind context of- ContextPrivateEventAddition ->- compatibleVector- { cvOldBinaryReadNewEvents = VBreaking- , cvRollout = Set.singleton RolloutProducerLast- }- ContextSnapshot -> advisoryVector SnapshotHydration Set.empty- _ -> compatibleVector- | code `elem` additiveCodes = compatibleVector- | otherwise = case contextOriginalLabel context of- LabelAdditive -> compatibleVector- LabelAdvisory -> advisoryVector (surfaceForContext context) Set.empty- LabelBreaking -> breakingVectorForContext context- where- privateDecodeCodes =- [ EvtFieldAddedWithoutBump- , EvtFieldRemovedSameVersion- , EvtFieldTypeChanged- , EvtVersionDecreased- , EvtVersionMissingUpcaster- , UpcasterChainGap- , EvtRemovedNotDeprecated- , EnumCtorRemoved- , EnumWireSpellingChanged- , WireSpecChanged- , ProcessInputChanged- , WorkflowShapeChanged- , WorkflowBodyChanged- , WorkflowPatchRemoved- , WorkflowContinueSeedChanged- ]- identityCodes =- [ DerivedIdentityChanged- , IdPrefixChanged- , DedupeIdentityChanged- , QueueIdentityChanged- , RouterStableNameChanged- , WorkflowStableNameChanged- ]- publicBreakingCodes =- [ ContractEventRemoved- , ContractFieldChanged- , ContractDiscriminatorChanged- , ContractTopicChanged- , ContractSchemaVersionDecreased- ]- queueBreakingCodes = [WqPayloadFieldChanged, WqOrderingChanged, WqProvisionChanged, WqGroupKeyChanged]- readModelBreakingCodes =- [ ReadModelVersionDecreased- , ReadModelShapeChangedWithoutBump- , ReadModelFeedChanged- , ReadModelConsistencyWeakened- ]- additiveCodes =- [ DeclarationAdded- , VersionBumped- , CompatibilityStrengthened- , EventRetirementAbandoned- , ContractEventAdded- , ContractTopicAdded- , WorkflowEvolutionGuardAdded- ]--mappedWireBreakingCodes :: [DiagnosticCode]-mappedWireBreakingCodes =- [ MappedFieldAddedNoDefault- , MappedFieldRemoved- , MappedFieldTypeChanged- , MappedPresenceChanged- , MappedNullabilityChanged- , MappedDefaultRemoved- , MappedDefaultChanged- , MappedWireKeyChanged- , MappedUnionEncodingChanged- , MappedArmRemoved- , MappedArmTagChanged- , MappedEnumValueRemoved- , MappedEnumSpellingChanged- , MappedOpaqueCodecChanged- , MappedModeCrossed- , MappedDeclRemoved- ]--mappedFieldAdditionVector :: ChangeContext -> CompatibilityVector-mappedFieldAdditionVector context = case contextKind context of- ContextPrivateEvent ->- compatibleVector- { cvOldBinaryReadNewEvents = oldBinaryVerdict- , cvRollout = rollout- }- where- rejectsUnknown = contextOriginalLabel context == LabelBreaking- oldBinaryVerdict = if rejectsUnknown then VBreaking else VCompatible- rollout = if rejectsUnknown then Set.singleton RolloutProducerLast else Set.empty- ContextSnapshot -> mappedSnapshotVector- ContextConsumerBuild -> mappedBuildVector- _ -> compatibleVector--mappedDirectionalAdditionVector :: ChangeContext -> CompatibilityVector-mappedDirectionalAdditionVector context = case contextKind context of- ContextPrivateEvent ->- compatibleVector- { cvOldBinaryReadNewEvents = VBreaking- , cvRollout = Set.singleton RolloutProducerLast- }- ContextSnapshot -> mappedSnapshotVector- ContextConsumerBuild -> mappedBuildVector- _ -> compatibleVector--mappedWireBreakingVector :: ChangeContext -> CompatibilityVector-mappedWireBreakingVector context = case contextKind context of- ContextPrivateEvent ->- CompatibilityVector- VBreaking- VBreaking- VNotApplicable- VNotApplicable- VNotApplicable- VNotApplicable- (Set.singleton RolloutStopTheWorld)- ContextSnapshot -> mappedSnapshotVector- ContextConsumerBuild -> mappedBuildVector- _ -> mappedBuildVector--mappedBuildVector :: CompatibilityVector-mappedBuildVector =- CompatibilityVector- VCompatible- VCompatible- VNotApplicable- VNotApplicable- VNotApplicable- VAdvisory- Set.empty--mappedSnapshotVector :: CompatibilityVector-mappedSnapshotVector =- CompatibilityVector- VCompatible- VCompatible- VAdvisory- VNotApplicable- VNotApplicable- VNotApplicable- Set.empty--mappedBindingVector :: ChangeContext -> CompatibilityVector-mappedBindingVector context = case contextKind context of- ContextPrivateEvent ->- CompatibilityVector- VAdvisory- VAdvisory- VNotApplicable- VNotApplicable- VNotApplicable- VAdvisory- Set.empty- ContextSnapshot ->- mappedSnapshotVector{cvConsumerBuild = VAdvisory}- _ -> mappedBuildVector--mappedSnapshotBuildVector :: ChangeContext -> CompatibilityVector-mappedSnapshotBuildVector context = case contextKind context of- ContextSnapshot -> mappedSnapshotVector{cvConsumerBuild = VAdvisory}- _ -> mappedBuildVector--surfaceForContext :: ChangeContext -> CompatibilitySurface-surfaceForContext context = case contextKind context of- ContextPrivateEvent -> PrivateHistoryRead- ContextPrivateEventAddition -> OldBinaryReadNewEvents- ContextSnapshot -> SnapshotHydration- ContextQueue -> PrivateHistoryRead- ContextPublicContract -> PublicConsumer- ContextPersistedIdentity -> PersistedIdentity- ContextConsumerBuild -> ConsumerBuild- ContextGeneral -> PrivateHistoryRead--breakingVectorForContext :: ChangeContext -> CompatibilityVector-breakingVectorForContext context = case contextKind context of- ContextPublicContract -> publicBreakingVector- ContextPersistedIdentity -> persistedIdentityBreakingVector- ContextQueue -> queueBreakingVector- ContextConsumerBuild -> (advisoryVector ConsumerBuild Set.empty){cvConsumerBuild = VBreaking}- _ -> privateDecodeBreakingVector--verdictFor :: CompatibilitySurface -> CompatibilityVector -> SurfaceVerdict-verdictFor surface vector = case surface of- PrivateHistoryRead -> cvPrivateHistoryRead vector- OldBinaryReadNewEvents -> cvOldBinaryReadNewEvents vector- SnapshotHydration -> cvSnapshotHydration vector- PublicConsumer -> cvPublicConsumer vector- PersistedIdentity -> cvPersistedIdentity vector- ConsumerBuild -> cvConsumerBuild vector--defaultGate :: Set CompatibilitySurface-defaultGate = Set.delete OldBinaryReadNewEvents (Set.fromList [minBound .. maxBound])--gateWith :: [CompatibilitySurface] -> Set CompatibilitySurface-gateWith surfaces = defaultGate <> Set.fromList surfaces--deriveLabel :: Set CompatibilitySurface -> CompatibilityVector -> Label-deriveLabel gate vector- | any ((== VBreaking) . (`verdictFor` vector)) (Set.toList gate) = LabelBreaking- | any (`elem` [VAdvisory, VBreaking]) verdicts || not (Set.null (cvRollout vector)) = LabelAdvisory- | otherwise = LabelAdditive- where- verdicts = [verdictFor surface vector | surface <- [minBound .. maxBound]]--gatedBreaking :: Set CompatibilitySurface -> Change -> Bool-gatedBreaking gate change = deriveLabel gate (ckVector (changeKind change)) == LabelBreaking--changeKind :: Change -> ChangeKind-changeKind (Additive kind) = kind-changeKind (Advisory kind) = kind-changeKind (Breaking kind) = kind--isBreaking :: Change -> Bool-isBreaking (Breaking _) = True-isBreaking (Additive _) = False-isBreaking (Advisory _) = False--isAdvisory :: Change -> Bool-isAdvisory (Advisory _) = True-isAdvisory (Additive _) = False-isAdvisory (Breaking _) = False---- | Both specs supplied to a node-family differ, always old then new.-data DiffEnv = DiffEnv- { deOld :: !Spec- , deNew :: !Spec- }- deriving stock (Eq, Show)---- | The closed set of node families currently present in 'Node'.-data NodeFamily- = FamAggregate- | FamProcess- | FamRouter- | FamContract- | FamIntake- | FamEmit- | FamPublisher- | FamWorkqueue- | FamPgmqDispatch- | FamReadModel- | FamWorkflow- | FamOperation- deriving stock (Eq, Ord, Show, Enum, Bounded)---- | Total by construction: one explicit arm per 'Node' constructor.-familyOf :: Node -> NodeFamily-familyOf (NAggregate _) = FamAggregate-familyOf (NProcess _) = FamProcess-familyOf (NRouter _) = FamRouter-familyOf (NContract _) = FamContract-familyOf (NIntake _) = FamIntake-familyOf (NEmit _) = FamEmit-familyOf (NPublisher _) = FamPublisher-familyOf (NWorkqueue _) = FamWorkqueue-familyOf (NPgmqDispatch _) = FamPgmqDispatch-familyOf (NReadModel _) = FamReadModel-familyOf (NWorkflow _) = FamWorkflow-familyOf (NOperation _) = FamOperation---- | A family either has a differ or an explicit reason it is not compared.-data FamilyDiff- = DiffFamily (DiffEnv -> [Change])- | OutOfDiffScope Text---- | Pair the old and new declarations of one node family by stable name.-data Paired n = Paired- { prMatched :: ![(n, n)]- , prAdded :: ![n]- , prRemoved :: ![n]- }- deriving stock (Eq, Show)--pairByName :: (Node -> Maybe n) -> (n -> Name) -> DiffEnv -> Paired n-pairByName project nameOf env =- Paired- { prMatched =- [ (oldNode, newNode)- | newNode <- newNodes- , Just oldNode <- [find ((== nameOf newNode) . nameOf) oldNodes]- ]- , prAdded =- [ newNode- | newNode <- newNodes- , isNothing (find ((== nameOf newNode) . nameOf) oldNodes)- ]- , prRemoved =- [ oldNode- | oldNode <- oldNodes- , isNothing (find ((== nameOf oldNode) . nameOf) newNodes)- ]- }- where- oldNodes = mapMaybe project (specNodes (deOld env))- newNodes = mapMaybe project (specNodes (deNew env))--{- | Registry invariant: every 'Node' constructor maps to a family via the-total 'familyOf' case, and every family occurs exactly once here. The unit-suite enforces registry coverage and non-empty out-of-scope rationales.--}-familyRegistry :: [(NodeFamily, FamilyDiff)]-familyRegistry =- [ (FamAggregate, DiffFamily aggregateDiff)- , (FamProcess, DiffFamily processDiff)- , (FamRouter, DiffFamily routerDiff)- , (FamContract, DiffFamily contractDiff)- , (FamIntake, DiffFamily intakeDiff)- , (FamEmit, DiffFamily emitDiff)- , (FamPublisher, DiffFamily publisherDiff)- , (FamWorkqueue, DiffFamily workqueueDiff)- , (FamPgmqDispatch, DiffFamily pgmqDispatchDiff)- , (FamReadModel, DiffFamily readModelDiff)- , (FamWorkflow, DiffFamily workflowDiff)- , (FamOperation, OutOfDiffScope "operations own no persisted decode or identity surface; their references and workflow signal/await pairing are single-spec validation concerns")- ]--diffSpecs :: Spec -> Spec -> [Change]-diffSpecs old new =- sharedDeclarationDiff env- ++ concatMap (runFamily env . snd) familyRegistry- where- env = DiffEnv old new--runFamily :: DiffEnv -> FamilyDiff -> [Change]-runFamily env (DiffFamily f) = f env-runFamily _ (OutOfDiffScope _) = []---- Rules are outside the decode and persisted-identity axes, but referenced--- rule bodies are compared as part of each aggregate's replay fold surface.-sharedDeclarationDiff :: DiffEnv -> [Change]-sharedDeclarationDiff env = enumDiff env ++ idDiff env ++ mappedDeclarationDiff env--mappedDeclarationDiff :: DiffEnv -> [Change]-mappedDeclarationDiff env = concatMap mappedFindingChanges (diffMapped (deOld env) (deNew env))--mappedFindingChanges :: MappedFinding -> [Change]-mappedFindingChanges finding- | mfCode finding == MappedDeclAdded = [mappedDeclarationChange LabelAdditive finding]- | mfCode finding `elem` [MappedHaskellSourceChanged, MappedRecordConstructorChanged, MappedFixturesChanged] =- [mappedBuildChange finding]- | mfCode finding `elem` [MappedInitialChanged, MappedCanonicalTypeChanged] =- mappedBuildChange finding : map (mappedUseChange finding) registerPaths- | null paths = [mappedBuildChange finding]- | otherwise = map (mappedUseChange finding) paths- where- paths = mfUsePaths finding- registerPaths = [path | path@UsePath{upRoot = RootRegister{}} <- paths]--mappedBuildChange :: MappedFinding -> Change-mappedBuildChange finding =- mappedChange context (mfDeclaration finding) "mapped-build" subject finding- where- subject = declarationSubject finding- renderedPaths = map (\path -> renderMappedSubject path (mfLeaf finding)) (mfUsePaths finding)- context = (consumerBuildContext (mfDeclaration finding) renderedPaths){contextOriginalLabel = LabelAdvisory}--mappedDeclarationChange :: Label -> MappedFinding -> Change-mappedDeclarationChange label finding =- mappedChange context (mfDeclaration finding) "mapped-declaration" (declarationSubject finding) finding- where- context = ChangeContext (mfDeclaration finding) [] ContextGeneral label--mappedUseChange :: MappedFinding -> UsePath -> Change-mappedUseChange finding path =- mappedChange context root facet subject finding- where- subject = renderMappedSubject path (mfLeaf finding)- (root, facet, kind) = case upRoot path of- RootCommandField aggregate _ _ _ -> (aggregate, "mapped-command", ContextConsumerBuild)- RootEventField aggregate _ _ _ -> (aggregate, "mapped-event", ContextPrivateEvent)- RootRegister aggregate _ _ -> (aggregate, "mapped-register", ContextSnapshot)- context = ChangeContext root [subject] kind (mappedContextHint finding kind)--mappedContextHint :: MappedFinding -> ContextKind -> Label-mappedContextHint finding kind = case kind of- ContextSnapshot -> LabelAdvisory- ContextConsumerBuild -> LabelAdvisory- ContextPrivateEvent- | mfCode finding == MappedFieldAddedWithDefault -> case mfOldUnknownFields finding of- Just IgnoreUnknown -> LabelAdditive- _ -> LabelBreaking- | mfCode finding `elem` [MappedArmAdded, MappedEnumValueAdded] -> LabelAdvisory- | mfCode finding `elem` [MappedBindingChanged, MappedInitialChanged, MappedCanonicalTypeChanged] -> LabelAdvisory- | otherwise -> LabelBreaking- _ -> LabelAdvisory--mappedChange :: ChangeContext -> Name -> Text -> Text -> MappedFinding -> Change-mappedChange context node facet subject finding =- mkChange label context node facet subject (mfCode finding) (mfDetail finding)- where- label = deriveLabel defaultGate (classifyCompatibility context (mfCode finding))--declarationSubject :: MappedFinding -> Text-declarationSubject finding =- mfDeclaration finding <> if T.null (mfLeaf finding) then "" else " " <> mfLeaf finding--nodeAggregate :: Node -> Maybe Aggregate-nodeAggregate (NAggregate a) = Just a-nodeAggregate _ = Nothing--nodeProcess :: Node -> Maybe ProcessNode-nodeProcess (NProcess process) = Just process-nodeProcess _ = Nothing--nodeRouter :: Node -> Maybe RouterNode-nodeRouter (NRouter router) = Just router-nodeRouter _ = Nothing--nodeContract :: Node -> Maybe ContractNode-nodeContract (NContract contract) = Just contract-nodeContract _ = Nothing--nodeIntake :: Node -> Maybe IntakeNode-nodeIntake (NIntake intake) = Just intake-nodeIntake _ = Nothing--nodeEmit :: Node -> Maybe EmitNode-nodeEmit (NEmit emit) = Just emit-nodeEmit _ = Nothing--nodePublisher :: Node -> Maybe PublisherNode-nodePublisher (NPublisher publisher) = Just publisher-nodePublisher _ = Nothing--nodeWorkqueue :: Node -> Maybe WorkqueueNode-nodeWorkqueue (NWorkqueue workqueue) = Just workqueue-nodeWorkqueue _ = Nothing--nodePgmqDispatch :: Node -> Maybe PgmqDispatchNode-nodePgmqDispatch (NPgmqDispatch dispatch) = Just dispatch-nodePgmqDispatch _ = Nothing--nodeReadModel :: Node -> Maybe ReadModelNode-nodeReadModel (NReadModel readModel) = Just readModel-nodeReadModel _ = Nothing--nodeWorkflow :: Node -> Maybe WorkflowNode-nodeWorkflow (NWorkflow workflow) = Just workflow-nodeWorkflow _ = Nothing--{- | Router identity is replay-sensitive: the stable name and key feed every-target-keyed dispatch id, and the target selects the persisted stream family.--}-routerDiff :: DiffEnv -> [Change]-routerDiff env =- concatMap (uncurry routerPairDiff) (prMatched paired)- ++ [additive (rtId router) "router" (rtId router) DeclarationAdded "new router declaration" | router <- prAdded paired]- ++ [breaking (rtId router) "router-identity" (rtId router) RouterStableNameChanged "router removed while replayable source events may still derive target-keyed dispatch ids from its stable identity" | router <- prRemoved paired]- where- paired = pairByName nodeRouter rtId env--routerPairDiff :: RouterNode -> RouterNode -> [Change]-routerPairDiff oldRouter newRouter =- stableName- ++ keyDerivation- ++ target- ++ routerDecideSurfaceDiff oldRouter newRouter- where- nodeName = rtId newRouter- stableName =- [ breaking nodeName "router-stable-name" nodeName RouterStableNameChanged $- "router stable name changed from '" <> rtName oldRouter <> "' to '" <> rtName newRouter <> "'; every deterministicRouterCommandId is re-keyed, so redelivery can duplicate the full resolved fan-out"- | rtName oldRouter /= rtName newRouter- ]- keyDerivation =- [ breaking nodeName "router-key" (corrField (rtKey newRouter)) DerivedIdentityChanged "router key field or derivation changed; replay derives different target dispatch ids"- | rtKey oldRouter /= rtKey newRouter- ]- target =- [ breaking nodeName "router-target" (rtTarget newRouter) DerivedIdentityChanged "router target aggregate changed; replay addresses a different persisted stream family"- | rtTarget oldRouter /= rtTarget newRouter- ]--routerDecideSurfaceDiff :: RouterNode -> RouterNode -> [Change]-routerDecideSurfaceDiff oldRouter newRouter =- [ advisory- (rtId newRouter)- "router-decide"- (rtId newRouter)- RouterDecideSurfaceChanged- "router dispatch surface changed: a source event redelivered across the deploy dispatches under the same deterministic ids, so half-old/half-new fan-out merges silently. Drain or pause the router's subscription and replay or discard dead letters before deploying; see docs/user/deploy-ordering.md. Hole-only decide changes are not visible to diff; the same drain rule applies to those too."- | oldSurface /= newSurface- ]- where- oldSurface =- ( renderResolveSurface (rtResolve oldRouter)- , renderRouterDispatchSurface (rtDispatch oldRouter)- )- newSurface =- ( renderResolveSurface (rtResolve newRouter)- , renderRouterDispatchSurface (rtDispatch newRouter)- )--readModelDiff :: DiffEnv -> [Change]-readModelDiff env =- concatMap (uncurry (readModelPairDiff env)) (prMatched paired)- ++ concatMap addedReadModelDiff (prAdded paired)- ++ concatMap removedReadModelDiff (prRemoved paired)- where- paired = pairByName nodeReadModel rmName env--readModelPairDiff :: DiffEnv -> ReadModelNode -> ReadModelNode -> [Change]-readModelPairDiff env oldReadModel newReadModel =- versionChanges- ++ shapeChanges- ++ identityChanges- ++ feedChanges- ++ consistencyChanges- ++ scopeChanges- where- nodeName = rmName newReadModel- versionChanges- | rmVersion newReadModel < rmVersion oldReadModel =- [ breaking nodeName "read-model-version" nodeName ReadModelVersionDecreased ("version decreased from " <> tInt (rmVersion oldReadModel) <> " to " <> tInt (rmVersion newReadModel))- ]- | rmVersion newReadModel > rmVersion oldReadModel =- [ additive nodeName "read-model-version" nodeName VersionBumped ("version increased from " <> tInt (rmVersion oldReadModel) <> " to " <> tInt (rmVersion newReadModel) <> "; register and rebuild the new shape before serving it")- ]- | otherwise = []- oldShape = (rmColumns oldReadModel, rmShape oldReadModel)- newShape = (rmColumns newReadModel, rmShape newReadModel)- shapeChanges =- [ breaking nodeName "read-model-shape" nodeName ReadModelShapeChangedWithoutBump ("declared columns or captured shape hash changed at version " <> tInt (rmVersion newReadModel) <> "; bump version and rebuild")- | oldShape /= newShape- , rmVersion oldReadModel == rmVersion newReadModel- ]- oldRegistry = registryNameFor (specContext (deOld env)) oldReadModel- newRegistry = registryNameFor (specContext (deNew env)) newReadModel- oldSubscription = subscriptionNameFor (specContext (deOld env)) oldReadModel- newSubscription = subscriptionNameFor (specContext (deNew env)) newReadModel- identityChanges =- [ breaking nodeName "read-model-identity" nodeName DerivedIdentityChanged ("registry name changed '" <> oldRegistry <> "' -> '" <> newRegistry <> "'; the old registration row is orphaned")- | oldRegistry /= newRegistry- ]- ++ [ breaking nodeName "read-model-table" nodeName DerivedIdentityChanged ("qualified table changed '" <> qualifiedIdentity oldReadModel <> "' -> '" <> qualifiedIdentity newReadModel <> "'; existing data remains under the old identity")- | (rmSchema oldReadModel, rmTable oldReadModel) /= (rmSchema newReadModel, rmTable newReadModel)- ]- ++ [ breaking nodeName "read-model-subscription" nodeName DerivedIdentityChanged ("subscription changed '" <> oldSubscription <> "' -> '" <> newSubscription <> "'; the worker cursor remains under the old identity")- | oldSubscription /= newSubscription- ]- feedChanges =- [ breaking nodeName "read-model-feed" nodeName ReadModelFeedChanged ("feed changed " <> renderFeed (rmFeed oldReadModel) <> " -> " <> renderFeed (rmFeed newReadModel) <> "; projection wiring and rebuild identities changed")- | rmFeed oldReadModel /= rmFeed newReadModel- ]- consistencyChanges = case (rmConsistency oldReadModel, rmConsistency newReadModel) of- (Strong, Eventual) ->- [breaking nodeName "read-model-consistency" nodeName ReadModelConsistencyWeakened "default consistency changed Strong -> Eventual; callers lose the cursor-wait guarantee"]- (Eventual, Strong) ->- [additive nodeName "read-model-consistency" nodeName CompatibilityStrengthened "default consistency changed Eventual -> Strong; callers gain a cursor-wait guarantee"]- _ -> []- oldScope = effectiveScope (rmScope oldReadModel)- newScope = effectiveScope (rmScope newReadModel)- scopeChanges- | oldScope == newScope = []- | scopeStrengthened oldScope newScope =- [additive nodeName "read-model-scope" nodeName CompatibilityStrengthened ("Strong scope widened " <> renderScope oldScope <> " -> " <> renderScope newScope)]- | otherwise =- [breaking nodeName "read-model-scope" nodeName ReadModelConsistencyWeakened ("Strong scope changed " <> renderScope oldScope <> " -> " <> renderScope newScope <> "; callers no longer wait on the same event surface")]--addedReadModelDiff :: ReadModelNode -> [Change]-addedReadModelDiff readModel =- [additive (rmName readModel) "read-model" (rmName readModel) DeclarationAdded "new read model"]--removedReadModelDiff :: ReadModelNode -> [Change]-removedReadModelDiff readModel =- [breaking (rmName readModel) "read-model-identity" (rmName readModel) DerivedIdentityChanged "read model removed while registered metadata, data, subscription cursors, and callers may remain"]--qualifiedIdentity :: ReadModelNode -> Text-qualifiedIdentity readModel = rmSchema readModel <> "." <> rmTable readModel--renderFeed :: RmFeed -> Text-renderFeed RmInline = "inline"-renderFeed RmSubscription = "subscription"--effectiveScope :: Maybe RmScope -> RmScope-effectiveScope Nothing = RmEntireLog-effectiveScope (Just scope) = scope--scopeStrengthened :: RmScope -> RmScope -> Bool-scopeStrengthened (RmCategory _) RmEntireLog = True-scopeStrengthened _ _ = False--renderScope :: RmScope -> Text-renderScope RmEntireLog = "entire-log"-renderScope (RmCategory categoryName) = "category '" <> categoryName <> "'"--aggregateDiff :: DiffEnv -> [Change]-aggregateDiff env =- concatMap- (\(oldAggregate, newAggregate) -> aggregatePairDiff (deOld env) (deNew env) oldAggregate newAggregate)- (prMatched paired)- ++ concatMap addedAggregateDiff (prAdded paired)- ++ concatMap removedAggregateDiff (prRemoved paired)- where- paired = pairByName nodeAggregate aggName env--aggregatePairDiff :: Spec -> Spec -> Aggregate -> Aggregate -> [Change]-aggregatePairDiff oldSpec newSpec oldAgg newAgg =- concatMap (eventDiff oldAgg newAgg) (aggEvents newAgg)- ++ removedEvents oldAgg newAgg- ++ wireDiff oldAgg newAgg- ++ projectionDiff oldAgg newAgg- ++ guardTighteningDiff oldAgg newAgg- ++ transitionSurfaceDiff oldSpec newSpec oldAgg newAgg--{- | Report replay-fold evolution. Regenerated scaffold code carries the new-fingerprint and invalidates old snapshots, so this remains advisory.--}-transitionSurfaceDiff :: Spec -> Spec -> Aggregate -> Aggregate -> [Change]-transitionSurfaceDiff oldSpec newSpec oldAgg newAgg- | aggregateFoldSurface oldSpec oldAgg == aggregateFoldSurface newSpec newAgg = []- | otherwise =- [ advisory- (aggName newAgg)- "transitions"- (aggName newAgg)- AggFoldSurfaceChanged- "aggregate fold surface changed: replay now interprets the existing log under the new fold. Old snapshots are invalidated automatically once the regenerated fold fingerprint deploys; if the change is fold-neutral confirm it, otherwise re-scaffold and redeploy, and bump `state-codec version=` for any accompanying Holes-only change."- ]--{- | Plan 143: guard changes are replay-relevant. Hydration re-inverts each-stored event and re-checks the edge guard, so a stored event legally appended-under the old guard may no longer satisfy the new one — the next command on-any stream containing such an event fails hydration with no inverting edge.-The remedy is mechanical, so the tool computes it: the removed region is-@old-guard ∧ ¬new-guard@ ('complementExpr' eliminates the negation inside the-existing grammar), and the advisory prints a paste-ready replay-only twin-carrying that region with the OLD transition's writes\/emits\/goto. Whether-history should stay replayable (paste the twin) or be truncated instead is a-business decision, so the twin is never auto-applied.--Detection is conservative: any guard change on a paired live (source,-command) transition where the new spec declares a guard and does not already-contain a replay-only twin for the pair. A pure loosening also matches; the-advisory says how to confirm no stored data is affected (the replay audit,-docs/plans/142) rather than guessing.--}-guardTighteningDiff :: Aggregate -> Aggregate -> [Change]-guardTighteningDiff oldAgg newAgg =- [ advisory (aggName newAgg) "transition" subject AggGuardTightened detail- | newT <- aggTransitions newAgg- , tMode newT == TmLive- , Just oldT <-- [ find- (\o -> tSource o == tSource newT && tCommand o == tCommand newT && tMode o == TmLive)- (aggTransitions oldAgg)- ]- , tGuard newT /= tGuard oldT- , Just newGuard <- [tGuard newT]- , not (hasReplayOnlyTwin newT)- , let subject = tSource newT <> " -- " <> tCommand newT- , let removedRegion =- maybe (complementExpr newGuard) (\o -> EAnd o (complementExpr newGuard)) (tGuard oldT)- , let twin = oldT{tGuard = Just removedRegion, tMode = TmReplayOnly}- , let detail =- "guard changed on "- <> subject- <> ". Stored events appended under the old guard may no longer invert: "- <> "the next command on any stream containing one fails hydration with "- <> "no inverting edge. Either confirm via the replay audit that no stored "- <> "stream exercises the removed region, or keep history replayable by "- <> "adding the computed replay-only twin (the removed region with the old "- <> "transition's writes/emits/goto):\n\n"- <> renderTransition twin- ]- where- hasReplayOnlyTwin newT =- any- (\t -> tMode t == TmReplayOnly && tSource t == tSource newT && tCommand t == tCommand newT)- (aggTransitions newAgg)--addedAggregateDiff :: Aggregate -> [Change]-addedAggregateDiff newAgg =- [ additive (aggName newAgg) "event" (evName e) DeclarationAdded "new event type (new aggregate)"- | e <- aggEvents newAgg- ]--removedAggregateDiff :: Aggregate -> [Change]-removedAggregateDiff oldAgg =- [ breaking (aggName oldAgg) "event" (evName e) EvtRemovedNotDeprecated "aggregate removed; its event tags are no longer decodable"- | e <- aggEvents oldAgg- ]---- | Per-event classification for an event present in the new aggregate.-eventDiff :: Aggregate -> Aggregate -> Event -> [Change]-eventDiff oldAgg newAgg e =- case find ((== evName e) . evName) (aggEvents oldAgg) of- Nothing ->- [additive (aggName newAgg) "event" (evName e) DeclarationAdded "new event type"]- Just oldE- | evVersion e > evVersion oldE ->- if evVersion e == evVersion oldE + 1 && evUpcastFrom e `hasSource` evVersion oldE- then- [additive (aggName newAgg) "event" (evName e) VersionBumped ("new version v" <> tInt (evVersion e) <> " with upcaster from v" <> tInt (evVersion oldE))]- ++ [ breaking- (aggName newAgg)- "event"- (evName e)- UpcasterChainGap- ( "bumping v"- <> tInt (evVersion oldE)- <> " to v"- <> tInt (evVersion e)- <> " replaced the 'upcast from v"- <> tInt vanishedSource- <> "' rung; stored v"- <> tInt vanishedSource- <> " payloads can no longer decode"- )- | Just (vanishedSource, _) <- [evUpcastFrom oldE]- , not (aggregateHasUpcasterSource newAgg vanishedSource)- ]- else- [ breaking- (aggName newAgg)- "event"- (evName e)- EvtVersionMissingUpcaster- ( "version changed from v"- <> tInt (evVersion oldE)- <> " to v"- <> tInt (evVersion e)- <> " without the required contiguous upcaster from v"- <> tInt (evVersion oldE)- )- ]- | evVersion e < evVersion oldE ->- [breaking (aggName newAgg) "event" (evName e) EvtVersionDecreased ("version decreased from v" <> tInt (evVersion oldE) <> " to v" <> tInt (evVersion e))]- | otherwise ->- sameVersionEventDiff oldAgg newAgg oldE e--{- | Events present in the old aggregate but absent in the new one. Removing a-tag entirely is breaking; deprecation preserves decoding but needs a retained-replay-only emitter to preserve replay.--}-removedEvents :: Aggregate -> Aggregate -> [Change]-removedEvents oldAgg newAgg =- [ breaking (aggName newAgg) "event" (evName oldE) EvtRemovedNotDeprecated "event removed entirely; its stored payloads can neither decode nor replay. Deprecating instead restores decode-ability only — replay still fails on live streams unless an equivalent replay-only emitting transition is retained; truncate or terminalize affected streams before deleting it"- | oldE <- aggEvents oldAgg- , isNothing (find ((== evName oldE) . evName) (aggEvents newAgg))- ]--hasSource :: Maybe (Int, Hole) -> Int -> Bool-hasSource (Just (m, _)) n = m == n-hasSource Nothing _ = False--aggregateHasUpcasterSource :: Aggregate -> Int -> Bool-aggregateHasUpcasterSource aggregate source =- any ((== Just source) . fmap fst . evUpcastFrom) (aggEvents aggregate)--hasReplayOnlyEmitter :: Aggregate -> Name -> Bool-hasReplayOnlyEmitter aggregate eventName =- any- (\transition -> tMode transition == TmReplayOnly && eventName `elem` tEmits transition)- (aggTransitions aggregate)--eventFieldSigs :: Aggregate -> Event -> [(Name, Maybe Name)]-eventFieldSigs agg e = case evBody e of- EventFields fs -> map fieldSig fs- EventFromCommand cn ->- maybe [] (map fieldSig . cmdFields) (find ((== cn) . cmdName) (aggCommands agg))- where- fieldSig f = (fieldName f, fieldType f)--sameVersionEventDiff :: Aggregate -> Aggregate -> Event -> Event -> [Change]-sameVersionEventDiff oldAgg newAgg oldE newE =- addedChanges- ++ removedChanges- ++ typeChanges- ++ deprecationChanges- ++ retirementChanges- where- oldFields = eventFieldSigs oldAgg oldE- newFields = eventFieldSigs newAgg newE- oldNames = map fst oldFields- newNames = map fst newFields- added = newNames \\ oldNames- removed = oldNames \\ newNames- changed =- [ (field, oldType, newType)- | (field, oldType) <- oldFields- , Just newType <- [lookup field newFields]- , oldType /= newType- ]- addedChanges =- [ breaking (aggName newAgg) "event" (evName newE) EvtFieldAddedWithoutBump ("field(s) " <> commas added <> " added at the same version v" <> tInt (evVersion newE) <> " without a version bump or upcaster")- | not (null added)- ]- removedChanges =- [ breaking (aggName newAgg) "event" (evName newE) EvtFieldRemovedSameVersion ("field(s) " <> commas removed <> " removed at the same version v" <> tInt (evVersion newE))- | not (null removed)- ]- typeChanges =- [ breaking- (aggName newAgg)- "event-field"- (evName newE <> "." <> field)- EvtFieldTypeChanged- ("type changed " <> renderFieldType oldType <> " -> " <> renderFieldType newType <> " at the same version v" <> tInt (evVersion newE))- | (field, oldType, newType) <- changed- ]- deprecationChanges- | not (evDeprecated oldE) && evDeprecated newE =- [ if hasReplayOnlyEmitter newAgg (evName newE)- then- advisory- (aggName newAgg)- "event"- (evName newE)- EventRetirementInProgress- "event deprecated and removed from the live write path, while an equivalent replay-only transition preserves hydration. Retain that transition until every affected stream is terminal, truncated, or passes the replay audit"- else- advisory- (aggName newAgg)- "event"- (evName newE)- DeprecatedEventReplayHazard- ( "event deprecated: old payloads remain decodable but are no longer replayable — hydration of live streams containing them fails at the first command (HydrationNoInvertingEdge). Add an equivalent replay-only emitting transition or confirm every affected stream is terminal or truncated before deploying"- <> if evRetiring oldE then "" else "; consider a 'retiring event' stage first"- )- ]- | evDeprecated oldE && not (evDeprecated newE) && not (evRetiring newE) =- [advisory (aggName newAgg) "event" (evName newE) EventUndeprecated "event returned to the write surface; old payloads remain decodable but new writes resume"]- | otherwise = []- retirementChanges- | not (evRetiring oldE) && evRetiring newE =- [advisory (aggName newAgg) "event" (evName newE) EventRetirementInProgress "retirement started; keep the live emitting transition until affected streams are terminal or truncated, then cut over to deprecated plus an equivalent replay-only emitting transition"]- | evRetiring oldE && not (evRetiring newE) && not (evDeprecated newE) =- [additive (aggName newAgg) "event" (evName newE) EventRetirementAbandoned "event retirement abandoned; ordinary live writes continue"]- | otherwise = []--renderFieldType :: Maybe Name -> Text-renderFieldType Nothing = "(declared)"-renderFieldType (Just name) = name--wireDiff :: Aggregate -> Aggregate -> [Change]-wireDiff oldAgg newAgg- | effectiveWire (aggWire oldAgg) == effectiveWire (aggWire newAgg) = []- | otherwise =- [ breaking- (aggName newAgg)- "wire"- (aggName newAgg)- WireSpecChanged- ("effective wire convention changed " <> renderWire (effectiveWire (aggWire oldAgg)) <> " -> " <> renderWire (effectiveWire (aggWire newAgg)))- ]--effectiveWire :: Maybe WireSpec -> (Text, Text)-effectiveWire Nothing = ("ctorName", "camelCase")-effectiveWire (Just w) = (wireKind w, wireFields w)--renderWire :: (Text, Text) -> Text-renderWire (kindName, fieldNames) = "kind=" <> kindName <> ", fields=" <> fieldNames--projectionDiff :: Aggregate -> Aggregate -> [Change]-projectionDiff oldAggregate newAggregate- | projectionSurface (aggProjection oldAggregate) == projectionSurface (aggProjection newAggregate) = []- | otherwise =- [ advisory- (aggName newAggregate)- "projection"- (aggName newAggregate)- ProjectionChanged- "projection table, consistency, key, or status mapping changed; coordinate the read-model migration"- ]--projectionSurface :: Maybe ProjectionSpec -> Maybe (Name, Maybe Consistency, Name, Maybe Mapping)-projectionSurface projection = do- value <- projection- pure (projTable value, projConsistency value, projKey value, projStatusMap value)--idDiff :: DiffEnv -> [Change]-idDiff env =- concatMap (uncurry idPairDiff) (prMatched paired)- ++ concatMap addedIdDiff (prAdded paired)- ++ concatMap removedIdDiff (prRemoved paired)- where- paired = pairDeclarations idName (specIds (deOld env)) (specIds (deNew env))--idPairDiff :: IdDecl -> IdDecl -> [Change]-idPairDiff oldId newId =- [ breaking (idName newId) "id-prefix" (idName newId) IdPrefixChanged ("prefix changed '" <> idPrefix oldId <> "' -> '" <> idPrefix newId <> "'; stored and newly minted ids no longer share an identity domain")- | idPrefix oldId /= idPrefix newId- ]--addedIdDiff :: IdDecl -> [Change]-addedIdDiff declaration = [additive (idName declaration) "id-prefix" (idName declaration) DeclarationAdded "new id declaration"]--removedIdDiff :: IdDecl -> [Change]-removedIdDiff declaration = [breaking (idName declaration) "id-prefix" (idName declaration) IdPrefixChanged "id declaration removed; persisted ids still use its prefix"]--enumDiff :: DiffEnv -> [Change]-enumDiff env =- concatMap (uncurry (enumPairDiff (deOld env))) (prMatched paired)- ++ concatMap addedEnumDiff (prAdded paired)- ++ concatMap (removedEnumDiff (deOld env)) (prRemoved paired)- where- paired = pairDeclarations enumName (specEnums (deOld env)) (specEnums (deNew env))--enumPairDiff :: Spec -> EnumDecl -> EnumDecl -> [Change]-enumPairDiff oldSpec oldEnum newEnum =- [ breaking (enumName newEnum) "enum-constructor" ctor EnumCtorRemoved ("constructor removed; stored wire value '" <> wire <> "' no longer decodes" <> enumUsageSuffix oldSpec (enumName oldEnum))- | (ctor, wire) <- enumCtors oldEnum- , isNothing (lookup ctor (enumCtors newEnum))- ]- ++ [ breaking (enumName newEnum) "enum-constructor" ctor EnumWireSpellingChanged ("wire spelling changed '" <> oldWire <> "' -> '" <> newWire <> "'; stored values using the old spelling no longer decode" <> enumUsageSuffix oldSpec (enumName oldEnum))- | (ctor, oldWire) <- enumCtors oldEnum- , Just newWire <- [lookup ctor (enumCtors newEnum)]- , oldWire /= newWire- ]- ++ concat- [ enumAdditionDiff oldSpec newEnum ctor wire- | (ctor, wire) <- enumCtors newEnum- , isNothing (lookup ctor (enumCtors oldEnum))- ]--addedEnumDiff :: EnumDecl -> [Change]-addedEnumDiff enumDecl =- [additive (enumName enumDecl) "enum-constructor" ctor EnumCtorAdded ("new enum constructor with wire spelling '" <> wire <> "'") | (ctor, wire) <- enumCtors enumDecl]--enumAdditionDiff :: Spec -> EnumDecl -> Name -> Text -> [Change]-enumAdditionDiff oldSpec enumDecl ctor wire = case enumUsages oldSpec (enumName enumDecl) of- [] ->- [ additive- (enumName enumDecl)- "enum-constructor"- ctor- EnumCtorAdded- ("new constructor with wire spelling '" <> wire <> "'")- ]- usages -> map finding usages- where- finding usage- | ".reg." `T.isInfixOf` usage =- advisoryAt- (snapshotContext (enumName enumDecl) [usage])- (enumName enumDecl)- "enum-constructor"- ctor- EnumCtorAdded- ("new constructor with wire spelling '" <> wire <> "' is used by " <> usage <> "; invalidate or rebuild snapshots before values using the new arm hydrate")- | otherwise =- advisoryAt- (privateEventAdditionContext (enumName enumDecl) [usage])- (enumName enumDecl)- "enum-constructor"- ctor- EnumCtorAdded- ("new constructor with wire spelling '" <> wire <> "' is used by " <> usage <> "; deploy consumers before producers emit the new arm")--removedEnumDiff :: Spec -> EnumDecl -> [Change]-removedEnumDiff oldSpec enumDecl =- [ breaking (enumName enumDecl) "enum-constructor" ctor EnumCtorRemoved ("enum removed; stored wire value '" <> wire <> "' no longer decodes" <> enumUsageSuffix oldSpec (enumName enumDecl))- | (ctor, wire) <- enumCtors enumDecl- ]--enumUsageSuffix :: Spec -> Name -> Text-enumUsageSuffix spec enumType = case enumUsages spec enumType of- [] -> ""- usages -> "; used by " <> commas usages--enumUsages :: Spec -> Name -> [Text]-enumUsages spec enumType =- [aggName agg <> ".reg." <> regName reg | agg <- aggregates, reg <- aggRegs agg, regType reg == enumType]- ++ [ aggName agg <> ".event." <> evName event <> "." <> field- | agg <- aggregates- , event <- aggEvents agg- , (field, Just fieldTypeName) <- eventFieldSigs agg event- , fieldTypeName == enumType- ]- where- aggregates = [agg | NAggregate agg <- specNodes spec]--pairDeclarations :: (n -> Name) -> [n] -> [n] -> Paired n-pairDeclarations nameOf oldNodes newNodes =- Paired- { prMatched =- [ (oldNode, newNode)- | newNode <- newNodes- , Just oldNode <- [find ((== nameOf newNode) . nameOf) oldNodes]- ]- , prAdded = [newNode | newNode <- newNodes, isNothing (find ((== nameOf newNode) . nameOf) oldNodes)]- , prRemoved = [oldNode | oldNode <- oldNodes, isNothing (find ((== nameOf oldNode) . nameOf) newNodes)]- }--contractDiff :: DiffEnv -> [Change]-contractDiff env =- concatMap (uncurry contractPairDiff) (prMatched paired)- ++ concatMap addedContractDiff (prAdded paired)- ++ concatMap removedContractDiff (prRemoved paired)- where- paired = pairByName nodeContract ctrName env--contractPairDiff :: ContractNode -> ContractNode -> [Change]-contractPairDiff oldContract newContract =- schemaChanges- ++ discriminatorChanges- ++ topicChanges- ++ concatMap eventPairChanges matchedEvents- ++ concatMap addedEventChanges addedEvents- ++ concatMap removedEventChanges removedEvents'- where- schemaChanges =- [ breaking- (ctrName newContract)- "schema-version"- (ctrName newContract)- ContractSchemaVersionDecreased- ("schemaVersion decreased from " <> tInt (ctrSchemaVersion oldContract) <> " to " <> tInt (ctrSchemaVersion newContract))- | ctrSchemaVersion newContract < ctrSchemaVersion oldContract- ]- discriminatorChanges =- [ breaking- (ctrName newContract)- "discriminator"- (ctrName newContract)- ContractDiscriminatorChanged- ("discriminator changed " <> ctrDiscriminator oldContract <> " -> " <> ctrDiscriminator newContract)- | ctrDiscriminator oldContract /= ctrDiscriminator newContract- ]- topicChanges = contractTopicDiff oldContract newContract- eventPairs = pairDeclarations ceName (ctrEvents oldContract) (ctrEvents newContract)- matchedEvents = prMatched eventPairs- addedEvents = prAdded eventPairs- removedEvents' = prRemoved eventPairs- eventPairChanges (oldEvent, newEvent) = contractEventDiff oldContract newContract oldEvent newEvent- addedEventChanges event =- [additive (ctrName newContract) "contract-event" (ceName event) ContractEventAdded "new contract event"]- removedEventChanges event =- [breaking (ctrName newContract) "contract-event" (ceName event) ContractEventRemoved "contract event removed; existing cross-service payloads no longer have a declared decoder"]--addedContractDiff :: ContractNode -> [Change]-addedContractDiff contract =- [additive (ctrName contract) "contract-event" (ceName event) ContractEventAdded "new event in a new contract" | event <- ctrEvents contract]--removedContractDiff :: ContractNode -> [Change]-removedContractDiff contract =- [breaking (ctrName contract) "contract-event" (ceName event) ContractEventRemoved "contract removed; its cross-service event decoder is no longer declared" | event <- ctrEvents contract]--contractTopicDiff :: ContractNode -> ContractNode -> [Change]-contractTopicDiff oldContract newContract =- [ breaking- (ctrName newContract)- "contract-topic"- alias- ContractTopicChanged- ("topic alias removed; previous topic was '" <> oldTopic <> "'")- | (alias, oldTopic) <- ctrTopics oldContract- , isNothing (lookup alias (ctrTopics newContract))- ]- ++ [ breaking- (ctrName newContract)- "contract-topic"- alias- ContractTopicChanged- ("real topic changed '" <> oldTopic <> "' -> '" <> newTopic <> "'")- | (alias, oldTopic) <- ctrTopics oldContract- , Just newTopic <- [lookup alias (ctrTopics newContract)]- , oldTopic /= newTopic- ]- ++ [ additive (ctrName newContract) "contract-topic" alias ContractTopicAdded ("new topic alias for '" <> topic <> "'")- | (alias, topic) <- ctrTopics newContract- , isNothing (lookup alias (ctrTopics oldContract))- ]--contractEventDiff :: ContractNode -> ContractNode -> ContractEvent -> ContractEvent -> [Change]-contractEventDiff oldContract newContract oldEvent newEvent =- topicAliasChange- ++ removedFieldChanges- ++ changedFieldChanges- ++ addedFieldChanges- where- fieldPairs = pairDeclarations cfName (ceFields oldEvent) (ceFields newEvent)- topicAliasChange =- [ breaking- (ctrName newContract)- "contract-topic"- (ceName newEvent)- ContractTopicChanged- ("event topic alias changed " <> ceTopic oldEvent <> " -> " <> ceTopic newEvent)- | ceTopic oldEvent /= ceTopic newEvent- ]- removedFieldChanges =- [ breaking (ctrName newContract) "contract-field" (ceName newEvent <> "." <> cfName field) ContractFieldChanged "field removed; existing messages still carry the old contract shape"- | field <- prRemoved fieldPairs- ]- changedFieldChanges =- [ breaking- (ctrName newContract)- "contract-field"- (ceName newEvent <> "." <> cfName newField)- ContractFieldChanged- ("field type changed " <> renderContractType (cfType oldField) <> " -> " <> renderContractType (cfType newField))- | (oldField, newField) <- prMatched fieldPairs- , cfType oldField /= cfType newField- ]- addedFieldChanges =- [ if ctrSchemaVersion newContract > ctrSchemaVersion oldContract- then advisory (ctrName newContract) "contract-field" subject ContractSchemaVersionBumped ("field added with schemaVersion bump " <> tInt (ctrSchemaVersion oldContract) <> " -> " <> tInt (ctrSchemaVersion newContract) <> "; coordinate the cross-service rollout")- else breaking (ctrName newContract) "contract-field" subject ContractFieldChanged "field added without a schemaVersion bump; older in-flight messages do not contain it"- | field <- prAdded fieldPairs- , let subject = ceName newEvent <> "." <> cfName field- ]--renderContractType :: ContractType -> Text-renderContractType (CTypeId prefix) = "typeid '" <> prefix <> "'"-renderContractType CText = "text"-renderContractType CInt = "int"--workqueueDiff :: DiffEnv -> [Change]-workqueueDiff env =- concatMap (uncurry workqueuePairDiff) (prMatched paired)- ++ concatMap addedWorkqueueDiff (prAdded paired)- ++ concatMap removedWorkqueueDiff (prRemoved paired)- where- paired = pairByName nodeWorkqueue wqName env--workqueuePairDiff :: WorkqueueNode -> WorkqueueNode -> [Change]-workqueuePairDiff oldQueue newQueue =- concatMap pairedFieldDiff (prMatched fields)- ++ concatMap addedFieldDiff (prAdded fields)- ++ concatMap removedFieldDiff (prRemoved fields)- ++ queueIdentityDiff oldQueue newQueue- ++ queuePolicyDiff oldQueue newQueue- where- -- wqPayloadName is a generated Haskell type name, not a wire-visible name.- fields = pairDeclarations wqfName (wqPayload oldQueue) (wqPayload newQueue)- pairedFieldDiff (oldField, newField)- | wqfWire oldField /= wqfWire newField = [payloadBreaking newField ("wire name changed '" <> wqfWire oldField <> "' -> '" <> wqfWire newField <> "'")]- | wqfType oldField /= wqfType newField = [payloadBreaking newField ("type changed " <> wqfType oldField <> " -> " <> wqfType newField)]- | not (wqfRequired oldField) && wqfRequired newField = [payloadBreaking newField "field changed from optional to required; queued jobs may omit it"]- | wqfRequired oldField && not (wqfRequired newField) = [additive (wqName newQueue) "payload-field" (wqfName newField) CompatibilityStrengthened "field changed from required to optional"]- | otherwise = []- addedFieldDiff field- | wqfRequired field = [payloadBreaking field "new required field; queued jobs do not contain it"]- | otherwise = [additive (wqName newQueue) "payload-field" (wqfName field) CompatibilityStrengthened "new optional field"]- removedFieldDiff field = [payloadBreaking field "field removed; queued jobs still contain the old payload shape"]- payloadBreaking field detail = breaking (wqName newQueue) "payload-field" (wqfName field) WqPayloadFieldChanged detail--addedWorkqueueDiff :: WorkqueueNode -> [Change]-addedWorkqueueDiff queue =- [additive (wqName queue) "payload-field" (wqfName field) DeclarationAdded "field belongs to a new workqueue payload" | field <- wqPayload queue]--removedWorkqueueDiff :: WorkqueueNode -> [Change]-removedWorkqueueDiff queue =- [breaking (wqName queue) "payload-field" (wqfName field) WqPayloadFieldChanged "workqueue removed while persisted jobs may still carry this payload" | field <- wqPayload queue]- ++ [breaking (wqName queue) "queue-identity" (wqName queue) QueueIdentityChanged "workqueue removed; its physical queue, DLQ, and pgmq table may still hold state"]--queueIdentityDiff :: WorkqueueNode -> WorkqueueNode -> [Change]-queueIdentityDiff oldQueue newQueue =- [ breaking- (wqName newQueue)- "queue-identity"- (wqName newQueue)- QueueIdentityChanged- "logical, physical, DLQ, or table name changed; queued jobs and dispatch dedupe records remain under the old identity"- | queueIdentity oldQueue /= queueIdentity newQueue- ]--queueIdentity :: WorkqueueNode -> (Text, Text, Text, Text)-queueIdentity queue = (wqLogical queue, wqPhysical queue, wqDlq queue, wqTable queue)--queuePolicyDiff :: WorkqueueNode -> WorkqueueNode -> [Change]-queuePolicyDiff oldQueue newQueue = ordering ++ provision ++ groupKey- where- nodeName = wqName newQueue- ordering =- [ breaking nodeName "queue-ordering" nodeName WqOrderingChanged $- "ordering changed " <> renderWqOrdering (wqOrdering oldQueue) <> " -> " <> renderWqOrdering (wqOrdering newQueue) <> "; consumers were written against the old delivery-order contract"- | wqOrdering oldQueue /= wqOrdering newQueue- ]- provision =- [ breaking nodeName "queue-provision" nodeName WqProvisionChanged $- "provision changed " <> renderWqProvision (wqProvision oldQueue) <> " -> " <> renderWqProvision (wqProvision newQueue) <> "; provisioning is create-time only, so migrate the existing queue operationally before changing the spec"- | wqProvision oldQueue /= wqProvision newQueue- ]- groupKey =- [ breaking nodeName "queue-group-key" nodeName WqGroupKeyChanged $- "group key derivation changed " <> renderWqGroupKey (wqGroupKey oldQueue) <> " -> " <> renderWqGroupKey (wqGroupKey newQueue) <> "; FIFO messages are re-partitioned across durable ordering groups"- | wqGroupKey oldQueue /= wqGroupKey newQueue- ]--renderWqOrdering :: WqOrdering -> Text-renderWqOrdering WqUnordered = "unordered"-renderWqOrdering WqFifoThroughput = "fifo-throughput"-renderWqOrdering WqFifoRoundRobin = "fifo-roundrobin"--renderWqProvision :: WqProvision -> Text-renderWqProvision WqStandard = "standard"-renderWqProvision WqUnlogged = "unlogged"-renderWqProvision (WqPartitioned interval duration) = "partitioned(interval=" <> interval <> ", retention=" <> duration <> ")"--renderWqGroupKey :: Maybe WqGroupKey -> Text-renderWqGroupKey Nothing = "none"-renderWqGroupKey (Just groupKey) =- gkField groupKey- <> " via "- <> gkVia groupKey- <> maybe "" (" fixture " <>) (gkFixture groupKey)--processDiff :: DiffEnv -> [Change]-processDiff env =- concatMap (uncurry processPairDiff) (prMatched paired)- ++ concatMap addedProcessDiff (prAdded paired)- ++ concatMap removedProcessDiff (prRemoved paired)- where- paired = pairByName nodeProcess procId env--processPairDiff :: ProcessNode -> ProcessNode -> [Change]-processPairDiff oldProcess newProcess =- concatMap pairedFieldDiff (prMatched fields)- ++ map (fieldChange "field added; source events at the old shape cannot populate it") (prAdded fields)- ++ map (fieldChange "field removed; the generated process input decoder changed") (prRemoved fields)- ++ processIdentityDiff oldProcess newProcess- ++ processTimerWindowDiff oldProcess newProcess- ++ processDecideSurfaceDiff oldProcess newProcess- ++ processTimerPayloadDiff oldProcess newProcess- where- -- inName is a generated Haskell type name; the wire shape is inFields.- fields = pairDeclarations fieldName (inFields (procInput oldProcess)) (inFields (procInput newProcess))- pairedFieldDiff (oldField, newField)- | fieldType oldField /= fieldType newField = [fieldChange ("type changed " <> renderFieldType (fieldType oldField) <> " -> " <> renderFieldType (fieldType newField)) newField]- | otherwise = []- fieldChange detail field = breaking (procId newProcess) "input-field" (fieldName field) ProcessInputChanged (detail <> "; version the source event before changing process input")--addedProcessDiff :: ProcessNode -> [Change]-addedProcessDiff process =- [additive (procId process) "input-field" (fieldName field) DeclarationAdded "field belongs to a new process input" | field <- inFields (procInput process)]--removedProcessDiff :: ProcessNode -> [Change]-removedProcessDiff process =- [breaking (procId process) "input-field" (fieldName field) ProcessInputChanged "process removed while persisted source events may still require this input decoder" | field <- inFields (procInput process)]- ++ [breaking (procId process) "derived-identity" (procId process) DerivedIdentityChanged "process removed while persisted saga, dispatch, and timer identities may still exist"]--processIdentityDiff :: ProcessNode -> ProcessNode -> [Change]-processIdentityDiff oldProcess newProcess =- [ breaking- (procId newProcess)- "derived-identity"- (procId newProcess)- DerivedIdentityChanged- "process name, correlation derivation, saga stream category, timer id prefix, or fired-event-id prefix changed; replays and retries no longer derive the persisted identity"- | processIdentity oldProcess /= processIdentity newProcess- ]--processIdentity :: ProcessNode -> (Text, Name, Name, Text, Text, Text)-processIdentity process =- ( procName process- , corrField (procCorrelate process)- , corrVia (procCorrelate process)- , sagaCategory (procSaga process)- , idePrefix (tmId (procTimer process))- , idePrefix (fireFiredEventId (tmFire (procTimer process)))- )--processTimerWindowDiff :: ProcessNode -> ProcessNode -> [Change]-processTimerWindowDiff oldProcess newProcess =- [ advisory- (procId newProcess)- "timer"- (tmName (procTimer newProcess))- TimerWindowChanged- ( "fireAt source/window changed "- <> renderFireAt (tmFireAt (procTimer oldProcess))- <> " -> "- <> renderFireAt (tmFireAt (procTimer newProcess))- <> "; already-scheduled timers keep their persisted deadline"- )- | tmFireAt (procTimer oldProcess) /= tmFireAt (procTimer newProcess)- ]--processDecideSurfaceDiff :: ProcessNode -> ProcessNode -> [Change]-processDecideSurfaceDiff oldProcess newProcess =- [ advisory- (procId newProcess)- "process-decide"- (procId newProcess)- ProcessDecideSurfaceChanged- "process dispatch surface changed: a source event redelivered across the deploy dispatches under the same deterministic ids, so half-old/half-new fan-out merges silently. Drain or pause the process subscription and replay or discard dead letters before deploying; see docs/user/deploy-ordering.md. Hole-only decide changes are not visible to diff; the same drain rule applies to those too."- | renderHandleSurface (procHandle oldProcess)- /= renderHandleSurface (procHandle newProcess)- ]--processTimerPayloadDiff :: ProcessNode -> ProcessNode -> [Change]-processTimerPayloadDiff oldProcess newProcess =- [ advisory- (procId newProcess)- "timer-payload"- (tmName (procTimer newProcess))- ProcessTimerPayloadChanged- "timer payload shape changed: rows scheduled before the deploy carry the old shape, unversioned, and fire under new code — the fire decoder must accept every historically scheduled shape or the timer dead-letters after maxAttempts. Hole-only timer-decoder changes are not visible to diff; the same drain rule applies to those too."- | renderTimerPayloadSurface (procTimer oldProcess)- /= renderTimerPayloadSurface (procTimer newProcess)- ]--renderFireAt :: FireAtExpr -> Text-renderFireAt expression = "input." <> faField expression <> " + " <> faWindow expression--workflowDiff :: DiffEnv -> [Change]-workflowDiff env =- concatMap (uncurry workflowPairDiff) (prMatched paired)- ++ concatMap addedWorkflowDiff (prAdded paired)- ++ concatMap removedWorkflowDiff (prRemoved paired)- where- paired = pairByName nodeWorkflow wfId env--workflowPairDiff :: WorkflowNode -> WorkflowNode -> [Change]-workflowPairDiff oldWorkflow newWorkflow =- inputChanges- ++ outputChanges- ++ classifyWorkflowBody oldWorkflow newWorkflow- ++ workflowIdentityDiff oldWorkflow newWorkflow- where- fields = pairDeclarations fieldName (wfInputFields oldWorkflow) (wfInputFields newWorkflow)- inputChanges =- [workflowShape field "input field added; journaled inputs at the old shape do not contain it" | field <- prAdded fields]- ++ [workflowShape field "input field removed; journaled inputs still contain the old shape" | field <- prRemoved fields]- ++ [ workflowShape newField ("input field type changed " <> renderFieldType (fieldType oldField) <> " -> " <> renderFieldType (fieldType newField))- | (oldField, newField) <- prMatched fields- , fieldType oldField /= fieldType newField- ]- outputChanges =- [ breaking (wfId newWorkflow) "workflow-output" (wfOutput newWorkflow) WorkflowShapeChanged ("output type changed " <> wfOutput oldWorkflow <> " -> " <> wfOutput newWorkflow <> "; persisted outcomes may no longer decode")- | wfOutput oldWorkflow /= wfOutput newWorkflow- ]- workflowShape field detail = breaking (wfId newWorkflow) "workflow-input" (fieldName field) WorkflowShapeChanged detail--addedWorkflowDiff :: WorkflowNode -> [Change]-addedWorkflowDiff workflow = [additive (wfId workflow) "workflow" (wfId workflow) DeclarationAdded "new workflow"]--removedWorkflowDiff :: WorkflowNode -> [Change]-removedWorkflowDiff workflow = [breaking (wfId workflow) "workflow" (wfId workflow) WorkflowShapeChanged "workflow removed while in-flight journals and outcomes may still require its decoder"]--workflowIdentityDiff :: WorkflowNode -> WorkflowNode -> [Change]-workflowIdentityDiff oldWorkflow newWorkflow =- [ breaking- (wfId newWorkflow)- "workflow-name"- (wfId newWorkflow)- WorkflowStableNameChanged- ("stable name changed '" <> wfStable oldWorkflow <> "' -> '" <> wfStable newWorkflow <> "'; in-flight journals remain under the old stream name")- | wfStable oldWorkflow /= wfStable newWorkflow- ]- ++ [ breaking- (wfId newWorkflow)- "derived-identity"- (wfId newWorkflow)- DerivedIdentityChanged- "workflow id source field or derivation changed; journal and deterministic child/step identities no longer coalesce with persisted executions"- | (wfIdField oldWorkflow, wfIdVia oldWorkflow) /= (wfIdField newWorkflow, wfIdVia newWorkflow)- ]--intakeDiff :: DiffEnv -> [Change]-intakeDiff env =- concatMap (uncurry intakePairDiff) (prMatched paired)- ++ concatMap addedIntakeDiff (prAdded paired)- ++ concatMap removedIntakeDiff (prRemoved paired)- where- paired = pairByName nodeIntake inkName env--intakePairDiff :: IntakeNode -> IntakeNode -> [Change]-intakePairDiff oldIntake newIntake =- [ breaking- (inkName newIntake)- "dedupe-identity"- (inkName newIntake)- DedupeIdentityChanged- "dedupe key or policy changed; redelivered messages no longer match their persisted dedupe record"- | (inkDedupeKey oldIntake, inkDedupePolicy oldIntake) /= (inkDedupeKey newIntake, inkDedupePolicy newIntake)- ]- ++ [ advisory- (inkName newIntake)- "decode-posture"- (inkName newIntake)- DecodePostureChanged- "envelope/body decode posture changed; future messages are accepted or rejected differently"- | inkDecode oldIntake /= inkDecode newIntake- ]- ++ [ advisory- (inkName newIntake)- "inbox-persistence"- (inkName newIntake)- IntakePersistenceChanged- ("success-path envelope persistence changed " <> renderInkPersist (inkPersist oldIntake) <> " -> " <> renderInkPersist (inkPersist newIntake) <> "; existing rows are unchanged while future successful rows retain a different envelope shape")- | inkPersist oldIntake /= inkPersist newIntake- ]--renderInkPersist :: InkPersist -> Text-renderInkPersist InkPersistFull = "full-envelope"-renderInkPersist InkPersistDedupeOnly = "dedupe-only"--addedIntakeDiff :: IntakeNode -> [Change]-addedIntakeDiff intake = [additive (inkName intake) "intake" (inkName intake) DeclarationAdded "new intake"]--removedIntakeDiff :: IntakeNode -> [Change]-removedIntakeDiff intake = [breaking (inkName intake) "dedupe-identity" (inkName intake) DedupeIdentityChanged "intake removed while persisted dedupe records and redeliveries may remain"]--emitDiff :: DiffEnv -> [Change]-emitDiff env =- concatMap (uncurry emitPairDiff) (prMatched paired)- ++ concatMap addedEmitDiff (prAdded paired)- ++ concatMap removedEmitDiff (prRemoved paired)- where- paired = pairByName nodeEmit emName env--emitPairDiff :: EmitNode -> EmitNode -> [Change]-emitPairDiff oldEmit newEmit =- [ breaking- (emName newEmit)- "derived-identity"- "messageId"- DerivedIdentityChanged- "messageId derive prefix changed; outbox retries no longer coalesce with persisted messages"- | emMessageId oldEmit /= emMessageId newEmit- ]- ++ [ breaking- (emName newEmit)- "derived-identity"- "idempotencyKey"- DerivedIdentityChanged- "idempotencyKey derive prefix changed; downstream dedupe no longer matches persisted messages"- | emIdempotencyKey oldEmit /= emIdempotencyKey newEmit- ]- ++ [ advisory- (emName newEmit)- "emit-mapping"- (emName newEmit)- EmitMappingChanged- "emit key, status discriminant, mapping rows, or explicit skip posture changed"- | emitMapping oldEmit /= emitMapping newEmit- ]--emitMapping :: EmitNode -> (Name, Name, [EmitMapRow], Bool)-emitMapping emit = (emKey emit, emDiscriminant emit, emMap emit, emSkip emit)--addedEmitDiff :: EmitNode -> [Change]-addedEmitDiff emit = [additive (emName emit) "emit" (emName emit) DeclarationAdded "new emit mapping"]--removedEmitDiff :: EmitNode -> [Change]-removedEmitDiff emit = [breaking (emName emit) "derived-identity" (emName emit) DerivedIdentityChanged "emit removed while persisted outbox identities may still retry"]--publisherDiff :: DiffEnv -> [Change]-publisherDiff env =- concatMap (uncurry publisherPairDiff) (prMatched paired)- ++ concatMap addedPublisherDiff (prAdded paired)- ++ concatMap removedPublisherDiff (prRemoved paired)- where- paired = pairByName nodePublisher pubName env--publisherPairDiff :: PublisherNode -> PublisherNode -> [Change]-publisherPairDiff oldPublisher newPublisher =- -- maxAttempts/backoff are retry tuning, not persisted decode or identity.- [ breaking- (pubName newPublisher)- "derived-identity"- "outboxId"- DerivedIdentityChanged- "stable outbox-id source field changed; retries no longer coalesce with persisted outbox rows"- | pubOutboxField oldPublisher /= pubOutboxField newPublisher- ]- ++ [ advisory- (pubName newPublisher)- "publisher-policy"- (pubName newPublisher)- PublisherPolicyChanged- ("ordering changed " <> pubOrdering oldPublisher <> " -> " <> pubOrdering newPublisher)- | pubOrdering oldPublisher /= pubOrdering newPublisher- ]--addedPublisherDiff :: PublisherNode -> [Change]-addedPublisherDiff publisher = [additive (pubName publisher) "publisher" (pubName publisher) DeclarationAdded "new publisher"]--removedPublisherDiff :: PublisherNode -> [Change]-removedPublisherDiff publisher = [breaking (pubName publisher) "derived-identity" (pubName publisher) DerivedIdentityChanged "publisher removed while persisted outbox rows may still require its stable identity"]--pgmqDispatchDiff :: DiffEnv -> [Change]-pgmqDispatchDiff env =- concatMap (uncurry pgmqDispatchPairDiff) (prMatched paired)- ++ concatMap addedPgmqDispatchDiff (prAdded paired)- ++ concatMap removedPgmqDispatchDiff (prRemoved paired)- where- paired = pairByName nodePgmqDispatch pdName env--pgmqDispatchPairDiff :: PgmqDispatchNode -> PgmqDispatchNode -> [Change]-pgmqDispatchPairDiff oldDispatch newDispatch =- [ breaking- (pdName newDispatch)- "dedupe-identity"- (pdName newDispatch)- DedupeIdentityChanged- "dispatch dedupe key/read-model/queue surface changed; prior enqueue records no longer match"- | dispatchDedupe oldDispatch /= dispatchDedupe newDispatch- ]- ++ [ advisory- (pdName newDispatch)- "retarget"- (pdName newDispatch)- DispatchRetargeted- "source read model or target queue changed; future fan-out is routed differently"- | dispatchTargets oldDispatch /= dispatchTargets newDispatch- ]--dispatchDedupe :: PgmqDispatchNode -> (Name, Name, Text, Name, Text)-dispatchDedupe dispatch =- ( pdDedupKey dispatch- , pdDedupReadModel dispatch- , pdDedupReadModelField dispatch- , pdDedupQueue dispatch- , pdDedupQueueField dispatch- )--dispatchTargets :: PgmqDispatchNode -> (Name, Name)-dispatchTargets dispatch = (pdSourceReadModel dispatch, pdEnqueueTo dispatch)--addedPgmqDispatchDiff :: PgmqDispatchNode -> [Change]-addedPgmqDispatchDiff dispatch = [additive (pdName dispatch) "dispatch" (pdName dispatch) DeclarationAdded "new pgmq dispatch"]--removedPgmqDispatchDiff :: PgmqDispatchNode -> [Change]-removedPgmqDispatchDiff dispatch = [breaking (pdName dispatch) "dedupe-identity" (pdName dispatch) DedupeIdentityChanged "dispatch removed while persisted queue and read-model dedupe records may remain"]--{- | Classify the runtime's sanctioned workflow-evolution mechanisms before-falling back to the conservative unguarded-body rule.--}-classifyWorkflowBody :: WorkflowNode -> WorkflowNode -> [Change]-classifyWorkflowBody oldWorkflow newWorkflow- | oldBody == newBody = []- | not (null removedPatchIds) = map removedPatch removedPatchIds- | Just (oldSeedType, newSeedType) <- changedSeed =- [ breaking nodeName "workflow-continue-as-new" nodeName WorkflowContinueSeedChanged $- "continueAsNew seed type changed " <> oldSeedType <> " -> " <> newSeedType <> "; the next generation's restoreSeed must decode the seed written by the previous generation"- ]- | safeAdditions =- map addedPatch newPatchIds- ++ [ additive nodeName "workflow-continue-as-new" seedType WorkflowEvolutionGuardAdded "terminal continueAsNew is additive; old generations carry no rotation marker"- | Just seedType <- [appendedSeed]- ]- | otherwise =- [ breaking- nodeName- "workflow-body"- nodeName- WorkflowBodyChanged- "workflow body labels, kinds, result types, or order changed without a new patch guard; wrap a cross-cutting change in patch, or rename the replay label for one changed step"- ]- where- nodeName = wfId newWorkflow- oldBody = normaliseWorkflowBody (wfBody oldWorkflow)- newBody = normaliseWorkflowBody (wfBody newWorkflow)- oldPatchIds = workflowBodyPatchIds oldBody- newPatchIdsAll = workflowBodyPatchIds newBody- newPatchIds = newPatchIdsAll \\ oldPatchIds- removedPatchIds = oldPatchIds \\ newPatchIdsAll- oldSeed = terminalContinueSeed oldBody- newSeed = terminalContinueSeed newBody- changedSeed = case (oldSeed, newSeed) of- (Just oldSeedType, Just newSeedType)- | oldSeedType /= newSeedType -> Just (oldSeedType, newSeedType)- _ -> Nothing- appendedSeed = case (oldSeed, newSeed) of- (Nothing, Just seedType) -> Just seedType- _ -> Nothing- strippedNewBody = stripNewPatches newPatchIds newBody- comparableNewBody = case appendedSeed of- Just _ -> dropTerminalContinue strippedNewBody- Nothing -> strippedNewBody- safeAdditions =- (not (null newPatchIds) || isJust appendedSeed)- && comparableNewBody == oldBody- removedPatch patchId =- breaking nodeName "workflow-patch" patchId WorkflowPatchRemoved "patch id existed in the old spec but was removed; the differ cannot prove that no workflow generation still replays its journaled branch"- addedPatch patchId =- additive nodeName "workflow-patch" patchId WorkflowEvolutionGuardAdded "new patch guard contains the entire body change, so in-flight generations retain their journaled branch"--normaliseWorkflowBody :: [WfBodyItem] -> [WfBodyItem]-normaliseWorkflowBody = map go- where- go (WfStep label result _) = WfStep label result noLoc- go (WfAwait label result _) = WfAwait label result noLoc- go (WfSleep label delay _) = WfSleep label delay noLoc- go (WfChild label via result _) = WfChild label via result noLoc- go (WfPatch patchId items _) = WfPatch patchId (normaliseWorkflowBody items) noLoc- go (WfContinueAsNew seedType _) = WfContinueAsNew seedType noLoc--workflowBodyPatchIds :: [WfBodyItem] -> [Name]-workflowBodyPatchIds = concatMap go- where- go (WfPatch patchId items _) = patchId : workflowBodyPatchIds items- go _ = []--stripNewPatches :: [Name] -> [WfBodyItem] -> [WfBodyItem]-stripNewPatches newPatchIds = concatMap go- where- go (WfPatch patchId _ _) | patchId `elem` newPatchIds = []- go (WfPatch patchId items loc) = [WfPatch patchId (stripNewPatches newPatchIds items) loc]- go item = [item]--terminalContinueSeed :: [WfBodyItem] -> Maybe Name-terminalContinueSeed items = case reverse items of- WfContinueAsNew seedType _ : _ -> Just seedType- _ -> Nothing--dropTerminalContinue :: [WfBodyItem] -> [WfBodyItem]-dropTerminalContinue items = case reverse items of- WfContinueAsNew{} : rest -> reverse rest- _ -> items--additive :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change-additive n facet subj code detail =- mkChange LabelAdditive (contextFor LabelAdditive n facet subj code) n facet subj code detail--breaking :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change-breaking n facet subj code detail =- mkChange LabelBreaking (contextFor LabelBreaking n facet subj code) n facet subj code detail--advisory :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change-advisory n facet subj code detail =- mkChange LabelAdvisory (contextFor LabelAdvisory n facet subj code) n facet subj code detail--advisoryAt :: ChangeContext -> Name -> Text -> Text -> DiagnosticCode -> Text -> Change-advisoryAt context n facet subj code detail =- mkChange LabelAdvisory context n facet subj code detail--mkChange :: Label -> ChangeContext -> Name -> Text -> Text -> DiagnosticCode -> Text -> Change-mkChange label context n facet subj code detail =- wrap- ChangeKind- { ckNode = n- , ckFacet = facet- , ckSubject = subj- , ckCode = code- , ckContext = context- , ckVector = classifyCompatibility context code- , ckPaths = changeContextPaths context- , ckDetail = detail- }- where- wrap = case label of- LabelAdditive -> Additive- LabelAdvisory -> Advisory- LabelBreaking -> Breaking--contextFor :: Label -> Name -> Text -> Text -> DiagnosticCode -> ChangeContext-contextFor label root facet subject code =- setLabel $ case () of- _- | 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- | otherwise -> ChangeContext root paths ContextGeneral label- where- paths = [pathFor root facet subject]- setLabel context = context{contextOriginalLabel = label}- publicCodes =- [ ContractEventRemoved- , ContractFieldChanged- , ContractDiscriminatorChanged- , ContractTopicChanged- , ContractSchemaVersionDecreased- , ContractSchemaVersionBumped- , ContractEventAdded- , ContractTopicAdded- ]- queueCodes = [WqPayloadFieldChanged, WqOrderingChanged, WqProvisionChanged, WqGroupKeyChanged, QueueIdentityChanged]- identityCodes =- [ DerivedIdentityChanged- , IdPrefixChanged- , DedupeIdentityChanged- , RouterStableNameChanged- , WorkflowStableNameChanged- , ReadModelVersionDecreased- , ReadModelShapeChangedWithoutBump- , ReadModelFeedChanged- , ReadModelConsistencyWeakened- ]- privateCodes =- [ EvtFieldAddedWithoutBump- , EvtFieldRemovedSameVersion- , EvtFieldTypeChanged- , EvtVersionDecreased- , EvtVersionMissingUpcaster- , UpcasterChainGap- , EvtRemovedNotDeprecated- , EnumCtorRemoved- , EnumWireSpellingChanged- , WireSpecChanged- , ProcessInputChanged- , WorkflowShapeChanged- , WorkflowBodyChanged- , WorkflowPatchRemoved- , WorkflowContinueSeedChanged- , AggGuardTightened- , DeprecatedEventReplayHazard- , EventRetirementInProgress- , EventUndeprecated- , ProcessTimerPayloadChanged- ]--pathFor :: Name -> Text -> Text -> Text-pathFor root facet subject- | facet `elem` ["event", "event-field"] = root <> ".event." <> subject- | facet `elem` ["contract-event", "contract-field"] = root <> ".event." <> subject- | root == subject = root <> "." <> facet- | otherwise = root <> "." <> facet <> "." <> subject+-- | The spec evolution differ. 'diffSpecs' compares an /old/ and a /new/ 'Spec'+-- and classifies changes over the persisted decode and identity surfaces.+--+-- Changes are __ADDITIVE__ when they preserve stored data, __WARNING__ when they+-- change forward behaviour without invalidating persisted data, and __BREAKING__+-- when stored payloads may stop decoding or persisted identities may be re-keyed.+-- The @diff --since@ CLI exits non-zero only when a breaking change is present.+--+-- Every 'Node' constructor maps to a 'NodeFamily', and 'familyRegistry' contains+-- exactly one entry for each family. A family is either handled by an explicit+-- differ or carries a non-empty out-of-scope rationale. This makes omissions+-- visible when the grammar grows instead of silently treating new node kinds as+-- safe.+module Keiro.Dsl.Diff+ ( Change (..),+ ChangeKind (..),+ Label (..),+ CompatibilitySurface (..),+ SurfaceVerdict (..),+ RolloutConstraint (..),+ CompatibilityVector (..),+ ChangeContext,+ privateEventContext,+ privateEventAdditionContext,+ snapshotContext,+ queueContext,+ publicContractContext,+ persistedIdentityContext,+ consumerBuildContext,+ advisoryAt,+ changeContextRoot,+ changeContextPaths,+ classifyCompatibility,+ verdictFor,+ defaultGate,+ gateWith,+ deriveLabel,+ gatedBreaking,+ isBreaking,+ isAdvisory,+ diffSources,+ sourceLanguageChange,+ diffSpecs,+ DiffEnv (..),+ NodeFamily (..),+ familyOf,+ FamilyDiff (..),+ familyRegistry,+ Paired (..),+ pairByName,+ readModelDiff,+ classifyWorkflowBody,+ )+where++import Data.Char (toUpper)+import Data.List (find, (\\))+import Data.Maybe (isJust, isNothing, mapMaybe)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Keiro.Dsl.AggregateType (typeExprCanonicalName)+import Keiro.Dsl.FoldFingerprint (aggregateFoldSurface)+import Keiro.Dsl.Grammar+import Keiro.Dsl.LanguageVersion (ParsedSource (..), SourceLanguage, declaredLanguageVersionMaybe, languageVersionText, sourceFormText)+import Keiro.Dsl.MappedDiff (MappedFinding (..), diffMapped, renderMappedSubject)+import Keiro.Dsl.PrettyPrint+ ( renderHandleSurface,+ renderResolveSurface,+ renderRouterDispatchSurface,+ renderTimerPayloadSurface,+ renderTransition,+ )+import Keiro.Dsl.ReadModelShape (registryNameFor, subscriptionNameFor)+import Keiro.Dsl.TypeGraph (UsePath (..), UseSite (..))+import Keiro.Dsl.Validate (DiagnosticCode (..))++-- | A classified spec change.+data Change+ = Additive ChangeKind+ | Advisory ChangeKind+ | Breaking ChangeKind+ deriving stock (Eq, Show)++-- | The stable headline classification retained by the text interface.+data Label = LabelAdditive | LabelAdvisory | LabelBreaking+ deriving stock (Eq, Show)++-- | Independently gateable compatibility questions for one finding.+data CompatibilitySurface+ = PrivateHistoryRead+ | OldBinaryReadNewEvents+ | SnapshotHydration+ | PublicConsumer+ | PersistedIdentity+ | ConsumerBuild+ deriving stock (Eq, Ord, Show, Enum, Bounded)++-- | A verdict on one surface. Constructor order is deliberately not policy.+data SurfaceVerdict = VCompatible | VAdvisory | VBreaking | VNotApplicable+ deriving stock (Eq, Show)++-- | Deployment ordering that remains after byte compatibility is classified.+data RolloutConstraint+ = RolloutStopTheWorld+ | RolloutWorkersFirst+ | RolloutDrainRequired+ | RolloutProducerLast+ deriving stock (Eq, Ord, Show)++-- | The explicit, compile-forcing compatibility result for one finding.+data CompatibilityVector = CompatibilityVector+ { cvPrivateHistoryRead :: !SurfaceVerdict,+ cvOldBinaryReadNewEvents :: !SurfaceVerdict,+ cvSnapshotHydration :: !SurfaceVerdict,+ cvPublicConsumer :: !SurfaceVerdict,+ cvPersistedIdentity :: !SurfaceVerdict,+ cvConsumerBuild :: !SurfaceVerdict,+ cvRollout :: !(Set RolloutConstraint)+ }+ deriving stock (Eq, Show)++data ContextKind+ = ContextGeneral+ | ContextPrivateEvent+ | ContextPrivateEventAddition+ | ContextSnapshot+ | ContextQueue+ | ContextPublicContract+ | ContextPersistedIdentity+ | ContextConsumerBuild+ deriving stock (Eq, Show)++-- | Facts that select a compatibility row. The constructor stays private so+-- callers cannot manufacture contradictory ownership and surface claims.+data ChangeContext = ChangeContext+ { changeContextRoot :: !Name,+ changeContextPaths :: ![Text],+ contextKind :: !ContextKind,+ contextOriginalLabel :: !Label+ }+ deriving stock (Eq, Show)++data ChangeKind = ChangeKind+ { ckNode :: !Name,+ ckFacet :: !Text,+ ckSubject :: !Text,+ ckCode :: !DiagnosticCode,+ ckContext :: !ChangeContext,+ ckVector :: !CompatibilityVector,+ ckPaths :: ![Text],+ ckDetail :: !Text+ }+ deriving stock (Eq, Show)++privateEventContext :: Name -> [Text] -> ChangeContext+privateEventContext root paths = ChangeContext root paths ContextPrivateEvent LabelBreaking++privateEventAdditionContext :: Name -> [Text] -> ChangeContext+privateEventAdditionContext root paths = ChangeContext root paths ContextPrivateEventAddition LabelAdvisory++snapshotContext :: Name -> [Text] -> ChangeContext+snapshotContext root paths = ChangeContext root paths ContextSnapshot LabelAdvisory++queueContext :: Name -> [Text] -> ChangeContext+queueContext root paths = ChangeContext root paths ContextQueue LabelBreaking++publicContractContext :: Name -> [Text] -> ChangeContext+publicContractContext root paths = ChangeContext root paths ContextPublicContract LabelBreaking++persistedIdentityContext :: Name -> [Text] -> ChangeContext+persistedIdentityContext root paths = ChangeContext root paths ContextPersistedIdentity LabelBreaking++consumerBuildContext :: Name -> [Text] -> ChangeContext+consumerBuildContext root paths = ChangeContext root paths ContextConsumerBuild LabelAdvisory++compatibleVector :: CompatibilityVector+compatibleVector =+ CompatibilityVector+ VCompatible+ VCompatible+ VNotApplicable+ VNotApplicable+ VNotApplicable+ VNotApplicable+ Set.empty++sourceProvenanceVector :: CompatibilityVector+sourceProvenanceVector =+ CompatibilityVector+ VCompatible+ VCompatible+ VCompatible+ VCompatible+ VCompatible+ VCompatible+ Set.empty++privateDecodeBreakingVector :: CompatibilityVector+privateDecodeBreakingVector =+ CompatibilityVector+ VBreaking+ VBreaking+ VAdvisory+ VNotApplicable+ VNotApplicable+ VNotApplicable+ (Set.singleton RolloutStopTheWorld)++persistedIdentityBreakingVector :: CompatibilityVector+persistedIdentityBreakingVector =+ CompatibilityVector+ VNotApplicable+ VNotApplicable+ VNotApplicable+ VNotApplicable+ VBreaking+ VNotApplicable+ Set.empty++publicBreakingVector :: CompatibilityVector+publicBreakingVector =+ CompatibilityVector+ VNotApplicable+ VNotApplicable+ VNotApplicable+ VBreaking+ VNotApplicable+ VNotApplicable+ (Set.singleton RolloutProducerLast)++queueBreakingVector :: CompatibilityVector+queueBreakingVector =+ CompatibilityVector+ VBreaking+ VBreaking+ VNotApplicable+ VNotApplicable+ VAdvisory+ VNotApplicable+ (Set.singleton RolloutWorkersFirst)++advisoryVector :: CompatibilitySurface -> Set RolloutConstraint -> CompatibilityVector+advisoryVector surface rollout =+ compatibleVector+ { cvPrivateHistoryRead = verdict PrivateHistoryRead,+ cvOldBinaryReadNewEvents = verdict OldBinaryReadNewEvents,+ cvSnapshotHydration = verdict SnapshotHydration,+ cvPublicConsumer = verdict PublicConsumer,+ cvPersistedIdentity = verdict PersistedIdentity,+ cvConsumerBuild = verdict ConsumerBuild,+ cvRollout = rollout+ }+ where+ verdict candidate+ | candidate == surface = VAdvisory+ | otherwise = verdictFor candidate compatibleVector++-- | Classify one code at an explicitly owned use site. Codes emitted by the+-- differ are grouped by their actual persisted/public surface; the context is+-- load-bearing for codes such as 'EnumCtorAdded' that vary by use site.+classifyCompatibility :: ChangeContext -> DiagnosticCode -> CompatibilityVector+classifyCompatibility context code+ | code == SourceLanguageDeclarationChanged = sourceProvenanceVector+ | code `elem` [OwnershipMoved, WorkspaceAuthorityChanged] = mappedBuildVector+ | code == MappedFieldAddedWithDefault = mappedFieldAdditionVector context+ | code `elem` [MappedArmAdded, MappedEnumValueAdded] = mappedDirectionalAdditionVector context+ | code `elem` mappedWireBreakingCodes = mappedWireBreakingVector context+ | code `elem` [MappedHaskellSourceChanged, MappedRecordConstructorChanged, MappedFixturesChanged] = mappedBuildVector+ | code == MappedBindingChanged = mappedBindingVector context+ | code `elem` [MappedInitialChanged, MappedCanonicalTypeChanged] = mappedSnapshotBuildVector context+ | code == NominalFixturesChanged = mappedBuildVector+ | code == NominalBindingChanged = mappedBindingVector context+ | code `elem` [NominalInitialChanged, NominalCanonicalTypeChanged] = mappedSnapshotBuildVector context+ | code == NominalRepresentationChanged = mappedWireBreakingVector context+ | code == NominalIdDecoderTightened =+ (advisoryVector PrivateHistoryRead Set.empty) {cvConsumerBuild = VAdvisory}+ | code == MappedDeclAdded = compatibleVector+ | code `elem` privateDecodeCodes = privateDecodeBreakingVector+ | code `elem` identityCodes = persistedIdentityBreakingVector+ | code `elem` publicBreakingCodes = publicBreakingVector+ | code `elem` queueBreakingCodes = queueBreakingVector+ | code `elem` readModelBreakingCodes = persistedIdentityBreakingVector+ | code == ContractSchemaVersionBumped = advisoryVector PublicConsumer (Set.singleton RolloutProducerLast)+ | code == AggFoldSurfaceChanged =+ (advisoryVector PrivateHistoryRead Set.empty) {cvSnapshotHydration = VAdvisory}+ | code == AggGuardTightened = advisoryVector PrivateHistoryRead Set.empty+ | code `elem` [RouterDecideSurfaceChanged, ProcessDecideSurfaceChanged] =+ compatibleVector {cvRollout = Set.singleton RolloutDrainRequired}+ | code == ProcessTimerPayloadChanged = advisoryVector PrivateHistoryRead (Set.singleton RolloutProducerLast)+ | code == TimerWindowChanged = advisoryVector PrivateHistoryRead Set.empty+ | code == ProjectionChanged = advisoryVector PersistedIdentity Set.empty+ | code == EmitMappingChanged = advisoryVector PublicConsumer (Set.singleton RolloutProducerLast)+ | code == DecodePostureChanged = advisoryVector PublicConsumer Set.empty+ | code == IntakePersistenceChanged = advisoryVector PrivateHistoryRead Set.empty+ | code `elem` [PublisherPolicyChanged, DispatchRetargeted] = advisoryVector PersistedIdentity Set.empty+ | code `elem` [DeprecatedEventReplayHazard, EventRetirementInProgress] = advisoryVector PrivateHistoryRead Set.empty+ | code == EventUndeprecated = advisoryVector OldBinaryReadNewEvents (Set.singleton RolloutProducerLast)+ | code == EnumCtorAdded = case contextKind context of+ ContextPrivateEventAddition ->+ compatibleVector+ { cvOldBinaryReadNewEvents = VBreaking,+ cvRollout = Set.singleton RolloutProducerLast+ }+ ContextSnapshot -> advisoryVector SnapshotHydration Set.empty+ _ -> compatibleVector+ | code `elem` additiveCodes = compatibleVector+ | otherwise = case contextOriginalLabel context of+ LabelAdditive -> compatibleVector+ LabelAdvisory -> advisoryVector (surfaceForContext context) Set.empty+ LabelBreaking -> breakingVectorForContext context+ where+ privateDecodeCodes =+ [ EvtFieldAddedWithoutBump,+ EvtFieldRemovedSameVersion,+ EvtFieldTypeChanged,+ EvtVersionDecreased,+ EvtVersionMissingUpcaster,+ UpcasterChainGap,+ EvtRemovedNotDeprecated,+ EnumCtorRemoved,+ EnumWireSpellingChanged,+ WireSpecChanged,+ ProcessInputChanged,+ WorkflowShapeChanged,+ WorkflowBodyChanged,+ WorkflowPatchRemoved,+ WorkflowContinueSeedChanged+ ]+ identityCodes =+ [ DerivedIdentityChanged,+ IdPrefixChanged,+ DedupeIdentityChanged,+ QueueIdentityChanged,+ RouterStableNameChanged,+ WorkflowStableNameChanged+ ]+ publicBreakingCodes =+ [ ContractEventRemoved,+ ContractFieldChanged,+ ContractDiscriminatorChanged,+ ContractTopicChanged,+ ContractSchemaVersionDecreased+ ]+ queueBreakingCodes = [WqPayloadFieldChanged, WqOrderingChanged, WqProvisionChanged, WqGroupKeyChanged]+ readModelBreakingCodes =+ [ ReadModelVersionDecreased,+ ReadModelShapeChangedWithoutBump,+ ReadModelFeedChanged,+ ReadModelConsistencyWeakened+ ]+ additiveCodes =+ [ DeclarationAdded,+ VersionBumped,+ CompatibilityStrengthened,+ EventRetirementAbandoned,+ ContractEventAdded,+ ContractTopicAdded,+ WorkflowEvolutionGuardAdded+ ]++mappedWireBreakingCodes :: [DiagnosticCode]+mappedWireBreakingCodes =+ [ MappedFieldAddedNoDefault,+ MappedFieldRemoved,+ MappedFieldTypeChanged,+ MappedPresenceChanged,+ MappedNullabilityChanged,+ MappedDefaultRemoved,+ MappedDefaultChanged,+ MappedWireKeyChanged,+ MappedUnionEncodingChanged,+ MappedArmRemoved,+ MappedArmTagChanged,+ MappedEnumValueRemoved,+ MappedEnumSpellingChanged,+ MappedOpaqueCodecChanged,+ MappedModeCrossed,+ MappedDeclRemoved+ ]++mappedFieldAdditionVector :: ChangeContext -> CompatibilityVector+mappedFieldAdditionVector context = case contextKind context of+ ContextPrivateEvent ->+ compatibleVector+ { cvOldBinaryReadNewEvents = oldBinaryVerdict,+ cvRollout = rollout+ }+ where+ rejectsUnknown = contextOriginalLabel context == LabelBreaking+ oldBinaryVerdict = if rejectsUnknown then VBreaking else VCompatible+ rollout = if rejectsUnknown then Set.singleton RolloutProducerLast else Set.empty+ ContextSnapshot -> mappedSnapshotVector+ ContextConsumerBuild -> mappedBuildVector+ _ -> compatibleVector++mappedDirectionalAdditionVector :: ChangeContext -> CompatibilityVector+mappedDirectionalAdditionVector context = case contextKind context of+ ContextPrivateEvent ->+ compatibleVector+ { cvOldBinaryReadNewEvents = VBreaking,+ cvRollout = Set.singleton RolloutProducerLast+ }+ ContextSnapshot -> mappedSnapshotVector+ ContextConsumerBuild -> mappedBuildVector+ _ -> compatibleVector++mappedWireBreakingVector :: ChangeContext -> CompatibilityVector+mappedWireBreakingVector context = case contextKind context of+ ContextPrivateEvent ->+ CompatibilityVector+ VBreaking+ VBreaking+ VNotApplicable+ VNotApplicable+ VNotApplicable+ VNotApplicable+ (Set.singleton RolloutStopTheWorld)+ ContextSnapshot -> mappedSnapshotVector+ ContextConsumerBuild -> mappedBuildVector+ _ -> mappedBuildVector++mappedBuildVector :: CompatibilityVector+mappedBuildVector =+ CompatibilityVector+ VCompatible+ VCompatible+ VNotApplicable+ VNotApplicable+ VNotApplicable+ VAdvisory+ Set.empty++mappedSnapshotVector :: CompatibilityVector+mappedSnapshotVector =+ CompatibilityVector+ VCompatible+ VCompatible+ VAdvisory+ VNotApplicable+ VNotApplicable+ VNotApplicable+ Set.empty++mappedBindingVector :: ChangeContext -> CompatibilityVector+mappedBindingVector context = case contextKind context of+ ContextPrivateEvent ->+ CompatibilityVector+ VAdvisory+ VAdvisory+ VNotApplicable+ VNotApplicable+ VNotApplicable+ VAdvisory+ Set.empty+ ContextSnapshot ->+ mappedSnapshotVector {cvConsumerBuild = VAdvisory}+ _ -> mappedBuildVector++mappedSnapshotBuildVector :: ChangeContext -> CompatibilityVector+mappedSnapshotBuildVector context = case contextKind context of+ ContextSnapshot -> mappedSnapshotVector {cvConsumerBuild = VAdvisory}+ _ -> mappedBuildVector++surfaceForContext :: ChangeContext -> CompatibilitySurface+surfaceForContext context = case contextKind context of+ ContextPrivateEvent -> PrivateHistoryRead+ ContextPrivateEventAddition -> OldBinaryReadNewEvents+ ContextSnapshot -> SnapshotHydration+ ContextQueue -> PrivateHistoryRead+ ContextPublicContract -> PublicConsumer+ ContextPersistedIdentity -> PersistedIdentity+ ContextConsumerBuild -> ConsumerBuild+ ContextGeneral -> PrivateHistoryRead++breakingVectorForContext :: ChangeContext -> CompatibilityVector+breakingVectorForContext context = case contextKind context of+ ContextPublicContract -> publicBreakingVector+ ContextPersistedIdentity -> persistedIdentityBreakingVector+ ContextQueue -> queueBreakingVector+ ContextConsumerBuild -> (advisoryVector ConsumerBuild Set.empty) {cvConsumerBuild = VBreaking}+ _ -> privateDecodeBreakingVector++verdictFor :: CompatibilitySurface -> CompatibilityVector -> SurfaceVerdict+verdictFor surface vector = case surface of+ PrivateHistoryRead -> cvPrivateHistoryRead vector+ OldBinaryReadNewEvents -> cvOldBinaryReadNewEvents vector+ SnapshotHydration -> cvSnapshotHydration vector+ PublicConsumer -> cvPublicConsumer vector+ PersistedIdentity -> cvPersistedIdentity vector+ ConsumerBuild -> cvConsumerBuild vector++defaultGate :: Set CompatibilitySurface+defaultGate = Set.delete OldBinaryReadNewEvents (Set.fromList [minBound .. maxBound])++gateWith :: [CompatibilitySurface] -> Set CompatibilitySurface+gateWith surfaces = defaultGate <> Set.fromList surfaces++deriveLabel :: Set CompatibilitySurface -> CompatibilityVector -> Label+deriveLabel gate vector+ | any ((== VBreaking) . (`verdictFor` vector)) (Set.toList gate) = LabelBreaking+ | any (`elem` [VAdvisory, VBreaking]) verdicts || not (Set.null (cvRollout vector)) = LabelAdvisory+ | otherwise = LabelAdditive+ where+ verdicts = [verdictFor surface vector | surface <- [minBound .. maxBound]]++gatedBreaking :: Set CompatibilitySurface -> Change -> Bool+gatedBreaking gate change = deriveLabel gate (ckVector (changeKind change)) == LabelBreaking++changeKind :: Change -> ChangeKind+changeKind (Additive kind) = kind+changeKind (Advisory kind) = kind+changeKind (Breaking kind) = kind++isBreaking :: Change -> Bool+isBreaking (Breaking _) = True+isBreaking (Additive _) = False+isBreaking (Advisory _) = False++isAdvisory :: Change -> Bool+isAdvisory (Advisory _) = True+isAdvisory (Additive _) = False+isAdvisory (Breaking _) = False++-- | Both specs supplied to a node-family differ, always old then new.+data DiffEnv = DiffEnv+ { deOld :: !Spec,+ deNew :: !Spec+ }+ deriving stock (Eq, Show)++-- | The closed set of node families currently present in 'Node'.+data NodeFamily+ = FamAggregate+ | FamProcess+ | FamRouter+ | FamContract+ | FamIntake+ | FamEmit+ | FamPublisher+ | FamWorkqueue+ | FamPgmqDispatch+ | FamReadModel+ | FamWorkflow+ | FamOperation+ deriving stock (Eq, Ord, Show, Enum, Bounded)++-- | Total by construction: one explicit arm per 'Node' constructor.+familyOf :: Node -> NodeFamily+familyOf (NAggregate _) = FamAggregate+familyOf (NProcess _) = FamProcess+familyOf (NRouter _) = FamRouter+familyOf (NContract _) = FamContract+familyOf (NIntake _) = FamIntake+familyOf (NEmit _) = FamEmit+familyOf (NPublisher _) = FamPublisher+familyOf (NWorkqueue _) = FamWorkqueue+familyOf (NPgmqDispatch _) = FamPgmqDispatch+familyOf (NReadModel _) = FamReadModel+familyOf (NWorkflow _) = FamWorkflow+familyOf (NOperation _) = FamOperation++-- | A family either has a differ or an explicit reason it is not compared.+data FamilyDiff+ = DiffFamily (DiffEnv -> [Change])+ | OutOfDiffScope Text++-- | Pair the old and new declarations of one node family by stable name.+data Paired n = Paired+ { prMatched :: ![(n, n)],+ prAdded :: ![n],+ prRemoved :: ![n]+ }+ deriving stock (Eq, Show)++pairByName :: (Node -> Maybe n) -> (n -> Name) -> DiffEnv -> Paired n+pairByName project nameOf env =+ Paired+ { prMatched =+ [ (oldNode, newNode)+ | newNode <- newNodes,+ Just oldNode <- [find ((== nameOf newNode) . nameOf) oldNodes]+ ],+ prAdded =+ [ newNode+ | newNode <- newNodes,+ isNothing (find ((== nameOf newNode) . nameOf) oldNodes)+ ],+ prRemoved =+ [ oldNode+ | oldNode <- oldNodes,+ isNothing (find ((== nameOf oldNode) . nameOf) newNodes)+ ]+ }+ where+ oldNodes = mapMaybe project (specNodes (deOld env))+ newNodes = mapMaybe project (specNodes (deNew env))++-- | Registry invariant: every 'Node' constructor maps to a family via the+-- total 'familyOf' case, and every family occurs exactly once here. The unit+-- suite enforces registry coverage and non-empty out-of-scope rationales.+familyRegistry :: [(NodeFamily, FamilyDiff)]+familyRegistry =+ [ (FamAggregate, DiffFamily aggregateDiff),+ (FamProcess, DiffFamily processDiff),+ (FamRouter, DiffFamily routerDiff),+ (FamContract, DiffFamily contractDiff),+ (FamIntake, DiffFamily intakeDiff),+ (FamEmit, DiffFamily emitDiff),+ (FamPublisher, DiffFamily publisherDiff),+ (FamWorkqueue, DiffFamily workqueueDiff),+ (FamPgmqDispatch, DiffFamily pgmqDispatchDiff),+ (FamReadModel, DiffFamily readModelDiff),+ (FamWorkflow, DiffFamily workflowDiff),+ (FamOperation, OutOfDiffScope "operations own no persisted decode or identity surface; their references and workflow signal/await pairing are single-spec validation concerns")+ ]++diffSpecs :: Spec -> Spec -> [Change]+diffSpecs old new =+ sharedDeclarationDiff env+ ++ concatMap (runFamily env . snd) familyRegistry+ where+ env = DiffEnv old new++-- | Compare provenance first, then delegate semantic graphs to 'diffSpecs'.+diffSources :: ParsedSource -> ParsedSource -> [Change]+diffSources old new =+ sourceLanguageChange+ (specContext (parsedSpec new))+ "declaration"+ (parsedSourceLanguage old)+ (parsedSourceLanguage new)+ <> diffSpecs (parsedSpec old) (parsedSpec new)++-- | One all-compatible source-provenance finding, reusable per workspace member.+sourceLanguageChange :: Name -> Text -> SourceLanguage -> SourceLanguage -> [Change]+sourceLanguageChange root subject old new+ | old == new = []+ | otherwise =+ [ mkChange+ LabelAdditive+ (ChangeContext root [] ContextGeneral LabelAdditive)+ root+ "source-language"+ subject+ SourceLanguageDeclarationChanged+ ( "source form changed "+ <> renderSourceLanguage old+ <> " -> "+ <> renderSourceLanguage new+ <> "; the effective semantic contract is unchanged"+ )+ ]+ where+ renderSourceLanguage sourceLanguage =+ sourceFormText sourceLanguage+ <> maybe "" ((" v" <>) . languageVersionText) (declaredLanguageVersionMaybe sourceLanguage)++runFamily :: DiffEnv -> FamilyDiff -> [Change]+runFamily env (DiffFamily f) = f env+runFamily _ (OutOfDiffScope _) = []++-- Rules are outside the decode and persisted-identity axes, but referenced+-- rule bodies are compared as part of each aggregate's replay fold surface.+sharedDeclarationDiff :: DiffEnv -> [Change]+sharedDeclarationDiff env = enumDiff env ++ idDiff env ++ nominalScalarDiff env ++ mappedDeclarationDiff env++mappedDeclarationDiff :: DiffEnv -> [Change]+mappedDeclarationDiff env = concatMap mappedFindingChanges (diffMapped (deOld env) (deNew env))++mappedFindingChanges :: MappedFinding -> [Change]+mappedFindingChanges finding+ | mfCode finding == MappedDeclAdded = [mappedDeclarationChange LabelAdditive finding]+ | mfCode finding `elem` [MappedHaskellSourceChanged, MappedRecordConstructorChanged, MappedFixturesChanged] =+ [mappedBuildChange finding]+ | mfCode finding `elem` [MappedInitialChanged, MappedCanonicalTypeChanged] =+ mappedBuildChange finding : map (mappedUseChange finding) registerPaths+ | null paths = [mappedBuildChange finding]+ | otherwise = map (mappedUseChange finding) paths+ where+ paths = mfUsePaths finding+ registerPaths = [path | path@UsePath {upRoot = RootRegister {}} <- paths]++mappedBuildChange :: MappedFinding -> Change+mappedBuildChange finding =+ mappedChange context (mfDeclaration finding) "mapped-build" subject finding+ where+ subject = declarationSubject finding+ renderedPaths = map (\path -> renderMappedSubject path (mfLeaf finding)) (mfUsePaths finding)+ context = (consumerBuildContext (mfDeclaration finding) renderedPaths) {contextOriginalLabel = LabelAdvisory}++mappedDeclarationChange :: Label -> MappedFinding -> Change+mappedDeclarationChange label finding =+ mappedChange context (mfDeclaration finding) "mapped-declaration" (declarationSubject finding) finding+ where+ context = ChangeContext (mfDeclaration finding) [] ContextGeneral label++mappedUseChange :: MappedFinding -> UsePath -> Change+mappedUseChange finding path =+ mappedChange context root facet subject finding+ where+ subject = renderMappedSubject path (mfLeaf finding)+ (root, facet, kind) = case upRoot path of+ RootCommandField aggregate _ _ _ -> (aggregate, "mapped-command", ContextConsumerBuild)+ RootEventField aggregate _ _ _ -> (aggregate, "mapped-event", ContextPrivateEvent)+ RootRegister aggregate _ _ -> (aggregate, "mapped-register", ContextSnapshot)+ context = ChangeContext root [subject] kind (mappedContextHint finding kind)++mappedContextHint :: MappedFinding -> ContextKind -> Label+mappedContextHint finding kind = case kind of+ ContextSnapshot -> LabelAdvisory+ ContextConsumerBuild -> LabelAdvisory+ ContextPrivateEvent+ | mfCode finding == MappedFieldAddedWithDefault -> case mfOldUnknownFields finding of+ Just IgnoreUnknown -> LabelAdditive+ _ -> LabelBreaking+ | mfCode finding `elem` [MappedArmAdded, MappedEnumValueAdded] -> LabelAdvisory+ | mfCode finding `elem` [MappedBindingChanged, MappedInitialChanged, MappedCanonicalTypeChanged] -> LabelAdvisory+ | otherwise -> LabelBreaking+ _ -> LabelAdvisory++mappedChange :: ChangeContext -> Name -> Text -> Text -> MappedFinding -> Change+mappedChange context node facet subject finding =+ mkChange label context node facet subject (mfCode finding) (mfDetail finding)+ where+ label = deriveLabel defaultGate (classifyCompatibility context (mfCode finding))++declarationSubject :: MappedFinding -> Text+declarationSubject finding =+ mfDeclaration finding <> if T.null (mfLeaf finding) then "" else " " <> mfLeaf finding++nodeAggregate :: Node -> Maybe Aggregate+nodeAggregate (NAggregate a) = Just a+nodeAggregate _ = Nothing++nodeProcess :: Node -> Maybe ProcessNode+nodeProcess (NProcess process) = Just process+nodeProcess _ = Nothing++nodeRouter :: Node -> Maybe RouterNode+nodeRouter (NRouter router) = Just router+nodeRouter _ = Nothing++nodeContract :: Node -> Maybe ContractNode+nodeContract (NContract contract) = Just contract+nodeContract _ = Nothing++nodeIntake :: Node -> Maybe IntakeNode+nodeIntake (NIntake intake) = Just intake+nodeIntake _ = Nothing++nodeEmit :: Node -> Maybe EmitNode+nodeEmit (NEmit emit) = Just emit+nodeEmit _ = Nothing++nodePublisher :: Node -> Maybe PublisherNode+nodePublisher (NPublisher publisher) = Just publisher+nodePublisher _ = Nothing++nodeWorkqueue :: Node -> Maybe WorkqueueNode+nodeWorkqueue (NWorkqueue workqueue) = Just workqueue+nodeWorkqueue _ = Nothing++nodePgmqDispatch :: Node -> Maybe PgmqDispatchNode+nodePgmqDispatch (NPgmqDispatch dispatch) = Just dispatch+nodePgmqDispatch _ = Nothing++nodeReadModel :: Node -> Maybe ReadModelNode+nodeReadModel (NReadModel readModel) = Just readModel+nodeReadModel _ = Nothing++nodeWorkflow :: Node -> Maybe WorkflowNode+nodeWorkflow (NWorkflow workflow) = Just workflow+nodeWorkflow _ = Nothing++-- | Router identity is replay-sensitive: the stable name and key feed every+-- target-keyed dispatch id, and the target selects the persisted stream family.+routerDiff :: DiffEnv -> [Change]+routerDiff env =+ concatMap (uncurry routerPairDiff) (prMatched paired)+ ++ [additive (rtId router) "router" (rtId router) DeclarationAdded "new router declaration" | router <- prAdded paired]+ ++ [breaking (rtId router) "router-identity" (rtId router) RouterStableNameChanged "router removed while replayable source events may still derive target-keyed dispatch ids from its stable identity" | router <- prRemoved paired]+ where+ paired = pairByName nodeRouter rtId env++routerPairDiff :: RouterNode -> RouterNode -> [Change]+routerPairDiff oldRouter newRouter =+ stableName+ ++ keyDerivation+ ++ target+ ++ routerDecideSurfaceDiff oldRouter newRouter+ where+ nodeName = rtId newRouter+ stableName =+ [ breaking nodeName "router-stable-name" nodeName RouterStableNameChanged $+ "router stable name changed from '" <> rtName oldRouter <> "' to '" <> rtName newRouter <> "'; every deterministicRouterCommandId is re-keyed, so redelivery can duplicate the full resolved fan-out"+ | rtName oldRouter /= rtName newRouter+ ]+ keyDerivation =+ [ breaking nodeName "router-key" (corrField (rtKey newRouter)) DerivedIdentityChanged "router key field or derivation changed; replay derives different target dispatch ids"+ | rtKey oldRouter /= rtKey newRouter+ ]+ target =+ [ breaking nodeName "router-target" (rtTarget newRouter) DerivedIdentityChanged "router target aggregate changed; replay addresses a different persisted stream family"+ | rtTarget oldRouter /= rtTarget newRouter+ ]++routerDecideSurfaceDiff :: RouterNode -> RouterNode -> [Change]+routerDecideSurfaceDiff oldRouter newRouter =+ [ advisory+ (rtId newRouter)+ "router-decide"+ (rtId newRouter)+ RouterDecideSurfaceChanged+ "router dispatch surface changed: a source event redelivered across the deploy dispatches under the same deterministic ids, so half-old/half-new fan-out merges silently. Drain or pause the router's subscription and replay or discard dead letters before deploying; see docs/user/deploy-ordering.md. Hole-only decide changes are not visible to diff; the same drain rule applies to those too."+ | oldSurface /= newSurface+ ]+ where+ oldSurface =+ ( renderResolveSurface (rtResolve oldRouter),+ renderRouterDispatchSurface (rtDispatch oldRouter)+ )+ newSurface =+ ( renderResolveSurface (rtResolve newRouter),+ renderRouterDispatchSurface (rtDispatch newRouter)+ )++readModelDiff :: DiffEnv -> [Change]+readModelDiff env =+ concatMap (uncurry (readModelPairDiff env)) (prMatched paired)+ ++ concatMap addedReadModelDiff (prAdded paired)+ ++ concatMap removedReadModelDiff (prRemoved paired)+ where+ paired = pairByName nodeReadModel rmName env++readModelPairDiff :: DiffEnv -> ReadModelNode -> ReadModelNode -> [Change]+readModelPairDiff env oldReadModel newReadModel =+ versionChanges+ ++ shapeChanges+ ++ identityChanges+ ++ feedChanges+ ++ consistencyChanges+ ++ scopeChanges+ where+ nodeName = rmName newReadModel+ versionChanges+ | rmVersion newReadModel < rmVersion oldReadModel =+ [ breaking nodeName "read-model-version" nodeName ReadModelVersionDecreased ("version decreased from " <> tInt (rmVersion oldReadModel) <> " to " <> tInt (rmVersion newReadModel))+ ]+ | rmVersion newReadModel > rmVersion oldReadModel =+ [ additive nodeName "read-model-version" nodeName VersionBumped ("version increased from " <> tInt (rmVersion oldReadModel) <> " to " <> tInt (rmVersion newReadModel) <> "; register and rebuild the new shape before serving it")+ ]+ | otherwise = []+ oldShape = (rmColumns oldReadModel, rmShape oldReadModel)+ newShape = (rmColumns newReadModel, rmShape newReadModel)+ shapeChanges =+ [ breaking nodeName "read-model-shape" nodeName ReadModelShapeChangedWithoutBump ("declared columns or captured shape hash changed at version " <> tInt (rmVersion newReadModel) <> "; bump version and rebuild")+ | oldShape /= newShape,+ rmVersion oldReadModel == rmVersion newReadModel+ ]+ oldRegistry = registryNameFor (specContext (deOld env)) oldReadModel+ newRegistry = registryNameFor (specContext (deNew env)) newReadModel+ oldSubscription = subscriptionNameFor (specContext (deOld env)) oldReadModel+ newSubscription = subscriptionNameFor (specContext (deNew env)) newReadModel+ identityChanges =+ [ breaking nodeName "read-model-identity" nodeName DerivedIdentityChanged ("registry name changed '" <> oldRegistry <> "' -> '" <> newRegistry <> "'; the old registration row is orphaned")+ | oldRegistry /= newRegistry+ ]+ ++ [ breaking nodeName "read-model-table" nodeName DerivedIdentityChanged ("qualified table changed '" <> qualifiedIdentity oldReadModel <> "' -> '" <> qualifiedIdentity newReadModel <> "'; existing data remains under the old identity")+ | (rmSchema oldReadModel, rmTable oldReadModel) /= (rmSchema newReadModel, rmTable newReadModel)+ ]+ ++ [ breaking nodeName "read-model-subscription" nodeName DerivedIdentityChanged ("subscription changed '" <> oldSubscription <> "' -> '" <> newSubscription <> "'; the worker cursor remains under the old identity")+ | oldSubscription /= newSubscription+ ]+ feedChanges =+ [ breaking nodeName "read-model-feed" nodeName ReadModelFeedChanged ("feed changed " <> renderFeed (rmFeed oldReadModel) <> " -> " <> renderFeed (rmFeed newReadModel) <> "; projection wiring and rebuild identities changed")+ | rmFeed oldReadModel /= rmFeed newReadModel+ ]+ consistencyChanges = case (rmConsistency oldReadModel, rmConsistency newReadModel) of+ (Strong, Eventual) ->+ [breaking nodeName "read-model-consistency" nodeName ReadModelConsistencyWeakened "default consistency changed Strong -> Eventual; callers lose the cursor-wait guarantee"]+ (Eventual, Strong) ->+ [additive nodeName "read-model-consistency" nodeName CompatibilityStrengthened "default consistency changed Eventual -> Strong; callers gain a cursor-wait guarantee"]+ _ -> []+ oldScope = effectiveScope (rmScope oldReadModel)+ newScope = effectiveScope (rmScope newReadModel)+ scopeChanges+ | oldScope == newScope = []+ | scopeStrengthened oldScope newScope =+ [additive nodeName "read-model-scope" nodeName CompatibilityStrengthened ("Strong scope widened " <> renderScope oldScope <> " -> " <> renderScope newScope)]+ | otherwise =+ [breaking nodeName "read-model-scope" nodeName ReadModelConsistencyWeakened ("Strong scope changed " <> renderScope oldScope <> " -> " <> renderScope newScope <> "; callers no longer wait on the same event surface")]++addedReadModelDiff :: ReadModelNode -> [Change]+addedReadModelDiff readModel =+ [additive (rmName readModel) "read-model" (rmName readModel) DeclarationAdded "new read model"]++removedReadModelDiff :: ReadModelNode -> [Change]+removedReadModelDiff readModel =+ [breaking (rmName readModel) "read-model-identity" (rmName readModel) DerivedIdentityChanged "read model removed while registered metadata, data, subscription cursors, and callers may remain"]++qualifiedIdentity :: ReadModelNode -> Text+qualifiedIdentity readModel = rmSchema readModel <> "." <> rmTable readModel++renderFeed :: RmFeed -> Text+renderFeed RmInline = "inline"+renderFeed RmSubscription = "subscription"++effectiveScope :: Maybe RmScope -> RmScope+effectiveScope Nothing = RmEntireLog+effectiveScope (Just scope) = scope++scopeStrengthened :: RmScope -> RmScope -> Bool+scopeStrengthened (RmCategory _) RmEntireLog = True+scopeStrengthened _ _ = False++renderScope :: RmScope -> Text+renderScope RmEntireLog = "entire-log"+renderScope (RmCategory categoryName) = "category '" <> categoryName <> "'"++aggregateDiff :: DiffEnv -> [Change]+aggregateDiff env =+ concatMap+ (\(oldAggregate, newAggregate) -> aggregatePairDiff (deOld env) (deNew env) oldAggregate newAggregate)+ (prMatched paired)+ ++ concatMap addedAggregateDiff (prAdded paired)+ ++ concatMap removedAggregateDiff (prRemoved paired)+ where+ paired = pairByName nodeAggregate aggName env++aggregatePairDiff :: Spec -> Spec -> Aggregate -> Aggregate -> [Change]+aggregatePairDiff oldSpec newSpec oldAgg newAgg =+ concatMap (eventDiff oldAgg newAgg) (aggEvents newAgg)+ ++ removedEvents oldAgg newAgg+ ++ wireDiff oldAgg newAgg+ ++ projectionDiff oldAgg newAgg+ ++ guardTighteningDiff oldAgg newAgg+ ++ transitionSurfaceDiff oldSpec newSpec oldAgg newAgg++-- | Report replay-fold evolution. Regenerated scaffold code carries the new+-- fingerprint and invalidates old snapshots, so this remains advisory.+transitionSurfaceDiff :: Spec -> Spec -> Aggregate -> Aggregate -> [Change]+transitionSurfaceDiff oldSpec newSpec oldAgg newAgg+ | aggregateFoldSurface oldSpec oldAgg == aggregateFoldSurface newSpec newAgg = []+ | otherwise =+ [ advisory+ (aggName newAgg)+ "transitions"+ (aggName newAgg)+ AggFoldSurfaceChanged+ "aggregate fold surface changed: replay now interprets the existing log under the new fold. Old snapshots are invalidated automatically once the regenerated fold fingerprint deploys; if the change is fold-neutral confirm it, otherwise re-scaffold and redeploy, and bump `state-codec version=` for any accompanying Holes-only change."+ ]++-- | Plan 143: guard changes are replay-relevant. Hydration re-inverts each+-- stored event and re-checks the edge guard, so a stored event legally appended+-- under the old guard may no longer satisfy the new one — the next command on+-- any stream containing such an event fails hydration with no inverting edge.+-- The remedy is mechanical, so the tool computes it: the removed region is+-- @old-guard ∧ ¬new-guard@ ('complementExpr' eliminates the negation inside the+-- existing grammar), and the advisory prints a paste-ready replay-only twin+-- carrying that region with the OLD transition's writes\/emits\/goto. Whether+-- history should stay replayable (paste the twin) or be truncated instead is a+-- business decision, so the twin is never auto-applied.+--+-- Detection is conservative: any guard change on a paired live (source,+-- command) transition where the new spec declares a guard and does not already+-- contain a replay-only twin for the pair. A pure loosening also matches; the+-- advisory says how to confirm no stored data is affected (the replay audit,+-- docs/plans/142) rather than guessing.+guardTighteningDiff :: Aggregate -> Aggregate -> [Change]+guardTighteningDiff oldAgg newAgg =+ [ advisory (aggName newAgg) "transition" subject AggGuardTightened detail+ | newT <- aggTransitions newAgg,+ tMode newT == TmLive,+ Just oldT <-+ [ find+ (\o -> tSource o == tSource newT && tCommand o == tCommand newT && tMode o == TmLive)+ (aggTransitions oldAgg)+ ],+ tGuard newT /= tGuard oldT,+ Just newGuard <- [tGuard newT],+ not (hasReplayOnlyTwin newT),+ let subject = tSource newT <> " -- " <> tCommand newT,+ let removedRegion =+ maybe (complementExpr newGuard) (\o -> EAnd o (complementExpr newGuard)) (tGuard oldT),+ let twin = oldT {tGuard = Just removedRegion, tMode = TmReplayOnly},+ let detail =+ "guard changed on "+ <> subject+ <> ". Stored events appended under the old guard may no longer invert: "+ <> "the next command on any stream containing one fails hydration with "+ <> "no inverting edge. Either confirm via the replay audit that no stored "+ <> "stream exercises the removed region, or keep history replayable by "+ <> "adding the computed replay-only twin (the removed region with the old "+ <> "transition's writes/emits/goto):\n\n"+ <> renderTransition twin+ ]+ where+ hasReplayOnlyTwin newT =+ any+ (\t -> tMode t == TmReplayOnly && tSource t == tSource newT && tCommand t == tCommand newT)+ (aggTransitions newAgg)++addedAggregateDiff :: Aggregate -> [Change]+addedAggregateDiff newAgg =+ [ additive (aggName newAgg) "event" (evName e) DeclarationAdded "new event type (new aggregate)"+ | e <- aggEvents newAgg+ ]++removedAggregateDiff :: Aggregate -> [Change]+removedAggregateDiff oldAgg =+ [ breaking (aggName oldAgg) "event" (evName e) EvtRemovedNotDeprecated "aggregate removed; its event tags are no longer decodable"+ | e <- aggEvents oldAgg+ ]++-- | Per-event classification for an event present in the new aggregate.+eventDiff :: Aggregate -> Aggregate -> Event -> [Change]+eventDiff oldAgg newAgg e =+ case find ((== evName e) . evName) (aggEvents oldAgg) of+ Nothing ->+ [additive (aggName newAgg) "event" (evName e) DeclarationAdded "new event type"]+ Just oldE+ | evVersion e > evVersion oldE ->+ if evVersion e == evVersion oldE + 1 && evUpcastFrom e `hasSource` evVersion oldE+ then+ [additive (aggName newAgg) "event" (evName e) VersionBumped ("new version v" <> tInt (evVersion e) <> " with upcaster from v" <> tInt (evVersion oldE))]+ ++ [ breaking+ (aggName newAgg)+ "event"+ (evName e)+ UpcasterChainGap+ ( "bumping v"+ <> tInt (evVersion oldE)+ <> " to v"+ <> tInt (evVersion e)+ <> " replaced the 'upcast from v"+ <> tInt vanishedSource+ <> "' rung; stored v"+ <> tInt vanishedSource+ <> " payloads can no longer decode"+ )+ | Just (vanishedSource, _) <- [evUpcastFrom oldE],+ not (aggregateHasUpcasterSource newAgg vanishedSource)+ ]+ else+ [ breaking+ (aggName newAgg)+ "event"+ (evName e)+ EvtVersionMissingUpcaster+ ( "version changed from v"+ <> tInt (evVersion oldE)+ <> " to v"+ <> tInt (evVersion e)+ <> " without the required contiguous upcaster from v"+ <> tInt (evVersion oldE)+ )+ ]+ | evVersion e < evVersion oldE ->+ [breaking (aggName newAgg) "event" (evName e) EvtVersionDecreased ("version decreased from v" <> tInt (evVersion oldE) <> " to v" <> tInt (evVersion e))]+ | otherwise ->+ sameVersionEventDiff oldAgg newAgg oldE e++-- | Events present in the old aggregate but absent in the new one. Removing a+-- tag entirely is breaking; deprecation preserves decoding but needs a retained+-- replay-only emitter to preserve replay.+removedEvents :: Aggregate -> Aggregate -> [Change]+removedEvents oldAgg newAgg =+ [ breaking (aggName newAgg) "event" (evName oldE) EvtRemovedNotDeprecated "event removed entirely; its stored payloads can neither decode nor replay. Deprecating instead restores decode-ability only — replay still fails on live streams unless an equivalent replay-only emitting transition is retained; truncate or terminalize affected streams before deleting it"+ | oldE <- aggEvents oldAgg,+ isNothing (find ((== evName oldE) . evName) (aggEvents newAgg))+ ]++hasSource :: Maybe (Int, Hole) -> Int -> Bool+hasSource (Just (m, _)) n = m == n+hasSource Nothing _ = False++aggregateHasUpcasterSource :: Aggregate -> Int -> Bool+aggregateHasUpcasterSource aggregate source =+ any ((== Just source) . fmap fst . evUpcastFrom) (aggEvents aggregate)++hasReplayOnlyEmitter :: Aggregate -> Name -> Bool+hasReplayOnlyEmitter aggregate eventName =+ any+ (\transition -> tMode transition == TmReplayOnly && eventName `elem` tEmits transition)+ (aggTransitions aggregate)++eventFieldSigs :: Aggregate -> Event -> [(Name, Maybe TypeExpr)]+eventFieldSigs agg e = case evBody e of+ EventFields fs -> map fieldSig fs+ EventFromCommand cn ->+ maybe [] (map fieldSig . cmdFields) (find ((== cn) . cmdName) (aggCommands agg))+ where+ fieldSig f = (aggregateFieldName f, aggregateFieldType f)++sameVersionEventDiff :: Aggregate -> Aggregate -> Event -> Event -> [Change]+sameVersionEventDiff oldAgg newAgg oldE newE =+ addedChanges+ ++ removedChanges+ ++ typeChanges+ ++ deprecationChanges+ ++ retirementChanges+ where+ oldFields = eventFieldSigs oldAgg oldE+ newFields = eventFieldSigs newAgg newE+ oldNames = map fst oldFields+ newNames = map fst newFields+ added = newNames \\ oldNames+ removed = oldNames \\ newNames+ changed =+ [ (field, oldType, newType)+ | (field, oldType) <- oldFields,+ Just newType <- [lookup field newFields],+ oldType /= newType+ ]+ addedChanges =+ [ breaking (aggName newAgg) "event" (evName newE) EvtFieldAddedWithoutBump ("field(s) " <> commas added <> " added at the same version v" <> tInt (evVersion newE) <> " without a version bump or upcaster")+ | not (null added)+ ]+ removedChanges =+ [ breaking (aggName newAgg) "event" (evName newE) EvtFieldRemovedSameVersion ("field(s) " <> commas removed <> " removed at the same version v" <> tInt (evVersion newE))+ | not (null removed)+ ]+ typeChanges =+ [ breaking+ (aggName newAgg)+ "event-field"+ (evName newE <> "." <> field)+ EvtFieldTypeChanged+ ("type changed " <> renderAggregateFieldType oldType <> " -> " <> renderAggregateFieldType newType <> " at the same version v" <> tInt (evVersion newE))+ | (field, oldType, newType) <- changed+ ]+ deprecationChanges+ | not (evDeprecated oldE) && evDeprecated newE =+ [ if hasReplayOnlyEmitter newAgg (evName newE)+ then+ advisory+ (aggName newAgg)+ "event"+ (evName newE)+ EventRetirementInProgress+ "event deprecated and removed from the live write path, while an equivalent replay-only transition preserves hydration. Retain that transition until every affected stream is terminal, truncated, or passes the replay audit"+ else+ advisory+ (aggName newAgg)+ "event"+ (evName newE)+ DeprecatedEventReplayHazard+ ( "event deprecated: old payloads remain decodable but are no longer replayable — hydration of live streams containing them fails at the first command (HydrationNoInvertingEdge). Add an equivalent replay-only emitting transition or confirm every affected stream is terminal or truncated before deploying"+ <> if evRetiring oldE then "" else "; consider a 'retiring event' stage first"+ )+ ]+ | evDeprecated oldE && not (evDeprecated newE) && not (evRetiring newE) =+ [advisory (aggName newAgg) "event" (evName newE) EventUndeprecated "event returned to the write surface; old payloads remain decodable but new writes resume"]+ | otherwise = []+ retirementChanges+ | not (evRetiring oldE) && evRetiring newE =+ [advisory (aggName newAgg) "event" (evName newE) EventRetirementInProgress "retirement started; keep the live emitting transition until affected streams are terminal or truncated, then cut over to deprecated plus an equivalent replay-only emitting transition"]+ | evRetiring oldE && not (evRetiring newE) && not (evDeprecated newE) =+ [additive (aggName newAgg) "event" (evName newE) EventRetirementAbandoned "event retirement abandoned; ordinary live writes continue"]+ | otherwise = []++renderAggregateFieldType :: Maybe TypeExpr -> Text+renderAggregateFieldType Nothing = "(declared)"+renderAggregateFieldType (Just expression) = typeExprCanonicalName expression++renderFieldType :: Maybe Name -> Text+renderFieldType Nothing = "(declared)"+renderFieldType (Just name) = name++wireDiff :: Aggregate -> Aggregate -> [Change]+wireDiff oldAgg newAgg+ | effectiveWire (aggWire oldAgg) == effectiveWire (aggWire newAgg) = []+ | otherwise =+ [ breaking+ (aggName newAgg)+ "wire"+ (aggName newAgg)+ WireSpecChanged+ ("effective wire convention changed " <> renderWire (effectiveWire (aggWire oldAgg)) <> " -> " <> renderWire (effectiveWire (aggWire newAgg)))+ ]++effectiveWire :: Maybe WireSpec -> (Text, Text)+effectiveWire Nothing = ("ctorName", "camelCase")+effectiveWire (Just w) = (wireKind w, wireFields w)++renderWire :: (Text, Text) -> Text+renderWire (kindName, fieldNames) = "kind=" <> kindName <> ", fields=" <> fieldNames++projectionDiff :: Aggregate -> Aggregate -> [Change]+projectionDiff oldAggregate newAggregate+ | projectionSurface (aggProjection oldAggregate) == projectionSurface (aggProjection newAggregate) = []+ | otherwise =+ [ advisory+ (aggName newAggregate)+ "projection"+ (aggName newAggregate)+ ProjectionChanged+ "projection table, consistency, key, or status mapping changed; coordinate the read-model migration"+ ]++projectionSurface :: Maybe ProjectionSpec -> Maybe (Name, Maybe Consistency, Name, Maybe Mapping)+projectionSurface projection = do+ value <- projection+ pure (projTable value, projConsistency value, projKey value, projStatusMap value)++idDiff :: DiffEnv -> [Change]+idDiff env =+ concatMap (uncurry (idPairDiff (deOld env))) (prMatched paired)+ ++ concatMap addedIdDiff (prAdded paired)+ ++ concatMap removedIdDiff (prRemoved paired)+ where+ paired = pairDeclarations idName (specIds (deOld env)) (specIds (deNew env))++idPairDiff :: Spec -> IdDecl -> IdDecl -> [Change]+idPairDiff oldSpec oldId newId =+ [ breaking (idName newId) "id-prefix" (idName newId) IdPrefixChanged ("prefix changed '" <> idPrefix oldId <> "' -> '" <> idPrefix newId <> "'; stored and newly minted ids no longer share an identity domain")+ | idPrefix oldId /= idPrefix newId+ ]+ <> nominalBindingDeclDiff oldSpec "id" (idName newId) (idBinding oldId) (idBinding newId)+ <> [ nominalUseChange+ use+ NominalIdDecoderTightened+ "adopting a checked KindID binding tightens historical decoding; keep a committed valid old-payload fixture and run the targeted real-log audit for this event"+ | idBinding oldId == Nothing,+ isJust (idBinding newId),+ use@NominalEventUse {} <- nominalUses oldSpec (idName oldId)+ ]++addedIdDiff :: IdDecl -> [Change]+addedIdDiff declaration = [additive (idName declaration) "id-prefix" (idName declaration) DeclarationAdded "new id declaration"]++removedIdDiff :: IdDecl -> [Change]+removedIdDiff declaration = [breaking (idName declaration) "id-prefix" (idName declaration) IdPrefixChanged "id declaration removed; persisted ids still use its prefix"]++enumDiff :: DiffEnv -> [Change]+enumDiff env =+ concatMap (uncurry (enumPairDiff (deOld env))) (prMatched paired)+ ++ concatMap addedEnumDiff (prAdded paired)+ ++ concatMap (removedEnumDiff (deOld env)) (prRemoved paired)+ where+ paired = pairDeclarations enumName (specEnums (deOld env)) (specEnums (deNew env))++enumPairDiff :: Spec -> EnumDecl -> EnumDecl -> [Change]+enumPairDiff oldSpec oldEnum newEnum =+ [ breaking (enumName newEnum) "enum-constructor" ctor EnumCtorRemoved ("constructor removed; stored wire value '" <> wire <> "' no longer decodes" <> enumUsageSuffix oldSpec (enumName oldEnum))+ | (ctor, wire) <- enumCtors oldEnum,+ isNothing (lookup ctor (enumCtors newEnum))+ ]+ ++ [ breaking (enumName newEnum) "enum-constructor" ctor EnumWireSpellingChanged ("wire spelling changed '" <> oldWire <> "' -> '" <> newWire <> "'; stored values using the old spelling no longer decode" <> enumUsageSuffix oldSpec (enumName oldEnum))+ | (ctor, oldWire) <- enumCtors oldEnum,+ Just newWire <- [lookup ctor (enumCtors newEnum)],+ oldWire /= newWire+ ]+ ++ concat+ [ enumAdditionDiff oldSpec newEnum ctor wire+ | (ctor, wire) <- enumCtors newEnum,+ isNothing (lookup ctor (enumCtors oldEnum))+ ]+ <> nominalBindingDeclDiff oldSpec "enum" (enumName newEnum) (enumBinding oldEnum) (enumBinding newEnum)++nominalScalarDiff :: DiffEnv -> [Change]+nominalScalarDiff env =+ concatMap (uncurry scalarPairDiff) (prMatched paired)+ <> [nominalDeclarationChange (nominalScalarName declaration) DeclarationAdded "new nominal scalar declaration" | declaration <- prAdded paired]+ <> [nominalDeclarationChange (nominalScalarName declaration) NominalRepresentationChanged "nominal scalar declaration removed while persisted uses may remain" | declaration <- prRemoved paired]+ where+ paired = pairDeclarations nominalScalarName (specNominalScalars (deOld env)) (specNominalScalars (deNew env))+ scalarPairDiff oldDeclaration newDeclaration =+ [ nominalDeclarationChange+ (nominalScalarName newDeclaration)+ NominalRepresentationChanged+ ( "nominal scalar representation changed '"+ <> nominalScalarRepresentation oldDeclaration+ <> "' -> '"+ <> nominalScalarRepresentation newDeclaration+ <> "'"+ )+ | nominalScalarRepresentation oldDeclaration /= nominalScalarRepresentation newDeclaration+ ]+ <> nominalBindingDeclDiff+ (deOld env)+ "scalar"+ (nominalScalarName newDeclaration)+ (Just (nominalScalarBinding oldDeclaration))+ (Just (nominalScalarBinding newDeclaration))++data NominalUse+ = NominalCommandUse !Name !Name !Name+ | NominalEventUse !Name !Name !Name+ | NominalRegisterUse !Name !Name++nominalUses :: Spec -> Name -> [NominalUse]+nominalUses spec target = concatMap usesInAggregate [aggregate | NAggregate aggregate <- specNodes spec]+ where+ usesInAggregate aggregate =+ [ NominalCommandUse (aggName aggregate) (cmdName command) (aggregateFieldName field)+ | command <- aggCommands aggregate,+ field <- cmdFields command,+ fieldReferences target field+ ]+ <> [ NominalEventUse (aggName aggregate) (evName event) (aggregateFieldName field)+ | event <- aggEvents aggregate,+ field <- eventFields aggregate event,+ fieldReferences target field+ ]+ <> [ NominalRegisterUse (aggName aggregate) (regName register)+ | register <- aggRegs aggregate,+ regType register == TRef target+ ]+ eventFields aggregate event = case evBody event of+ EventFields fields -> fields+ EventFromCommand commandName -> concat [cmdFields command | command <- aggCommands aggregate, cmdName command == commandName]+ fieldReferences targetName field = case aggregateFieldType field of+ Just (TRef typeName) -> typeName == targetName+ Just _ -> False+ Nothing -> pascalName (aggregateFieldName field) == targetName+ pascalName value = case T.uncons value of+ Nothing -> value+ Just (initialChar, rest) -> T.cons (toUpper initialChar) rest++nominalBindingDeclDiff :: Spec -> Text -> Name -> Maybe NominalBindingDecl -> Maybe NominalBindingDecl -> [Change]+nominalBindingDeclDiff oldSpec category name oldBinding newBinding =+ concat+ [ nominalFinding NominalBindingChanged "binding source, symbol, or version changed; rebuild every consumer use and audit persisted event uses because hand-written conversion behavior is opaque"+ | bindingRuntimeFacts oldBinding /= bindingRuntimeFacts newBinding+ ]+ <> concat+ [ nominalFinding NominalFixturesChanged "fixture symbol changed; rerun nominal conformance without claiming runtime wire behavior changed"+ | (nominalFixtures =<< oldBinding) /= (nominalFixtures =<< newBinding)+ ]+ <> concat+ [ nominalFinding NominalCanonicalTypeChanged "canonical nominal identity changed; rebuild consumers and invalidate snapshot caches at register uses"+ | (nominalCanonicalType =<< oldBinding) /= (nominalCanonicalType =<< newBinding)+ ]+ <> concat+ [ nominalFinding NominalInitialChanged "consumer-owned initial value symbol changed; rebuild and invalidate snapshot-bearing register streams"+ | (nominalInitial =<< oldBinding) /= (nominalInitial =<< newBinding)+ ]+ where+ bindingRuntimeFacts declaration =+ ( nominalHaskell =<< declaration,+ nominalBinding =<< declaration,+ nominalBindingVersion =<< declaration+ )+ nominalFinding code detail =+ nominalDeclarationChange name code (category <> " " <> detail)+ : [nominalUseChange use code detail | use <- nominalUses oldSpec name, includeUse code use]+ includeUse NominalFixturesChanged _ = False+ includeUse NominalCanonicalTypeChanged NominalRegisterUse {} = True+ includeUse NominalCanonicalTypeChanged _ = False+ includeUse NominalInitialChanged NominalRegisterUse {} = True+ includeUse NominalInitialChanged _ = False+ includeUse _ NominalCommandUse {} = False+ includeUse _ _ = True++nominalDeclarationChange :: Name -> DiagnosticCode -> Text -> Change+nominalDeclarationChange name code detail =+ mkChange+ (deriveLabel defaultGate vector)+ context+ name+ "nominal-build"+ name+ code+ detail+ where+ context = (consumerBuildContext name [name]) {contextOriginalLabel = LabelAdvisory}+ vector = classifyCompatibility context code++nominalUseChange :: NominalUse -> DiagnosticCode -> Text -> Change+nominalUseChange use code detail =+ mkChange (deriveLabel defaultGate vector) context root facet subject code detail+ where+ (root, facet, subject, kind) = case use of+ NominalCommandUse aggregate command field -> (aggregate, "nominal-command", aggregate <> " command " <> command <> " ." <> field, ContextConsumerBuild)+ NominalEventUse aggregate event field -> (aggregate, "nominal-event", aggregate <> " event " <> event <> " ." <> field, ContextPrivateEvent)+ NominalRegisterUse aggregate register -> (aggregate, "nominal-register", aggregate <> " register " <> register, ContextSnapshot)+ context = ChangeContext root [subject] kind LabelAdvisory+ vector = classifyCompatibility context code++addedEnumDiff :: EnumDecl -> [Change]+addedEnumDiff enumDecl =+ [additive (enumName enumDecl) "enum-constructor" ctor EnumCtorAdded ("new enum constructor with wire spelling '" <> wire <> "'") | (ctor, wire) <- enumCtors enumDecl]++enumAdditionDiff :: Spec -> EnumDecl -> Name -> Text -> [Change]+enumAdditionDiff oldSpec enumDecl ctor wire = case enumUsages oldSpec (enumName enumDecl) of+ [] ->+ [ additive+ (enumName enumDecl)+ "enum-constructor"+ ctor+ EnumCtorAdded+ ("new constructor with wire spelling '" <> wire <> "'")+ ]+ usages -> map finding usages+ where+ finding usage+ | ".reg." `T.isInfixOf` usage =+ advisoryAt+ (snapshotContext (enumName enumDecl) [usage])+ (enumName enumDecl)+ "enum-constructor"+ ctor+ EnumCtorAdded+ ("new constructor with wire spelling '" <> wire <> "' is used by " <> usage <> "; invalidate or rebuild snapshots before values using the new arm hydrate")+ | otherwise =+ advisoryAt+ (privateEventAdditionContext (enumName enumDecl) [usage])+ (enumName enumDecl)+ "enum-constructor"+ ctor+ EnumCtorAdded+ ("new constructor with wire spelling '" <> wire <> "' is used by " <> usage <> "; deploy consumers before producers emit the new arm")++removedEnumDiff :: Spec -> EnumDecl -> [Change]+removedEnumDiff oldSpec enumDecl =+ [ breaking (enumName enumDecl) "enum-constructor" ctor EnumCtorRemoved ("enum removed; stored wire value '" <> wire <> "' no longer decodes" <> enumUsageSuffix oldSpec (enumName enumDecl))+ | (ctor, wire) <- enumCtors enumDecl+ ]++enumUsageSuffix :: Spec -> Name -> Text+enumUsageSuffix spec enumType = case enumUsages spec enumType of+ [] -> ""+ usages -> "; used by " <> commas usages++enumUsages :: Spec -> Name -> [Text]+enumUsages spec enumType =+ [aggName agg <> ".reg." <> regName reg | agg <- aggregates, reg <- aggRegs agg, regType reg == TRef enumType]+ ++ [ aggName agg <> ".event." <> evName event <> "." <> field+ | agg <- aggregates,+ event <- aggEvents agg,+ (field, Just fieldTypeName) <- eventFieldSigs agg event,+ fieldTypeName == TRef enumType+ ]+ where+ aggregates = [agg | NAggregate agg <- specNodes spec]++pairDeclarations :: (n -> Name) -> [n] -> [n] -> Paired n+pairDeclarations nameOf oldNodes newNodes =+ Paired+ { prMatched =+ [ (oldNode, newNode)+ | newNode <- newNodes,+ Just oldNode <- [find ((== nameOf newNode) . nameOf) oldNodes]+ ],+ prAdded = [newNode | newNode <- newNodes, isNothing (find ((== nameOf newNode) . nameOf) oldNodes)],+ prRemoved = [oldNode | oldNode <- oldNodes, isNothing (find ((== nameOf oldNode) . nameOf) newNodes)]+ }++contractDiff :: DiffEnv -> [Change]+contractDiff env =+ concatMap (uncurry contractPairDiff) (prMatched paired)+ ++ concatMap addedContractDiff (prAdded paired)+ ++ concatMap removedContractDiff (prRemoved paired)+ where+ paired = pairByName nodeContract ctrName env++contractPairDiff :: ContractNode -> ContractNode -> [Change]+contractPairDiff oldContract newContract =+ schemaChanges+ ++ discriminatorChanges+ ++ topicChanges+ ++ concatMap eventPairChanges matchedEvents+ ++ concatMap addedEventChanges addedEvents+ ++ concatMap removedEventChanges removedEvents'+ where+ schemaChanges =+ [ breaking+ (ctrName newContract)+ "schema-version"+ (ctrName newContract)+ ContractSchemaVersionDecreased+ ("schemaVersion decreased from " <> tInt (ctrSchemaVersion oldContract) <> " to " <> tInt (ctrSchemaVersion newContract))+ | ctrSchemaVersion newContract < ctrSchemaVersion oldContract+ ]+ discriminatorChanges =+ [ breaking+ (ctrName newContract)+ "discriminator"+ (ctrName newContract)+ ContractDiscriminatorChanged+ ("discriminator changed " <> ctrDiscriminator oldContract <> " -> " <> ctrDiscriminator newContract)+ | ctrDiscriminator oldContract /= ctrDiscriminator newContract+ ]+ topicChanges = contractTopicDiff oldContract newContract+ eventPairs = pairDeclarations ceName (ctrEvents oldContract) (ctrEvents newContract)+ matchedEvents = prMatched eventPairs+ addedEvents = prAdded eventPairs+ removedEvents' = prRemoved eventPairs+ eventPairChanges (oldEvent, newEvent) = contractEventDiff oldContract newContract oldEvent newEvent+ addedEventChanges event =+ [additive (ctrName newContract) "contract-event" (ceName event) ContractEventAdded "new contract event"]+ removedEventChanges event =+ [breaking (ctrName newContract) "contract-event" (ceName event) ContractEventRemoved "contract event removed; existing cross-service payloads no longer have a declared decoder"]++addedContractDiff :: ContractNode -> [Change]+addedContractDiff contract =+ [additive (ctrName contract) "contract-event" (ceName event) ContractEventAdded "new event in a new contract" | event <- ctrEvents contract]++removedContractDiff :: ContractNode -> [Change]+removedContractDiff contract =+ [breaking (ctrName contract) "contract-event" (ceName event) ContractEventRemoved "contract removed; its cross-service event decoder is no longer declared" | event <- ctrEvents contract]++contractTopicDiff :: ContractNode -> ContractNode -> [Change]+contractTopicDiff oldContract newContract =+ [ breaking+ (ctrName newContract)+ "contract-topic"+ alias+ ContractTopicChanged+ ("topic alias removed; previous topic was '" <> oldTopic <> "'")+ | (alias, oldTopic) <- ctrTopics oldContract,+ isNothing (lookup alias (ctrTopics newContract))+ ]+ ++ [ breaking+ (ctrName newContract)+ "contract-topic"+ alias+ ContractTopicChanged+ ("real topic changed '" <> oldTopic <> "' -> '" <> newTopic <> "'")+ | (alias, oldTopic) <- ctrTopics oldContract,+ Just newTopic <- [lookup alias (ctrTopics newContract)],+ oldTopic /= newTopic+ ]+ ++ [ additive (ctrName newContract) "contract-topic" alias ContractTopicAdded ("new topic alias for '" <> topic <> "'")+ | (alias, topic) <- ctrTopics newContract,+ isNothing (lookup alias (ctrTopics oldContract))+ ]++contractEventDiff :: ContractNode -> ContractNode -> ContractEvent -> ContractEvent -> [Change]+contractEventDiff oldContract newContract oldEvent newEvent =+ topicAliasChange+ ++ removedFieldChanges+ ++ changedFieldChanges+ ++ addedFieldChanges+ where+ fieldPairs = pairDeclarations cfName (ceFields oldEvent) (ceFields newEvent)+ topicAliasChange =+ [ breaking+ (ctrName newContract)+ "contract-topic"+ (ceName newEvent)+ ContractTopicChanged+ ("event topic alias changed " <> ceTopic oldEvent <> " -> " <> ceTopic newEvent)+ | ceTopic oldEvent /= ceTopic newEvent+ ]+ removedFieldChanges =+ [ breaking (ctrName newContract) "contract-field" (ceName newEvent <> "." <> cfName field) ContractFieldChanged "field removed; existing messages still carry the old contract shape"+ | field <- prRemoved fieldPairs+ ]+ changedFieldChanges =+ [ breaking+ (ctrName newContract)+ "contract-field"+ (ceName newEvent <> "." <> cfName newField)+ ContractFieldChanged+ ("field type changed " <> renderContractType (cfType oldField) <> " -> " <> renderContractType (cfType newField))+ | (oldField, newField) <- prMatched fieldPairs,+ cfType oldField /= cfType newField+ ]+ addedFieldChanges =+ [ if ctrSchemaVersion newContract > ctrSchemaVersion oldContract+ then advisory (ctrName newContract) "contract-field" subject ContractSchemaVersionBumped ("field added with schemaVersion bump " <> tInt (ctrSchemaVersion oldContract) <> " -> " <> tInt (ctrSchemaVersion newContract) <> "; coordinate the cross-service rollout")+ else breaking (ctrName newContract) "contract-field" subject ContractFieldChanged "field added without a schemaVersion bump; older in-flight messages do not contain it"+ | field <- prAdded fieldPairs,+ let subject = ceName newEvent <> "." <> cfName field+ ]++renderContractType :: ContractType -> Text+renderContractType (CTypeId prefix) = "typeid '" <> prefix <> "'"+renderContractType CText = "text"+renderContractType CInt = "int"++workqueueDiff :: DiffEnv -> [Change]+workqueueDiff env =+ concatMap (uncurry workqueuePairDiff) (prMatched paired)+ ++ concatMap addedWorkqueueDiff (prAdded paired)+ ++ concatMap removedWorkqueueDiff (prRemoved paired)+ where+ paired = pairByName nodeWorkqueue wqName env++workqueuePairDiff :: WorkqueueNode -> WorkqueueNode -> [Change]+workqueuePairDiff oldQueue newQueue =+ concatMap pairedFieldDiff (prMatched fields)+ ++ concatMap addedFieldDiff (prAdded fields)+ ++ concatMap removedFieldDiff (prRemoved fields)+ ++ queueIdentityDiff oldQueue newQueue+ ++ queuePolicyDiff oldQueue newQueue+ where+ -- wqPayloadName is a generated Haskell type name, not a wire-visible name.+ fields = pairDeclarations wqfName (wqPayload oldQueue) (wqPayload newQueue)+ pairedFieldDiff (oldField, newField)+ | wqfWire oldField /= wqfWire newField = [payloadBreaking newField ("wire name changed '" <> wqfWire oldField <> "' -> '" <> wqfWire newField <> "'")]+ | wqfType oldField /= wqfType newField = [payloadBreaking newField ("type changed " <> wqfType oldField <> " -> " <> wqfType newField)]+ | not (wqfRequired oldField) && wqfRequired newField = [payloadBreaking newField "field changed from optional to required; queued jobs may omit it"]+ | wqfRequired oldField && not (wqfRequired newField) = [additive (wqName newQueue) "payload-field" (wqfName newField) CompatibilityStrengthened "field changed from required to optional"]+ | otherwise = []+ addedFieldDiff field+ | wqfRequired field = [payloadBreaking field "new required field; queued jobs do not contain it"]+ | otherwise = [additive (wqName newQueue) "payload-field" (wqfName field) CompatibilityStrengthened "new optional field"]+ removedFieldDiff field = [payloadBreaking field "field removed; queued jobs still contain the old payload shape"]+ payloadBreaking field detail = breaking (wqName newQueue) "payload-field" (wqfName field) WqPayloadFieldChanged detail++addedWorkqueueDiff :: WorkqueueNode -> [Change]+addedWorkqueueDiff queue =+ [additive (wqName queue) "payload-field" (wqfName field) DeclarationAdded "field belongs to a new workqueue payload" | field <- wqPayload queue]++removedWorkqueueDiff :: WorkqueueNode -> [Change]+removedWorkqueueDiff queue =+ [breaking (wqName queue) "payload-field" (wqfName field) WqPayloadFieldChanged "workqueue removed while persisted jobs may still carry this payload" | field <- wqPayload queue]+ ++ [breaking (wqName queue) "queue-identity" (wqName queue) QueueIdentityChanged "workqueue removed; its physical queue, DLQ, and pgmq table may still hold state"]++queueIdentityDiff :: WorkqueueNode -> WorkqueueNode -> [Change]+queueIdentityDiff oldQueue newQueue =+ [ breaking+ (wqName newQueue)+ "queue-identity"+ (wqName newQueue)+ QueueIdentityChanged+ "logical, physical, DLQ, or table name changed; queued jobs and dispatch dedupe records remain under the old identity"+ | queueIdentity oldQueue /= queueIdentity newQueue+ ]++queueIdentity :: WorkqueueNode -> (Text, Text, Text, Text)+queueIdentity queue = (wqLogical queue, wqPhysical queue, wqDlq queue, wqTable queue)++queuePolicyDiff :: WorkqueueNode -> WorkqueueNode -> [Change]+queuePolicyDiff oldQueue newQueue = ordering ++ provision ++ groupKey+ where+ nodeName = wqName newQueue+ ordering =+ [ breaking nodeName "queue-ordering" nodeName WqOrderingChanged $+ "ordering changed " <> renderWqOrdering (wqOrdering oldQueue) <> " -> " <> renderWqOrdering (wqOrdering newQueue) <> "; consumers were written against the old delivery-order contract"+ | wqOrdering oldQueue /= wqOrdering newQueue+ ]+ provision =+ [ breaking nodeName "queue-provision" nodeName WqProvisionChanged $+ "provision changed " <> renderWqProvision (wqProvision oldQueue) <> " -> " <> renderWqProvision (wqProvision newQueue) <> "; provisioning is create-time only, so migrate the existing queue operationally before changing the spec"+ | wqProvision oldQueue /= wqProvision newQueue+ ]+ groupKey =+ [ breaking nodeName "queue-group-key" nodeName WqGroupKeyChanged $+ "group key derivation changed " <> renderWqGroupKey (wqGroupKey oldQueue) <> " -> " <> renderWqGroupKey (wqGroupKey newQueue) <> "; FIFO messages are re-partitioned across durable ordering groups"+ | wqGroupKey oldQueue /= wqGroupKey newQueue+ ]++renderWqOrdering :: WqOrdering -> Text+renderWqOrdering WqUnordered = "unordered"+renderWqOrdering WqFifoThroughput = "fifo-throughput"+renderWqOrdering WqFifoRoundRobin = "fifo-roundrobin"++renderWqProvision :: WqProvision -> Text+renderWqProvision WqStandard = "standard"+renderWqProvision WqUnlogged = "unlogged"+renderWqProvision (WqPartitioned interval duration) = "partitioned(interval=" <> interval <> ", retention=" <> duration <> ")"++renderWqGroupKey :: Maybe WqGroupKey -> Text+renderWqGroupKey Nothing = "none"+renderWqGroupKey (Just groupKey) =+ gkField groupKey+ <> " via "+ <> gkVia groupKey+ <> maybe "" (" fixture " <>) (gkFixture groupKey)++processDiff :: DiffEnv -> [Change]+processDiff env =+ concatMap (uncurry processPairDiff) (prMatched paired)+ ++ concatMap addedProcessDiff (prAdded paired)+ ++ concatMap removedProcessDiff (prRemoved paired)+ where+ paired = pairByName nodeProcess procId env++processPairDiff :: ProcessNode -> ProcessNode -> [Change]+processPairDiff oldProcess newProcess =+ concatMap pairedFieldDiff (prMatched fields)+ ++ map (fieldChange "field added; source events at the old shape cannot populate it") (prAdded fields)+ ++ map (fieldChange "field removed; the generated process input decoder changed") (prRemoved fields)+ ++ processIdentityDiff oldProcess newProcess+ ++ processTimerWindowDiff oldProcess newProcess+ ++ processDecideSurfaceDiff oldProcess newProcess+ ++ processTimerPayloadDiff oldProcess newProcess+ where+ -- inName is a generated Haskell type name; the wire shape is inFields.+ fields = pairDeclarations fieldName (inFields (procInput oldProcess)) (inFields (procInput newProcess))+ pairedFieldDiff (oldField, newField)+ | fieldType oldField /= fieldType newField = [fieldChange ("type changed " <> renderFieldType (fieldType oldField) <> " -> " <> renderFieldType (fieldType newField)) newField]+ | otherwise = []+ fieldChange detail field = breaking (procId newProcess) "input-field" (fieldName field) ProcessInputChanged (detail <> "; version the source event before changing process input")++addedProcessDiff :: ProcessNode -> [Change]+addedProcessDiff process =+ [additive (procId process) "input-field" (fieldName field) DeclarationAdded "field belongs to a new process input" | field <- inFields (procInput process)]++removedProcessDiff :: ProcessNode -> [Change]+removedProcessDiff process =+ [breaking (procId process) "input-field" (fieldName field) ProcessInputChanged "process removed while persisted source events may still require this input decoder" | field <- inFields (procInput process)]+ ++ [breaking (procId process) "derived-identity" (procId process) DerivedIdentityChanged "process removed while persisted saga, dispatch, and timer identities may still exist"]++processIdentityDiff :: ProcessNode -> ProcessNode -> [Change]+processIdentityDiff oldProcess newProcess =+ [ breaking+ (procId newProcess)+ "derived-identity"+ (procId newProcess)+ DerivedIdentityChanged+ "process name, correlation derivation, saga stream category, timer id prefix, or fired-event-id prefix changed; replays and retries no longer derive the persisted identity"+ | processIdentity oldProcess /= processIdentity newProcess+ ]++processIdentity :: ProcessNode -> (Text, Name, Name, Text, Text, Text)+processIdentity process =+ ( procName process,+ corrField (procCorrelate process),+ corrVia (procCorrelate process),+ sagaCategory (procSaga process),+ idePrefix (tmId (procTimer process)),+ idePrefix (fireFiredEventId (tmFire (procTimer process)))+ )++processTimerWindowDiff :: ProcessNode -> ProcessNode -> [Change]+processTimerWindowDiff oldProcess newProcess =+ [ advisory+ (procId newProcess)+ "timer"+ (tmName (procTimer newProcess))+ TimerWindowChanged+ ( "fireAt source/window changed "+ <> renderFireAt (tmFireAt (procTimer oldProcess))+ <> " -> "+ <> renderFireAt (tmFireAt (procTimer newProcess))+ <> "; already-scheduled timers keep their persisted deadline"+ )+ | tmFireAt (procTimer oldProcess) /= tmFireAt (procTimer newProcess)+ ]++processDecideSurfaceDiff :: ProcessNode -> ProcessNode -> [Change]+processDecideSurfaceDiff oldProcess newProcess =+ [ advisory+ (procId newProcess)+ "process-decide"+ (procId newProcess)+ ProcessDecideSurfaceChanged+ "process dispatch surface changed: a source event redelivered across the deploy dispatches under the same deterministic ids, so half-old/half-new fan-out merges silently. Drain or pause the process subscription and replay or discard dead letters before deploying; see docs/user/deploy-ordering.md. Hole-only decide changes are not visible to diff; the same drain rule applies to those too."+ | renderHandleSurface (procHandle oldProcess)+ /= renderHandleSurface (procHandle newProcess)+ ]++processTimerPayloadDiff :: ProcessNode -> ProcessNode -> [Change]+processTimerPayloadDiff oldProcess newProcess =+ [ advisory+ (procId newProcess)+ "timer-payload"+ (tmName (procTimer newProcess))+ ProcessTimerPayloadChanged+ "timer payload shape changed: rows scheduled before the deploy carry the old shape, unversioned, and fire under new code — the fire decoder must accept every historically scheduled shape or the timer dead-letters after maxAttempts. Hole-only timer-decoder changes are not visible to diff; the same drain rule applies to those too."+ | renderTimerPayloadSurface (procTimer oldProcess)+ /= renderTimerPayloadSurface (procTimer newProcess)+ ]++renderFireAt :: FireAtExpr -> Text+renderFireAt expression = "input." <> faField expression <> " + " <> faWindow expression++workflowDiff :: DiffEnv -> [Change]+workflowDiff env =+ concatMap (uncurry workflowPairDiff) (prMatched paired)+ ++ concatMap addedWorkflowDiff (prAdded paired)+ ++ concatMap removedWorkflowDiff (prRemoved paired)+ where+ paired = pairByName nodeWorkflow wfId env++workflowPairDiff :: WorkflowNode -> WorkflowNode -> [Change]+workflowPairDiff oldWorkflow newWorkflow =+ inputChanges+ ++ outputChanges+ ++ classifyWorkflowBody oldWorkflow newWorkflow+ ++ workflowIdentityDiff oldWorkflow newWorkflow+ where+ fields = pairDeclarations fieldName (wfInputFields oldWorkflow) (wfInputFields newWorkflow)+ inputChanges =+ [workflowShape field "input field added; journaled inputs at the old shape do not contain it" | field <- prAdded fields]+ ++ [workflowShape field "input field removed; journaled inputs still contain the old shape" | field <- prRemoved fields]+ ++ [ workflowShape newField ("input field type changed " <> renderFieldType (fieldType oldField) <> " -> " <> renderFieldType (fieldType newField))+ | (oldField, newField) <- prMatched fields,+ fieldType oldField /= fieldType newField+ ]+ outputChanges =+ [ breaking (wfId newWorkflow) "workflow-output" (wfOutput newWorkflow) WorkflowShapeChanged ("output type changed " <> wfOutput oldWorkflow <> " -> " <> wfOutput newWorkflow <> "; persisted outcomes may no longer decode")+ | wfOutput oldWorkflow /= wfOutput newWorkflow+ ]+ workflowShape field detail = breaking (wfId newWorkflow) "workflow-input" (fieldName field) WorkflowShapeChanged detail++addedWorkflowDiff :: WorkflowNode -> [Change]+addedWorkflowDiff workflow = [additive (wfId workflow) "workflow" (wfId workflow) DeclarationAdded "new workflow"]++removedWorkflowDiff :: WorkflowNode -> [Change]+removedWorkflowDiff workflow = [breaking (wfId workflow) "workflow" (wfId workflow) WorkflowShapeChanged "workflow removed while in-flight journals and outcomes may still require its decoder"]++workflowIdentityDiff :: WorkflowNode -> WorkflowNode -> [Change]+workflowIdentityDiff oldWorkflow newWorkflow =+ [ breaking+ (wfId newWorkflow)+ "workflow-name"+ (wfId newWorkflow)+ WorkflowStableNameChanged+ ("stable name changed '" <> wfStable oldWorkflow <> "' -> '" <> wfStable newWorkflow <> "'; in-flight journals remain under the old stream name")+ | wfStable oldWorkflow /= wfStable newWorkflow+ ]+ ++ [ breaking+ (wfId newWorkflow)+ "derived-identity"+ (wfId newWorkflow)+ DerivedIdentityChanged+ "workflow id source field or derivation changed; journal and deterministic child/step identities no longer coalesce with persisted executions"+ | (wfIdField oldWorkflow, wfIdVia oldWorkflow) /= (wfIdField newWorkflow, wfIdVia newWorkflow)+ ]++intakeDiff :: DiffEnv -> [Change]+intakeDiff env =+ concatMap (uncurry intakePairDiff) (prMatched paired)+ ++ concatMap addedIntakeDiff (prAdded paired)+ ++ concatMap removedIntakeDiff (prRemoved paired)+ where+ paired = pairByName nodeIntake inkName env++intakePairDiff :: IntakeNode -> IntakeNode -> [Change]+intakePairDiff oldIntake newIntake =+ [ breaking+ (inkName newIntake)+ "dedupe-identity"+ (inkName newIntake)+ DedupeIdentityChanged+ "dedupe key or policy changed; redelivered messages no longer match their persisted dedupe record"+ | (inkDedupeKey oldIntake, inkDedupePolicy oldIntake) /= (inkDedupeKey newIntake, inkDedupePolicy newIntake)+ ]+ ++ [ advisory+ (inkName newIntake)+ "decode-posture"+ (inkName newIntake)+ DecodePostureChanged+ "envelope/body decode posture changed; future messages are accepted or rejected differently"+ | inkDecode oldIntake /= inkDecode newIntake+ ]+ ++ [ advisory+ (inkName newIntake)+ "inbox-persistence"+ (inkName newIntake)+ IntakePersistenceChanged+ ("success-path envelope persistence changed " <> renderInkPersist (inkPersist oldIntake) <> " -> " <> renderInkPersist (inkPersist newIntake) <> "; existing rows are unchanged while future successful rows retain a different envelope shape")+ | inkPersist oldIntake /= inkPersist newIntake+ ]++renderInkPersist :: InkPersist -> Text+renderInkPersist InkPersistFull = "full-envelope"+renderInkPersist InkPersistDedupeOnly = "dedupe-only"++addedIntakeDiff :: IntakeNode -> [Change]+addedIntakeDiff intake = [additive (inkName intake) "intake" (inkName intake) DeclarationAdded "new intake"]++removedIntakeDiff :: IntakeNode -> [Change]+removedIntakeDiff intake = [breaking (inkName intake) "dedupe-identity" (inkName intake) DedupeIdentityChanged "intake removed while persisted dedupe records and redeliveries may remain"]++emitDiff :: DiffEnv -> [Change]+emitDiff env =+ concatMap (uncurry emitPairDiff) (prMatched paired)+ ++ concatMap addedEmitDiff (prAdded paired)+ ++ concatMap removedEmitDiff (prRemoved paired)+ where+ paired = pairByName nodeEmit emName env++emitPairDiff :: EmitNode -> EmitNode -> [Change]+emitPairDiff oldEmit newEmit =+ [ breaking+ (emName newEmit)+ "derived-identity"+ "messageId"+ DerivedIdentityChanged+ "messageId derive prefix changed; outbox retries no longer coalesce with persisted messages"+ | emMessageId oldEmit /= emMessageId newEmit+ ]+ ++ [ breaking+ (emName newEmit)+ "derived-identity"+ "idempotencyKey"+ DerivedIdentityChanged+ "idempotencyKey derive prefix changed; downstream dedupe no longer matches persisted messages"+ | emIdempotencyKey oldEmit /= emIdempotencyKey newEmit+ ]+ ++ [ advisory+ (emName newEmit)+ "emit-mapping"+ (emName newEmit)+ EmitMappingChanged+ "emit key, status discriminant, mapping rows, or explicit skip posture changed"+ | emitMapping oldEmit /= emitMapping newEmit+ ]++emitMapping :: EmitNode -> (Name, Name, [EmitMapRow], Bool)+emitMapping emit = (emKey emit, emDiscriminant emit, emMap emit, emSkip emit)++addedEmitDiff :: EmitNode -> [Change]+addedEmitDiff emit = [additive (emName emit) "emit" (emName emit) DeclarationAdded "new emit mapping"]++removedEmitDiff :: EmitNode -> [Change]+removedEmitDiff emit = [breaking (emName emit) "derived-identity" (emName emit) DerivedIdentityChanged "emit removed while persisted outbox identities may still retry"]++publisherDiff :: DiffEnv -> [Change]+publisherDiff env =+ concatMap (uncurry publisherPairDiff) (prMatched paired)+ ++ concatMap addedPublisherDiff (prAdded paired)+ ++ concatMap removedPublisherDiff (prRemoved paired)+ where+ paired = pairByName nodePublisher pubName env++publisherPairDiff :: PublisherNode -> PublisherNode -> [Change]+publisherPairDiff oldPublisher newPublisher =+ -- maxAttempts/backoff are retry tuning, not persisted decode or identity.+ [ breaking+ (pubName newPublisher)+ "derived-identity"+ "outboxId"+ DerivedIdentityChanged+ "stable outbox-id source field changed; retries no longer coalesce with persisted outbox rows"+ | pubOutboxField oldPublisher /= pubOutboxField newPublisher+ ]+ ++ [ advisory+ (pubName newPublisher)+ "publisher-policy"+ (pubName newPublisher)+ PublisherPolicyChanged+ ("ordering changed " <> pubOrdering oldPublisher <> " -> " <> pubOrdering newPublisher)+ | pubOrdering oldPublisher /= pubOrdering newPublisher+ ]++addedPublisherDiff :: PublisherNode -> [Change]+addedPublisherDiff publisher = [additive (pubName publisher) "publisher" (pubName publisher) DeclarationAdded "new publisher"]++removedPublisherDiff :: PublisherNode -> [Change]+removedPublisherDiff publisher = [breaking (pubName publisher) "derived-identity" (pubName publisher) DerivedIdentityChanged "publisher removed while persisted outbox rows may still require its stable identity"]++pgmqDispatchDiff :: DiffEnv -> [Change]+pgmqDispatchDiff env =+ concatMap (uncurry pgmqDispatchPairDiff) (prMatched paired)+ ++ concatMap addedPgmqDispatchDiff (prAdded paired)+ ++ concatMap removedPgmqDispatchDiff (prRemoved paired)+ where+ paired = pairByName nodePgmqDispatch pdName env++pgmqDispatchPairDiff :: PgmqDispatchNode -> PgmqDispatchNode -> [Change]+pgmqDispatchPairDiff oldDispatch newDispatch =+ [ breaking+ (pdName newDispatch)+ "dedupe-identity"+ (pdName newDispatch)+ DedupeIdentityChanged+ "dispatch dedupe key/read-model/queue surface changed; prior enqueue records no longer match"+ | dispatchDedupe oldDispatch /= dispatchDedupe newDispatch+ ]+ ++ [ advisory+ (pdName newDispatch)+ "retarget"+ (pdName newDispatch)+ DispatchRetargeted+ "source read model or target queue changed; future fan-out is routed differently"+ | dispatchTargets oldDispatch /= dispatchTargets newDispatch+ ]++dispatchDedupe :: PgmqDispatchNode -> (Name, Name, Text, Name, Text)+dispatchDedupe dispatch =+ ( pdDedupKey dispatch,+ pdDedupReadModel dispatch,+ pdDedupReadModelField dispatch,+ pdDedupQueue dispatch,+ pdDedupQueueField dispatch+ )++dispatchTargets :: PgmqDispatchNode -> (Name, Name)+dispatchTargets dispatch = (pdSourceReadModel dispatch, pdEnqueueTo dispatch)++addedPgmqDispatchDiff :: PgmqDispatchNode -> [Change]+addedPgmqDispatchDiff dispatch = [additive (pdName dispatch) "dispatch" (pdName dispatch) DeclarationAdded "new pgmq dispatch"]++removedPgmqDispatchDiff :: PgmqDispatchNode -> [Change]+removedPgmqDispatchDiff dispatch = [breaking (pdName dispatch) "dedupe-identity" (pdName dispatch) DedupeIdentityChanged "dispatch removed while persisted queue and read-model dedupe records may remain"]++-- | Classify the runtime's sanctioned workflow-evolution mechanisms before+-- falling back to the conservative unguarded-body rule.+classifyWorkflowBody :: WorkflowNode -> WorkflowNode -> [Change]+classifyWorkflowBody oldWorkflow newWorkflow+ | oldBody == newBody = []+ | not (null removedPatchIds) = map removedPatch removedPatchIds+ | Just (oldSeedType, newSeedType) <- changedSeed =+ [ breaking nodeName "workflow-continue-as-new" nodeName WorkflowContinueSeedChanged $+ "continueAsNew seed type changed " <> oldSeedType <> " -> " <> newSeedType <> "; the next generation's restoreSeed must decode the seed written by the previous generation"+ ]+ | safeAdditions =+ map addedPatch newPatchIds+ ++ [ additive nodeName "workflow-continue-as-new" seedType WorkflowEvolutionGuardAdded "terminal continueAsNew is additive; old generations carry no rotation marker"+ | Just seedType <- [appendedSeed]+ ]+ | otherwise =+ [ breaking+ nodeName+ "workflow-body"+ nodeName+ WorkflowBodyChanged+ "workflow body labels, kinds, result types, or order changed without a new patch guard; wrap a cross-cutting change in patch, or rename the replay label for one changed step"+ ]+ where+ nodeName = wfId newWorkflow+ oldBody = normaliseWorkflowBody (wfBody oldWorkflow)+ newBody = normaliseWorkflowBody (wfBody newWorkflow)+ oldPatchIds = workflowBodyPatchIds oldBody+ newPatchIdsAll = workflowBodyPatchIds newBody+ newPatchIds = newPatchIdsAll \\ oldPatchIds+ removedPatchIds = oldPatchIds \\ newPatchIdsAll+ oldSeed = terminalContinueSeed oldBody+ newSeed = terminalContinueSeed newBody+ changedSeed = case (oldSeed, newSeed) of+ (Just oldSeedType, Just newSeedType)+ | oldSeedType /= newSeedType -> Just (oldSeedType, newSeedType)+ _ -> Nothing+ appendedSeed = case (oldSeed, newSeed) of+ (Nothing, Just seedType) -> Just seedType+ _ -> Nothing+ strippedNewBody = stripNewPatches newPatchIds newBody+ comparableNewBody = case appendedSeed of+ Just _ -> dropTerminalContinue strippedNewBody+ Nothing -> strippedNewBody+ safeAdditions =+ (not (null newPatchIds) || isJust appendedSeed)+ && comparableNewBody == oldBody+ removedPatch patchId =+ breaking nodeName "workflow-patch" patchId WorkflowPatchRemoved "patch id existed in the old spec but was removed; the differ cannot prove that no workflow generation still replays its journaled branch"+ addedPatch patchId =+ additive nodeName "workflow-patch" patchId WorkflowEvolutionGuardAdded "new patch guard contains the entire body change, so in-flight generations retain their journaled branch"++normaliseWorkflowBody :: [WfBodyItem] -> [WfBodyItem]+normaliseWorkflowBody = map go+ where+ go (WfStep label result _) = WfStep label result noLoc+ go (WfAwait label result _) = WfAwait label result noLoc+ go (WfSleep label delay _) = WfSleep label delay noLoc+ go (WfChild label via result _) = WfChild label via result noLoc+ go (WfPatch patchId items _) = WfPatch patchId (normaliseWorkflowBody items) noLoc+ go (WfContinueAsNew seedType _) = WfContinueAsNew seedType noLoc++workflowBodyPatchIds :: [WfBodyItem] -> [Name]+workflowBodyPatchIds = concatMap go+ where+ go (WfPatch patchId items _) = patchId : workflowBodyPatchIds items+ go _ = []++stripNewPatches :: [Name] -> [WfBodyItem] -> [WfBodyItem]+stripNewPatches newPatchIds = concatMap go+ where+ go (WfPatch patchId _ _) | patchId `elem` newPatchIds = []+ go (WfPatch patchId items loc) = [WfPatch patchId (stripNewPatches newPatchIds items) loc]+ go item = [item]++terminalContinueSeed :: [WfBodyItem] -> Maybe Name+terminalContinueSeed items = case reverse items of+ WfContinueAsNew seedType _ : _ -> Just seedType+ _ -> Nothing++dropTerminalContinue :: [WfBodyItem] -> [WfBodyItem]+dropTerminalContinue items = case reverse items of+ WfContinueAsNew {} : rest -> reverse rest+ _ -> items++additive :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change+additive n facet subj code detail =+ mkChange LabelAdditive (contextFor LabelAdditive n facet subj code) n facet subj code detail++breaking :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change+breaking n facet subj code detail =+ mkChange LabelBreaking (contextFor LabelBreaking n facet subj code) n facet subj code detail++advisory :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change+advisory n facet subj code detail =+ mkChange LabelAdvisory (contextFor LabelAdvisory n facet subj code) n facet subj code detail++advisoryAt :: ChangeContext -> Name -> Text -> Text -> DiagnosticCode -> Text -> Change+advisoryAt context n facet subj code detail =+ mkChange LabelAdvisory context n facet subj code detail++mkChange :: Label -> ChangeContext -> Name -> Text -> Text -> DiagnosticCode -> Text -> Change+mkChange label context n facet subj code detail =+ wrap+ ChangeKind+ { ckNode = n,+ ckFacet = facet,+ ckSubject = subj,+ ckCode = code,+ ckContext = context,+ ckVector = classifyCompatibility context code,+ ckPaths = changeContextPaths context,+ ckDetail = detail+ }+ where+ wrap = case label of+ LabelAdditive -> Additive+ LabelAdvisory -> Advisory+ LabelBreaking -> Breaking++contextFor :: Label -> Name -> Text -> Text -> DiagnosticCode -> ChangeContext+contextFor label root facet subject code =+ setLabel $ case () of+ _+ | 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+ | otherwise -> ChangeContext root paths ContextGeneral label+ where+ paths = [pathFor root facet subject]+ setLabel context = context {contextOriginalLabel = label}+ publicCodes =+ [ ContractEventRemoved,+ ContractFieldChanged,+ ContractDiscriminatorChanged,+ ContractTopicChanged,+ ContractSchemaVersionDecreased,+ ContractSchemaVersionBumped,+ ContractEventAdded,+ ContractTopicAdded+ ]+ queueCodes = [WqPayloadFieldChanged, WqOrderingChanged, WqProvisionChanged, WqGroupKeyChanged, QueueIdentityChanged]+ identityCodes =+ [ DerivedIdentityChanged,+ IdPrefixChanged,+ DedupeIdentityChanged,+ RouterStableNameChanged,+ WorkflowStableNameChanged,+ ReadModelVersionDecreased,+ ReadModelShapeChangedWithoutBump,+ ReadModelFeedChanged,+ ReadModelConsistencyWeakened+ ]+ privateCodes =+ [ EvtFieldAddedWithoutBump,+ EvtFieldRemovedSameVersion,+ EvtFieldTypeChanged,+ EvtVersionDecreased,+ EvtVersionMissingUpcaster,+ UpcasterChainGap,+ EvtRemovedNotDeprecated,+ EnumCtorRemoved,+ EnumWireSpellingChanged,+ WireSpecChanged,+ ProcessInputChanged,+ WorkflowShapeChanged,+ WorkflowBodyChanged,+ WorkflowPatchRemoved,+ WorkflowContinueSeedChanged,+ AggGuardTightened,+ DeprecatedEventReplayHazard,+ EventRetirementInProgress,+ EventUndeprecated,+ ProcessTimerPayloadChanged+ ]++pathFor :: Name -> Text -> Text -> Text+pathFor root facet subject+ | facet `elem` ["event", "event-field"] = root <> ".event." <> subject+ | facet `elem` ["contract-event", "contract-field"] = root <> ".event." <> subject+ | root == subject = root <> "." <> facet+ | otherwise = root <> "." <> facet <> "." <> subject commas :: [Text] -> Text commas = T.intercalate ", "
src/Keiro/Dsl/DiffReport.hs view
@@ -1,14 +1,13 @@-{- | Pure rendering and JSON encoding for compatibility-vector diff reports.--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. 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 (..),+-- | Pure rendering and JSON encoding for compatibility-vector diff reports.+--+-- 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. 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 (..),@@ -25,7 +24,8 @@ parseSurfaceName, verdictName, rolloutName,-) where+ )+where import Data.Aeson (ToJSON (..), Value, object, (.=)) import Data.Aeson.Types (Pair)@@ -39,105 +39,106 @@ import Keiro.Dsl.Validate (DiagnosticCode (..)) data Remedy- = RemedyVersionBump- | RemedyUpcaster- | RemedyDeploymentOrder RolloutConstraint- | RemedyContractRevision- | RemedyReplayOnlyEdge- | RemedyStateCodecBump- | RemedyRecompileConsumers- | RemedyRescaffoldWorkspace- | RemedyRunConformance- | RemedyDoNotDeploy Text- deriving stock (Eq, Show)+ = RemedyVersionBump+ | RemedyUpcaster+ | RemedyDeploymentOrder RolloutConstraint+ | RemedyContractRevision+ | RemedyReplayOnlyEdge+ | RemedyStateCodecBump+ | RemedyRecompileConsumers+ | RemedyRescaffoldWorkspace+ | RemedyRunConformance+ | RemedyNoSemanticAction+ | RemedyDoNotDeploy Text+ deriving stock (Eq, Show) data DiffReport = DiffReport- { reportGate :: !(Set CompatibilitySurface)- , reportFindings :: ![Change]- }- deriving stock (Eq, Show)+ { reportGate :: !(Set CompatibilitySurface),+ reportFindings :: ![Change]+ }+ deriving stock (Eq, Show) 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)+ { 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)+ { 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)+ { 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)+ { 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- [ "schema" .= ("keiro-dsl/diff-report/1" :: Text)- , "gate" .= map surfaceName (Set.toAscList (reportGate report))- , "breaking" .= any (gatedBreaking (reportGate report)) (reportFindings report)- , "findings" .= map (findingValue (reportGate report)) (reportFindings report)- ]+ toJSON report =+ object+ [ "schema" .= ("keiro-dsl/diff-report/1" :: Text),+ "gate" .= map surfaceName (Set.toAscList (reportGate report)),+ "breaking" .= any (gatedBreaking (reportGate report)) (reportFindings report),+ "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)- ]+ 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 = object (findingPairs gate change) workspaceFindingValue :: Set CompatibilitySurface -> WorkspaceChange -> Value workspaceFindingValue gate workspaceChange =- object- ( findingPairs gate (wcChange workspaceChange)- <> maybe [] (\site -> ["declaration" .= ownedSiteValue site]) (wcDeclarationSite workspaceChange)- <> ["useSites" .= map useSiteValue (wcUseSites workspaceChange) | not (null (wcUseSites workspaceChange))]- )+ object+ ( 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)))- ]+ [ "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 @@ -146,258 +147,260 @@ useSiteValue :: (Text, Maybe OwnedSite) -> Value useSiteValue (path, site) =- object- ( ["path" .= path]- <> maybe [] (\owned -> ["file" .= osFile owned, "line" .= osLine owned]) 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- ]+ 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- [ "private-history-read" .= verdictName (cvPrivateHistoryRead vector)- , "old-binary-read-new-events" .= verdictName (cvOldBinaryReadNewEvents vector)- , "snapshot-hydration" .= verdictName (cvSnapshotHydration vector)- , "public-consumer" .= verdictName (cvPublicConsumer vector)- , "persisted-identity" .= verdictName (cvPersistedIdentity vector)- , "consumer-build" .= verdictName (cvConsumerBuild vector)- , "rollout" .= map rolloutName (Set.toAscList (cvRollout vector))- ]+ object+ [ "private-history-read" .= verdictName (cvPrivateHistoryRead vector),+ "old-binary-read-new-events" .= verdictName (cvOldBinaryReadNewEvents vector),+ "snapshot-hydration" .= verdictName (cvSnapshotHydration vector),+ "public-consumer" .= verdictName (cvPublicConsumer vector),+ "persisted-identity" .= verdictName (cvPersistedIdentity vector),+ "consumer-build" .= verdictName (cvConsumerBuild vector),+ "rollout" .= map rolloutName (Set.toAscList (cvRollout vector))+ ] 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- | code `elem` [MappedFieldAddedWithDefault, MappedArmAdded, MappedEnumValueAdded] = mappedAdditionRemedy- | code `elem` [MappedHaskellSourceChanged, MappedRecordConstructorChanged] =- RemedyRecompileConsumers :| [RemedyRunConformance]- | code == MappedBindingChanged = mappedConformanceRemedy- | code == MappedFixturesChanged = RemedyRunConformance :| []- | code == MappedInitialChanged = mappedSnapshotConformanceRemedy- | code == MappedCanonicalTypeChanged = mappedCanonicalRemedy- | code == MappedDeclAdded = RemedyRunConformance :| []- | code `elem` eventDecodeCodes =- RemedyVersionBump :| [RemedyUpcaster, RemedyDeploymentOrder RolloutStopTheWorld]- | code `elem` contractCodes =- RemedyContractRevision :| [RemedyDeploymentOrder RolloutProducerLast]- | code `elem` queueCodes =- RemedyDeploymentOrder RolloutWorkersFirst :| [RemedyRunConformance]- | code `elem` identityCodes =- RemedyDoNotDeploy "revert the re-keying change or perform an explicit operational identity migration" :| []- | code == EnumCtorAdded = case Set.toAscList (cvRollout vector) of- rollout : _ -> RemedyDeploymentOrder rollout :| [snapshotRemedy]- [] -> snapshotRemedy :| []- | cvConsumerBuild vector `elem` [VAdvisory, VBreaking] =- RemedyRecompileConsumers :| [RemedyRunConformance]- | Just rollout <- firstRollout = RemedyDeploymentOrder rollout :| [RemedyRunConformance]- | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]- | otherwise = RemedyRunConformance :| []+ | code == SourceLanguageDeclarationChanged = RemedyNoSemanticAction :| []+ | code == OwnershipMoved = RemedyRescaffoldWorkspace :| []+ | code == WorkspaceAuthorityChanged = RemedyRescaffoldWorkspace :| [RemedyRecompileConsumers]+ | code == AggGuardTightened = RemedyReplayOnlyEdge :| [RemedyRunConformance]+ | code == AggFoldSurfaceChanged = RemedyStateCodecBump :| [RemedyRunConformance]+ | code `elem` mappedWireCodes = mappedWireRemedy+ | code `elem` [MappedFieldAddedWithDefault, MappedArmAdded, MappedEnumValueAdded] = mappedAdditionRemedy+ | code `elem` [MappedHaskellSourceChanged, MappedRecordConstructorChanged] =+ RemedyRecompileConsumers :| [RemedyRunConformance]+ | code == MappedBindingChanged = mappedConformanceRemedy+ | code == MappedFixturesChanged = RemedyRunConformance :| []+ | code == MappedInitialChanged = mappedSnapshotConformanceRemedy+ | code == MappedCanonicalTypeChanged = mappedCanonicalRemedy+ | code == MappedDeclAdded = RemedyRunConformance :| []+ | code `elem` eventDecodeCodes =+ RemedyVersionBump :| [RemedyUpcaster, RemedyDeploymentOrder RolloutStopTheWorld]+ | code `elem` contractCodes =+ RemedyContractRevision :| [RemedyDeploymentOrder RolloutProducerLast]+ | code `elem` queueCodes =+ RemedyDeploymentOrder RolloutWorkersFirst :| [RemedyRunConformance]+ | code `elem` identityCodes =+ RemedyDoNotDeploy "revert the re-keying change or perform an explicit operational identity migration" :| []+ | code == EnumCtorAdded = case Set.toAscList (cvRollout vector) of+ rollout : _ -> RemedyDeploymentOrder rollout :| [snapshotRemedy]+ [] -> snapshotRemedy :| []+ | cvConsumerBuild vector `elem` [VAdvisory, VBreaking] =+ RemedyRecompileConsumers :| [RemedyRunConformance]+ | Just rollout <- firstRollout = RemedyDeploymentOrder rollout :| [RemedyRunConformance]+ | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]+ | otherwise = RemedyRunConformance :| [] where vector = classifyCompatibility context code firstRollout = case Set.toAscList (cvRollout vector) of- rollout : _ -> Just rollout- [] -> Nothing+ rollout : _ -> Just rollout+ [] -> Nothing snapshotRemedy- | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump- | otherwise = RemedyRunConformance+ | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump+ | otherwise = RemedyRunConformance mappedWireRemedy- | cvPrivateHistoryRead vector == VBreaking =- RemedyVersionBump :| [RemedyUpcaster, RemedyDeploymentOrder RolloutStopTheWorld]- | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]- | otherwise = RemedyRecompileConsumers :| [RemedyRunConformance]+ | cvPrivateHistoryRead vector == VBreaking =+ RemedyVersionBump :| [RemedyUpcaster, RemedyDeploymentOrder RolloutStopTheWorld]+ | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]+ | otherwise = RemedyRecompileConsumers :| [RemedyRunConformance] mappedAdditionRemedy- | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]- | Just rollout <- firstRollout = RemedyDeploymentOrder rollout :| [RemedyRunConformance]- | otherwise = RemedyRunConformance :| []+ | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]+ | Just rollout <- firstRollout = RemedyDeploymentOrder rollout :| [RemedyRunConformance]+ | otherwise = RemedyRunConformance :| [] mappedConformanceRemedy- | cvSnapshotHydration vector == VAdvisory = RemedyRunConformance :| [RemedyStateCodecBump]- | otherwise = RemedyRunConformance :| []+ | cvSnapshotHydration vector == VAdvisory = RemedyRunConformance :| [RemedyStateCodecBump]+ | otherwise = RemedyRunConformance :| [] mappedSnapshotConformanceRemedy- | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]- | otherwise = RemedyRunConformance :| []+ | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]+ | otherwise = RemedyRunConformance :| [] mappedCanonicalRemedy- | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRecompileConsumers, RemedyRunConformance]- | otherwise = RemedyRecompileConsumers :| [RemedyRunConformance]+ | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRecompileConsumers, RemedyRunConformance]+ | otherwise = RemedyRecompileConsumers :| [RemedyRunConformance] mappedWireCodes =- [ MappedFieldAddedNoDefault- , MappedFieldRemoved- , MappedFieldTypeChanged- , MappedPresenceChanged- , MappedNullabilityChanged- , MappedDefaultRemoved- , MappedDefaultChanged- , MappedWireKeyChanged- , MappedUnionEncodingChanged- , MappedArmRemoved- , MappedArmTagChanged- , MappedEnumValueRemoved- , MappedEnumSpellingChanged- , MappedOpaqueCodecChanged- , MappedModeCrossed- , MappedDeclRemoved- ]+ [ MappedFieldAddedNoDefault,+ MappedFieldRemoved,+ MappedFieldTypeChanged,+ MappedPresenceChanged,+ MappedNullabilityChanged,+ MappedDefaultRemoved,+ MappedDefaultChanged,+ MappedWireKeyChanged,+ MappedUnionEncodingChanged,+ MappedArmRemoved,+ MappedArmTagChanged,+ MappedEnumValueRemoved,+ MappedEnumSpellingChanged,+ MappedOpaqueCodecChanged,+ MappedModeCrossed,+ MappedDeclRemoved+ ] eventDecodeCodes =- [ EvtFieldAddedWithoutBump- , EvtFieldRemovedSameVersion- , EvtFieldTypeChanged- , EvtVersionDecreased- , EvtVersionMissingUpcaster- , UpcasterChainGap- , EvtRemovedNotDeprecated- , EnumCtorRemoved- , EnumWireSpellingChanged- , WireSpecChanged- , ProcessInputChanged- , WorkflowShapeChanged- , WorkflowBodyChanged- , WorkflowPatchRemoved- , WorkflowContinueSeedChanged- ]+ [ EvtFieldAddedWithoutBump,+ EvtFieldRemovedSameVersion,+ EvtFieldTypeChanged,+ EvtVersionDecreased,+ EvtVersionMissingUpcaster,+ UpcasterChainGap,+ EvtRemovedNotDeprecated,+ EnumCtorRemoved,+ EnumWireSpellingChanged,+ WireSpecChanged,+ ProcessInputChanged,+ WorkflowShapeChanged,+ WorkflowBodyChanged,+ WorkflowPatchRemoved,+ WorkflowContinueSeedChanged+ ] contractCodes =- [ ContractEventRemoved- , ContractFieldChanged- , ContractDiscriminatorChanged- , ContractTopicChanged- , ContractSchemaVersionDecreased- , ContractSchemaVersionBumped- ]+ [ ContractEventRemoved,+ ContractFieldChanged,+ ContractDiscriminatorChanged,+ ContractTopicChanged,+ ContractSchemaVersionDecreased,+ ContractSchemaVersionBumped+ ] queueCodes = [WqPayloadFieldChanged, WqOrderingChanged, WqProvisionChanged, WqGroupKeyChanged, QueueIdentityChanged] identityCodes =- [ DerivedIdentityChanged- , IdPrefixChanged- , DedupeIdentityChanged- , RouterStableNameChanged- , WorkflowStableNameChanged- , ReadModelVersionDecreased- , ReadModelShapeChangedWithoutBump- , ReadModelFeedChanged- , ReadModelConsistencyWeakened- ]+ [ DerivedIdentityChanged,+ IdPrefixChanged,+ DedupeIdentityChanged,+ RouterStableNameChanged,+ WorkflowStableNameChanged,+ ReadModelVersionDecreased,+ ReadModelShapeChangedWithoutBump,+ ReadModelFeedChanged,+ ReadModelConsistencyWeakened+ ] renderRemedy :: Remedy -> Text renderRemedy remedy = case remedy of- RemedyVersionBump -> "bump the owning schema or event version"- RemedyUpcaster -> "add and retain a contiguous upcaster for every historical version"- RemedyDeploymentOrder rollout -> "deploy in " <> rolloutName rollout <> " order"- RemedyContractRevision -> "revise the independently owned public contract"- 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+ RemedyVersionBump -> "bump the owning schema or event version"+ RemedyUpcaster -> "add and retain a contiguous upcaster for every historical version"+ RemedyDeploymentOrder rollout -> "deploy in " <> rolloutName rollout <> " order"+ RemedyContractRevision -> "revise the independently owned public contract"+ 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"+ RemedyNoSemanticAction -> "no semantic action is required; only source-language provenance changed"+ RemedyDoNotDeploy detail -> detail renderFinding :: Change -> Text renderFinding change =- headline- <> if vectorIsUniform (ckVector kind)- then ""- else "\n" <> renderVectorLine (ckVector kind)+ headline+ <> if vectorIsUniform (ckVector kind)+ then ""+ else "\n" <> renderVectorLine (ckVector kind) where kind = changeKind change headline =- headlineName change- <> ": "- <> ckNode kind- <> " "- <> ckFacet kind- <> " "- <> ckSubject kind- <> ": "- <> ckDetail kind- <> codeSuffix change kind+ headlineName change+ <> ": "+ <> ckNode kind+ <> " "+ <> ckFacet kind+ <> " "+ <> ckSubject kind+ <> ": "+ <> ckDetail kind+ <> codeSuffix change kind renderVectorLine :: CompatibilityVector -> Text renderVectorLine vector =- " vector: "- <> T.unwords- ( [ surfaceName surface <> "=" <> verdictName verdict- | surface <- [minBound .. maxBound]- , let verdict = verdictFor surface vector- , verdict /= VNotApplicable- ]- <> ["rollout=" <> T.intercalate "," (map rolloutName (Set.toAscList (cvRollout vector))) | not (Set.null (cvRollout vector))]- )+ " vector: "+ <> T.unwords+ ( [ surfaceName surface <> "=" <> verdictName verdict+ | surface <- [minBound .. maxBound],+ let verdict = verdictFor surface vector,+ verdict /= VNotApplicable+ ]+ <> ["rollout=" <> T.intercalate "," (map rolloutName (Set.toAscList (cvRollout vector))) | not (Set.null (cvRollout vector))]+ ) renderExplainBlock :: Change -> Text renderExplainBlock change =- "explain ["- <> T.pack (show (ckCode kind))- <> "]\n"- <> T.unlines [" path: " <> path | path <- ckPaths kind]- <> T.unlines (map (" direction: " <>) directions)- <> T.unlines [" remedy: " <> renderRemedy remedy | remedy <- NonEmpty.toList remedies]+ "explain ["+ <> T.pack (show (ckCode kind))+ <> "]\n"+ <> T.unlines [" path: " <> path | path <- ckPaths kind]+ <> T.unlines (map (" direction: " <>) directions)+ <> T.unlines [" remedy: " <> renderRemedy remedy | remedy <- NonEmpty.toList remedies] where kind = changeKind change vector = ckVector kind directions =- [ surfaceName surface <> " is " <> verdictName verdict <> "; " <> directionMeaning surface verdict- | surface <- [minBound .. maxBound]- , let verdict = verdictFor surface vector- , verdict `elem` [VAdvisory, VBreaking]- ]+ [ surfaceName surface <> " is " <> verdictName verdict <> "; " <> directionMeaning surface verdict+ | surface <- [minBound .. maxBound],+ let verdict = verdictFor surface vector,+ verdict `elem` [VAdvisory, VBreaking]+ ] remedies = remediationFor (ckContext kind) (ckCode kind) surfaceName :: CompatibilitySurface -> Text surfaceName surface = case surface of- PrivateHistoryRead -> "private-history-read"- OldBinaryReadNewEvents -> "old-binary-read-new-events"- SnapshotHydration -> "snapshot-hydration"- PublicConsumer -> "public-consumer"- PersistedIdentity -> "persisted-identity"- ConsumerBuild -> "consumer-build"+ PrivateHistoryRead -> "private-history-read"+ OldBinaryReadNewEvents -> "old-binary-read-new-events"+ SnapshotHydration -> "snapshot-hydration"+ PublicConsumer -> "public-consumer"+ PersistedIdentity -> "persisted-identity"+ ConsumerBuild -> "consumer-build" parseSurfaceName :: String -> Either String CompatibilitySurface parseSurfaceName raw = case lookup (T.pack raw) [(surfaceName surface, surface) | surface <- [minBound .. maxBound]] of- Just surface -> Right surface- Nothing ->- Left- ( "unknown compatibility surface '"- <> raw- <> "'; expected one of: "- <> T.unpack (T.intercalate ", " (map surfaceName [minBound .. maxBound]))- )+ Just surface -> Right surface+ Nothing ->+ Left+ ( "unknown compatibility surface '"+ <> raw+ <> "'; expected one of: "+ <> T.unpack (T.intercalate ", " (map surfaceName [minBound .. maxBound]))+ ) verdictName :: SurfaceVerdict -> Text verdictName verdict = case verdict of- VCompatible -> "compatible"- VAdvisory -> "advisory"- VBreaking -> "breaking"- VNotApplicable -> "n/a"+ VCompatible -> "compatible"+ VAdvisory -> "advisory"+ VBreaking -> "breaking"+ VNotApplicable -> "n/a" rolloutName :: RolloutConstraint -> Text rolloutName rollout = case rollout of- RolloutStopTheWorld -> "stop-the-world"- RolloutWorkersFirst -> "workers-first"- RolloutDrainRequired -> "drain-required"- RolloutProducerLast -> "producer-last"+ RolloutStopTheWorld -> "stop-the-world"+ RolloutWorkersFirst -> "workers-first"+ RolloutDrainRequired -> "drain-required"+ RolloutProducerLast -> "producer-last" labelName :: Label -> Text labelName label = case label of- LabelAdditive -> "additive"- LabelAdvisory -> "warning"- LabelBreaking -> "breaking"+ LabelAdditive -> "additive"+ LabelAdvisory -> "warning"+ LabelBreaking -> "breaking" headlineName :: Change -> Text-headlineName Additive{} = "ADDITIVE"-headlineName Advisory{} = "WARNING"-headlineName Breaking{} = "BREAKING"+headlineName Additive {} = "ADDITIVE"+headlineName Advisory {} = "WARNING"+headlineName Breaking {} = "BREAKING" codeSuffix :: Change -> ChangeKind -> Text-codeSuffix Additive{} _ = ""+codeSuffix Additive {} _ = "" codeSuffix _ kind = " [" <> T.pack (show (ckCode kind)) <> "]" changeKind :: Change -> ChangeKind@@ -407,14 +410,14 @@ vectorIsUniform :: CompatibilityVector -> Bool vectorIsUniform vector =- Set.null (cvRollout vector)- && all (`elem` [VCompatible, VNotApplicable]) [verdictFor surface vector | surface <- [minBound .. maxBound]]+ Set.null (cvRollout vector)+ && all (`elem` [VCompatible, VNotApplicable]) [verdictFor surface vector | surface <- [minBound .. maxBound]] directionMeaning :: CompatibilitySurface -> SurfaceVerdict -> Text directionMeaning surface verdict = case (surface, verdict) of- (PrivateHistoryRead, _) -> "the candidate binary may reinterpret or fail to read stored private history"- (OldBinaryReadNewEvents, _) -> "a still-running old binary may reject events emitted by the candidate"- (SnapshotHydration, _) -> "persisted snapshot seeds require invalidation or rebuild"- (PublicConsumer, _) -> "an independently deployed consumer may reject the candidate contract"- (PersistedIdentity, _) -> "replay or retry may derive a different persisted identity"- (ConsumerBuild, _) -> "consumer or generated source must be rebuilt"+ (PrivateHistoryRead, _) -> "the candidate binary may reinterpret or fail to read stored private history"+ (OldBinaryReadNewEvents, _) -> "a still-running old binary may reject events emitted by the candidate"+ (SnapshotHydration, _) -> "persisted snapshot seeds require invalidation or rebuild"+ (PublicConsumer, _) -> "an independently deployed consumer may reject the candidate contract"+ (PersistedIdentity, _) -> "replay or retry may derive a different persisted identity"+ (ConsumerBuild, _) -> "consumer or generated source must be rebuilt"
src/Keiro/Dsl/ExplainBindings.hs view
@@ -1,131 +1,161 @@ {-# OPTIONS_GHC -Werror=incomplete-patterns #-} -{- | Consumer-owned Haskell obligations implied by checked structural mapped-declarations. The same values drive create-once skeletons, scaffold-record-diffs, and the @check --explain-bindings@ report.--}-module Keiro.Dsl.ExplainBindings (+-- | Consumer-owned Haskell obligations implied by checked structural mapped+-- declarations. The same values drive create-once skeletons, scaffold-record+-- diffs, and the @check --explain-bindings@ report.+module Keiro.Dsl.ExplainBindings+ ( BindingResolutionError (..), BindingObligationKind (..), BindingObligation (..), BindingHole (..), bindingObligations, bindingHoles, renderBindingObligations,-) where+ )+where import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))+import Data.Bifunctor (first)+import Data.Char (toUpper) import Data.List (groupBy, sortOn) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as T-import Keiro.Dsl.Grammar (HaskellSource (..), Name, Spec (..), WireEnum (..))+import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType import Keiro.Dsl.TypeGraph +data BindingResolutionError+ = BindingTypeGraphError !TypeGraphError+ | BindingNominalTypeError !NominalTypeError+ deriving stock (Eq, Show)+ data BindingObligationKind- = BindingValue- | FixtureValue- | InitialValue- deriving stock (Eq, Ord, Show)+ = BindingValue+ | FixtureValue+ | InitialValue+ deriving stock (Eq, Ord, Show) data BindingObligation = BindingObligation- { obligationMappedName :: !Name- , obligationPackage :: !Text- , obligationModule :: !Text- , obligationSymbol :: !Text- , obligationKind :: !BindingObligationKind- , obligationSignature :: !Text- , obligationUseSites :: ![Text]- , obligationBindingVersion :: !(Maybe Text)- }- deriving stock (Eq, Ord, Show)+ { obligationMappedName :: !Name,+ obligationPackage :: !Text,+ obligationModule :: !Text,+ obligationSymbol :: !Text,+ obligationKind :: !BindingObligationKind,+ obligationSignature :: !Text,+ obligationUseSites :: ![Text],+ obligationBindingVersion :: !(Maybe Text),+ obligationCanonicalType :: !(Maybe Text),+ obligationCategory :: !Text+ }+ deriving stock (Eq, Ord, Show) data BindingHole = BindingHole- { holeMappedName :: !Name- , holeModule :: !Text- , holeSymbol :: !Text- , holeKind :: !BindingObligationKind- , holePath :: !(Maybe Text)- , holeSignature :: !Text- }- deriving stock (Eq, Ord, Show)+ { holeMappedName :: !Name,+ holeModule :: !Text,+ holeSymbol :: !Text,+ holeKind :: !BindingObligationKind,+ holePath :: !(Maybe Text),+ holeSignature :: !Text+ }+ deriving stock (Eq, Ord, Show) instance ToJSON BindingObligation where- toJSON obligation =- object- [ "schema" .= (1 :: Int)- , "mappedName" .= obligationMappedName obligation- , "package" .= obligationPackage obligation- , "module" .= obligationModule obligation- , "symbol" .= obligationSymbol obligation- , "kind" .= renderKind (obligationKind obligation)- , "signature" .= obligationSignature obligation- , "useSites" .= obligationUseSites obligation- , "bindingVersion" .= obligationBindingVersion obligation- ]+ toJSON obligation =+ object+ [ "schema" .= (1 :: Int),+ "mappedName" .= obligationMappedName obligation,+ "package" .= obligationPackage obligation,+ "module" .= obligationModule obligation,+ "symbol" .= obligationSymbol obligation,+ "kind" .= renderKind (obligationKind obligation),+ "signature" .= obligationSignature obligation,+ "useSites" .= obligationUseSites obligation,+ "bindingVersion" .= obligationBindingVersion obligation,+ "canonicalType" .= obligationCanonicalType obligation,+ "category" .= obligationCategory obligation+ ] instance FromJSON BindingObligation where- parseJSON = withObject "keiro-dsl binding obligation" $ \value -> do- schema <- value .: "schema"- if schema /= (1 :: Int)- then fail "unsupported binding obligation schema"- else do- kindText <- value .: "kind"- kindValue <- maybe (fail "unknown binding obligation kind") pure (parseKind kindText)- BindingObligation- <$> value .: "mappedName"- <*> value .: "package"- <*> value .: "module"- <*> value .: "symbol"- <*> pure kindValue- <*> value .: "signature"- <*> value .: "useSites"- <*> value .:? "bindingVersion"+ parseJSON = withObject "keiro-dsl binding obligation" $ \value -> do+ schema <- value .: "schema"+ if schema /= (1 :: Int)+ then fail "unsupported binding obligation schema"+ else do+ kindText <- value .: "kind"+ kindValue <- maybe (fail "unknown binding obligation kind") pure (parseKind kindText)+ BindingObligation+ <$> value .: "mappedName"+ <*> value .: "package"+ <*> value .: "module"+ <*> value .: "symbol"+ <*> pure kindValue+ <*> value .: "signature"+ <*> value .: "useSites"+ <*> value .:? "bindingVersion"+ <*> value .:? "canonicalType"+ <*> (value .:? "category" >>= pure . maybe "structural" id) instance ToJSON BindingHole where- toJSON hole =- object- [ "schema" .= (1 :: Int)- , "mappedName" .= holeMappedName hole- , "module" .= holeModule hole- , "symbol" .= holeSymbol hole- , "kind" .= renderKind (holeKind hole)- , "path" .= holePath hole- , "signature" .= holeSignature hole- ]+ toJSON hole =+ object+ [ "schema" .= (1 :: Int),+ "mappedName" .= holeMappedName hole,+ "module" .= holeModule hole,+ "symbol" .= holeSymbol hole,+ "kind" .= renderKind (holeKind hole),+ "path" .= holePath hole,+ "signature" .= holeSignature hole+ ] instance FromJSON BindingHole where- parseJSON = withObject "keiro-dsl binding hole" $ \value -> do- schema <- value .: "schema"- if schema /= (1 :: Int)- then fail "unsupported binding hole schema"- else do- kindText <- value .: "kind"- kindValue <- maybe (fail "unknown binding hole kind") pure (parseKind kindText)- BindingHole- <$> value .: "mappedName"- <*> value .: "module"- <*> value .: "symbol"- <*> pure kindValue- <*> value .:? "path"- <*> value .: "signature"+ parseJSON = withObject "keiro-dsl binding hole" $ \value -> do+ schema <- value .: "schema"+ if schema /= (1 :: Int)+ then fail "unsupported binding hole schema"+ else do+ kindText <- value .: "kind"+ kindValue <- maybe (fail "unknown binding hole kind") pure (parseKind kindText)+ BindingHole+ <$> value .: "mappedName"+ <*> value .: "module"+ <*> value .: "symbol"+ <*> pure kindValue+ <*> value .:? "path"+ <*> value .: "signature" -bindingObligations :: Spec -> Either (NonEmpty TypeGraphError) [BindingObligation]+bindingObligations :: Spec -> Either (NonEmpty BindingResolutionError) [BindingObligation] bindingObligations spec = do- graph <- resolveTypeGraph spec- pure . sortOn obligationSortKey . concat $- [ obligationsFor graph declaration- | ResolvedStructural declaration _ <- Map.elems (tgDeclarations graph)- ]+ graph <- first (fmap BindingTypeGraphError) (resolveTypeGraph spec)+ nominalRegistry <- first (fmap BindingNominalTypeError) (resolveNominalTypes spec)+ pure . sortOn obligationSortKey $+ concat+ [ obligationsFor graph declaration+ | ResolvedStructural declaration _ <- Map.elems (tgDeclarations graph)+ ]+ <> concatMap (nominalObligationsFor spec) (Map.elems (nominalTypes nominalRegistry)) -bindingHoles :: Spec -> Either (NonEmpty TypeGraphError) [BindingHole]+bindingHoles :: Spec -> Either (NonEmpty BindingResolutionError) [BindingHole] bindingHoles spec = do- graph <- resolveTypeGraph spec- obligations <- bindingObligations spec- pure . sortOn holeSortKey . concat $- [ holesFor graph declaration shape obligations- | ResolvedStructural declaration shape <- Map.elems (tgDeclarations graph)- ]+ graph <- first (fmap BindingTypeGraphError) (resolveTypeGraph spec)+ obligations <- bindingObligations spec+ pure . sortOn holeSortKey $+ concat+ [ holesFor graph declaration shape obligations+ | ResolvedStructural declaration shape <- Map.elems (tgDeclarations graph)+ ]+ <> [ BindingHole+ { holeMappedName = obligationMappedName obligation,+ holeModule = obligationModule obligation,+ holeSymbol = obligationSymbol obligation,+ holeKind = obligationKind obligation,+ holePath = Nothing,+ holeSignature = obligationSignature obligation+ }+ | obligation <- obligations,+ obligationCategory obligation /= "structural"+ ] holesFor :: TypeGraph -> StructuralDecl -> ResolvedMappedShape -> [BindingObligation] -> [BindingHole] holesFor _graph declaration shape obligations = bindingEntries <> auxiliaryEntries@@ -133,60 +163,61 @@ own = filter ((== sdName declaration) . obligationMappedName) obligations binding = onlyKind BindingValue bindingEntries = case binding of- Nothing -> []- Just obligation -> map (bindingHole obligation) (shapeHolePaths shape)+ Nothing -> []+ Just obligation -> map (bindingHole obligation) (shapeHolePaths shape) auxiliaryEntries =- [ BindingHole- { holeMappedName = obligationMappedName obligation- , holeModule = obligationModule obligation- , holeSymbol = obligationSymbol obligation- , holeKind = obligationKind obligation- , holePath = Nothing- , holeSignature = obligationSignature obligation- }- | obligation <- own- , obligationKind obligation /= BindingValue- ]+ [ BindingHole+ { holeMappedName = obligationMappedName obligation,+ holeModule = obligationModule obligation,+ holeSymbol = obligationSymbol obligation,+ holeKind = obligationKind obligation,+ holePath = Nothing,+ holeSignature = obligationSignature obligation+ }+ | obligation <- own,+ obligationKind obligation /= BindingValue+ ] onlyKind wanted = case filter ((== wanted) . obligationKind) own of- entry : _ -> Just entry- [] -> Nothing+ entry : _ -> Just entry+ [] -> Nothing bindingHole obligation (path, expectedType) =- BindingHole- { holeMappedName = obligationMappedName obligation- , holeModule = obligationModule obligation- , holeSymbol = obligationSymbol obligation- , holeKind = BindingValue- , holePath = Just path- , holeSignature = obligationSymbol obligation <> "." <> path <> " :: " <> expectedType- }+ BindingHole+ { holeMappedName = obligationMappedName obligation,+ holeModule = obligationModule obligation,+ holeSymbol = obligationSymbol obligation,+ holeKind = BindingValue,+ holePath = Just path,+ holeSignature = obligationSymbol obligation <> "." <> path <> " :: " <> expectedType+ } shapeHolePaths :: ResolvedMappedShape -> [(Text, Text)] shapeHolePaths =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields -> [(rwfHaskell field, renderExprType (rwfType field)) | field <- fields]- , onEnum = \entries -> [(weCtor entry, "constructor case") | entry <- entries]- , onUnion = \_ arms ->- [ (rwaCtor arm, maybe "constructor case" renderExprType (rwaPayload arm))- | arm <- arms- ]- }+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields -> [(rwfHaskell field, renderExprType (rwfType field)) | field <- fields],+ onEnum = \entries -> [(weCtor entry, "constructor case") | entry <- entries],+ onUnion = \_ arms ->+ [ (rwaCtor arm, maybe "constructor case" renderExprType (rwaPayload arm))+ | arm <- arms+ ]+ } renderExprType :: ResolvedTypeExpr -> Text renderExprType =- foldTypeExpr- TypeExprAlgebra- { onText = "Text"- , onInt = "Int"- , onBool = "Bool"- , onNatural = "Natural"- , onTime = "UTCTime"- , onJson = "Value"- , onOptional = \value -> "Maybe (" <> value <> ")"- , onList = \value -> "[" <> value <> "]"- , onMap = \value -> "Map Text (" <> value <> ")"- , onRef = unMappedKey- }+ foldTypeExpr+ TypeExprAlgebra+ { onText = "Text",+ onInt = "Int",+ onInteger = "Integer",+ onBool = "Bool",+ onNatural = "Natural",+ onTime = "UTCTime",+ onJson = "Value",+ onOptional = \value -> "Maybe (" <> value <> ")",+ onList = \value -> "[" <> value <> "]",+ onMap = \value -> "Map Text (" <> value <> ")",+ onRef = unMappedKey+ } obligationsFor :: TypeGraph -> StructuralDecl -> [BindingObligation] obligationsFor graph declaration = bindingEntry : fixtureEntry : initialEntries@@ -196,85 +227,173 @@ shapeType = sdName declaration <> "Shape" paths = map renderUsePath (usePaths graph (sdName declaration)) registerPaths =- [ renderUsePath path- | path@UsePath{upRoot = RootRegister{}} <- usePaths graph (sdName declaration)- ]+ [ renderUsePath path+ | path@UsePath {upRoot = RootRegister {}} <- usePaths graph (sdName declaration)+ ] bindingEntry =- obligationFor- declaration- (sdBinding declaration)- BindingValue- ("StructuralBinding " <> consumerType <> " " <> shapeType)- paths- (Just (unBindingVersion (sdBindingVersion declaration)))+ obligationFor+ declaration+ (sdBinding declaration)+ BindingValue+ ("StructuralBinding " <> consumerType <> " " <> shapeType)+ paths+ (Just (unBindingVersion (sdBindingVersion declaration)))+ (Just (unCanonicalTypeId (sdCanonical declaration))) fixtureEntry =- obligationFor- declaration- (sdFixtures declaration)- FixtureValue- ("FixtureCases " <> consumerType)- paths- Nothing+ obligationFor+ declaration+ (sdFixtures declaration)+ FixtureValue+ ("FixtureCases " <> consumerType)+ paths+ Nothing+ (Just (unCanonicalTypeId (sdCanonical declaration))) initialEntries = case (registerPaths, sdInitial declaration) of+ ([], _) -> []+ (_, Nothing) -> []+ (_, Just initialValue) ->+ [ obligationFor declaration initialValue InitialValue consumerType registerPaths Nothing (Just (unCanonicalTypeId (sdCanonical declaration)))+ ]++obligationFor :: StructuralDecl -> QualifiedValueName -> BindingObligationKind -> Text -> [Text] -> Maybe Text -> Maybe Text -> BindingObligation+obligationFor declaration qualified kindValue signature paths version canonical =+ BindingObligation+ { obligationMappedName = sdName declaration,+ obligationPackage = hsPackage (sdHaskell declaration),+ obligationModule = ownerModule,+ obligationSymbol = symbol,+ obligationKind = kindValue,+ obligationSignature = symbol <> " :: " <> signature,+ obligationUseSites = paths,+ obligationBindingVersion = version,+ obligationCanonicalType = canonical,+ obligationCategory = "structural"+ }+ where+ (ownerModule, symbol) = splitQualified (unQualifiedValueName qualified)++nominalObligationsFor :: Spec -> ResolvedNominalType -> [BindingObligation]+nominalObligationsFor spec nominal = case resolvedNominalOwnership nominal of+ GeneratedNominal -> []+ ConsumerNominal binding -> bindingEntry : fixtureEntry : initialEntries+ where+ name = resolvedNominalName nominal+ source = consumerNominalHaskell binding+ consumerType = hsModule source <> "." <> hsType source+ paths = nominalUseSites spec name+ registerPaths = [path | path <- paths, " register " `T.isInfixOf` path]+ category = case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> "nominal-id"+ EnumRepresentation {} -> "nominal-enum"+ ScalarRepresentation {} -> "nominal-scalar"+ representation = case resolvedNominalRepresentation nominal of+ IdRepresentation prefix -> "(KindID " <> quoted prefix <> ")"+ EnumRepresentation {} -> nominalEnumRepresentationModule spec name <> "." <> name <> "Representation"+ ScalarRepresentation NominalText -> "Text"+ ScalarRepresentation NominalInt -> "Int"+ ScalarRepresentation NominalNatural -> "Natural"+ ScalarRepresentation NominalBool -> "Bool"+ ScalarRepresentation NominalTime -> "UTCTime"+ canonical = Just (unCanonicalTypeId (consumerNominalCanonical binding))+ bindingEntry = nominalObligation name binding category (consumerNominalBinding binding) BindingValue ("NominalBinding " <> consumerType <> " " <> representation) paths (Just (unBindingVersion (consumerNominalBindingVersion binding))) canonical+ fixtureEntry = nominalObligation name binding category (consumerNominalFixtures binding) FixtureValue ("NominalFixtureCases " <> consumerType) paths Nothing canonical+ initialEntries = case (registerPaths, consumerNominalInitial binding) of ([], _) -> [] (_, Nothing) -> []- (_, Just initialValue) ->- [ obligationFor declaration initialValue InitialValue consumerType registerPaths Nothing- ]+ (_, Just initialValue) -> [nominalObligation name binding category initialValue InitialValue consumerType registerPaths Nothing canonical]+ where+ quoted value = T.pack (show value) -obligationFor :: StructuralDecl -> QualifiedValueName -> BindingObligationKind -> Text -> [Text] -> Maybe Text -> BindingObligation-obligationFor declaration qualified kindValue signature paths version =- BindingObligation- { obligationMappedName = sdName declaration- , obligationPackage = hsPackage (sdHaskell declaration)- , obligationModule = ownerModule- , obligationSymbol = symbol- , obligationKind = kindValue- , obligationSignature = symbol <> " :: " <> signature- , obligationUseSites = paths- , obligationBindingVersion = version- }+nominalObligation :: Name -> ConsumerNominalBinding -> Text -> QualifiedValueName -> BindingObligationKind -> Text -> [Text] -> Maybe Text -> Maybe Text -> BindingObligation+nominalObligation name binding category qualified kindValue signature paths version canonical =+ BindingObligation+ { obligationMappedName = name,+ obligationPackage = hsPackage (consumerNominalHaskell binding),+ obligationModule = ownerModule,+ obligationSymbol = symbol,+ obligationKind = kindValue,+ obligationSignature = symbol <> " :: " <> signature,+ obligationUseSites = paths,+ obligationBindingVersion = version,+ obligationCanonicalType = canonical,+ obligationCategory = category+ } where (ownerModule, symbol) = splitQualified (unQualifiedValueName qualified) +nominalUseSites :: Spec -> Name -> [Text]+nominalUseSites spec target = concatMap aggregatePaths [aggregate | NAggregate aggregate <- specNodes spec]+ where+ aggregatePaths aggregate =+ [ aggName aggregate <> " command " <> cmdName command <> " ." <> aggregateFieldName field <> " : " <> target+ | command <- aggCommands aggregate,+ field <- cmdFields command,+ fieldUses field+ ]+ <> [ aggName aggregate <> " event " <> evName event <> " ." <> aggregateFieldName field <> " : " <> target+ | event <- aggEvents aggregate,+ field <- eventFields aggregate event,+ fieldUses field+ ]+ <> [ aggName aggregate <> " register " <> regName register <> " : " <> target+ | register <- aggRegs aggregate,+ regType register == TRef target+ ]+ eventFields aggregate event = case evBody event of+ EventFields fields -> fields+ EventFromCommand commandName -> concat [cmdFields command | command <- aggCommands aggregate, cmdName command == commandName]+ fieldUses field = aggregateFieldType field == Just (TRef target)++nominalEnumRepresentationModule :: Spec -> Name -> Text+nominalEnumRepresentationModule spec name = case maybe GeneratedPrefix id (specLayout spec) of+ GeneratedPrefix -> root <> "Generated." <> contextName <> ".Nominal.Shape." <> name+ CollocatedLeaf -> root <> contextName <> ".Nominal.Shape." <> name <> ".Generated"+ where+ root = maybe "" (<> ".") (specModuleRoot spec)+ contextName = T.concat (map titleSegment (T.splitOn "-" (specContext spec)))+ titleSegment value = case T.uncons value of+ Nothing -> ""+ Just (initialChar, rest) -> T.cons (toUpper initialChar) rest+ renderBindingObligations :: Text -> [BindingObligation] -> Text renderBindingObligations context obligations = case obligations of- [] -> "no binding obligations for context " <> context- _ ->- T.unlines $- ["binding obligations for context " <> context]- <> concatMap renderGroup grouped+ [] -> "no binding obligations for context " <> context+ _ ->+ T.unlines $+ ["binding obligations for context " <> context]+ <> concatMap renderGroup grouped where grouped = groupBy sameOwner (sortOn obligationSortKey obligations) sameOwner left right = ownerKey left == ownerKey right renderGroup [] = []- renderGroup entries@(first : _) =- (" " <> obligationModule first <> " (package " <> obligationPackage first <> ")")- : concatMap renderEntry entries+ renderGroup entries@(firstEntry : _) =+ (" " <> obligationModule firstEntry <> " (package " <> obligationPackage firstEntry <> ")")+ : concatMap renderEntry entries renderEntry obligation =- [ " " <> obligationSignature obligation- , " reason: " <> renderKind (obligationKind obligation) <> " — structural mapped type " <> obligationMappedName obligation <> renderPaths (obligationUseSites obligation)- ]- <> maybe [] (\version -> [" provenance: binding-version " <> quoted version]) (obligationBindingVersion obligation)+ [ " " <> obligationSignature obligation,+ " reason: " <> renderKind (obligationKind obligation) <> " — " <> obligationCategory obligation <> " type " <> obligationMappedName obligation <> renderPaths (obligationUseSites obligation)+ ]+ <> maybe [] (\version -> [" provenance: binding-version " <> quoted version]) (obligationBindingVersion obligation)+ <> maybe [] (\canonical -> [" canonical-type: " <> quoted canonical]) (obligationCanonicalType obligation) renderPaths [] = " (not currently used by an aggregate root)" renderPaths paths = " (" <> T.intercalate "; " paths <> ")" quoted value = T.pack (show value) obligationSortKey :: BindingObligation -> (Text, Text, Text, BindingObligationKind, Text) obligationSortKey obligation =- ( obligationPackage obligation- , obligationModule obligation- , obligationMappedName obligation- , obligationKind obligation- , obligationSymbol obligation- )+ ( obligationPackage obligation,+ obligationModule obligation,+ obligationMappedName obligation,+ obligationKind obligation,+ obligationSymbol obligation+ ) ownerKey :: BindingObligation -> (Text, Text) ownerKey obligation = (obligationPackage obligation, obligationModule obligation) holeSortKey :: BindingHole -> (Text, Name, BindingObligationKind, Maybe Text, Text) holeSortKey hole =- (holeModule hole, holeMappedName hole, holeKind hole, holePath hole, holeSymbol hole)+ (holeModule hole, holeMappedName hole, holeKind hole, holePath hole, holeSymbol hole) renderKind :: BindingObligationKind -> Text renderKind BindingValue = "binding"@@ -289,5 +408,5 @@ splitQualified :: Text -> (Text, Text) splitQualified value =- let (prefix, name) = T.breakOnEnd "." value- in (T.dropEnd 1 prefix, name)+ let (prefix, name) = T.breakOnEnd "." value+ in (T.dropEnd 1 prefix, name)
+ src/Keiro/Dsl/Expression.hs view
@@ -0,0 +1,584 @@+{-# OPTIONS_GHC -Werror=incomplete-patterns #-}++-- | The authoritative version-2 aggregate scalar-expression resolver.+--+-- The parser records syntax and exact rows. This module resolves every root,+-- required structural path, literal, and operator once, retaining the evidence+-- needed by generated Keiki lowering. It deliberately has no evaluator: Keiki's+-- structural term tree remains the one production meaning for concrete and+-- symbolic execution.+module Keiro.Dsl.Expression+ ( ExpressionEnvironment,+ expressionEnvironment,+ ExpectedScalarType (..),+ ScalarRootProvenance (..),+ ResolvedScalarProjection (..),+ ArithmeticEvidence (..),+ ScalarValue (..),+ TypedScalarNode (..),+ TypedScalarExpr (..),+ ExpressionDiagnosticCode (..),+ ExpressionDiagnostic (..),+ resolveScalarExpr,+ resolveGuardExpr,+ resolveWriteExpr,+ )+where++import Data.List (find)+import Data.List.NonEmpty (NonEmpty (..))+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time.Clock (UTCTime)+import Data.Time.Format.ISO8601 (iso8601ParseM)+import Data.TypeID qualified as TypeID+import Keiro.Dsl.AggregateType+import Keiro.Dsl.Grammar+import Keiro.Dsl.TypeGraph++data ExpressionEnvironment = ExpressionEnvironment+ { environmentSpec :: !Spec,+ environmentAggregate :: !Aggregate,+ environmentTransition :: !Transition,+ environmentSymbols :: !AggregateSymbols,+ environmentTypeGraph :: !(Maybe TypeGraph)+ }++expressionEnvironment :: Spec -> Aggregate -> Transition -> ExpressionEnvironment+expressionEnvironment spec aggregate transition =+ ExpressionEnvironment+ { environmentSpec = spec,+ environmentAggregate = aggregate,+ environmentTransition = transition,+ environmentSymbols = aggregateSymbols spec,+ environmentTypeGraph = either (const Nothing) Just (resolveTypeGraph spec)+ }++data ExpectedScalarType+ = InferScalarType+ | ExpectScalarType !ResolvedAggregateType+ deriving stock (Eq, Show)++data ScalarRootProvenance+ = ScalarRegisterRoot !Name !ResolvedAggregateType+ | ScalarCommandRoot !Name !ResolvedAggregateType+ deriving stock (Eq, Show)++data ResolvedScalarProjection = ResolvedScalarProjection+ { scalarProjectionOwner :: !MappedKey,+ scalarProjectionPointer :: !Text,+ scalarProjectionFields :: ![Name]+ }+ deriving stock (Eq, Show)++data ArithmeticEvidence+ = ExactIntegerArithmetic+ | TotalNaturalArithmetic+ deriving stock (Eq, Ord, Show)++data ScalarValue+ = ScalarTextValue !Text+ | ScalarIntValue !Int+ | ScalarIntegerValue !Integer+ | ScalarNaturalValue !Integer+ | ScalarBoolValue !Bool+ | ScalarTimeValue !UTCTime+ | ScalarEnumValue !Name !Name+ | ScalarIdValue !Name !Text+ deriving stock (Eq, Show)++data TypedScalarNode+ = TypedLiteral !ScalarValue+ | TypedRoot !ScalarRootProvenance+ | TypedProject !ScalarRootProvenance !ResolvedScalarProjection+ | TypedAdd !ArithmeticEvidence !TypedScalarExpr !TypedScalarExpr+ | TypedSubtract !ArithmeticEvidence !TypedScalarExpr !TypedScalarExpr+ | TypedMultiply !ArithmeticEvidence !TypedScalarExpr !TypedScalarExpr+ | TypedEqual !TypedScalarExpr !TypedScalarExpr+ | TypedNotEqual !TypedScalarExpr !TypedScalarExpr+ | TypedCompare !CmpOp !TypedScalarExpr !TypedScalarExpr+ | TypedAnd !TypedScalarExpr !TypedScalarExpr+ | TypedOr !TypedScalarExpr !TypedScalarExpr+ deriving stock (Eq, Show)++data TypedScalarExpr = TypedScalarExpr+ { typedScalarType :: !ResolvedAggregateType,+ typedScalarLoc :: !Loc,+ typedScalarNode :: !TypedScalarNode+ }+ deriving stock (Eq, Show)++data ExpressionDiagnosticCode+ = ScalarRootUnknown+ | ScalarRootAmbiguous+ | ScalarPathInvalid+ | ScalarPathUnsupported+ | ScalarLiteralNeedsType+ | ScalarLiteralInvalid+ | ScalarOperandTypeMismatch+ | ScalarOperatorUnsupported+ | ScalarBooleanOperandRequired+ | ScalarGuardBoolRequired+ | ScalarWriteTargetUnknown+ | ScalarWriteTypeMismatch+ deriving stock (Eq, Ord, Show)++data ExpressionDiagnostic = ExpressionDiagnostic+ { expressionDiagnosticLoc :: !Loc,+ expressionDiagnosticCode :: !ExpressionDiagnosticCode,+ expressionDiagnosticMessage :: !Text+ }+ deriving stock (Eq, Show)++resolveScalarExpr :: ExpressionEnvironment -> ExpectedScalarType -> Expr -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolveScalarExpr environment expected expression =+ checkExpected expected =<< resolve expression expected+ where+ resolve syntax wanted = case syntax of+ EOr left right -> resolveBoolean TypedOr left right+ EAnd left right -> resolveBoolean TypedAnd left right+ ECmp operator left right -> resolveComparison operator left right+ EAdd loc left right -> resolveArithmetic loc TypedAdd left right wanted+ ESubtract loc left right -> resolveArithmetic loc TypedSubtract left right wanted+ EMultiply loc left right -> resolveArithmetic loc TypedMultiply left right wanted+ EPath loc root path -> resolvePath environment loc root path+ ELiteral loc literal -> resolveLiteral environment loc wanted literal+ EAtom (ABool value) -> pure (literalExpr noLoc AggregateBool (ScalarBoolValue value))+ EAtom (AName name) -> resolvePath environment noLoc UnqualifiedRoot [name]++ resolveBoolean constructor left right = do+ (resolvedLeft, resolvedRight) <- resolveBoth (resolve left (ExpectScalarType AggregateBool)) (resolve right (ExpectScalarType AggregateBool))+ pure+ TypedScalarExpr+ { typedScalarType = AggregateBool,+ typedScalarLoc = expressionLoc left right,+ typedScalarNode = constructor resolvedLeft resolvedRight+ }++ resolveComparison operator left right = do+ (resolvedLeft, resolvedRight) <- resolvePair left right+ requireSameType resolvedLeft resolvedRight+ requireComparisonCapability operator resolvedLeft+ pure+ TypedScalarExpr+ { typedScalarType = AggregateBool,+ typedScalarLoc = expressionLoc left right,+ typedScalarNode = case operator of+ OpEq -> TypedEqual resolvedLeft resolvedRight+ OpNeq -> TypedNotEqual resolvedLeft resolvedRight+ OpLt -> TypedCompare OpLt resolvedLeft resolvedRight+ OpLe -> TypedCompare OpLe resolvedLeft resolvedRight+ OpGt -> TypedCompare OpGt resolvedLeft resolvedRight+ OpGe -> TypedCompare OpGe resolvedLeft resolvedRight+ }++ resolveArithmetic loc constructor left right wanted = do+ pair <- case wanted of+ ExpectScalarType scalarType+ | scalarType `elem` [AggregateInteger, AggregateNatural] ->+ resolveBoth (resolve left wanted) (resolve right wanted)+ _ -> resolvePair left right+ let (resolvedLeft, resolvedRight) = pair+ requireSameType resolvedLeft resolvedRight+ evidence <- arithmeticEvidence loc (typedScalarType resolvedLeft)+ pure+ TypedScalarExpr+ { typedScalarType = typedScalarType resolvedLeft,+ typedScalarLoc = loc,+ typedScalarNode = constructor evidence resolvedLeft resolvedRight+ }++ resolvePair left right+ | contextualLiteral left && not (contextualLiteral right) = do+ resolvedRight <- resolve right InferScalarType+ resolvedLeft <- resolve left (ExpectScalarType (typedScalarType resolvedRight))+ pure (resolvedLeft, resolvedRight)+ | contextualLiteral right && not (contextualLiteral left) = do+ resolvedLeft <- resolve left InferScalarType+ resolvedRight <- resolve right (ExpectScalarType (typedScalarType resolvedLeft))+ pure (resolvedLeft, resolvedRight)+ | otherwise = case (resolve left InferScalarType, resolve right InferScalarType) of+ (Right resolvedLeft, Right resolvedRight) -> Right (resolvedLeft, resolvedRight)+ (Left _, Right resolvedRight) -> do+ resolvedLeft <- resolve left (ExpectScalarType (typedScalarType resolvedRight))+ pure (resolvedLeft, resolvedRight)+ (Right resolvedLeft, Left _) -> do+ resolvedRight <- resolve right (ExpectScalarType (typedScalarType resolvedLeft))+ pure (resolvedLeft, resolvedRight)+ (Left leftErrors, Left rightErrors) -> Left (leftErrors <> rightErrors)++ resolveBoth left right = case (left, right) of+ (Right resolvedLeft, Right resolvedRight) -> Right (resolvedLeft, resolvedRight)+ (Left leftErrors, Left rightErrors) -> Left (leftErrors <> rightErrors)+ (Left errors, _) -> Left errors+ (_, Left errors) -> Left errors++ requireSameType left right+ | typedScalarType left == typedScalarType right = Right ()+ | otherwise =+ failure+ (typedScalarLoc right)+ ScalarOperandTypeMismatch+ ( "expression operands have different scalar types '"+ <> aggregateCanonicalName (typedScalarType left)+ <> "' and '"+ <> aggregateCanonicalName (typedScalarType right)+ <> "'; numeric coercion is not supported"+ )++ requireComparisonCapability operator operand =+ let useSite = case operator of+ OpEq -> EqualityGuardUse+ OpNeq -> EqualityGuardUse+ OpLt -> OrderingGuardUse+ OpLe -> OrderingGuardUse+ OpGt -> OrderingGuardUse+ OpGe -> OrderingGuardUse+ in case aggregateCapability useSite (typedScalarType operand) of+ SolverVisible -> Right ()+ OpaqueOnly -> unsupported useSite+ Unsupported -> unsupported useSite+ where+ unsupported useSite =+ failure+ (typedScalarLoc operand)+ ScalarOperatorUnsupported+ ( renderUseSite useSite+ <> " is unsupported for scalar type '"+ <> aggregateCanonicalName (typedScalarType operand)+ <> "'"+ )++ checkExpected InferScalarType resolved = Right resolved+ checkExpected (ExpectScalarType wanted) resolved+ | wanted == typedScalarType resolved = Right resolved+ | wanted == AggregateBool =+ failure+ (typedScalarLoc resolved)+ ScalarBooleanOperandRequired+ ("Boolean expression requires Bool, found '" <> aggregateCanonicalName (typedScalarType resolved) <> "'")+ | otherwise =+ failure+ (typedScalarLoc resolved)+ ScalarOperandTypeMismatch+ ( "expected scalar type '"+ <> aggregateCanonicalName wanted+ <> "', found '"+ <> aggregateCanonicalName (typedScalarType resolved)+ <> "'"+ )++resolveGuardExpr :: ExpressionEnvironment -> Expr -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolveGuardExpr environment expression =+ case resolveScalarExpr environment (ExpectScalarType AggregateBool) expression of+ Left diagnostics+ | all ((== ScalarBooleanOperandRequired) . expressionDiagnosticCode) (NE.toList diagnostics) ->+ Left+ ( fmap+ ( \diagnostic ->+ diagnostic+ { expressionDiagnosticCode = ScalarGuardBoolRequired,+ expressionDiagnosticMessage = "aggregate guard must resolve to Bool; " <> expressionDiagnosticMessage diagnostic+ }+ )+ diagnostics+ )+ other -> other++resolveWriteExpr :: ExpressionEnvironment -> Name -> Expr -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolveWriteExpr environment registerName expression =+ case find ((== registerName) . regName) (aggRegs (environmentAggregate environment)) of+ Nothing ->+ failure+ (exprLoc expression)+ ScalarWriteTargetUnknown+ ("write target '" <> registerName <> "' is not an aggregate register")+ Just register -> case resolveAggregateType (environmentSymbols environment) (regLoc register) RegisterUse (regType register) of+ Left _ -> Right unresolvedSentinel+ Right expected -> case resolveScalarExpr environment (ExpectScalarType expected) expression of+ Left diagnostics -> Left (fmap writeDiagnostic diagnostics)+ Right resolved+ | predicateValued resolved ->+ failure+ (typedScalarLoc resolved)+ ScalarOperatorUnsupported+ "comparison and Boolean operators are guard predicates and cannot be written as scalar terms"+ | otherwise -> Right resolved+ where+ writeDiagnostic diagnostic+ | expressionDiagnosticCode diagnostic == ScalarOperandTypeMismatch =+ diagnostic+ { expressionDiagnosticCode = ScalarWriteTypeMismatch,+ expressionDiagnosticMessage =+ "write to register '"+ <> registerName+ <> "' has the wrong scalar type; "+ <> expressionDiagnosticMessage diagnostic+ }+ | otherwise = diagnostic+ where+ -- The aggregate type validator reports the primary type error first. This+ -- value is never scaffolded because any error prevents generation.+ unresolvedSentinel = TypedScalarExpr AggregateBool noLoc (TypedLiteral (ScalarBoolValue False))++resolvePath :: ExpressionEnvironment -> Loc -> ExprRoot -> [Name] -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolvePath environment loc root path = case path of+ [] -> failure loc ScalarRootUnknown "scalar path is empty"+ rootName : fields -> do+ provenance <- resolveRoot environment loc root rootName+ case fields of+ [] -> pure (rootExpr loc provenance)+ _ -> resolveProjectionPath environment loc provenance fields++resolveRoot :: ExpressionEnvironment -> Loc -> ExprRoot -> Name -> Either (NonEmpty ExpressionDiagnostic) ScalarRootProvenance+resolveRoot environment loc root name = case root of+ RegisterRoot -> maybe unknown (Right . registerRoot) register+ CommandRoot -> maybe unknown (Right . commandRoot) commandField+ UnqualifiedRoot -> case (register, commandField) of+ (Just _, Just _) ->+ failure+ loc+ ScalarRootAmbiguous+ ("unqualified scalar root '" <> name <> "' matches both a register and command field; use reg." <> name <> " or cmd." <> name)+ (Just value, Nothing) -> Right (registerRoot value)+ (Nothing, Just value) -> Right (commandRoot value)+ (Nothing, Nothing) -> unknown+ where+ aggregate = environmentAggregate environment+ transition = environmentTransition environment+ symbols = environmentSymbols environment+ register = find ((== name) . regName) (aggRegs aggregate)+ commandField = do+ command <- find ((== tCommand transition) . cmdName) (aggCommands aggregate)+ find ((== name) . aggregateFieldName) (cmdFields command)+ registerRoot value =+ ScalarRegisterRoot name (resolvedOrUnknown (resolveAggregateType symbols (regLoc value) RegisterUse (regType value)))+ commandRoot value =+ ScalarCommandRoot name (resolvedOrUnknown (inferAggregateFieldType symbols aggregate CommandFieldUse value))+ resolvedOrUnknown = either (const (AggregateMapped (MappedKey "<invalid>"))) id+ unknown =+ failure+ loc+ ScalarRootUnknown+ ("scalar root '" <> name <> "' resolves to no register or field of command '" <> tCommand transition <> "'")++resolveProjectionPath :: ExpressionEnvironment -> Loc -> ScalarRootProvenance -> [Name] -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolveProjectionPath environment loc provenance fields = do+ owner <- case rootType provenance of+ AggregateMapped key -> Right key+ other ->+ failure+ loc+ ScalarPathInvalid+ ("cannot project fields through scalar type '" <> aggregateCanonicalName other <> "'")+ graph <- maybe (failure loc ScalarPathUnsupported "mapped structural graph is unavailable") Right (environmentTypeGraph environment)+ (resolvedType, wireKeys) <- walk graph owner fields+ if scalarLeaf resolvedType+ then+ pure+ TypedScalarExpr+ { typedScalarType = resolvedType,+ typedScalarLoc = loc,+ typedScalarNode =+ TypedProject+ provenance+ ResolvedScalarProjection+ { scalarProjectionOwner = owner,+ scalarProjectionPointer = T.concat ["/" <> escapePointer key | key <- wireKeys],+ scalarProjectionFields = fields+ }+ }+ else+ failure+ loc+ ScalarPathUnsupported+ ("path ends at unsupported non-scalar type '" <> aggregateCanonicalName resolvedType <> "'")+ where+ walk graph ownerKey remaining = case Map.lookup ownerKey (tgDeclarations graph) of+ Just (ResolvedStructural _ (RRecord _ _ recordFields)) -> selectField graph recordFields remaining+ Just ResolvedStructural {} -> failure loc ScalarPathUnsupported "scalar paths may cross required structural records only"+ Just ResolvedOpaque {} -> failure loc ScalarPathUnsupported "scalar paths cannot cross an opaque mapped declaration"+ Nothing -> failure loc ScalarPathInvalid ("unknown mapped path owner '" <> unMappedKey ownerKey <> "'")++ selectField _ _ [] = failure loc ScalarPathInvalid "scalar path is empty"+ selectField graph recordFields (fieldName : rest) = case find ((== fieldName) . rwfHaskell) recordFields of+ Nothing -> failure loc ScalarPathInvalid ("required structural field '" <> fieldName <> "' does not exist")+ Just field+ | rwfPresence field /= PRequired -> failure loc ScalarPathUnsupported ("field '" <> fieldName <> "' is optional; scalar paths must be total")+ | null rest -> (,[rwfKey field]) <$> resolvedLeaf (rwfType field)+ | RRef nextOwner <- rwfType field -> do+ (leafType, keys) <- walk graph nextOwner rest+ pure (leafType, rwfKey field : keys)+ | otherwise -> failure loc ScalarPathUnsupported ("field '" <> fieldName <> "' is not a required structural record")++ resolvedLeaf = \case+ RText -> Right AggregateText+ RInt -> Right AggregateInt+ RInteger -> Right AggregateInteger+ RBool -> Right AggregateBool+ RNatural -> Right AggregateNatural+ RTime -> Right AggregateTime+ RJson -> unsupported "Json"+ ROptional {} -> unsupported "Optional"+ RList {} -> unsupported "List"+ RMap {} -> unsupported "Map"+ RRef key -> Right (AggregateMapped key)+ unsupported name = failure loc ScalarPathUnsupported (name <> " is not a supported scalar path leaf")++resolveLiteral :: ExpressionEnvironment -> Loc -> ExpectedScalarType -> ScalarLiteral -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolveLiteral environment loc expected literal = case literal of+ LiteralBool value -> pure (literalExpr loc AggregateBool (ScalarBoolValue value))+ LiteralText value -> case expected of+ ExpectScalarType AggregateTime -> case iso8601ParseM (T.unpack value) of+ Just parsed -> pure (literalExpr loc AggregateTime (ScalarTimeValue parsed))+ Nothing -> invalid "Time literal must be a valid ISO-8601 UTC timestamp"+ ExpectScalarType AggregateText -> pure (literalExpr loc AggregateText (ScalarTextValue value))+ InferScalarType -> pure (literalExpr loc AggregateText (ScalarTextValue value))+ ExpectScalarType other -> mismatch other "quoted Text/Time"+ LiteralIntegral value -> case expected of+ ExpectScalarType AggregateInt+ | value >= fromIntegral (minBound :: Int) && value <= fromIntegral (maxBound :: Int) ->+ pure (literalExpr loc AggregateInt (ScalarIntValue (fromInteger value)))+ | otherwise -> invalid "Int literal is outside the Haskell Int range"+ ExpectScalarType AggregateInteger -> pure (literalExpr loc AggregateInteger (ScalarIntegerValue value))+ ExpectScalarType AggregateNatural+ | value >= 0 -> pure (literalExpr loc AggregateNatural (ScalarNaturalValue value))+ | otherwise -> invalid "Natural literal must be non-negative"+ InferScalarType -> failure loc ScalarLiteralNeedsType "integral literal needs an expected Int, Integer, or Natural type"+ ExpectScalarType other -> mismatch other "integral"+ LiteralQualified typeName constructor -> resolveEnumLiteral environment loc expected typeName constructor+ LiteralId typeName value -> resolveIdLiteral environment loc expected typeName value+ where+ invalid message = failure loc ScalarLiteralInvalid message+ mismatch other syntax =+ failure+ loc+ ScalarOperandTypeMismatch+ (syntax <> " literal cannot inhabit scalar type '" <> aggregateCanonicalName other <> "'")++resolveEnumLiteral :: ExpressionEnvironment -> Loc -> ExpectedScalarType -> Name -> Name -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolveEnumLiteral environment loc expected typeName constructor = case find ((== typeName) . enumName) (specEnums (environmentSpec environment)) of+ Nothing -> failure loc ScalarLiteralInvalid ("unknown enum literal type '" <> typeName <> "'")+ Just declaration+ | constructor `notElem` map fst (enumCtors declaration) ->+ failure loc ScalarLiteralInvalid ("enum '" <> typeName <> "' has no constructor '" <> constructor <> "'")+ | otherwise -> do+ resolved <- resolveDeclared typeName+ requireExpected resolved+ pure (literalExpr loc resolved (ScalarEnumValue typeName constructor))+ where+ resolveDeclared name = case resolveAggregateType (environmentSymbols environment) loc WholeValueWriteUse (TRef name) of+ Left _ -> failure loc ScalarLiteralInvalid ("enum type '" <> name <> "' is not available at this aggregate use")+ Right resolved -> Right resolved+ requireExpected resolved = case expected of+ InferScalarType -> Right ()+ ExpectScalarType wanted+ | wanted == resolved -> Right ()+ | otherwise -> failure loc ScalarOperandTypeMismatch ("enum literal has type '" <> aggregateCanonicalName resolved <> "', expected '" <> aggregateCanonicalName wanted <> "'")++resolveIdLiteral :: ExpressionEnvironment -> Loc -> ExpectedScalarType -> Name -> Text -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr+resolveIdLiteral environment loc expected typeName value = case find ((== typeName) . idName) (specIds (environmentSpec environment)) of+ Nothing -> failure loc ScalarLiteralInvalid ("unknown ID literal type '" <> typeName <> "'")+ Just declaration -> case TypeID.parseText value of+ Left parseError -> failure loc ScalarLiteralInvalid ("invalid " <> typeName <> " literal: " <> T.pack (show parseError))+ Right parsed+ | TypeID.getPrefix parsed /= idPrefix declaration ->+ failure loc ScalarLiteralInvalid ("ID literal prefix must be '" <> idPrefix declaration <> "'")+ | otherwise -> do+ resolved <- case resolveAggregateType (environmentSymbols environment) loc WholeValueWriteUse (TRef typeName) of+ Left _ -> failure loc ScalarLiteralInvalid ("ID type '" <> typeName <> "' is not available at this aggregate use")+ Right valueType -> Right valueType+ case expected of+ InferScalarType -> pure ()+ ExpectScalarType wanted+ | wanted == resolved -> pure ()+ | otherwise -> failure loc ScalarOperandTypeMismatch ("ID literal has type '" <> aggregateCanonicalName resolved <> "', expected '" <> aggregateCanonicalName wanted <> "'")+ pure (literalExpr loc resolved (ScalarIdValue typeName value))++arithmeticEvidence :: Loc -> ResolvedAggregateType -> Either (NonEmpty ExpressionDiagnostic) ArithmeticEvidence+arithmeticEvidence _ AggregateInteger = Right ExactIntegerArithmetic+arithmeticEvidence _ AggregateNatural = Right TotalNaturalArithmetic+arithmeticEvidence loc scalarType =+ failure+ loc+ ScalarOperatorUnsupported+ ( "arithmetic is unsupported for scalar type '"+ <> aggregateCanonicalName scalarType+ <> "'; only exact Integer and total Natural arithmetic are admitted"+ )++rootType :: ScalarRootProvenance -> ResolvedAggregateType+rootType (ScalarRegisterRoot _ scalarType) = scalarType+rootType (ScalarCommandRoot _ scalarType) = scalarType++rootExpr :: Loc -> ScalarRootProvenance -> TypedScalarExpr+rootExpr loc provenance = TypedScalarExpr (rootType provenance) loc (TypedRoot provenance)++literalExpr :: Loc -> ResolvedAggregateType -> ScalarValue -> TypedScalarExpr+literalExpr loc scalarType value = TypedScalarExpr scalarType loc (TypedLiteral value)++scalarLeaf :: ResolvedAggregateType -> Bool+scalarLeaf = \case+ AggregateText -> True+ AggregateInt -> True+ AggregateInteger -> True+ AggregateBool -> True+ AggregateTime -> True+ AggregateNatural -> True+ AggregateNominal {} -> True+ AggregateVertex {} -> False+ AggregateMapped {} -> False++predicateValued :: TypedScalarExpr -> Bool+predicateValued expression = case typedScalarNode expression of+ TypedEqual {} -> True+ TypedNotEqual {} -> True+ TypedCompare {} -> True+ TypedAnd {} -> True+ TypedOr {} -> True+ TypedLiteral {} -> False+ TypedRoot {} -> False+ TypedProject {} -> False+ TypedAdd {} -> False+ TypedSubtract {} -> False+ TypedMultiply {} -> False++contextualLiteral :: Expr -> Bool+contextualLiteral = \case+ ELiteral _ LiteralIntegral {} -> True+ ELiteral _ LiteralText {} -> True+ _ -> False++expressionLoc :: Expr -> Expr -> Loc+expressionLoc left right = case exprLoc left of+ Loc 0 -> exprLoc right+ loc -> loc++escapePointer :: Text -> Text+escapePointer = T.replace "/" "~1" . T.replace "~" "~0"++renderUseSite :: AggregateUseSite -> Text+renderUseSite = \case+ EqualityGuardUse -> "equality"+ OrderingGuardUse -> "ordering"+ CommandFieldUse -> "command field"+ EventFieldUse -> "event field"+ RegisterUse -> "register"+ WholeValueWriteUse -> "whole-value write"+ CodecUse -> "JSON codec"+ SnapshotUse -> "snapshot"+ HarnessSampleUse -> "harness sample"+ HaskellLoweringUse -> "Haskell lowering"++failure :: Loc -> ExpressionDiagnosticCode -> Text -> Either (NonEmpty ExpressionDiagnostic) a+failure loc diagnosticCode diagnosticMessage =+ Left+ ( ExpressionDiagnostic+ { expressionDiagnosticLoc = loc,+ expressionDiagnosticCode = diagnosticCode,+ expressionDiagnosticMessage = diagnosticMessage+ }+ :| []+ )
src/Keiro/Dsl/FoldFingerprint.hs view
@@ -1,20 +1,23 @@-{- | Canonical identities for the aggregate fold surface used while hydrating-event streams. The fingerprint deliberately excludes payload codecs,-projections, snapshot policy, and source locations: those inputs do not change-how an existing event log becomes aggregate state.--}-module Keiro.Dsl.FoldFingerprint (- aggregateFoldFingerprint,+-- | Canonical identities for the aggregate fold surface used while hydrating+-- event streams. The fingerprint deliberately excludes payload codecs,+-- projections, snapshot policy, and source locations: those inputs do not change+-- how an existing event log becomes aggregate state.+module Keiro.Dsl.FoldFingerprint+ ( aggregateFoldFingerprint, aggregateFoldSurface,-) where+ )+where import Data.List (find)+import Data.List.NonEmpty qualified as NE import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as T+import Keiro.Dsl.AggregateType import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType import Keiro.Dsl.PrettyPrint (renderExpr) import Keiro.Dsl.ReadModelShape (fnv1a64) import Keiro.Dsl.TypeGraph@@ -23,71 +26,127 @@ aggregateFoldFingerprint :: Spec -> Aggregate -> Text aggregateFoldFingerprint spec = fnv1a64 . aggregateFoldSurface spec -{- | Canonical pre-hash text for an aggregate's replay fold.--Rules are declarations on 'Spec', not children of 'Aggregate', so the complete-spec is required. Only rules reached from transition guards and writes are-included, transitively, in declaration order.--}+-- | Canonical pre-hash text for an aggregate's replay fold.+--+-- Rules are declarations on 'Spec', not children of 'Aggregate', so the complete+-- spec is required. Only rules reached from transition guards and writes are+-- included, transitively, in declaration order. aggregateFoldSurface :: Spec -> Aggregate -> Text aggregateFoldSurface spec aggregate =- T.intercalate- "\n"- ( map stateSegment (aggStates aggregate)- ++ map registerSegment (aggRegs aggregate)- ++ mappedRegisterSegments- ++ map transitionSegment (aggTransitions aggregate)- ++ map ruleSegment referencedRules- )+ T.intercalate+ "\n"+ ( map stateSegment (aggStates aggregate)+ ++ map (registerSegment symbols) (aggRegs aggregate)+ ++ mappedRegisterSegments+ ++ nominalSegments+ ++ map transitionSegment (aggTransitions aggregate)+ ++ map ruleSegment referencedRules+ ) where+ symbols = aggregateSymbols spec referencedRules =- [ rule- | rule <- specRules spec- , ruleName rule `Set.member` referencedRuleNames spec aggregate- ]+ [ rule+ | rule <- specRules spec,+ ruleName rule `Set.member` referencedRuleNames spec aggregate+ ] mappedRegisterSegments = case resolveTypeGraph spec of- Left _ -> []- Right graph ->- [ mappedRegisterSegment graph declaration- | register <- aggRegs aggregate- , Just declaration <- [Map.lookup (MappedKey (regType register)) (tgDeclarations graph)]- ]+ Left _ -> []+ Right graph ->+ [ mappedRegisterSegment graph declaration+ | register <- aggRegs aggregate,+ TRef typeName <- [regType register],+ Just declaration <- [Map.lookup (MappedKey typeName) (tgDeclarations graph)]+ ]+ nominalSegments = case resolveNominalTypes spec of+ Left _ -> []+ Right registry ->+ [ nominalUseSegment useSite nominal binding+ | (useSite, typeName) <- nominalUseNames aggregate,+ Just nominal <- [lookupNominalType typeName registry],+ ConsumerNominal binding <- [resolvedNominalOwnership nominal]+ ] +nominalUseNames :: Aggregate -> [(Text, Name)]+nominalUseNames aggregate =+ [ ("register:" <> regName register, typeName)+ | register <- aggRegs aggregate,+ TRef typeName <- [regType register]+ ]+ <> [ ("event:" <> evName event <> "." <> aggregateFieldName field, typeName)+ | event <- aggEvents aggregate,+ field <- eventFields event,+ TRef typeName <- maybe [] pure (aggregateFieldType field)+ ]+ where+ eventFields event = case evBody event of+ EventFields fields -> fields+ EventFromCommand commandName -> concat [cmdFields command | command <- aggCommands aggregate, cmdName command == commandName]++nominalUseSegment :: Text -> ResolvedNominalType -> ConsumerNominalBinding -> Text+nominalUseSegment useSite nominal binding =+ T.intercalate+ "|"+ [ "nominal-use:" <> useSite,+ "name=" <> resolvedNominalName nominal,+ "representation=" <> nominalRepresentationSegment (resolvedNominalRepresentation nominal),+ "canonical=" <> unCanonicalTypeId (consumerNominalCanonical binding),+ "binding=" <> unQualifiedValueName (consumerNominalBinding binding),+ "binding-version=" <> unBindingVersion (consumerNominalBindingVersion binding),+ "initial=" <> maybe "(none)" unQualifiedValueName (consumerNominalInitial binding)+ ]++nominalRepresentationSegment :: NominalRepresentation -> Text+nominalRepresentationSegment representation = case representation of+ IdRepresentation prefix -> "id:" <> prefix+ EnumRepresentation constructors -> "enum:" <> T.intercalate "," [constructor <> "=" <> wire | (constructor, wire) <- NE.toList constructors]+ ScalarRepresentation scalar -> case scalar of+ NominalText -> "Text"+ NominalInt -> "Int"+ NominalNatural -> "Natural"+ NominalBool -> "Bool"+ NominalTime -> "Time"+ mappedRegisterSegment :: TypeGraph -> ResolvedMappedDecl -> Text mappedRegisterSegment graph (ResolvedStructural declaration _) =- T.intercalate- "|"- [ "mapped-register:" <> sdName declaration- , "wire=" <> wireFingerprint graph (sdName declaration)- , "canonical=" <> unCanonicalTypeId (sdCanonical declaration)- , "binding=" <> unQualifiedValueName (sdBinding declaration)- , "binding-version=" <> unBindingVersion (sdBindingVersion declaration)- , "initial=" <> maybe "(missing)" unQualifiedValueName (sdInitial declaration)- ]+ T.intercalate+ "|"+ [ "mapped-register:" <> sdName declaration,+ "wire=" <> wireFingerprint graph (sdName declaration),+ "canonical=" <> unCanonicalTypeId (sdCanonical declaration),+ "binding=" <> unQualifiedValueName (sdBinding declaration),+ "binding-version=" <> unBindingVersion (sdBindingVersion declaration),+ "initial=" <> maybe "(missing)" unQualifiedValueName (sdInitial declaration)+ ] mappedRegisterSegment _ (ResolvedOpaque declaration) =- T.intercalate- "|"- [ "mapped-register:" <> odName declaration- , "codec=" <> unCodecIdentity (odCodecIdentity declaration)- , "codec-version=" <> unCodecVersion (odCodecVersion declaration)- , "initial=" <> maybe "(missing)" unQualifiedValueName (odInitial declaration)- ]+ T.intercalate+ "|"+ [ "mapped-register:" <> odName declaration,+ "codec=" <> unCodecIdentity (odCodecIdentity declaration),+ "codec-version=" <> unCodecVersion (odCodecVersion declaration),+ "initial=" <> maybe "(missing)" unQualifiedValueName (odInitial declaration)+ ] stateSegment :: StateDecl -> Text stateSegment state =- "state:"- <> stName state- <> "|terminal="- <> if stTerminal state then "true" else "false"+ "state:"+ <> stName state+ <> "|terminal="+ <> if stTerminal state then "true" else "false" -registerSegment :: RegDecl -> Text-registerSegment register =- "reg:"- <> regName register- <> ":"- <> regType register- <> "="- <> renderInitial (regInitial register)+registerSegment :: AggregateSymbols -> RegDecl -> Text+registerSegment symbols register =+ "reg:"+ <> regName register+ <> ":"+ <> typeExprCanonicalName (regType register)+ <> "="+ <> canonicalInitial+ where+ canonicalInitial = case resolveAggregateType symbols (regLoc register) RegisterUse (regType register) of+ Left _ -> renderInitial (regInitial register)+ Right resolvedType -> case resolveRegisterInitial symbols (regLoc register) resolvedType (regInitial register) of+ Left _ -> renderInitial (regInitial register)+ Right resolvedInitial -> registerInitialCanonicalName resolvedInitial renderInitial :: RegInitial -> Text renderInitial (RegInitBare value) = value@@ -95,27 +154,34 @@ escapeText :: Text -> Text escapeText = T.concatMap $ \case- '"' -> "\\\""- '\\' -> "\\\\"- '\n' -> "\\n"- '\t' -> "\\t"- '\r' -> "\\r"- character -> T.singleton character+ '"' -> "\\\""+ '\\' -> "\\\\"+ '\n' -> "\\n"+ '\t' -> "\\t"+ '\r' -> "\\r"+ character -> T.singleton character transitionSegment :: Transition -> Text transitionSegment transition =- T.intercalate- "|"- [ "transition:" <> renderMode (tMode transition)- , tSource transition- , tCommand transition- , "guard=" <> maybe "" renderExpr (tGuard transition)- , "writes=" <> T.intercalate ";" (map renderWrite (tWrites transition))- , "emits=" <> T.intercalate "," (tEmits transition)- , "goto=" <> tGoto transition- ]+ T.intercalate+ "|"+ ( [ "transition:" <> renderMode (tMode transition),+ tSource transition,+ tCommand transition+ ]+ ++ implementationSegment+ ++ [ "guard=" <> maybe "" renderExpr (tGuard transition),+ "writes=" <> T.intercalate ";" (map renderWrite (tWrites transition)),+ "emits=" <> T.intercalate "," (tEmits transition),+ "goto=" <> tGoto transition+ ]+ ) where renderWrite (registerName, expression) = registerName <> ":=" <> renderExpr expression+ implementationSegment = case tImplementation transition of+ LegacyHoleImplementation -> []+ GeneratedImplementation -> ["implementation=generated"]+ HoleImplementation -> ["implementation=hole"] renderMode :: TransitionMode -> Text renderMode TmLive = "live"@@ -123,13 +189,13 @@ ruleSegment :: RuleDecl -> Text ruleSegment rule =- T.intercalate- "|"- [ "rule:" <> ruleName rule- , ruleDomain rule- , ruleCodomain rule- , "cases=" <> T.intercalate ";" (map renderCase (ruleCases rule))- ]+ T.intercalate+ "|"+ [ "rule:" <> ruleName rule,+ ruleDomain rule,+ ruleCodomain rule,+ "cases=" <> T.intercalate ";" (map renderCase (ruleCases rule))+ ] where renderCase (constructorName, expression) = constructorName <> "=>" <> renderExpr expression @@ -138,29 +204,35 @@ where rules = specRules spec directNames =- Set.unions- [ exprNames expression- | transition <- aggTransitions aggregate- , expression <- maybeToList (tGuard transition) ++ map snd (tWrites transition)- ]+ Set.unions+ [ exprNames expression+ | transition <- aggTransitions aggregate,+ expression <- maybeToList (tGuard transition) ++ map snd (tWrites transition)+ ] close names =- let expanded =- Set.unions- ( names- : [ Set.unions (map (exprNames . snd) (ruleCases rule))- | name <- Set.toList names- , Just rule <- [find ((== name) . ruleName) rules]- ]- )- in if expanded == names then names else close expanded+ let expanded =+ Set.unions+ ( names+ : [ Set.unions (map (exprNames . snd) (ruleCases rule))+ | name <- Set.toList names,+ Just rule <- [find ((== name) . ruleName) rules]+ ]+ )+ in if expanded == names then names else close expanded exprNames :: Expr -> Set Name exprNames = \case- EOr left right -> exprNames left <> exprNames right- EAnd left right -> exprNames left <> exprNames right- ECmp _ left right -> exprNames left <> exprNames right- EAtom (AName name) -> Set.singleton name- EAtom (ABool _) -> Set.empty+ EOr left right -> exprNames left <> exprNames right+ EAnd left right -> exprNames left <> exprNames right+ ECmp _ left right -> exprNames left <> exprNames right+ EAdd _ left right -> exprNames left <> exprNames right+ ESubtract _ left right -> exprNames left <> exprNames right+ EMultiply _ left right -> exprNames left <> exprNames right+ EPath _ _ (name : _) -> Set.singleton name+ EPath _ _ [] -> Set.empty+ ELiteral {} -> Set.empty+ EAtom (AName name) -> Set.singleton name+ EAtom (ABool _) -> Set.empty maybeToList :: Maybe a -> [a] maybeToList Nothing = []
src/Keiro/Dsl/Goldens.hs view
@@ -1,221 +1,243 @@-{- | Versioned event-payload fixtures captured at spec-diff time.--The current aggregate specification cannot reconstruct an older payload shape,-so golden payloads are synthesized while both the old and new specifications-are available. Existing files are never overwritten: a hand-captured-production payload is always more authoritative than a synthesized sample.--}-module Keiro.Dsl.Goldens (- GoldenEvidence (..),+-- | Versioned event-payload fixtures captured at spec-diff time.+--+-- The current aggregate specification cannot reconstruct an older payload shape,+-- so golden payloads are synthesized while both the old and new specifications+-- are available. Existing files are never overwritten: a hand-captured+-- production payload is always more authoritative than a synthesized sample.+module Keiro.Dsl.Goldens+ ( GoldenEvidence (..), GoldenPayload (..), goldensForDiff, emitGoldenPayloads, loadGoldenPayloads, goldenRelativePath,-) where+ )+where import Data.Aeson (Value (..)) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap import Data.Aeson.Text qualified as AesonText import Data.List (find)+import Data.List.NonEmpty qualified as NE 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.Text.Lazy qualified as TL+import Keiro.Dsl.AggregateType import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType import Keiro.Dsl.Scaffold (Agg (..), ResolvedCtor (..), defaultContext, resolveAgg) import Keiro.Dsl.TypeGraph import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist) import System.FilePath (dropTrailingPathSeparator, takeDirectory, takeFileName, (</>)) data GoldenEvidence = SynthesizedWeakStandIn | FileOwnedFixture- deriving stock (Eq, Show)+ deriving stock (Eq, Show) data GoldenPayload = GoldenPayload- { goldenContext :: !Text- , goldenAggregate :: !Text- , goldenEvent :: !Text- , goldenVersion :: !Int- , goldenJson :: !Text- , goldenEvidence :: !GoldenEvidence- }- deriving stock (Eq, Show)+ { goldenContext :: !Text,+ goldenAggregate :: !Text,+ goldenEvent :: !Text,+ goldenVersion :: !Int,+ goldenJson :: !Text,+ goldenEvidence :: !GoldenEvidence+ }+ deriving stock (Eq, Show) -{- | Synthesize one old-shape payload for each event whose version increases.-The result is deterministic and ordered like the old specification.--}+-- | Synthesize one old-shape payload for each event whose version increases.+-- The result is deterministic and ordered like the old specification. goldensForDiff :: Spec -> Spec -> [GoldenPayload] goldensForDiff oldSpec newSpec =- [ GoldenPayload- { goldenContext = specContext oldSpec- , goldenAggregate = aggName oldAggregate- , goldenEvent = evName oldEvent- , goldenVersion = evVersion oldEvent- , goldenJson = renderGolden oldSpec oldResolved oldResolvedEvent- , goldenEvidence = SynthesizedWeakStandIn- }- | oldAggregate <- aggregates oldSpec- , Just newAggregate <- [find ((== aggName oldAggregate) . aggName) (aggregates newSpec)]- , let oldResolved = resolveAgg (defaultContext (specContext oldSpec)) oldSpec oldAggregate- , oldEvent <- aggEvents oldAggregate- , Just newEvent <- [find ((== evName oldEvent) . evName) (aggEvents newAggregate)]- , evVersion newEvent > evVersion oldEvent- , Just oldResolvedEvent <- [find ((== evName oldEvent) . rcName) (aEvents oldResolved)]- ]+ [ GoldenPayload+ { goldenContext = specContext oldSpec,+ goldenAggregate = aggName oldAggregate,+ goldenEvent = evName oldEvent,+ goldenVersion = evVersion oldEvent,+ goldenJson = renderGolden oldSpec oldResolved oldResolvedEvent,+ goldenEvidence = SynthesizedWeakStandIn+ }+ | oldAggregate <- aggregates oldSpec,+ Just newAggregate <- [find ((== aggName oldAggregate) . aggName) (aggregates newSpec)],+ let oldResolved = resolveAgg (defaultContext (specContext oldSpec)) oldSpec oldAggregate,+ oldEvent <- aggEvents oldAggregate,+ Just newEvent <- [find ((== evName oldEvent) . evName) (aggEvents newAggregate)],+ evVersion newEvent > evVersion oldEvent,+ Just oldResolvedEvent <- [find ((== evName oldEvent) . rcName) (aEvents oldResolved)]+ ] where aggregates spec = [aggregate | NAggregate aggregate <- specNodes spec] -{- | Write newly synthesized fixtures below-@<root>/<context>/<aggregate>/<event>.v<version>.json@. Existing files are-left untouched and omitted from the returned path list.--}+-- | Write newly synthesized fixtures below+-- @<root>/<context>/<aggregate>/<event>.v<version>.json@. Existing files are+-- left untouched and omitted from the returned path list. emitGoldenPayloads :: FilePath -> Spec -> Spec -> IO [FilePath] emitGoldenPayloads root oldSpec newSpec =- fmap concat . traverse writeIfMissing $ goldensForDiff oldSpec newSpec+ fmap concat . traverse writeIfMissing $ goldensForDiff oldSpec newSpec where writeIfMissing golden = do- let path = root </> goldenRelativePath golden- exists <- doesFileExist path- if exists- then pure []- else do- createDirectoryIfMissing True (takeDirectory path)- TIO.writeFile path (goldenJson golden)- pure [path]+ let path = root </> goldenRelativePath golden+ exists <- doesFileExist path+ if exists+ then pure []+ else do+ createDirectoryIfMissing True (takeDirectory path)+ TIO.writeFile path (goldenJson golden)+ pure [path] -{- | Load only the fixtures relevant to declared upcasters in @spec@.-@root@ may name the global golden root or its context child directory.--}+-- | Load only the fixtures relevant to declared upcasters in @spec@.+-- @root@ may name the global golden root or its context child directory. loadGoldenPayloads :: FilePath -> Spec -> IO [GoldenPayload] loadGoldenPayloads root spec = do- contextRoot <- resolveContextRoot root (T.unpack (specContext spec))- fmap concat . traverse (loadAggregate contextRoot) $ aggregates spec+ contextRoot <- resolveContextRoot root (T.unpack (specContext spec))+ fmap concat . traverse (loadAggregate contextRoot) $ aggregates spec where aggregates current = [aggregate | NAggregate aggregate <- specNodes current] loadAggregate contextRoot aggregate =- fmap concat . traverse (loadEvent contextRoot aggregate) $ aggEvents aggregate+ fmap concat . traverse (loadEvent contextRoot aggregate) $ aggEvents aggregate loadEvent contextRoot aggregate event = case evUpcastFrom event of- Nothing -> pure []- Just (sourceVersion, _) -> do- let golden =- GoldenPayload- { goldenContext = specContext spec- , goldenAggregate = aggName aggregate- , goldenEvent = evName event- , goldenVersion = sourceVersion- , goldenJson = ""- , goldenEvidence = FileOwnedFixture- }- path = contextRoot </> aggregateRelativePath golden- exists <- doesFileExist path- if exists- then do- contents <- TIO.readFile path- pure [golden{goldenJson = contents}]- else pure []+ Nothing -> pure []+ Just (sourceVersion, _) -> do+ let golden =+ GoldenPayload+ { goldenContext = specContext spec,+ goldenAggregate = aggName aggregate,+ goldenEvent = evName event,+ goldenVersion = sourceVersion,+ goldenJson = "",+ goldenEvidence = FileOwnedFixture+ }+ path = contextRoot </> aggregateRelativePath golden+ exists <- doesFileExist path+ if exists+ then do+ contents <- TIO.readFile path+ pure [golden {goldenJson = contents}]+ else pure [] goldenRelativePath :: GoldenPayload -> FilePath goldenRelativePath golden =- T.unpack (goldenContext golden) </> aggregateRelativePath golden+ T.unpack (goldenContext golden) </> aggregateRelativePath golden aggregateRelativePath :: GoldenPayload -> FilePath aggregateRelativePath golden =- T.unpack (goldenAggregate golden)- </> T.unpack (goldenEvent golden)- <> ".v"- <> show (goldenVersion golden)- <> ".json"+ T.unpack (goldenAggregate golden)+ </> T.unpack (goldenEvent golden)+ <> ".v"+ <> show (goldenVersion golden)+ <> ".json" resolveContextRoot :: FilePath -> FilePath -> IO FilePath resolveContextRoot root contextName = do- let nested = root </> contextName- nestedExists <- doesDirectoryExist nested- pure $- if nestedExists- then nested- else- if takeFileName (dropTrailingPathSeparator root) == contextName- then root- else nested+ let nested = root </> contextName+ nestedExists <- doesDirectoryExist nested+ pure $+ if nestedExists+ then nested+ else+ if takeFileName (dropTrailingPathSeparator root) == contextName+ then root+ else nested renderGolden :: Spec -> Agg -> ResolvedCtor -> Text renderGolden spec aggregate event =- TL.toStrict (AesonText.encodeToLazyText (Object (KeyMap.fromList entries))) <> "\n"+ TL.toStrict (AesonText.encodeToLazyText (Object (KeyMap.fromList entries))) <> "\n" where graph = either (const Nothing) Just (resolveTypeGraph spec) entries =- (Key.fromText "kind", String (rcName event))- : [(Key.fromText fieldName, sampleValue graph spec aggregate fieldType) | (fieldName, fieldType) <- rcFields event]+ (Key.fromText "kind", String (rcName event))+ : [(Key.fromText fieldName, sampleValue graph spec aggregate fieldType) | (fieldName, fieldType) <- rcFields event] -sampleValue :: Maybe TypeGraph -> Spec -> Agg -> Text -> Value-sampleValue graph spec _aggregate fieldType- | Just identifier <- find ((== fieldType) . idName) (specIds spec) =- String (idPrefix identifier <> "_01hzy3v7q2e8kaw2m5x0d41n9c")- | Just enum <- find ((== fieldType) . enumName) (specEnums spec)- , (_, wireValue) : _ <- enumCtors enum =- String wireValue- | Just resolved <- graph- , Just declaration <- Map.lookup (MappedKey fieldType) (tgDeclarations resolved) =- sampleMappedDeclaration resolved declaration- | fieldType == "Int" = Number 1- | fieldType == "Bool" = Bool True- | fieldType `elem` ["Time", "UTCTime"] = String "2026-01-01T00:00:00Z"- | otherwise = String "sample"+sampleValue :: Maybe TypeGraph -> Spec -> Agg -> ResolvedAggregateType -> Value+sampleValue graph spec _aggregate resolvedType =+ case resolvedType of+ AggregateNominal nominal -> case resolvedNominalRepresentation nominal of+ IdRepresentation prefix -> String (prefix <> "_01hzy3v7q2e8kaw2m5x0d41n9c")+ EnumRepresentation constructors -> String (snd (NE.head constructors))+ ScalarRepresentation NominalText -> String "sample"+ ScalarRepresentation NominalInt -> Number 1+ ScalarRepresentation NominalNatural -> Number 1+ ScalarRepresentation NominalBool -> Bool True+ ScalarRepresentation NominalTime -> String "2026-01-02T03:04:05.123456789012Z"+ AggregateVertex vertexType ->+ String+ ( fromMaybe+ "sample"+ ( stName+ <$> ( find ((== vertexType) . (<> "Vertex") . aggName) aggregates+ >>= listToMaybe . aggStates+ )+ )+ )+ AggregateMapped key+ | Just resolved <- graph,+ Just declaration <- Map.lookup key (tgDeclarations resolved) ->+ sampleMappedDeclaration resolved declaration+ | otherwise -> emptyObject+ AggregateInt -> Number 1+ AggregateInteger -> Number 1+ AggregateNatural -> Number 1+ AggregateBool -> Bool True+ AggregateTime -> String "2026-01-02T03:04:05.123456789012Z"+ AggregateText -> String "sample"+ where+ aggregates = [aggregate | NAggregate aggregate <- specNodes spec] sampleMappedDeclaration :: TypeGraph -> ResolvedMappedDecl -> Value sampleMappedDeclaration graph =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ -> sampleMappedShape graph- , onOpaqueDecl = const emptyObject- }+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ -> sampleMappedShape graph,+ onOpaqueDecl = const emptyObject+ } sampleMappedShape :: TypeGraph -> ResolvedMappedShape -> Value sampleMappedShape graph =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields ->- Object . KeyMap.fromList $- [ (Key.fromText (rwfKey field), sampleMappedExpression graph (rwfType field))- | field <- fields- , includeField field- ]- , onEnum = \entries -> case entries of- firstEntry : _ -> String (weTag firstEntry)- [] -> String "sample"- , onUnion = \encoding arms -> case arms of- firstArm : _ ->- Object . KeyMap.fromList $- [(Key.fromText (ueTagField encoding), String (rwaTag firstArm))]- <> [ (Key.fromText (ueContentsField encoding), sampleMappedExpression graph payload)- | payload <- maybeToList (rwaPayload firstArm)- ]- [] -> emptyObject- }+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields ->+ Object . KeyMap.fromList $+ [ (Key.fromText (rwfKey field), sampleMappedExpression graph (rwfType field))+ | field <- fields,+ includeField field+ ],+ onEnum = \entries -> case entries of+ firstEntry : _ -> String (weTag firstEntry)+ [] -> String "sample",+ onUnion = \encoding arms -> case arms of+ firstArm : _ ->+ Object . KeyMap.fromList $+ [(Key.fromText (ueTagField encoding), String (rwaTag firstArm))]+ <> [ (Key.fromText (ueContentsField encoding), sampleMappedExpression graph payload)+ | payload <- maybeToList (rwaPayload firstArm)+ ]+ [] -> emptyObject+ } where includeField field = case rwfPresence field of- PRequired -> True- POptional -> isNothingValue (rwfOnMissing field)+ PRequired -> True+ POptional -> isNothingValue (rwfOnMissing field) sampleMappedExpression :: TypeGraph -> ResolvedTypeExpr -> Value sampleMappedExpression graph =- foldTypeExpr- TypeExprAlgebra- { onText = String "sample"- , onInt = Number 1- , onBool = Bool True- , onNatural = Number 1- , onTime = String "2026-01-01T00:00:00Z"- , onJson = emptyObject- , onOptional = id- , onList = \value -> Array (pure value)- , onMap = \value -> Object (KeyMap.singleton (Key.fromText "sample") value)- , onRef = \key -> maybe emptyObject (sampleMappedDeclaration graph) (Map.lookup key (tgDeclarations graph))- }+ foldTypeExpr+ TypeExprAlgebra+ { onText = String "sample",+ onInt = Number 1,+ onInteger = Number 1,+ onBool = Bool True,+ onNatural = Number 1,+ onTime = String "2026-01-01T00:00:00Z",+ onJson = emptyObject,+ onOptional = id,+ onList = \value -> Array (pure value),+ onMap = \value -> Object (KeyMap.singleton (Key.fromText "sample") value),+ onRef = \key -> maybe emptyObject (sampleMappedDeclaration graph) (Map.lookup key (tgDeclarations graph))+ } emptyObject :: Value emptyObject = Object KeyMap.empty@@ -225,4 +247,4 @@ isNothingValue :: Maybe a -> Bool isNothingValue Nothing = True-isNothingValue Just{} = False+isNothingValue Just {} = False
src/Keiro/Dsl/Grammar.hs view
@@ -1,1170 +1,1228 @@-{- | The abstract syntax of the keiro DSL (@.keiro@) — the shared engine type that-every later vertical (EP-2…EP-6) extends additively. EP-1 defines the shared-declarations, the 'Expr' sublanguage, the eight hole-kind types, and the-'Aggregate' node. New node families add a 'Node' constructor here in lockstep-with their parser, validator, and scaffold cases.--}-module Keiro.Dsl.Grammar (- -- * Names and source locations- Name,- Loc (..),- noLoc,-- -- * Shared declarations- IdDecl (..),- EnumDecl (..),- RuleDecl (..),-- -- * Consumer-owned mapped types (EP-149)- TypeExpr (..),- Presence (..),- UnknownFields (..),- OnMissing (..),- WireField (..),- wireFieldLoc,- UnionEncoding (..),- WireEnum (..),- WireArm (..),- MappedShape (..),- HaskellSource (..),- MappedDecl (..),-- -- * The eight hole-kind types- Derivation (..),- DerivStrategy (..),- Disposition (..),- DispAction (..),- Mapping (..),- EnvelopeBinding (..),- EnvelopeLayer (..),-- -- * The Expr sublanguage- Expr (..),- CmpOp (..),- Atom (..),- complementExpr,-- -- * The aggregate node- RegInitial (..),- RegDecl (..),- StateDecl (..),- Field (..),- Command (..),- Event (..),- EventBody (..),- Hole (..),- Transition (..),- TransitionMode (..),- WireSpec (..),- ProjectionSpec (..),- Consistency (..),- SnapPolicy (..),- SnapshotSpec (..),- Aggregate (..),-- -- * The process + timer nodes (EP-3)- FieldBinding (..),- InputDecl (..),- CorrelateDecl (..),- SagaRef (..),- Disp (..),- DispatchDisposition (..),- AdvanceNode (..),- DispatchNode (..),- HandleNode (..),- IdExpr (..),- IdStrategy (..),- FireAtExpr (..),- FireOutcome (..),- FireDisposition (..),- FireNode (..),- TimerNode (..),- PolicyChoice (..),- ProcessNode (..),-- -- * The router node (EP-108)- ResolveSource (..),- ResolveDecl (..),- RouterDispatchNode (..),- RouterNode (..),-- -- * The integration contract node (EP-4)- ContractType (..),- ContractField (..),- ContractEvent (..),- ContractNode (..),-- -- * The integration intake (inbox) node (EP-4)- WireSource (..),- BindRow (..),- InboxAction (..),- DispositionRow (..),- DecodeSpec (..),- InkPersist (..),- IntakeNode (..),-- -- * The integration emit/publisher nodes (EP-4)- DeriveSpec (..),- EmitMapRow (..),- EmitNode (..),- BackoffSpec (..),- PublisherNode (..),-- -- * The pgmq workqueue/dispatch nodes (EP-5)- WqField (..),- WqDispRow (..),- WqOrdering (..),- WqGroupKey (..),- WqProvision (..),- WorkqueueNode (..),- PgmqDispatchNode (..),-- -- * Read-model nodes (EP-107)- RmColumn (..),- RmFeed (..),- RmScope (..),- ReadModelNode (..),-- -- * The workflow/operation nodes (EP-6)- WfBodyItem (..),- WorkflowNode (..),- workflowNodeLoc,- OperationShape (..),- OperationNode (..),-- -- * Top level- Placement (..),- Node (..),- Spec (..),-)-where--import Data.Text (Text)-import GHC.Generics (Generic)--{- | An identifier in the notation: a type name, register name, command/event-name, state name, enum constructor, etc. Always a non-empty 'Text'.--}-type Name = Text--{- | A source line number, attached to declarations so the validator can emit-line-numbered diagnostics. Its 'Eq' instance deliberately ignores the line-value: two ASTs that differ only in source position are considered equal, so-the @parse . pretty == id@ round-trip property holds without the-pretty-printer having to reproduce exact line numbers.--}-newtype Loc = Loc {unLoc :: Int}- deriving stock (Show)--instance Eq Loc where- _ == _ = True---- | A placeholder location used by generators and pretty-print round-trips.-noLoc :: Loc-noLoc = Loc 0--{- | @id TransferReservationId prefix=rsv@ — declares an id newtype over 'Text'-and its prefix tag.--}-data IdDecl = IdDecl- { idName :: !Name- , idPrefix :: !Text- , idLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | @enum PatientAcuity { RedTag=red … }@ — a closed enumeration; each-constructor carries its wire spelling (the right-hand side of @=@).--}-data EnumDecl = EnumDecl- { enumName :: !Name- , enumCtors :: ![(Name, Text)]- , enumLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | @rule lifeCriticalOverride : PatientAcuity -> Bool@ with an @ex@ line of-@Ctor => bool ; …@ — a total function from an enum to a value, used as a-derived atom inside guards.--}-data RuleDecl = RuleDecl- { ruleName :: !Name- , ruleDomain :: !Name- , ruleCodomain :: !Name- , ruleCases :: ![(Name, Expr)]- , ruleLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- Consumer-owned mapped types (EP-149). The parser-facing declarations keep--- required facts optional so `keiro-dsl check` can report stable, located--- diagnostics for omissions. Keiro.Dsl.TypeGraph turns valid values into a--- checked representation before downstream consumers inspect them.--data TypeExpr- = TText- | TInt- | TBool- | TNatural- | TTime- | TJson- | TOptional !TypeExpr- | TList !TypeExpr- | TMap !TypeExpr- | TRef !Name- deriving stock (Eq, Show, Generic)--data Presence = PRequired | POptional- deriving stock (Eq, Show, Generic)--data UnknownFields = RejectUnknown | IgnoreUnknown- deriving stock (Eq, Show, Generic)--data OnMissing- = OmNull- | OmText !Text- | OmInt !Integer- | OmBool !Bool- | OmEmptyList- | OmEmptyMap- | OmCtor !Name- deriving stock (Eq, Show, Generic)--data WireField = WireField- { wfHaskell :: !Name- , wfKey :: !Text- , wfType :: !TypeExpr- , wfPresence :: !Presence- , wfOnMissing :: !(Maybe OnMissing)- , wfLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--wireFieldLoc :: WireField -> Loc-wireFieldLoc WireField{wfLoc = loc} = loc--data UnionEncoding = TaggedObject- { ueTagField :: !Text- , ueContentsField :: !Text- , ueUnknownFields :: !UnknownFields- }- deriving stock (Eq, Show, Generic)--data WireEnum = WireEnum- { weCtor :: !Name- , weTag :: !Text- , weLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--data WireArm = WireArm- { waCtor :: !Name- , waTag :: !Text- , waPayload :: !(Maybe TypeExpr)- , waLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--data MappedShape- = ShapeRecord !Name !UnknownFields ![WireField]- | ShapeEnum ![WireEnum]- | ShapeUnion !UnionEncoding ![WireArm]- deriving stock (Eq, Show, Generic)--data HaskellSource = HaskellSource- { hsPackage :: !Text- , hsModule :: !Text- , hsType :: !Name- }- deriving stock (Eq, Show, Generic)--data MappedDecl- = MappedStructural- { msName :: !Name- , msHaskell :: !(Maybe HaskellSource)- , msBinding :: !(Maybe Text)- , msBindingVersion :: !(Maybe Text)- , msCanonical :: !(Maybe Text)- , msFixtures :: !(Maybe Text)- , msInitial :: !(Maybe Text)- , msShape :: !MappedShape- , msLoc :: !Loc- }- | MappedOpaque- { moName :: !Name- , moHaskell :: !(Maybe HaskellSource)- , moCodecId :: !(Maybe Text)- , moCodecVersion :: !(Maybe Text)- , moFixtures :: !(Maybe Text)- , moInitial :: !(Maybe Text)- , moLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- The eight hole-kind types. EP-1 only exercises hole-kinds 1–3 against the--- aggregate vertical; the rest exist so EP-3…EP-6 reuse the same types.--{- | Hole-kind 1: a deterministic id/string derivation. Opaque strategies must-carry a captured @fixture@ (not a prose rule) so two agents re-derive them-identically.--}-data Derivation = Derivation- { derivStrategy :: !DerivStrategy- , derivFixture :: !(Maybe Text)- }- deriving stock (Eq, Show, Generic)--data DerivStrategy = UuidV5 | SuffixSplice- deriving stock (Eq, Show, Generic)--{- | Hole-kind 2: a failure→action table. Carries the two dangerous inversions a-@duplicate@/@rejected replay@ being treated as success and a-@previously-failed@ being dead-lettered rather than retried (enforced by-EP-4's validator rules).--}-newtype Disposition = Disposition- { dispCases :: [(Name, DispAction)]- }- deriving stock (Eq, Show, Generic)--data DispAction = AckOk | Retry !Int | DeadLetter !Text- deriving stock (Eq, Show, Generic)--{- | Hole-kind 3: an explicit value→value table that is not an identity echo-(e.g. an event name → projection status). @mapPartial@ records whether the-spec author explicitly marked the table partial over its domain.--}-data Mapping = Mapping- { mapPairs :: ![(Name, Name)]- , mapPartial :: !Bool- }- deriving stock (Eq, Show, Generic)--{- | Hole-kind 4: which layer carries each envelope field, and whether the two-are cross-checked. Defined here for reuse; exercised by EP-4.--}-data EnvelopeBinding = EnvelopeBinding- { envField :: !Name- , envLayer :: !EnvelopeLayer- , envCrossChecked :: !Bool- }- deriving stock (Eq, Show, Generic)--data EnvelopeLayer = KafkaHeader | JsonBody- deriving stock (Eq, Show, Generic)--{- | The @Expr@ sublanguage used by @guard@ clauses and the right-hand side of-@write@ clauses. An infix expression over 'Atom's; operators in precedence-order are @||@ (lowest), @&&@, then the relational comparisons.--}-data Expr- = EOr !Expr !Expr- | EAnd !Expr !Expr- | ECmp !CmpOp !Expr !Expr- | EAtom !Atom- deriving stock (Eq, Show, Generic)--data CmpOp = OpEq | OpNeq | OpLt | OpLe | OpGt | OpGe- deriving stock (Eq, Show, Generic)--{- | An atom is either a bare boolean literal (@true@/@false@) or a name. Names-are kept syntactically neutral: at parse time an identifier is-indistinguishable between a register, a command field, an enum constructor,-and a rule, so the validator's scope-check (M2) resolves which one each-'AName' is against the declared sets. This keeps the parser honest and the-round-trip exact.--}-data Atom- = AName !Name- | ABool !Bool- deriving stock (Eq, Show, Generic)--{- | The logical complement of a guard, expressed inside the existing grammar —-'Expr' has no negation constructor, but negation is eliminable: De Morgan over-'EOr'\/'EAnd', comparison-operator flipping, boolean-literal flip, and-@x == false@ for a bare name atom (guards are boolean-valued, so a bare name-in guard position is a boolean read). Used by @diff@ to compute the-replay-only twin of a tightened guard (@old ∧ ¬new@, plan 143): the printed-complement re-parses as a valid guard today.--Caveat: comparison flipping is classical — @¬(a < b) = a >= b@ — which is-correct over the DSL's total ordered domains.--}-complementExpr :: Expr -> Expr-complementExpr = \case- EOr l r -> EAnd (complementExpr l) (complementExpr r)- EAnd l r -> EOr (complementExpr l) (complementExpr r)- ECmp op l r -> ECmp (complementCmp op) l r- EAtom (ABool b) -> EAtom (ABool (not b))- e@(EAtom (AName _)) -> ECmp OpEq e (EAtom (ABool False))- where- complementCmp = \case- OpEq -> OpNeq- OpNeq -> OpEq- OpLt -> OpGe- OpLe -> OpGt- OpGt -> OpLe- OpGe -> OpLt--{- | @name Type = initial@ — a named register with its declared type and the-initial value (an identifier: a literal like @placeholder@, an enum-constructor, or a state name).--}-data RegInitial- = RegInitBare !Text- | RegInitText !Text- deriving stock (Eq, Show, Generic)--data RegDecl = RegDecl- { regName :: !Name- , regType :: !Name- , regInitial :: !RegInitial- , regLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | One entry in a @states@ list. @stTerminal@ is set when the name carries a-trailing @!@ (no outgoing transitions allowed). The first 'StateDecl' in an-aggregate's list is its initial state.--}-data StateDecl = StateDecl- { stName :: !Name- , stTerminal :: !Bool- , stLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | A command/event field. A bare name (@fieldType = Nothing@) reuses the-field's declared type elsewhere; @name:Type@ gives an explicit type.--}-data Field = Field- { fieldName :: !Name- , fieldType :: !(Maybe Name)- }- deriving stock (Eq, Show, Generic)---- | @command Name { field … }@ — a command constructor.-data Command = Command- { cmdName :: !Name- , cmdFields :: ![Field]- , cmdLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | @event Name { … }@ or @event Name = fields(Command)@. EP-2 (evolution) adds-the version/upcaster/retirement fields: an unversioned event is @evVersion = 1@,-@evUpcastFrom = Nothing@, @evRetiring = False@, and @evDeprecated = False@,-reproducing the EP-1 surface. These fields live on the shared 'Event' so every-node family's events inherit schema-versioning for free.--}-data Event = Event- { evName :: !Name- , evBody :: !EventBody- , evVersion :: !Int- -- ^ The schema version of this event shape. Default 1; written @vN@ for N>1.- , evUpcastFrom :: !(Maybe (Int, Hole))- {- ^ The source version this shape migrates /from/, paired with the upcaster- hole. @Just (n-1, …)@ for a @vN@ shape; 'Nothing' for v1.- -}- , evRetiring :: !Bool- {- ^ Retirement is in progress. The event must keep at least one live- emitting transition while operators terminalize or truncate affected- streams; cut over to @deprecated@ plus a replay-only emitting transition- afterwards.- -}- , evDeprecated :: !Bool- {- ^ Retired from the write path (no live transition may @emit@ it) but- still decodable from the log. A replay-only emitting transition must remain- while live streams can still contain the event.- -}- , evLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--data EventBody- = EventFields ![Field]- | EventFromCommand !Name- deriving stock (Eq, Show, Generic)--{- | A spec hole: an unfilled placeholder ('Hole', written @HOLE@ in the-notation) or a value the author supplied inline ('Filled').--}-data Hole = Hole | Filled !Text- deriving stock (Eq, Show, Generic)--{- | A transition @Src -- Command --> clauses@. Clauses may be written-indentation-stacked or @;@-separated on one line.--}-data Transition = Transition- { tSource :: !Name- , tCommand :: !Name- , tGuard :: !(Maybe Expr)- , tWrites :: ![(Name, Expr)]- , tEmits :: ![Name]- , tGoto :: !Name- , tMode :: !TransitionMode- , tLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | Whether a transition serves forward execution or replay only (plan 143).-A @replay-only@ transition lowers to a keiki 'ReplayOnly' edge: it is never-taken by a new command and exists so events emitted under a retired rule keep-an inverting edge. Spelled as a @replay-only@ prefix on the transition line:--@-replay-only Held -- ConfirmReservation --> guard … ; emit … ; goto …-@--}-data TransitionMode = TmLive | TmReplayOnly- deriving stock (Eq, Show, Generic)--{- | @wire kind=ctorName fields=camelCase schemaVersion=1@ — how events-serialize.--}-data WireSpec = WireSpec- { wireKind :: !Text- , wireFields :: !Text- , wireSchemaVersion :: !Int- }- deriving stock (Eq, Show, Generic)--{- | @projection table consistency=… key=… status-map { … }@ — the read-model-projection and its event→status 'Mapping' (hole-kind 3).--}-data ProjectionSpec = ProjectionSpec- { projTable :: !Name- , projConsistency :: !(Maybe Consistency)- , projKey :: !Name- , projStatusMap :: !(Maybe Mapping)- , projLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--data Consistency = Strong | Eventual- deriving stock (Eq, Show, Generic)---- | A generated aggregate snapshot policy supported by the notation.-data SnapPolicy = SnapEvery !Int | SnapOnTerminal- deriving stock (Eq, Show, Generic)---- | Snapshot policy plus the captured live state-codec identity.-data SnapshotSpec = SnapshotSpec- { snapPolicy :: !SnapPolicy- , snapCodecVersion :: !Int- , snapShapeHash :: !Text- , snapLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | An @aggregate@ node: a consistency boundary whose state is rebuilt by-replaying events.--}-data Aggregate = Aggregate- { aggName :: !Name- , aggRegs :: ![RegDecl]- , aggStates :: ![StateDecl]- , aggCommands :: ![Command]- , aggEvents :: ![Event]- , aggTransitions :: ![Transition]- , aggWire :: !(Maybe WireSpec)- , aggProjection :: !(Maybe ProjectionSpec)- , aggSnapshot :: !(Maybe SnapshotSpec)- , aggLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-3: process manager + durable timer nodes.--{- | A @field@ or @field=value@ binding inside a command\/payload field list.-A bare field reuses the input field of the same name; @name=value@ binds it to-an expression (kept as raw text, e.g. @timerId=timer.id@).--}-data FieldBinding = FieldBinding- { fbName :: !Name- , fbValue :: !(Maybe Text)- }- deriving stock (Eq, Show, Generic)--{- | @input SurgeInput { hospitalId … observedAt:Time }@ — the process's incoming-event shape (one field must be a @:Time@ field used by the timer deadline).--}-data InputDecl = InputDecl- { inName :: !Name- , inFields :: ![Field]- }- deriving stock (Eq, Show, Generic)--{- | @correlate input.hospitalId via idText@ — the correlation key (hole-kind 1-derivation + hole-kind 4 field-source).--}-data CorrelateDecl = CorrelateDecl- { corrField :: !Name- , corrVia :: !Name- }- deriving stock (Eq, Show, Generic)--{- | @saga Surge category \"hospitalSurge\"@ — the saga's own aggregate plus-the validated stream category used with @Keiro.Stream.entityStream@. For a-correlation id @c@, the saga stream is @<category>-<c>@.--}-data SagaRef = SagaRef- { sagaAgg :: !Name- , sagaCategory :: !Text- }- deriving stock (Eq, Show, Generic)---- | A command dispatch outcome action.-data Disp = DAckOk | DRetry | DDeadLetter !Text- deriving stock (Eq, Show, Generic)--{- | The complete dispatch disposition table (every arm mandatory; the-@on-duplicate AckOk@ benign inversion is explicit). Named to avoid clashing-with the hole-kind 'Disposition'.--}-data DispatchDisposition = DispatchDisposition- { onAppended :: !Disp- , onDuplicate :: !Disp- , onFailed :: !Disp- }- deriving stock (Eq, Show, Generic)---- | @advance NoteSurgeThreshold { … }@ — the self-command that advances the saga.-data AdvanceNode = AdvanceNode- { advCommand :: !Name- , advFields :: ![FieldBinding]- }- deriving stock (Eq, Show, Generic)---- | @dispatch Hospital\@input.hospitalId ActivateSurge { … } on-appended … on-duplicate … on-failed …@.-data DispatchNode = DispatchNode- { dispTarget :: !Name- , dispKey :: !Text- , dispCommand :: !Name- , dispFields :: ![FieldBinding]- , dispDisposition :: !DispatchDisposition- , dispLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | The @on <Input>@ reaction: a self-advance, zero or more dispatches, and a-@schedule@ of the timer.--}-data HandleNode = HandleNode- { hOn :: !Name- , hAdvance :: !AdvanceNode- , hDispatch :: ![DispatchNode]- , hSchedule :: !Name- }- deriving stock (Eq, Show, Generic)---- | A deterministic id derivation: @uuidv5 \"prefix:\" <> correlationId@.-data IdExpr = IdExpr- { ideStrategy :: !IdStrategy- , idePrefix :: !Text- }- deriving stock (Eq, Show, Generic)--data IdStrategy = UuidV5Id- deriving stock (Eq, Show, Generic)--{- | @fireAt input.observedAt + 5m@ — an injected timestamp field plus a window.-There is no clock-sampling constructor, so the no-wall-clock rule holds by-construction.--}-data FireAtExpr = FireAtExpr- { faField :: !Name- , faWindow :: !Text- }- deriving stock (Eq, Show, Generic)--data FireOutcome = OFired | ORetry- deriving stock (Eq, Show, Generic)--{- | The complete timer-fire disposition table; @on-reject OFired@ is the benign-inversion (a CommandRejected means \"already applied\" = success).--}-data FireDisposition = FireDisposition- { onOk :: !FireOutcome- , onReject :: !FireOutcome- , onAmbiguous :: !FireOutcome- , onError :: !FireOutcome- , notMine :: !FireOutcome- }- deriving stock (Eq, Show, Generic)---- | @fire dispatch Surge\@correlationId MarkSurgeTimerFired { … } fired-event-id … on-ok …@.-data FireNode = FireNode- { fireTarget :: !Name- , fireKey :: !Text- , fireCommand :: !Name- , fireFields :: ![FieldBinding]- , fireFiredEventId :: !IdExpr- , fireDisposition :: !FireDisposition- }- deriving stock (Eq, Show, Generic)---- | A nested @timer@ sub-node of a process.-data TimerNode = TimerNode- { tmName :: !Name- , tmId :: !IdExpr- , tmFireAt :: !FireAtExpr- , tmPayload :: ![FieldBinding]- , tmFire :: !FireNode- , tmDecodeUnknown :: !Name- , tmMaxAttempts :: !Int- , tmDeadLetter :: !Text- , tmLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- | A node-level worker policy lowered to the runtime worker options.-data PolicyChoice = PolHalt | PolDeadLetter | PolSkip- deriving stock (Eq, Show, Generic)--{- | A @process@ (process manager / saga) node. The dispatch-id strategy is fixed-(runtime-owned uuidv5), so it is implicit in the AST and always rendered.--}-data ProcessNode = ProcessNode- { procId :: !Name- -- ^ The block identifier (@process HospitalSurge@), used for module names.- , procName :: !Text- -- ^ The define-once ProcessManager @name@ (@name \"hospital-surge\"@).- , procInput :: !InputDecl- , procCorrelate :: !CorrelateDecl- , procSaga :: !SagaRef- , procTarget :: !Name- , procProjections :: ![Name]- , procHandle :: !HandleNode- , procRejected :: !PolicyChoice- , procPoison :: !PolicyChoice- , procTimer :: !TimerNode- , procLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-108: stateless, effectful content-based routing.--data ResolveSource = ResolveReadModel !Name | ResolveHole- deriving stock (Eq, Show, Generic)--data ResolveDecl = ResolveDecl- { rvSource :: !ResolveSource- , rvRow :: ![Name]- , rvLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--data RouterDispatchNode = RouterDispatchNode- { rdCommand :: !Name- , rdFields :: ![FieldBinding]- , rdDisposition :: !DispatchDisposition- , rdLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | A stateless router. Its fixed dispatch-id strategy is runtime-owned, and-the mandatory @stable@ token on the resolve clause is an author acknowledgement-that retry attempts accumulate the union of resolved target identities.--}-data RouterNode = RouterNode- { rtId :: !Name- , rtName :: !Text- , rtInput :: !InputDecl- , rtKey :: !CorrelateDecl- , rtResolve :: !ResolveDecl- , rtTarget :: !Name- , rtProjections :: ![Name]- , rtDispatch :: !RouterDispatchNode- , rtRejected :: !PolicyChoice- , rtPoison :: !PolicyChoice- , rtLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-4: the cross-service @contract@ (shared Kafka message schema, define-once).---- | A contract field type: @typeid \"inc\"@, @text@, or @int@.-data ContractType = CTypeId !Text | CText | CInt- deriving stock (Eq, Show, Generic)--data ContractField = ContractField- { cfName :: !Name- , cfType :: !ContractType- }- deriving stock (Eq, Show, Generic)---- | @event <Name> on <topicAlias> { field: type … }@ within a contract.-data ContractEvent = ContractEvent- { ceName :: !Name- , ceTopic :: !Name- , ceFields :: ![ContractField]- }- deriving stock (Eq, Show, Generic)--{- | A @contract@ node: the shared cross-service message schema, declared once-and referenced by both producer (@emit@) and consumer (@intake@). EP-5's-pgmq @dispatch@ also couples to it.--}-data ContractNode = ContractNode- { ctrName :: !Name- , ctrSchemaVersion :: !Int- , ctrDiscriminator :: !Name- , ctrTopics :: ![(Name, Text)]- -- ^ (topic alias, real Kafka topic string)- , ctrEvents :: ![ContractEvent]- , ctrLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-4: the @intake@ (Kafka consumer / inbox) node.---- | Where an envelope field is read from on the wire.-data WireSource- = SrcHeader !Text- | SrcBody- | SrcKafkaKey- | SrcKafkaCursor- deriving stock (Eq, Show, Generic)---- | One envelope-binding row: @bind <field> from <source> [required] [cross-check body]@.-data BindRow = BindRow- { brField :: !Name- , brSource :: !WireSource- , brRequired :: !Bool- , brCrossCheck :: !Bool- }- deriving stock (Eq, Show, Generic)--{- | An inbox outcome action. The dangerous defaults the validator guards: a-@duplicate@\/@previouslyFailed@ must not be 'IRetry'; @decodeFailed@ must not-be an unbounded 'IRetry'.--}-data InboxAction- = IAckOk- | -- | @retry <window>@, e.g. @retry 5s@- IRetry !Text- | IDeadLetter !(Maybe Text)- deriving stock (Eq, Show, Generic)---- | One row of the mandatory, complete inbox disposition table.-data DispositionRow = DispositionRow- { drOutcome :: !Name- , drAction :: !InboxAction- , drLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- | The body decode-strictness decision (hole-kind 6).-data DecodeSpec = DecodeSpec- { decEnvelope :: !Text- -- ^ the envelope policy text, e.g. @strict-required lenient-optional@- , decBodyStrict :: !Bool- , decBodySchemaVersion :: !Int- }- deriving stock (Eq, Show, Generic)---- | How much of a successfully processed envelope the inbox retains.-data InkPersist = InkPersistFull | InkPersistDedupeOnly- deriving stock (Eq, Show, Generic)--{- | An @intake@ (Kafka consumer / inbox) node. The runtime-config @consumer@-block (brokers/groupId/offsetReset) is hole-kind 8, delegated to deployment-and not modelled here.--}-data IntakeNode = IntakeNode- { inkName :: !Name- , inkContract :: !Name- , inkTopic :: !Name- , inkAccept :: ![Name]- , inkBinds :: ![BindRow]- , inkDedupeKey :: !Name- , inkDedupePolicy :: !Name- , inkPersist :: !InkPersist- , inkDecode :: !DecodeSpec- , inkDisposition :: ![DispositionRow]- , inkLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-4: the @emit@ (outbox mapping) and @publisher@ nodes.---- | A deterministic id derivation hole: @derive [\"prefix\"] hole@.-newtype DeriveSpec = DeriveSpec {dsPrefix :: Maybe Text}- deriving stock (Eq, Show, Generic)---- | One @\"value\" => EventType@ row of an emit's status mapping.-data EmitMapRow = EmitMapRow- { emrValue :: !Text- , emrEvent :: !Name- , emrLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | An @emit@ (outbox) node: maps a private status discriminant to contract-event types, with a mandatory explicit @_ => skip@ catch-all.--}-data EmitNode = EmitNode- { emName :: !Name- , emContract :: !Name- , emTopic :: !Name- , emSource :: !Text- , emKey :: !Name- , emDiscriminant :: !Name- , emMap :: ![EmitMapRow]- , emSkip :: !Bool- -- ^ whether the explicit @_ => skip@ catch-all is present- , emMessageId :: !DeriveSpec- , emIdempotencyKey :: !DeriveSpec- , emLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- | @backoff <kind> <window>@, e.g. @backoff constant 2s@.-data BackoffSpec = BackoffSpec- { boKind :: !Name- , boWindow :: !Text- , boMax :: !(Maybe Text)- , boMultiplier :: !(Maybe Text)- }- deriving stock (Eq, Show, Generic)---- | A @publisher@ node: the at-least-once publishing policy for an emit's topic.-data PublisherNode = PublisherNode- { pubName :: !Name- , pubEmit :: !Name- , pubOrdering :: !Name- , pubMaxAttempts :: !Int- , pubBackoff :: !BackoffSpec- , pubOutboxField :: !Name- -- ^ @outboxId stable from <field>@: retries coalesce on (source, this field)- , pubLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-5: the pgmq @workqueue@ + @dispatch@ nodes.---- | One @field -> \"wire_name\" type required@ row of a workqueue payload.-data WqField = WqField- { wqfName :: !Name- , wqfWire :: !Text- , wqfType :: !Name- , wqfRequired :: !Bool- }- deriving stock (Eq, Show, Generic)--{- | One row of a workqueue's consumer @JobOutcome@ disposition (reusing-'InboxAction': @retry <window>@ \/ @deadLetter@).--}-data WqDispRow = WqDispRow- { wqdOutcome :: !Name- , wqdAction :: !InboxAction- , wqdLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- | The queue's semantic delivery-order contract.-data WqOrdering = WqUnordered | WqFifoThroughput | WqFifoRoundRobin- deriving stock (Eq, Show, Generic)---- | A FIFO message-group key derived from one payload field.-data WqGroupKey = WqGroupKey- { gkField :: !Name- , gkVia :: !Name- , gkFixture :: !(Maybe Text)- }- deriving stock (Eq, Show, Generic)---- | The PostgreSQL storage shape provisioned for a queue.-data WqProvision- = WqStandard- | WqUnlogged- | WqPartitioned !Text !Text- deriving stock (Eq, Show, Generic)--{- | A pgmq @workqueue@ node. The @derive@ trio (physical\/dlq\/table) is a-/captured fixture/ (hole-kind 1): the validator re-derives the physical name-from @logical@ and flags any divergence (the drift hazard at the dedup site).--}-data WorkqueueNode = WorkqueueNode- { wqName :: !Name- , wqLogical :: !Text- , wqPhysical :: !Text- , wqDlq :: !Text- , wqTable :: !Text- , wqOrdering :: !WqOrdering- , wqGroupKey :: !(Maybe WqGroupKey)- , wqProvision :: !WqProvision- , wqPayloadName :: !Name- , wqPayload :: ![WqField]- , wqMaxRetries :: !Int- , wqDelay :: !Text- , wqDlqOn :: !Bool- , wqDisposition :: ![WqDispRow]- , wqLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | A pgmq @dispatch@ node: a read-model→enqueue coupling with a fan-out hole-and a dedup check (one arm of which is a raw-SQL hole).--}-data PgmqDispatchNode = PgmqDispatchNode- { pdName :: !Name- , pdSourceReadModel :: !Name- , pdSourceKey :: !Name- , pdFanoutBody :: !Name- , pdDedupKey :: !Name- , pdDedupReadModel :: !Name- , pdDedupReadModelField :: !Text- , pdDedupQueue :: !Name- , pdDedupQueueField :: !Text- , pdEnqueueTo :: !Name- , pdLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-107: first-class read-model declarations.---- | One declared SQL column. The validator owns the closed type vocabulary.-data RmColumn = RmColumn- { rmcName :: !Text- , rmcType :: !Text- , rmcRequired :: !Bool- }- deriving stock (Eq, Show, Generic)---- | Whether the model is fed in a command transaction or by a subscription.-data RmFeed = RmInline | RmSubscription- deriving stock (Eq, Show, Generic)---- | Which event-log head a strong read waits for.-data RmScope = RmEntireLog | RmCategory !Text- deriving stock (Eq, Show, Generic)--{- | A registered, versioned SQL read model. Columns define its shape identity;-the runtime table remains owned by codd migrations rather than the DSL.--}-data ReadModelNode = ReadModelNode- { rmName :: !Name- , rmTable :: !Text- , rmSchema :: !Text- , rmColumns :: ![RmColumn]- , rmVersion :: !Int- , rmShape :: !Text- , rmConsistency :: !Consistency- , rmScope :: !(Maybe RmScope)- , rmFeed :: !RmFeed- , rmSubscription :: !(Maybe Text)- , rmLoc :: !Loc- }- deriving stock (Eq, Show, Generic)---- EP-6: the durable @workflow@ + @operation@ nodes.--{- | One ordered item of a workflow body. Replay matches on the label, not the-position. (Positional constructors avoid partial record fields.)--}-data WfBodyItem- = -- | @step <label> -> <ResultType>@- WfStep !Name !Name !Loc- | -- | @await <label> -> <ResultType>@- WfAwait !Name !Name !Loc- | -- | @sleep <label> after <injected-delay-field>@ (TIME INJECTED)- WfSleep !Name !Name !Loc- | -- | @child <label> id input via <childIdFn> -> <ResultType>@- WfChild !Name !Name !Name !Loc- | -- | @patch <patch-id> { <items> }@ — guard items behind a durable patch.- WfPatch !Name ![WfBodyItem] !Loc- | -- | @continueAsNew <SeedType>@ — rotate after the terminal top-level item.- WfContinueAsNew !Name !Loc- deriving stock (Eq, Show, Generic)---- | A durable @workflow@ node.-data WorkflowNode = WorkflowNode- { wfId :: !Name- -- ^ block identifier (e.g. @HospitalTransferReservation@)- , wfStable :: !Text- -- ^ the stable @name "…"@ (journal stream + every deterministic id)- , wfInput :: !Name- , wfInputFields :: ![Field]- , wfOutput :: !Name- , wfIdField :: !(Maybe Name)- -- ^ @id from input.<field>@; 'Nothing' for @id from input@- , wfIdVia :: !Name- , wfBody :: ![WfBodyItem]- , wfLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--workflowNodeLoc :: WorkflowNode -> Loc-workflowNodeLoc WorkflowNode{wfLoc = loc} = loc---- | The four operation shapes.-data OperationShape- = -- | @command on <Agg> stream from <field> via <fn> project [ … ]@- CommandOp !Name !Name !Name ![Name]- | -- | @query <ReadModel> input <T> result <Type> consistency <C>@- QueryOp !Name !Name !Text !Name- | -- | @signal <label> of <Workflow> key from <field> via <fn> value <T>@- SignalOp !Name !Name !Name !Name !Name- | -- | @run <Workflow> input <T> outcome -> <Result>@- RunOp !Name !Name !Name- deriving stock (Eq, Show, Generic)--data OperationNode = OperationNode- { opName :: !Name- , opShape :: !OperationShape- , opLoc :: !Loc- }- deriving stock (Eq, Show, Generic)--{- | A top-level node. EP-1 defines 'NAggregate'; EP-3 adds 'NProcess'; EP-4 adds-'NContract'\/'NIntake'\/'NEmit'\/'NPublisher'; EP-5 adds 'NWorkqueue'\/-'NPgmqDispatch'; EP-6 adds 'NWorkflow'\/'NOperation'; EP-107 adds-'NReadModel'.--}-data Node- = NAggregate Aggregate- | NProcess ProcessNode- | NRouter RouterNode- | NContract ContractNode- | NIntake IntakeNode- | NEmit EmitNode- | NPublisher PublisherNode- | NWorkqueue WorkqueueNode- | NPgmqDispatch PgmqDispatchNode- | NReadModel ReadModelNode- | NWorkflow WorkflowNode- | NOperation OperationNode- deriving stock (Eq, Show, Generic)--{- | The module-placement style for a scaffolded service. 'GeneratedPrefix' is-the historical default — @\<root\>.Generated.\<Ctx\>.\<Node\>@ for the generated-layer, holes at @\<root\>.\<Ctx\>.\<Node\>@. 'CollocatedLeaf' places the-generated layer as a leaf under the domain — @\<root\>.\<Ctx\>.\<Node\>.Generated@-— so it sits next to hand-written domain code (holes still at-@\<root\>.\<Ctx\>.\<Node\>@). Defined here (not in "Keiro.Dsl.Scaffold") so the-'Spec' AST can carry an author's standing choice; 'Keiro.Dsl.Scaffold'-re-exports it.--}-data Placement- = GeneratedPrefix- | CollocatedLeaf- deriving stock (Eq, Show, Generic)--{- | A whole @.keiro@ file: one context name, an optional module-placement-override (the @module@/@layout@ clauses), the shared id/enum/rule/mapped declarations,-and the list of nodes. 'specModuleRoot' and 'specLayout' are 'Nothing' when the-spec omits the clauses, reproducing the historical default.--}-data Spec = Spec- { specContext :: !Name- , specModuleRoot :: !(Maybe Text)- , specLayout :: !(Maybe Placement)- , specIds :: ![IdDecl]- , specEnums :: ![EnumDecl]- , specRules :: ![RuleDecl]- , specMapped :: ![MappedDecl]- , specNodes :: ![Node]- }- deriving stock (Eq, Show, Generic)+-- | The abstract syntax of the keiro DSL (@.keiro@) — the shared engine type that+-- every later vertical (EP-2…EP-6) extends additively. EP-1 defines the shared+-- declarations, the 'Expr' sublanguage, the eight hole-kind types, and the+-- 'Aggregate' node. New node families add a 'Node' constructor here in lockstep+-- with their parser, validator, and scaffold cases.+module Keiro.Dsl.Grammar+ ( -- * Names and source locations+ Name,+ Loc (..),+ noLoc,++ -- * Shared declarations+ IdDecl (..),+ EnumDecl (..),+ RuleDecl (..),++ -- * Consumer-owned mapped types (EP-149)+ TypeExpr (..),+ Presence (..),+ UnknownFields (..),+ OnMissing (..),+ WireField (..),+ wireFieldLoc,+ UnionEncoding (..),+ WireEnum (..),+ WireArm (..),+ MappedShape (..),+ HaskellSource (..),+ NominalBindingDecl (..),+ NominalScalarDecl (..),+ MappedDecl (..),++ -- * The eight hole-kind types+ Derivation (..),+ DerivStrategy (..),+ Disposition (..),+ DispAction (..),+ Mapping (..),+ EnvelopeBinding (..),+ EnvelopeLayer (..),++ -- * The Expr sublanguage+ Expr (..),+ ExprRoot (..),+ ScalarLiteral (..),+ exprLoc,+ CmpOp (..),+ Atom (..),+ complementExpr,++ -- * The aggregate node+ RegInitial (..),+ RegDecl (..),+ StateDecl (..),+ AggregateField (..),+ Field (..),+ Command (..),+ Event (..),+ EventBody (..),+ Hole (..),+ Transition (..),+ TransitionImplementation (..),+ TransitionMode (..),+ WireSpec (..),+ ProjectionSpec (..),+ Consistency (..),+ SnapPolicy (..),+ SnapshotSpec (..),+ Aggregate (..),++ -- * The process + timer nodes (EP-3)+ FieldBinding (..),+ InputDecl (..),+ CorrelateDecl (..),+ SagaRef (..),+ Disp (..),+ DispatchDisposition (..),+ AdvanceNode (..),+ DispatchNode (..),+ HandleNode (..),+ IdExpr (..),+ IdStrategy (..),+ FireAtExpr (..),+ FireOutcome (..),+ FireDisposition (..),+ FireNode (..),+ TimerNode (..),+ PolicyChoice (..),+ ProcessNode (..),++ -- * The router node (EP-108)+ ResolveSource (..),+ ResolveDecl (..),+ RouterDispatchNode (..),+ RouterNode (..),++ -- * The integration contract node (EP-4)+ ContractType (..),+ ContractField (..),+ ContractEvent (..),+ ContractNode (..),++ -- * The integration intake (inbox) node (EP-4)+ WireSource (..),+ BindRow (..),+ InboxAction (..),+ DispositionRow (..),+ DecodeSpec (..),+ InkPersist (..),+ IntakeNode (..),++ -- * The integration emit/publisher nodes (EP-4)+ DeriveSpec (..),+ EmitMapRow (..),+ EmitNode (..),+ BackoffSpec (..),+ PublisherNode (..),++ -- * The pgmq workqueue/dispatch nodes (EP-5)+ WqField (..),+ WqDispRow (..),+ WqOrdering (..),+ WqGroupKey (..),+ WqProvision (..),+ WorkqueueNode (..),+ PgmqDispatchNode (..),++ -- * Read-model nodes (EP-107)+ RmColumn (..),+ RmFeed (..),+ RmScope (..),+ ReadModelNode (..),++ -- * The workflow/operation nodes (EP-6)+ WfBodyItem (..),+ WorkflowNode (..),+ workflowNodeLoc,+ OperationShape (..),+ OperationNode (..),++ -- * Top level+ Placement (..),+ Node (..),+ Spec (..),+ )+where++import Data.Text (Text)+import GHC.Generics (Generic)++-- | An identifier in the notation: a type name, register name, command/event+-- name, state name, enum constructor, etc. Always a non-empty 'Text'.+type Name = Text++-- | A source line number, attached to declarations so the validator can emit+-- line-numbered diagnostics. Its 'Eq' instance deliberately ignores the line+-- value: two ASTs that differ only in source position are considered equal, so+-- the @parse . pretty == id@ round-trip property holds without the+-- pretty-printer having to reproduce exact line numbers.+newtype Loc = Loc {unLoc :: Int}+ deriving stock (Show)++instance Eq Loc where+ _ == _ = True++-- | A placeholder location used by generators and pretty-print round-trips.+noLoc :: Loc+noLoc = Loc 0++-- | @id TransferReservationId prefix=rsv@ — declares an id newtype over 'Text'+-- and its prefix tag.+data IdDecl = IdDecl+ { idName :: !Name,+ idPrefix :: !Text,+ idBinding :: !(Maybe NominalBindingDecl),+ idLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | @enum PatientAcuity { RedTag=red … }@ — a closed enumeration; each+-- constructor carries its wire spelling (the right-hand side of @=@).+data EnumDecl = EnumDecl+ { enumName :: !Name,+ enumCtors :: ![(Name, Text)],+ enumBinding :: !(Maybe NominalBindingDecl),+ enumLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | @rule lifeCriticalOverride : PatientAcuity -> Bool@ with an @ex@ line of+-- @Ctor => bool ; …@ — a total function from an enum to a value, used as a+-- derived atom inside guards.+data RuleDecl = RuleDecl+ { ruleName :: !Name,+ ruleDomain :: !Name,+ ruleCodomain :: !Name,+ ruleCases :: ![(Name, Expr)],+ ruleLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- Consumer-owned mapped types (EP-149). The parser-facing declarations keep+-- required facts optional so `keiro-dsl check` can report stable, located+-- diagnostics for omissions. Keiro.Dsl.TypeGraph turns valid values into a+-- checked representation before downstream consumers inspect them.++data TypeExpr+ = TText+ | TInt+ | TInteger+ | TBool+ | TNatural+ | TTime+ | TJson+ | TOptional !TypeExpr+ | TList !TypeExpr+ | TMap !TypeExpr+ | TRef !Name+ deriving stock (Eq, Show, Generic)++data Presence = PRequired | POptional+ deriving stock (Eq, Show, Generic)++data UnknownFields = RejectUnknown | IgnoreUnknown+ deriving stock (Eq, Show, Generic)++data OnMissing+ = OmNull+ | OmText !Text+ | OmInt !Integer+ | OmBool !Bool+ | OmEmptyList+ | OmEmptyMap+ | OmCtor !Name+ deriving stock (Eq, Show, Generic)++data WireField = WireField+ { wfHaskell :: !Name,+ wfKey :: !Text,+ wfType :: !TypeExpr,+ wfPresence :: !Presence,+ wfOnMissing :: !(Maybe OnMissing),+ wfLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++wireFieldLoc :: WireField -> Loc+wireFieldLoc WireField {wfLoc = loc} = loc++data UnionEncoding = TaggedObject+ { ueTagField :: !Text,+ ueContentsField :: !Text,+ ueUnknownFields :: !UnknownFields+ }+ deriving stock (Eq, Show, Generic)++data WireEnum = WireEnum+ { weCtor :: !Name,+ weTag :: !Text,+ weLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++data WireArm = WireArm+ { waCtor :: !Name,+ waTag :: !Text,+ waPayload :: !(Maybe TypeExpr),+ waLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++data MappedShape+ = ShapeRecord !Name !UnknownFields ![WireField]+ | ShapeEnum ![WireEnum]+ | ShapeUnion !UnionEncoding ![WireArm]+ deriving stock (Eq, Show, Generic)++data HaskellSource = HaskellSource+ { hsPackage :: !Text,+ hsModule :: !Text,+ hsType :: !Name+ }+ deriving stock (Eq, Ord, Show, Generic)++-- | Parser-facing facts for a total consumer-owned nominal binding.+--+-- The fields remain optional only so validation can report every missing fact at+-- the owning declaration. Downstream code consumes the checked nominal registry.+data NominalBindingDecl = NominalBindingDecl+ { nominalHaskell :: !(Maybe HaskellSource),+ nominalBinding :: !(Maybe Text),+ nominalBindingVersion :: !(Maybe Text),+ nominalCanonicalType :: !(Maybe Text),+ nominalFixtures :: !(Maybe Text),+ nominalInitial :: !(Maybe Text),+ nominalLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | A consumer-owned nominal scalar over one declared representation name.+--+-- The raw representation name is retained so @keiro-dsl check@ owns the stable+-- unsupported-representation diagnostic instead of the low-level parser.+data NominalScalarDecl = NominalScalarDecl+ { nominalScalarName :: !Name,+ nominalScalarRepresentation :: !Name,+ nominalScalarBinding :: !NominalBindingDecl,+ nominalScalarLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++data MappedDecl+ = MappedStructural+ { msName :: !Name,+ msHaskell :: !(Maybe HaskellSource),+ msBinding :: !(Maybe Text),+ msBindingVersion :: !(Maybe Text),+ msCanonical :: !(Maybe Text),+ msFixtures :: !(Maybe Text),+ msInitial :: !(Maybe Text),+ msShape :: !MappedShape,+ msLoc :: !Loc+ }+ | MappedOpaque+ { moName :: !Name,+ moHaskell :: !(Maybe HaskellSource),+ moCodecId :: !(Maybe Text),+ moCodecVersion :: !(Maybe Text),+ moFixtures :: !(Maybe Text),+ moInitial :: !(Maybe Text),+ moLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- The eight hole-kind types. EP-1 only exercises hole-kinds 1–3 against the+-- aggregate vertical; the rest exist so EP-3…EP-6 reuse the same types.++-- | Hole-kind 1: a deterministic id/string derivation. Opaque strategies must+-- carry a captured @fixture@ (not a prose rule) so two agents re-derive them+-- identically.+data Derivation = Derivation+ { derivStrategy :: !DerivStrategy,+ derivFixture :: !(Maybe Text)+ }+ deriving stock (Eq, Show, Generic)++data DerivStrategy = UuidV5 | SuffixSplice+ deriving stock (Eq, Show, Generic)++-- | Hole-kind 2: a failure→action table. Carries the two dangerous inversions a+-- @duplicate@/@rejected replay@ being treated as success and a+-- @previously-failed@ being dead-lettered rather than retried (enforced by+-- EP-4's validator rules).+newtype Disposition = Disposition+ { dispCases :: [(Name, DispAction)]+ }+ deriving stock (Eq, Show, Generic)++data DispAction = AckOk | Retry !Int | DeadLetter !Text+ deriving stock (Eq, Show, Generic)++-- | Hole-kind 3: an explicit value→value table that is not an identity echo+-- (e.g. an event name → projection status). @mapPartial@ records whether the+-- spec author explicitly marked the table partial over its domain.+data Mapping = Mapping+ { mapPairs :: ![(Name, Name)],+ mapPartial :: !Bool+ }+ deriving stock (Eq, Show, Generic)++-- | Hole-kind 4: which layer carries each envelope field, and whether the two+-- are cross-checked. Defined here for reuse; exercised by EP-4.+data EnvelopeBinding = EnvelopeBinding+ { envField :: !Name,+ envLayer :: !EnvelopeLayer,+ envCrossChecked :: !Bool+ }+ deriving stock (Eq, Show, Generic)++data EnvelopeLayer = KafkaHeader | JsonBody+ deriving stock (Eq, Show, Generic)++-- | The @Expr@ sublanguage used by @guard@ clauses and the right-hand side of+-- @write@ clauses. An infix expression over 'Atom's; operators in precedence+-- order are @||@ (lowest), @&&@, then the relational comparisons.+data Expr+ = EOr !Expr !Expr+ | EAnd !Expr !Expr+ | ECmp !CmpOp !Expr !Expr+ | EAdd !Loc !Expr !Expr+ | ESubtract !Loc !Expr !Expr+ | EMultiply !Loc !Expr !Expr+ | EPath !Loc !ExprRoot ![Name]+ | ELiteral !Loc !ScalarLiteral+ | EAtom !Atom+ deriving stock (Eq, Show, Generic)++-- | The provenance of a version-2 scalar path. The first path segment is the+-- register or active command-field name; remaining segments are required+-- structural record fields.+data ExprRoot+ = UnqualifiedRoot+ | RegisterRoot+ | CommandRoot+ deriving stock (Eq, Ord, Show, Generic)++-- | Surface scalar literals whose final type is selected by the resolver.+-- Quoted literals deliberately share one syntax for Text and Time; integral+-- literals share one syntax for Int, Integer, and Natural. No numeric coercion+-- follows from that syntactic sharing.+data ScalarLiteral+ = LiteralText !Text+ | LiteralIntegral !Integer+ | LiteralBool !Bool+ | LiteralQualified !Name !Name+ | LiteralId !Name !Text+ deriving stock (Eq, Show, Generic)++-- | Best available source row for an expression node. Version-2 atoms and+-- arithmetic retain their exact row; legacy nodes fall back through children+-- and ultimately to 'noLoc'.+exprLoc :: Expr -> Loc+exprLoc = \case+ EOr left right -> firstLocated left right+ EAnd left right -> firstLocated left right+ ECmp _ left right -> firstLocated left right+ EAdd loc _ _ -> loc+ ESubtract loc _ _ -> loc+ EMultiply loc _ _ -> loc+ EPath loc _ _ -> loc+ ELiteral loc _ -> loc+ EAtom {} -> noLoc+ where+ firstLocated left right = case exprLoc left of+ Loc 0 -> exprLoc right+ loc -> loc++data CmpOp = OpEq | OpNeq | OpLt | OpLe | OpGt | OpGe+ deriving stock (Eq, Show, Generic)++-- | An atom is either a bare boolean literal (@true@/@false@) or a name. Names+-- are kept syntactically neutral: at parse time an identifier is+-- indistinguishable between a register, a command field, an enum constructor,+-- and a rule, so the validator's scope-check (M2) resolves which one each+-- 'AName' is against the declared sets. This keeps the parser honest and the+-- round-trip exact.+data Atom+ = AName !Name+ | ABool !Bool+ deriving stock (Eq, Show, Generic)++-- | The logical complement of a guard, expressed inside the existing grammar —+-- 'Expr' has no negation constructor, but negation is eliminable: De Morgan over+-- 'EOr'\/'EAnd', comparison-operator flipping, boolean-literal flip, and+-- @x == false@ for a bare name atom (guards are boolean-valued, so a bare name+-- in guard position is a boolean read). Used by @diff@ to compute the+-- replay-only twin of a tightened guard (@old ∧ ¬new@, plan 143): the printed+-- complement re-parses as a valid guard today.+--+-- Caveat: comparison flipping is classical — @¬(a < b) = a >= b@ — which is+-- correct over the DSL's total ordered domains.+complementExpr :: Expr -> Expr+complementExpr = \case+ EOr l r -> EAnd (complementExpr l) (complementExpr r)+ EAnd l r -> EOr (complementExpr l) (complementExpr r)+ ECmp op l r -> ECmp (complementCmp op) l r+ ELiteral loc (LiteralBool value) -> ELiteral loc (LiteralBool (not value))+ EAtom (ABool b) -> EAtom (ABool (not b))+ e@(EAtom _) -> ECmp OpEq e (EAtom (ABool False))+ e -> ECmp OpEq e (ELiteral (exprLoc e) (LiteralBool False))+ where+ complementCmp = \case+ OpEq -> OpNeq+ OpNeq -> OpEq+ OpLt -> OpGe+ OpLe -> OpGt+ OpGt -> OpLe+ OpGe -> OpLt++-- | @name Type = initial@ — a named register with its declared type and the+-- initial value (an identifier: a literal like @placeholder@, an enum+-- constructor, or a state name).+data RegInitial+ = RegInitBare !Text+ | RegInitText !Text+ deriving stock (Eq, Show, Generic)++data RegDecl = RegDecl+ { regName :: !Name,+ regType :: !TypeExpr,+ regInitial :: !RegInitial,+ regLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | One entry in a @states@ list. @stTerminal@ is set when the name carries a+-- trailing @!@ (no outgoing transitions allowed). The first 'StateDecl' in an+-- aggregate's list is its initial state.+data StateDecl = StateDecl+ { stName :: !Name,+ stTerminal :: !Bool,+ stLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | An aggregate command/event field. A bare name reuses the field's inferred+-- aggregate type; @name:Type@ accepts the complete 'TypeExpr' grammar so semantic+-- validation can reject unsupported direct shapes with a located diagnostic.+data AggregateField = AggregateField+ { aggregateFieldName :: !Name,+ aggregateFieldType :: !(Maybe TypeExpr),+ aggregateFieldLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | A generic field used by process and router nodes. Aggregate fields are+-- kept separate so widening aggregate syntax does not widen those node families.+data Field = Field+ { fieldName :: !Name,+ fieldType :: !(Maybe Name)+ }+ deriving stock (Eq, Show, Generic)++-- | @command Name { field … }@ — a command constructor.+data Command = Command+ { cmdName :: !Name,+ cmdFields :: ![AggregateField],+ cmdLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | @event Name { … }@ or @event Name = fields(Command)@. EP-2 (evolution) adds+-- the version/upcaster/retirement fields: an unversioned event is @evVersion = 1@,+-- @evUpcastFrom = Nothing@, @evRetiring = False@, and @evDeprecated = False@,+-- reproducing the EP-1 surface. These fields live on the shared 'Event' so every+-- node family's events inherit schema-versioning for free.+data Event = Event+ { evName :: !Name,+ evBody :: !EventBody,+ -- | The schema version of this event shape. Default 1; written @vN@ for N>1.+ evVersion :: !Int,+ -- | The source version this shape migrates /from/, paired with the upcaster+ -- hole. @Just (n-1, …)@ for a @vN@ shape; 'Nothing' for v1.+ evUpcastFrom :: !(Maybe (Int, Hole)),+ -- | Retirement is in progress. The event must keep at least one live+ -- emitting transition while operators terminalize or truncate affected+ -- streams; cut over to @deprecated@ plus a replay-only emitting transition+ -- afterwards.+ evRetiring :: !Bool,+ -- | Retired from the write path (no live transition may @emit@ it) but+ -- still decodable from the log. A replay-only emitting transition must remain+ -- while live streams can still contain the event.+ evDeprecated :: !Bool,+ evLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++data EventBody+ = EventFields ![AggregateField]+ | EventFromCommand !Name+ deriving stock (Eq, Show, Generic)++-- | A spec hole: an unfilled placeholder ('Hole', written @HOLE@ in the+-- notation) or a value the author supplied inline ('Filled').+data Hole = Hole | Filled !Text+ deriving stock (Eq, Show, Generic)++-- | A transition @Src -- Command --> clauses@. Clauses may be written+-- indentation-stacked or @;@-separated on one line.+data Transition = Transition+ { tSource :: !Name,+ tCommand :: !Name,+ tImplementation :: !TransitionImplementation,+ tGuard :: !(Maybe Expr),+ tWrites :: ![(Name, Expr)],+ tEmits :: ![Name],+ tGoto :: !Name,+ tMode :: !TransitionMode,+ tLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | Exclusive behavior ownership. 'LegacyHoleImplementation' exists only for+-- the frozen version-1 parser and preserves its create-once aggregate-wide+-- transducer. Version 2 produces either generated ownership (the default) or+-- an explicit per-transition Hole implementation.+data TransitionImplementation+ = LegacyHoleImplementation+ | GeneratedImplementation+ | HoleImplementation+ deriving stock (Eq, Ord, Show, Generic)++-- | Whether a transition serves forward execution or replay only (plan 143).+-- A @replay-only@ transition lowers to a keiki 'ReplayOnly' edge: it is never+-- taken by a new command and exists so events emitted under a retired rule keep+-- an inverting edge. Spelled as a @replay-only@ prefix on the transition line:+--+-- @+-- replay-only Held -- ConfirmReservation --> guard … ; emit … ; goto …+-- @+data TransitionMode = TmLive | TmReplayOnly+ deriving stock (Eq, Show, Generic)++-- | @wire kind=ctorName fields=camelCase schemaVersion=1@ — how events+-- serialize.+data WireSpec = WireSpec+ { wireKind :: !Text,+ wireFields :: !Text,+ wireSchemaVersion :: !Int+ }+ deriving stock (Eq, Show, Generic)++-- | @projection table consistency=… key=… status-map { … }@ — the read-model+-- projection and its event→status 'Mapping' (hole-kind 3).+data ProjectionSpec = ProjectionSpec+ { projTable :: !Name,+ projConsistency :: !(Maybe Consistency),+ projKey :: !Name,+ projStatusMap :: !(Maybe Mapping),+ projLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++data Consistency = Strong | Eventual+ deriving stock (Eq, Show, Generic)++-- | A generated aggregate snapshot policy supported by the notation.+data SnapPolicy = SnapEvery !Int | SnapOnTerminal+ deriving stock (Eq, Show, Generic)++-- | Snapshot policy plus the captured live state-codec identity.+data SnapshotSpec = SnapshotSpec+ { snapPolicy :: !SnapPolicy,+ snapCodecVersion :: !Int,+ snapShapeHash :: !Text,+ snapLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | An @aggregate@ node: a consistency boundary whose state is rebuilt by+-- replaying events.+data Aggregate = Aggregate+ { aggName :: !Name,+ aggRegs :: ![RegDecl],+ aggStates :: ![StateDecl],+ aggCommands :: ![Command],+ aggEvents :: ![Event],+ aggTransitions :: ![Transition],+ aggWire :: !(Maybe WireSpec),+ aggProjection :: !(Maybe ProjectionSpec),+ aggSnapshot :: !(Maybe SnapshotSpec),+ aggLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-3: process manager + durable timer nodes.++-- | A @field@ or @field=value@ binding inside a command\/payload field list.+-- A bare field reuses the input field of the same name; @name=value@ binds it to+-- an expression (kept as raw text, e.g. @timerId=timer.id@).+data FieldBinding = FieldBinding+ { fbName :: !Name,+ fbValue :: !(Maybe Text)+ }+ deriving stock (Eq, Show, Generic)++-- | @input SurgeInput { hospitalId … observedAt:Time }@ — the process's incoming+-- event shape (one field must be a @:Time@ field used by the timer deadline).+data InputDecl = InputDecl+ { inName :: !Name,+ inFields :: ![Field]+ }+ deriving stock (Eq, Show, Generic)++-- | @correlate input.hospitalId via idText@ — the correlation key (hole-kind 1+-- derivation + hole-kind 4 field-source).+data CorrelateDecl = CorrelateDecl+ { corrField :: !Name,+ corrVia :: !Name+ }+ deriving stock (Eq, Show, Generic)++-- | @saga Surge category \"hospitalSurge\"@ — the saga's own aggregate plus+-- the validated stream category used with @Keiro.Stream.entityStream@. For a+-- correlation id @c@, the saga stream is @<category>-<c>@.+data SagaRef = SagaRef+ { sagaAgg :: !Name,+ sagaCategory :: !Text+ }+ deriving stock (Eq, Show, Generic)++-- | A command dispatch outcome action.+data Disp = DAckOk | DRetry | DDeadLetter !Text+ deriving stock (Eq, Show, Generic)++-- | The complete dispatch disposition table (every arm mandatory; the+-- @on-duplicate AckOk@ benign inversion is explicit). Named to avoid clashing+-- with the hole-kind 'Disposition'.+data DispatchDisposition = DispatchDisposition+ { onAppended :: !Disp,+ onDuplicate :: !Disp,+ onFailed :: !Disp+ }+ deriving stock (Eq, Show, Generic)++-- | @advance NoteSurgeThreshold { … }@ — the self-command that advances the saga.+data AdvanceNode = AdvanceNode+ { advCommand :: !Name,+ advFields :: ![FieldBinding]+ }+ deriving stock (Eq, Show, Generic)++-- | @dispatch Hospital\@input.hospitalId ActivateSurge { … } on-appended … on-duplicate … on-failed …@.+data DispatchNode = DispatchNode+ { dispTarget :: !Name,+ dispKey :: !Text,+ dispCommand :: !Name,+ dispFields :: ![FieldBinding],+ dispDisposition :: !DispatchDisposition,+ dispLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | The @on <Input>@ reaction: a self-advance, zero or more dispatches, and a+-- @schedule@ of the timer.+data HandleNode = HandleNode+ { hOn :: !Name,+ hAdvance :: !AdvanceNode,+ hDispatch :: ![DispatchNode],+ hSchedule :: !Name+ }+ deriving stock (Eq, Show, Generic)++-- | A deterministic id derivation: @uuidv5 \"prefix:\" <> correlationId@.+data IdExpr = IdExpr+ { ideStrategy :: !IdStrategy,+ idePrefix :: !Text+ }+ deriving stock (Eq, Show, Generic)++data IdStrategy = UuidV5Id+ deriving stock (Eq, Show, Generic)++-- | @fireAt input.observedAt + 5m@ — an injected timestamp field plus a window.+-- There is no clock-sampling constructor, so the no-wall-clock rule holds by+-- construction.+data FireAtExpr = FireAtExpr+ { faField :: !Name,+ faWindow :: !Text+ }+ deriving stock (Eq, Show, Generic)++data FireOutcome = OFired | ORetry+ deriving stock (Eq, Show, Generic)++-- | The complete timer-fire disposition table; @on-reject OFired@ is the benign+-- inversion (a CommandRejected means \"already applied\" = success).+data FireDisposition = FireDisposition+ { onOk :: !FireOutcome,+ onReject :: !FireOutcome,+ onAmbiguous :: !FireOutcome,+ onError :: !FireOutcome,+ notMine :: !FireOutcome+ }+ deriving stock (Eq, Show, Generic)++-- | @fire dispatch Surge\@correlationId MarkSurgeTimerFired { … } fired-event-id … on-ok …@.+data FireNode = FireNode+ { fireTarget :: !Name,+ fireKey :: !Text,+ fireCommand :: !Name,+ fireFields :: ![FieldBinding],+ fireFiredEventId :: !IdExpr,+ fireDisposition :: !FireDisposition+ }+ deriving stock (Eq, Show, Generic)++-- | A nested @timer@ sub-node of a process.+data TimerNode = TimerNode+ { tmName :: !Name,+ tmId :: !IdExpr,+ tmFireAt :: !FireAtExpr,+ tmPayload :: ![FieldBinding],+ tmFire :: !FireNode,+ tmDecodeUnknown :: !Name,+ tmMaxAttempts :: !Int,+ tmDeadLetter :: !Text,+ tmLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | A node-level worker policy lowered to the runtime worker options.+data PolicyChoice = PolHalt | PolDeadLetter | PolSkip+ deriving stock (Eq, Show, Generic)++-- | A @process@ (process manager / saga) node. The dispatch-id strategy is fixed+-- (runtime-owned uuidv5), so it is implicit in the AST and always rendered.+data ProcessNode = ProcessNode+ { -- | The block identifier (@process HospitalSurge@), used for module names.+ procId :: !Name,+ -- | The define-once ProcessManager @name@ (@name \"hospital-surge\"@).+ procName :: !Text,+ procInput :: !InputDecl,+ procCorrelate :: !CorrelateDecl,+ procSaga :: !SagaRef,+ procTarget :: !Name,+ procProjections :: ![Name],+ procHandle :: !HandleNode,+ procRejected :: !PolicyChoice,+ procPoison :: !PolicyChoice,+ procTimer :: !TimerNode,+ procLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-108: stateless, effectful content-based routing.++data ResolveSource = ResolveReadModel !Name | ResolveHole+ deriving stock (Eq, Show, Generic)++data ResolveDecl = ResolveDecl+ { rvSource :: !ResolveSource,+ rvRow :: ![Name],+ rvLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++data RouterDispatchNode = RouterDispatchNode+ { rdCommand :: !Name,+ rdFields :: ![FieldBinding],+ rdDisposition :: !DispatchDisposition,+ rdLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | A stateless router. Its fixed dispatch-id strategy is runtime-owned, and+-- the mandatory @stable@ token on the resolve clause is an author acknowledgement+-- that retry attempts accumulate the union of resolved target identities.+data RouterNode = RouterNode+ { rtId :: !Name,+ rtName :: !Text,+ rtInput :: !InputDecl,+ rtKey :: !CorrelateDecl,+ rtResolve :: !ResolveDecl,+ rtTarget :: !Name,+ rtProjections :: ![Name],+ rtDispatch :: !RouterDispatchNode,+ rtRejected :: !PolicyChoice,+ rtPoison :: !PolicyChoice,+ rtLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-4: the cross-service @contract@ (shared Kafka message schema, define-once).++-- | A contract field type: @typeid \"inc\"@, @text@, or @int@.+data ContractType = CTypeId !Text | CText | CInt+ deriving stock (Eq, Show, Generic)++data ContractField = ContractField+ { cfName :: !Name,+ cfType :: !ContractType+ }+ deriving stock (Eq, Show, Generic)++-- | @event <Name> on <topicAlias> { field: type … }@ within a contract.+data ContractEvent = ContractEvent+ { ceName :: !Name,+ ceTopic :: !Name,+ ceFields :: ![ContractField]+ }+ deriving stock (Eq, Show, Generic)++-- | A @contract@ node: the shared cross-service message schema, declared once+-- and referenced by both producer (@emit@) and consumer (@intake@). EP-5's+-- pgmq @dispatch@ also couples to it.+data ContractNode = ContractNode+ { ctrName :: !Name,+ ctrSchemaVersion :: !Int,+ ctrDiscriminator :: !Name,+ -- | (topic alias, real Kafka topic string)+ ctrTopics :: ![(Name, Text)],+ ctrEvents :: ![ContractEvent],+ ctrLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-4: the @intake@ (Kafka consumer / inbox) node.++-- | Where an envelope field is read from on the wire.+data WireSource+ = SrcHeader !Text+ | SrcBody+ | SrcKafkaKey+ | SrcKafkaCursor+ deriving stock (Eq, Show, Generic)++-- | One envelope-binding row: @bind <field> from <source> [required] [cross-check body]@.+data BindRow = BindRow+ { brField :: !Name,+ brSource :: !WireSource,+ brRequired :: !Bool,+ brCrossCheck :: !Bool+ }+ deriving stock (Eq, Show, Generic)++-- | An inbox outcome action. The dangerous defaults the validator guards: a+-- @duplicate@\/@previouslyFailed@ must not be 'IRetry'; @decodeFailed@ must not+-- be an unbounded 'IRetry'.+data InboxAction+ = IAckOk+ | -- | @retry <window>@, e.g. @retry 5s@+ IRetry !Text+ | IDeadLetter !(Maybe Text)+ deriving stock (Eq, Show, Generic)++-- | One row of the mandatory, complete inbox disposition table.+data DispositionRow = DispositionRow+ { drOutcome :: !Name,+ drAction :: !InboxAction,+ drLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | The body decode-strictness decision (hole-kind 6).+data DecodeSpec = DecodeSpec+ { -- | the envelope policy text, e.g. @strict-required lenient-optional@+ decEnvelope :: !Text,+ decBodyStrict :: !Bool,+ decBodySchemaVersion :: !Int+ }+ deriving stock (Eq, Show, Generic)++-- | How much of a successfully processed envelope the inbox retains.+data InkPersist = InkPersistFull | InkPersistDedupeOnly+ deriving stock (Eq, Show, Generic)++-- | An @intake@ (Kafka consumer / inbox) node. The runtime-config @consumer@+-- block (brokers/groupId/offsetReset) is hole-kind 8, delegated to deployment+-- and not modelled here.+data IntakeNode = IntakeNode+ { inkName :: !Name,+ inkContract :: !Name,+ inkTopic :: !Name,+ inkAccept :: ![Name],+ inkBinds :: ![BindRow],+ inkDedupeKey :: !Name,+ inkDedupePolicy :: !Name,+ inkPersist :: !InkPersist,+ inkDecode :: !DecodeSpec,+ inkDisposition :: ![DispositionRow],+ inkLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-4: the @emit@ (outbox mapping) and @publisher@ nodes.++-- | A deterministic id derivation hole: @derive [\"prefix\"] hole@.+newtype DeriveSpec = DeriveSpec {dsPrefix :: Maybe Text}+ deriving stock (Eq, Show, Generic)++-- | One @\"value\" => EventType@ row of an emit's status mapping.+data EmitMapRow = EmitMapRow+ { emrValue :: !Text,+ emrEvent :: !Name,+ emrLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | An @emit@ (outbox) node: maps a private status discriminant to contract+-- event types, with a mandatory explicit @_ => skip@ catch-all.+data EmitNode = EmitNode+ { emName :: !Name,+ emContract :: !Name,+ emTopic :: !Name,+ emSource :: !Text,+ emKey :: !Name,+ emDiscriminant :: !Name,+ emMap :: ![EmitMapRow],+ -- | whether the explicit @_ => skip@ catch-all is present+ emSkip :: !Bool,+ emMessageId :: !DeriveSpec,+ emIdempotencyKey :: !DeriveSpec,+ emLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | @backoff <kind> <window>@, e.g. @backoff constant 2s@.+data BackoffSpec = BackoffSpec+ { boKind :: !Name,+ boWindow :: !Text,+ boMax :: !(Maybe Text),+ boMultiplier :: !(Maybe Text)+ }+ deriving stock (Eq, Show, Generic)++-- | A @publisher@ node: the at-least-once publishing policy for an emit's topic.+data PublisherNode = PublisherNode+ { pubName :: !Name,+ pubEmit :: !Name,+ pubOrdering :: !Name,+ pubMaxAttempts :: !Int,+ pubBackoff :: !BackoffSpec,+ -- | @outboxId stable from <field>@: retries coalesce on (source, this field)+ pubOutboxField :: !Name,+ pubLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-5: the pgmq @workqueue@ + @dispatch@ nodes.++-- | One @field -> \"wire_name\" type required@ row of a workqueue payload.+data WqField = WqField+ { wqfName :: !Name,+ wqfWire :: !Text,+ wqfType :: !Name,+ wqfRequired :: !Bool+ }+ deriving stock (Eq, Show, Generic)++-- | One row of a workqueue's consumer @JobOutcome@ disposition (reusing+-- 'InboxAction': @retry <window>@ \/ @deadLetter@).+data WqDispRow = WqDispRow+ { wqdOutcome :: !Name,+ wqdAction :: !InboxAction,+ wqdLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | The queue's semantic delivery-order contract.+data WqOrdering = WqUnordered | WqFifoThroughput | WqFifoRoundRobin+ deriving stock (Eq, Show, Generic)++-- | A FIFO message-group key derived from one payload field.+data WqGroupKey = WqGroupKey+ { gkField :: !Name,+ gkVia :: !Name,+ gkFixture :: !(Maybe Text)+ }+ deriving stock (Eq, Show, Generic)++-- | The PostgreSQL storage shape provisioned for a queue.+data WqProvision+ = WqStandard+ | WqUnlogged+ | WqPartitioned !Text !Text+ deriving stock (Eq, Show, Generic)++-- | A pgmq @workqueue@ node. The @derive@ trio (physical\/dlq\/table) is a+-- /captured fixture/ (hole-kind 1): the validator re-derives the physical name+-- from @logical@ and flags any divergence (the drift hazard at the dedup site).+data WorkqueueNode = WorkqueueNode+ { wqName :: !Name,+ wqLogical :: !Text,+ wqPhysical :: !Text,+ wqDlq :: !Text,+ wqTable :: !Text,+ wqOrdering :: !WqOrdering,+ wqGroupKey :: !(Maybe WqGroupKey),+ wqProvision :: !WqProvision,+ wqPayloadName :: !Name,+ wqPayload :: ![WqField],+ wqMaxRetries :: !Int,+ wqDelay :: !Text,+ wqDlqOn :: !Bool,+ wqDisposition :: ![WqDispRow],+ wqLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | A pgmq @dispatch@ node: a read-model→enqueue coupling with a fan-out hole+-- and a dedup check (one arm of which is a raw-SQL hole).+data PgmqDispatchNode = PgmqDispatchNode+ { pdName :: !Name,+ pdSourceReadModel :: !Name,+ pdSourceKey :: !Name,+ pdFanoutBody :: !Name,+ pdDedupKey :: !Name,+ pdDedupReadModel :: !Name,+ pdDedupReadModelField :: !Text,+ pdDedupQueue :: !Name,+ pdDedupQueueField :: !Text,+ pdEnqueueTo :: !Name,+ pdLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-107: first-class read-model declarations.++-- | One declared SQL column. The validator owns the closed type vocabulary.+data RmColumn = RmColumn+ { rmcName :: !Text,+ rmcType :: !Text,+ rmcRequired :: !Bool+ }+ deriving stock (Eq, Show, Generic)++-- | Whether the model is fed in a command transaction or by a subscription.+data RmFeed = RmInline | RmSubscription+ deriving stock (Eq, Show, Generic)++-- | Which event-log head a strong read waits for.+data RmScope = RmEntireLog | RmCategory !Text+ deriving stock (Eq, Show, Generic)++-- | A registered, versioned SQL read model. Columns define its shape identity;+-- the runtime table remains owned by codd migrations rather than the DSL.+data ReadModelNode = ReadModelNode+ { rmName :: !Name,+ rmTable :: !Text,+ rmSchema :: !Text,+ rmColumns :: ![RmColumn],+ rmVersion :: !Int,+ rmShape :: !Text,+ rmConsistency :: !Consistency,+ rmScope :: !(Maybe RmScope),+ rmFeed :: !RmFeed,+ rmSubscription :: !(Maybe Text),+ rmLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- EP-6: the durable @workflow@ + @operation@ nodes.++-- | One ordered item of a workflow body. Replay matches on the label, not the+-- position. (Positional constructors avoid partial record fields.)+data WfBodyItem+ = -- | @step <label> -> <ResultType>@+ WfStep !Name !Name !Loc+ | -- | @await <label> -> <ResultType>@+ WfAwait !Name !Name !Loc+ | -- | @sleep <label> after <injected-delay-field>@ (TIME INJECTED)+ WfSleep !Name !Name !Loc+ | -- | @child <label> id input via <childIdFn> -> <ResultType>@+ WfChild !Name !Name !Name !Loc+ | -- | @patch <patch-id> { <items> }@ — guard items behind a durable patch.+ WfPatch !Name ![WfBodyItem] !Loc+ | -- | @continueAsNew <SeedType>@ — rotate after the terminal top-level item.+ WfContinueAsNew !Name !Loc+ deriving stock (Eq, Show, Generic)++-- | A durable @workflow@ node.+data WorkflowNode = WorkflowNode+ { -- | block identifier (e.g. @HospitalTransferReservation@)+ wfId :: !Name,+ -- | the stable @name "…"@ (journal stream + every deterministic id)+ wfStable :: !Text,+ wfInput :: !Name,+ wfInputFields :: ![Field],+ wfOutput :: !Name,+ -- | @id from input.<field>@; 'Nothing' for @id from input@+ wfIdField :: !(Maybe Name),+ wfIdVia :: !Name,+ wfBody :: ![WfBodyItem],+ wfLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++workflowNodeLoc :: WorkflowNode -> Loc+workflowNodeLoc WorkflowNode {wfLoc = loc} = loc++-- | The four operation shapes.+data OperationShape+ = -- | @command on <Agg> stream from <field> via <fn> project [ … ]@+ CommandOp !Name !Name !Name ![Name]+ | -- | @query <ReadModel> input <T> result <Type> consistency <C>@+ QueryOp !Name !Name !Text !Name+ | -- | @signal <label> of <Workflow> key from <field> via <fn> value <T>@+ SignalOp !Name !Name !Name !Name !Name+ | -- | @run <Workflow> input <T> outcome -> <Result>@+ RunOp !Name !Name !Name+ deriving stock (Eq, Show, Generic)++data OperationNode = OperationNode+ { opName :: !Name,+ opShape :: !OperationShape,+ opLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++-- | A top-level node. EP-1 defines 'NAggregate'; EP-3 adds 'NProcess'; EP-4 adds+-- 'NContract'\/'NIntake'\/'NEmit'\/'NPublisher'; EP-5 adds 'NWorkqueue'\/+-- 'NPgmqDispatch'; EP-6 adds 'NWorkflow'\/'NOperation'; EP-107 adds+-- 'NReadModel'.+data Node+ = NAggregate Aggregate+ | NProcess ProcessNode+ | NRouter RouterNode+ | NContract ContractNode+ | NIntake IntakeNode+ | NEmit EmitNode+ | NPublisher PublisherNode+ | NWorkqueue WorkqueueNode+ | NPgmqDispatch PgmqDispatchNode+ | NReadModel ReadModelNode+ | NWorkflow WorkflowNode+ | NOperation OperationNode+ deriving stock (Eq, Show, Generic)++-- | The module-placement style for a scaffolded service. 'GeneratedPrefix' is+-- the historical default — @\<root\>.Generated.\<Ctx\>.\<Node\>@ for the generated+-- layer, holes at @\<root\>.\<Ctx\>.\<Node\>@. 'CollocatedLeaf' places the+-- generated layer as a leaf under the domain — @\<root\>.\<Ctx\>.\<Node\>.Generated@+-- — so it sits next to hand-written domain code (holes still at+-- @\<root\>.\<Ctx\>.\<Node\>@). Defined here (not in "Keiro.Dsl.Scaffold") so the+-- 'Spec' AST can carry an author's standing choice; 'Keiro.Dsl.Scaffold'+-- re-exports it.+data Placement+ = GeneratedPrefix+ | CollocatedLeaf+ deriving stock (Eq, Show, Generic)++-- | A whole @.keiro@ file: one context name, an optional module-placement+-- override (the @module@/@layout@ clauses), the shared id/enum/rule/mapped declarations,+-- and the list of nodes. 'specModuleRoot' and 'specLayout' are 'Nothing' when the+-- spec omits the clauses, reproducing the historical default.+data Spec = Spec+ { specContext :: !Name,+ specModuleRoot :: !(Maybe Text),+ specLayout :: !(Maybe Placement),+ specIds :: ![IdDecl],+ specEnums :: ![EnumDecl],+ specRules :: ![RuleDecl],+ specNominalScalars :: ![NominalScalarDecl],+ specMapped :: ![MappedDecl],+ specNodes :: ![Node]+ }+ deriving stock (Eq, Show, Generic)
src/Keiro/Dsl/Harness.hs view
@@ -1,1080 +1,1191 @@-{- | The harness engine. From an aggregate spec it emits a @-- \@generated@ test-module that __pins the filled holes' behaviour__ — the project's actual-determinism guarantee, since the scaffolder no longer produces the transducer-body by construction. The emitted module exposes @harnessAssertions ::-[(String, Bool)]@, a list of labelled checks a driver runs (failing on any-@False@, naming the assertion). The checks are:-- 1. keiki's @validateTransducer defaultValidationOptions@ on the filled- transducer is empty (no hidden inputs / nondeterminism / dead edges);- 2. a /clock-free/ assertion baked from the spec (TIME IS INJECTED, NOT- SAMPLED) — @False@ would mean a guard\/write sampled a wall clock;- 3. a golden wire round-trip per event (@decode . encode == id@);- 4. a behavioural /accept/ check per transition out of the initial state:- stepping a sample command lands on the declared @goto@ vertex. This is the- check a wrong guard fails — flipping @./=@ to @.==@ in the filled body turns- it red while leaving the scaffold untouched.- 5. a forward/replay equality check per live, event-emitting transition out of- the initial state: emitted events cross the generated codec boundary, then- replay must reconstruct the forward vertex and every declared register.--@Text@ samples include their field name so same-typed field swaps remain visible-to the replay check. Other sample kinds remain uniform until fixture bindings can-supply a wider, consumer-owned corpus.--}-module Keiro.Dsl.Harness (- harnessFor,- harnessForWithGoldens,- harnessProcess,- harnessRouter,- harnessReadModel,- harnessWorkflow,-) where--import Data.List (find)-import Data.Map.Strict qualified as Map-import Data.Text (Text)-import Data.Text qualified as T-import Keiro.Dsl.Goldens (GoldenPayload (..))-import Keiro.Dsl.Grammar-import Keiro.Dsl.ReadModelShape (deriveShapeHash, registryNameFor, subscriptionNameFor)-import Keiro.Dsl.Scaffold-import Keiro.Dsl.TypeGraph--{- | Emit the harness test module for one aggregate. Like 'scaffoldAggregate',-it takes the 'Spec' for the shared id\/enum declarations.--}-harnessFor :: Context -> Spec -> Aggregate -> [ScaffoldModule]-harnessFor = harnessForWithGoldens []--{- | Emit an aggregate harness with checked-in old-payload fixtures embedded-as string literals. Embedding keeps the generated test independent of runtime-file paths while retaining the golden file as regeneration source of truth.--}-harnessForWithGoldens :: [GoldenPayload] -> Context -> Spec -> Aggregate -> [ScaffoldModule]-harnessForWithGoldens goldens ctx spec agg =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" (aGenPrefix a) <> "/Harness.hs")- , moduleText = emitHarness relevantGoldens a- , kind = Generated- , origin = "aggregate " <> aggName agg <> locSuffix (aggLoc agg)- }- ]- where- a = resolveAgg ctx spec agg- relevantGoldens =- [ golden- | golden <- goldens- , goldenContext golden == specContext spec- , goldenAggregate golden == aggName agg- ]--{- | Emit a self-contained, firewall-clean facts harness for a process manager,-pinning the spec's deterministic decisions: the time-injection formula, the-deterministic timer-id and fired-event-id derivation strings, the runtime-owned-dispatch-id (no user id), and the dispatch\/fire disposition tables (incl. the-@on-reject => Fired@ benign inversion). It exposes-@processHarnessFacts :: [(String, Bool)]@ over pure values, so it compiles and-runs without the effectful\/hasql runtime. (Behavioural conformance of the-/filled/ ProcessManager against the live runtime is the M5 step.)--}-harnessProcess :: Context -> ProcessNode -> [ScaffoldModule]-harnessProcess ctx p =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/ProcessHarness.hs")- , moduleText = emitProcessHarness genPrefix p- , kind = Generated- , origin = "process " <> procId p <> locSuffix (procLoc p)- }- ]- where- genPrefix = genPrefixFor ctx (procId p)--{- | Emit runtime-free facts for a router's identity, resolution, dispatch,-and worker-policy decisions. A hand-written conformance driver owns the-expected values so a spec mutation turns one focused assertion red.--}-harnessRouter :: Context -> RouterNode -> [ScaffoldModule]-harnessRouter ctx router =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/RouterHarness.hs")- , moduleText = emitRouterHarness genPrefix router- , kind = Generated- , origin = "router " <> rtId router <> locSuffix (rtLoc router)- }- ]- where- genPrefix = genPrefixFor ctx (rtId router)--emitRouterHarness :: Text -> RouterNode -> Text-emitRouterHarness genPrefix router =- nl- [ generatedBanner- , "module " <> genPrefix <> ".RouterHarness (routerHarnessValues) where"- , ""- , "routerHarnessValues :: [(String, String)]"- , "routerHarnessValues ="- , " [ (\"routerName\", " <> hs (rtName router) <> ")"- , " , (\"keyField\", " <> hs (corrField (rtKey router)) <> ")"- , " , (\"resolveSource\", " <> hs resolveSource <> ")"- , " , (\"resolveRow\", " <> hs (T.intercalate "," (rvRow (rtResolve router))) <> ")"- , " , (\"dispatchCommand\", " <> hs (rdCommand dispatch) <> ")"- , " , (\"dispatchIdInputs\", \"(name, key, sourceEventId, targetStreamName, occurrence)\")"- , " , (\"onDuplicate\", " <> hs (showDisp (onDuplicate disposition)) <> ")"- , " , (\"onFailed\", " <> hs (showDisp (onFailed disposition)) <> ")"- , " , (\"rejectedPolicy\", " <> hs (showPolicy (rtRejected router)) <> ")"- , " , (\"poisonPolicy\", " <> hs (showPolicy (rtPoison router)) <> ")"- , " ]"- ]- where- hs = tshow- dispatch = rtDispatch router- disposition = rdDisposition dispatch- resolveSource = case rvSource (rtResolve router) of- ResolveReadModel name -> "read-model " <> name- ResolveHole -> "hole"--{- | Emit runtime-free facts for a read-model node. Each row records the value-expected directly from the notation next to the value produced by the shared-derivation helpers. Committed conformance expectations pin the lowered values,-while a shape-fixture drift makes the generated harness itself fail.--}-harnessReadModel :: Context -> ReadModelNode -> [ScaffoldModule]-harnessReadModel ctx readModel =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/ReadModelHarness.hs")- , moduleText = emitReadModelHarness genPrefix ctx readModel- , kind = Generated- , origin = "readmodel " <> rmName readModel <> locSuffix (rmLoc readModel)- }- ]- where- genPrefix = genPrefixFor ctx (pascal (rmName readModel))--emitReadModelHarness :: Text -> Context -> ReadModelNode -> Text-emitReadModelHarness genPrefix ctx readModel =- nl- [ generatedBanner- , "module " <> genPrefix <> ".ReadModelHarness (readModelFacts, runReadModelFacts) where"- , ""- , "-- | (fact, expected from notation, actual shared derivation/lowering)."- , "readModelFacts :: [(String, String, String)]"- , "readModelFacts ="- , " [ (\"registryName\", " <> tshow expectedRegistry <> ", " <> tshow actualRegistry <> ")"- , " , (\"subscriptionName\", " <> tshow expectedSubscription <> ", " <> tshow actualSubscription <> ")"- , " , (\"shapeHash\", " <> tshow (rmShape readModel) <> ", " <> tshow (deriveShapeHash readModel) <> ")"- , " , (\"asyncProjectionName\", " <> tshow expectedAsync <> ", " <> tshow actualAsync <> ")"- , " , (\"consistency\", " <> tshow consistency <> ", " <> tshow consistency <> ")"- , " , (\"strongScope\", " <> tshow scope <> ", " <> tshow scope <> ")"- , " ]"- , ""- , "runReadModelFacts :: IO Bool"- , "runReadModelFacts = do"- , " let failures = [(fact, expected, actual) | (fact, expected, actual) <- readModelFacts, expected /= actual]"- , " mapM_ (\\(fact, expected, actual) -> putStrLn (\"FAIL \" <> fact <> \" expected=\" <> show expected <> \" actual=\" <> show actual)) failures"- , " pure (null failures)"- ]- where- expectedRegistry = contextName ctx <> "-" <> T.replace "_" "-" (rmName readModel)- actualRegistry = registryNameFor (contextName ctx) readModel- expectedSubscription = case rmSubscription readModel of- Just name -> name- Nothing -> expectedRegistry <> "-sub"- actualSubscription = subscriptionNameFor (contextName ctx) readModel- expectedAsync = case rmFeed readModel of- RmInline -> "none"- RmSubscription -> expectedRegistry <> "-async"- actualAsync = case rmFeed readModel of- RmInline -> "none"- RmSubscription -> actualRegistry <> "-async"- consistency = case rmConsistency readModel of- Strong -> "Strong"- Eventual -> "Eventual"- scope = case rmScope readModel of- Nothing -> "EntireLog"- Just RmEntireLog -> "EntireLog"- Just (RmCategory categoryName) -> "CategoryHead " <> categoryName--emitProcessHarness :: Text -> ProcessNode -> Text-emitProcessHarness genPrefix p =- nl- [ "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> genPrefix <> ".ProcessHarness (processHarnessValues) where"- , ""- , "{- | (label, value): the spec's deterministic process/timer decisions,"- , "lowered to plain values so a driver can assert them against a committed"- , "expectation. The driver's expectation is hand-written (not generated), so a"- , "spec change that alters a decision diverges from it and turns a specific"- , "assertion red — the spec->behaviour pin. (Live-runtime behavioural"- , "conformance of the filled ProcessManager is the M5 step.)"- , "-}"- , "processHarnessValues :: [(String, String)]"- , "processHarnessValues ="- , " [ (\"fireAtField\", " <> hs (faField (tmFireAt timer)) <> ")"- , " , (\"timerIdPrefix\", " <> hs (idePrefix (tmId timer)) <> ")"- , " , (\"firedEventIdPrefix\", " <> hs (idePrefix (fireFiredEventId timer')) <> ")"- , " , (\"dispatchIdUserField\", \"none\")"- , " , (\"onReject\", " <> hs (showFireOutcome (onReject fd)) <> ")"- , " , (\"onAmbiguous\", " <> hs (showFireOutcome (onAmbiguous fd)) <> ")"- , " , (\"onFailed\", " <> hs (showDisp (onFailed (firstDispDisposition p))) <> ")"- , " , (\"rejectedPolicy\", " <> hs (showPolicy (procRejected p)) <> ")"- , " , (\"poisonPolicy\", " <> hs (showPolicy (procPoison p)) <> ")"- , " , (\"maxAttempts\", " <> hs (tInt (tmMaxAttempts timer)) <> ")"- , " ]"- ]- where- timer = procTimer p- timer' = tmFire timer- fd = fireDisposition timer'- hs = tshow--firstDispDisposition :: ProcessNode -> DispatchDisposition-firstDispDisposition p = case hDispatch (procHandle p) of- (d : _) -> dispDisposition d- [] -> DispatchDisposition DAckOk DAckOk DRetry--showFireOutcome :: FireOutcome -> Text-showFireOutcome OFired = "Fired"-showFireOutcome ORetry = "Retry"--showDisp :: Disp -> Text-showDisp DAckOk = "AckOk"-showDisp DRetry = "Retry"-showDisp (DDeadLetter _) = "DeadLetter"--showPolicy :: PolicyChoice -> Text-showPolicy PolHalt = "halt"-showPolicy PolDeadLetter = "deadLetter"-showPolicy PolSkip = "skip"--{- | A self-contained, firewall-clean facts harness for a durable workflow,-pinning the spec's deterministic decisions: the stable name, the WorkflowId-derivation, the ordered body (step/await/sleep/child by label), and the await-labels (whose ids the signal operations must match). Exposes-@workflowFacts :: [(String, String)]@ so a driver asserts them against a-hand-written expectation — a spec change (e.g. renaming an await label) diverges-and reddens a specific assertion. Workflows intentionally have no domain scaffold-or hole stub: their behaviour-bearing body remains hand-written, while these facts-and the live-runtime module pin its declared structure.--}-harnessWorkflow :: Context -> WorkflowNode -> [ScaffoldModule]-harnessWorkflow ctx w =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/WorkflowFacts.hs")- , moduleText = emitWorkflowFacts genPrefix w- , kind = Generated- , origin = "workflow " <> wfId w <> locSuffix (workflowNodeLoc w)- }- , ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/WorkflowRuntime.hs")- , moduleText = emitWorkflowRuntime genPrefix w- , kind = Generated- , origin = "workflow " <> wfId w <> locSuffix (workflowNodeLoc w)- }- ]- where- genPrefix = genPrefixFor ctx (wfId w)--locSuffix :: Loc -> Text-locSuffix loc = case unLoc loc of- 0 -> ""- line -> " (line " <> tInt line <> ")"--emitWorkflowFacts :: Text -> WorkflowNode -> Text-emitWorkflowFacts genPrefix w =- nl- [ "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> genPrefix <> ".WorkflowFacts (workflowFacts) where"- , ""- , "{- | (label, value): the workflow's deterministic decisions, pinned as pure"- , "facts. A driver asserts them against a hand-written expectation, so a spec"- , "change (e.g. renaming an await) reddens a specific assertion."- , "-}"- , "workflowFacts :: [(String, String)]"- , "workflowFacts ="- , " [ (\"name\", " <> hs (wfStable w) <> ")"- , " , (\"idVia\", " <> hs (wfIdVia w) <> ")"- , " , (\"idField\", " <> hs (maybe "input" id (wfIdField w)) <> ")"- , " , (\"body\", " <> hs (T.intercalate "," (map bodyTag (wfBody w))) <> ")"- , " , (\"awaits\", " <> hs (T.intercalate "," (workflowAwaitLabels (wfBody w))) <> ")"- , " , (\"patches\", " <> hs (T.intercalate "," (workflowPatchIds (wfBody w))) <> ")"- , " ]"- ]- where- hs = tshow- bodyTag (WfStep l _ _) = "step:" <> l- bodyTag (WfAwait l _ _) = "await:" <> l- bodyTag (WfSleep l _ _) = "sleep:" <> l- bodyTag (WfChild l _ _ _) = "child:" <> l- bodyTag (WfPatch patchId items _) = "patch:" <> patchId <> "(" <> T.intercalate "," (map bodyTag items) <> ")"- bodyTag (WfContinueAsNew seedType _) = "continueAsNew:" <> seedType--{- | Emit the workflow's deterministic id derivation compiled against the LIVE-@Keiro.Workflow@: the 'WorkflowName' and the awakeable-id function (the actual-'deterministicAwakeableId'). A signal operation deriving the SAME (name, id,-label) lands on the same 'AwakeableId' — so this module compiling + the-conformance comparing the two sides proves the await↔signal coupling holds over-the real runtime function, not just by label-string equality.--}-emitWorkflowRuntime :: Text -> WorkflowNode -> Text-emitWorkflowRuntime genPrefix w =- nl $- [ "{-# LANGUAGE ImportQualifiedPost #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> genPrefix <> ".WorkflowRuntime"- , " ( workflowName"- , " , awaitAwakeableId"- , " , awaitLabels"- , " , declaredPatches"- , " , declaredPatchStepNames"- , " , withDeclaredPatches"- , " ) where"- , ""- , "import Data.Set (Set)"- , "import Data.Set qualified as Set"- , "import Data.Text (Text)"- , "import Keiro.Workflow (WorkflowRunOptions (..))"- , "import Keiro.Workflow.Awakeable (AwakeableId, deterministicAwakeableId)"- , "import Keiro.Workflow.Types (PatchId (..), WorkflowId, WorkflowName (..), patchStepName)"- , ""- , "workflowName :: WorkflowName"- , "workflowName = WorkflowName " <> tshow (wfStable w)- , ""- , "-- The awakeable id an await allocates — the real deterministicAwakeableId."- , "-- A signal op deriving the same (name, id, label) gets the same id."- , "awaitAwakeableId :: WorkflowId -> Text -> AwakeableId"- , "awaitAwakeableId wid label = deterministicAwakeableId workflowName wid label"- , ""- , "awaitLabels :: [Text]"- , "awaitLabels = [" <> T.intercalate ", " (map tshow (workflowAwaitLabels (wfBody w))) <> "]"- , ""- , "declaredPatches :: Set PatchId"- , "declaredPatches = Set.fromList [" <> T.intercalate ", " ["PatchId " <> tshow patchId | patchId <- workflowPatchIds (wfBody w)] <> "]"- , ""- , "-- The journal keys the runtime records patch decisions under."- , "declaredPatchStepNames :: [Text]"- , "declaredPatchStepNames = map patchStepName (Set.toList declaredPatches)"- , ""- , "-- Activate exactly the patches declared by this spec for a workflow run."- , "withDeclaredPatches :: WorkflowRunOptions -> WorkflowRunOptions"- , "withDeclaredPatches opts = opts{activePatches = declaredPatches}"- ]--workflowAwaitLabels :: [WfBodyItem] -> [Name]-workflowAwaitLabels = concatMap go- where- go (WfAwait label _ _) = [label]- go (WfPatch _ items _) = workflowAwaitLabels items- go _ = []--workflowPatchIds :: [WfBodyItem] -> [Name]-workflowPatchIds = concatMap go- where- go (WfPatch patchId items _) = patchId : workflowPatchIds items- go _ = []--emitHarness :: [GoldenPayload] -> Agg -> Text-emitHarness goldens a =- nl $- [ "{-# LANGUAGE DataKinds #-}"- , "{-# LANGUAGE OverloadedLabels #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- ]- ++ ["{-# LANGUAGE TypeApplications #-}" | hasMappedHarness a]- ++ [ generatedBanner- , "module " <> aGenPrefix a <> ".Harness (harnessAssertions) where"- , ""- , "import " <> aGenPrefix a <> ".Domain"- , "import " <> aGenPrefix a <> ".Codec (encode" <> nm <> "Event, parse" <> nm <> "Event" <> codecValueImport <> mappedCodecHarnessExports a <> ")"- , "import " <> aHolePrefix a <> ".Holes (" <> lowerFirst nm <> "Transducer)"- , "import Keiki.Core (" <> T.intercalate ", " coreImports <> ")"- , codecDecodeRawImport- ]- ++ mappedHarnessImports a- ++ goldenImports- ++ [ ""- , "{- | (label, passed). A driver runs these and exits non-zero on any False,"- , "naming the failing assertion. Filling a hole wrongly turns a specific"- , "entry False; the scaffold cannot."- , "-}"- , "harnessAssertions :: [(String, Bool)]"- , "harnessAssertions ="- , " [ (\"validateTransducer is empty\", null (validateTransducer defaultValidationOptions " <> lowerFirst nm <> "Transducer))"- , " , (\"clock-free: spec samples no wall clock\", " <> clockFreeLit <> ")"- ]- ++ [ " , (\"golden round-trip: " <> rcName e <> "\", roundTrips sampleEvent" <> rcName e <> ")"- | e <- aEvents a- ]- ++ [ " , (\"accepts " <> tCommand t <> " from " <> initialVertex a <> "\", accept" <> tCommand t <> ")"- | t <- initialTransitions a- ]- ++ [ " ]"- ]- ++ [" ++ mappedConformanceAssertions" | hasMappedHarness a]- ++ [ " ++ forwardReplay" <> tCommand t- | t <- replayTransitions- ]- ++ ( if null upcastEvents- then []- else- [ " ++ [ " <> T.intercalate "\n , " upcastAssertions- , " ]"- ]- )- ++ [ ""- , "roundTrips :: " <> nm <> "Event -> Bool"- , "roundTrips e = parse" <> nm <> "Event (eventType " <> lowerFirst nm <> "Codec e) (encode" <> nm <> "Event e) == Right e"- ]- ++ concatMap (sampleEventDecl a) (aEvents a)- ++ concatMap (acceptDecl a) (initialTransitions a)- ++ concatMap (forwardReplayDecl a) replayTransitions- ++ concatMap (upcastDecl goldens a) upcastEvents- ++ mappedHarnessDeclarations a- where- nm = aName a- -- Bake the clock-free result computed from the spec at scaffold time.- clockFreeLit = if specIsClockFree a then "True" else "False"- upcastEvents = [e | e <- aEvents a, rcUpcastFrom e /= Nothing]- replayTransitions =- [ t- | t <- initialTransitions a- , tMode t == TmLive- , not (null (tEmits t))- ]- coreImports =- ["applyEventsEither" | not (null replayTransitions)]- ++ ["defaultValidationOptions", "step", "validateTransducer"]- ++ ["fieldWitnessAgrees" | not (null (mappedProjectionSpecs a))]- ++ ["(!)" | not (null replayTransitions) && not (null (aRegs a))]- upcastAssertions =- [ "(" <> tshow (upcastLabel e m) <> ", upcasts" <> rcName e <> ")"- | e <- upcastEvents- , Just m <- [rcUpcastFrom e]- ]- codecValueImport = ", " <> lowerFirst nm <> "Codec"- codecDecodeRawImport =- if null upcastEvents- then "import Keiro.Codec (eventType)"- else "import Keiro.Codec (EventType (..), decodeRaw, eventType)"- goldenImports =- if any (hasGolden goldens) upcastEvents- then- [ "import Data.Aeson (eitherDecodeStrict)"- , "import Data.Text.Encoding (encodeUtf8)"- ]- else []- upcastLabel event source =- case goldenFor goldens event of- Just _ -> "golden " <> rcName event <> ".v" <> tInt source <> " decodes"- Nothing ->- "upcast "- <> rcName event- <> " chain wired (current-shape stand-in; add a golden payload)"--{- | Decode a genuine embedded old payload when available. Without a golden,-retain the weaker current-shape wiring assertion and label it honestly.--}-upcastDecl :: [GoldenPayload] -> Agg -> ResolvedCtor -> [Text]-upcastDecl goldens a e = case rcUpcastFrom e of- Nothing -> []- Just m -> case goldenFor goldens e of- Just golden ->- [ ""- , "upcasts" <> rcName e <> " :: Bool"- , "upcasts" <> rcName e <> " ="- , " case eitherDecodeStrict (encodeUtf8 " <> tshow (goldenJson golden) <> ") of"- , " Left _ -> False"- , " Right payload ->"- , " either (const False) (const True)"- , " (decodeRaw " <> lowerFirst (aName a) <> "Codec (EventType " <> tshow (rcName e) <> ") " <> tInt m <> " payload)"- ]- Nothing ->- [ ""- , "upcasts" <> rcName e <> " :: Bool"- , "upcasts" <> rcName e <> " ="- , " either (const False) (const True)"- , " (decodeRaw " <> lowerFirst (aName a) <> "Codec (EventType " <> tshow (rcName e) <> ") " <> tInt m <> " (encode" <> aName a <> "Event sampleEvent" <> rcName e <> "))"- ]--hasGolden :: [GoldenPayload] -> ResolvedCtor -> Bool-hasGolden goldens event = case goldenFor goldens event of- Just _ -> True- Nothing -> False--goldenFor :: [GoldenPayload] -> ResolvedCtor -> Maybe GoldenPayload-goldenFor goldens event = do- source <- rcUpcastFrom event- find- (\golden -> goldenEvent golden == rcName event && goldenVersion golden == source)- goldens--tInt :: Int -> Text-tInt = T.pack . show---- | Render a Text as a Haskell string literal (quoted, escaped).-tshow :: Text -> Text-tshow = T.pack . show--nl :: [Text] -> Text-nl = T.intercalate "\n"--specIsClockFree :: Agg -> Bool-specIsClockFree a = not (any transitionSamplesClock (aTransitions a))- where- clockAtoms = ["now", "currentTime", "wallClock", "today", "utcNow"]- transitionSamplesClock t =- let exprs = maybe [] pure (tGuard t) ++ map snd (tWrites t)- in any (\e -> any (`elem` clockAtoms) (exprNames e)) exprs--exprNames :: Expr -> [Text]-exprNames (EOr x y) = exprNames x ++ exprNames y-exprNames (EAnd x y) = exprNames x ++ exprNames y-exprNames (ECmp _ x y) = exprNames x ++ exprNames y-exprNames (EAtom (AName n)) = [n]-exprNames (EAtom (ABool _)) = []--initialTransitions :: Agg -> [Transition]-initialTransitions a = case map stName (aStates a) of- (s0 : _) -> [t | t <- aTransitions a, tSource t == s0]- [] -> []--{- | @sampleEvent<Ctor> :: <Agg>Event@ — a sample built from per-field sample-values (enum→first constructor, Bool→False, id→placeholder,-Text→\"sample-<fieldName>\").--}-sampleEventDecl :: Agg -> ResolvedCtor -> [Text]-sampleEventDecl a e =- [ ""- , "sampleEvent" <> rcName e <> " :: " <> aName a <> "Event"- , "sampleEvent" <> rcName e <> " = " <> ctorExpr a e- ]--acceptDecl :: Agg -> Transition -> [Text]-acceptDecl a t =- [ ""- , "accept" <> tCommand t <> " :: Bool"- , "accept" <> tCommand t <> " ="- , " case step " <> lowerFirst (aName a) <> "Transducer (" <> initialVertex a <> ", initial" <> aName a <> "Regs) " <> cmdSample <> " of"- , " Just (v, _, _) -> v == " <> vertexCtor a (tGoto t)- , " Nothing -> False"- ]- where- cmdSample = case [c | c <- aCommands a, rcName c == tCommand t] of- (c : _) -> "(" <> ctorExpr a c <> ")"- [] -> "(error \"no command\")"--forwardReplayDecl :: Agg -> Transition -> [Text]-forwardReplayDecl a t =- [ ""- , "-- forward/replay equality (plan 147): cross the persisted codec boundary,"- , "-- replay the emitted chain, and compare the final vertex and every register."- , helperName <> " :: [(String, Bool)]"- , helperName <> " ="- , " case step " <> transducer <> " (" <> initial <> ", " <> initialRegs <> ") " <> cmdSample <> " of"- , " Nothing -> [(prefix <> \"forward step accepted\", False)]"- , " Just (forwardVertex, " <> forwardRegsName <> ", emitted) ->"- , " case mapM (\\event -> parse" <> nm <> "Event (eventType " <> codec <> " event) (encode" <> nm <> "Event event)) emitted of"- , " Left _ -> [(prefix <> \"emitted chain decodes\", False)]"- , " Right decodedEvents ->"- , " case applyEventsEither " <> transducer <> " (" <> initial <> ", " <> initialRegs <> ") decodedEvents of"- , " Left _ -> [(prefix <> \"replay succeeds\", False)]"- , " Right (replayVertex, " <> replayRegsName <> ") ->"- , " [ (prefix <> \"final vertex\", replayVertex == forwardVertex)"- ]- ++ [ " , (prefix <> \"register " <> regName reg <> "\", (replayRegs ! #" <> regName reg <> ") == (forwardRegs ! #" <> regName reg <> "))"- | reg <- aRegs a- ]- ++ [ " ]"- , " where"- , " prefix = \"forward/replay equality: " <> tCommand t <> " from " <> initial <> " -- \""- ]- where- nm = aName a- helperName = "forwardReplay" <> tCommand t- transducer = lowerFirst nm <> "Transducer"- codec = lowerFirst nm <> "Codec"- initial = initialVertex a- initialRegs = "initial" <> nm <> "Regs"- forwardRegsName = if null (aRegs a) then "_forwardRegs" else "forwardRegs"- replayRegsName = if null (aRegs a) then "_replayRegs" else "replayRegs"- cmdSample = case [c | c <- aCommands a, rcName c == tCommand t] of- (c : _) -> "(" <> ctorExpr a c <> ")"- [] -> "(error \"no command\")"---- | @(<Ctor> (<Ctor>Data v1 v2 …))@ with positional sample field values.-ctorExpr :: Agg -> ResolvedCtor -> Text-ctorExpr a rc =- "(" <> rcName rc <> " (" <> rcName rc <> "Data" <> args <> "))"- where- args = T.concat [" " <> sampleValue a fieldName ty | (fieldName, ty) <- rcFields rc]--sampleValue :: Agg -> Text -> Text -> Text-sampleValue a fieldName ty = case fieldCat a ty of- IdCat -> "(" <> ty <> " \"sample\")"- EnumCat -> maybe ("(error \"no enum ctor\")") id (firstEnumCtor a ty)- MappedStructuralCat declaration _ -> fixtureSample (sdFixtures declaration)- MappedOpaqueCat declaration -> fixtureSample (odFixtures declaration)- OtherCat- | ty == "Bool" -> "False"- | ty == "Int" -> "0"- | ty == "Text" -> tshow ("sample-" <> fieldName)- | ty == aVertexType a -> initialVertex a- | otherwise -> "(error \"sample: unsupported type " <> ty <> "\")"--mappedHarnessImports :: Agg -> [Text]-mappedHarnessImports aggregate- | null fixtures = []- | otherwise =- [ "import Data.Aeson qualified as Aeson"- , "import Data.Aeson.Key qualified as AesonKey"- , "import Data.Aeson.KeyMap qualified as AesonKeyMap"- , "import Data.Either (isLeft, isRight)"- , "import Data.List (nub)"- , "import Data.List.NonEmpty qualified as NonEmpty"- , "import Data.Maybe (isJust, isNothing)"- , "import Data.Proxy (Proxy (..))"- , "import Data.Text qualified as T"- , "import Keiki.Shape (CanonicalTypeName (..))"- , "import Keiro.Codec.Structural (FixtureCases (..), bindingDomainRoundTrip, bindingShapeRoundTrip, bindingToShape)"- ]- ++ map (\moduleName -> "import " <> moduleName <> " qualified") (unique (modules <> bindingModules <> shapeModules <> consumerModules))- ++ ["import " <> structuralProjectionModuleName (aContext aggregate) <> " qualified as StructuralProjections" | not (null (mappedProjectionSpecs aggregate))]- where- fixtures = [mappedFixtures declaration | declaration <- mappedHarnessDeclarationsResolved aggregate]- modules = unique [fst (splitQualifiedHarness (unQualifiedValueName qualified)) | qualified <- fixtures]- bindingModules =- [ fst (splitQualifiedHarness (unQualifiedValueName (sdBinding declaration)))- | ResolvedStructural declaration _ <- mappedHarnessDeclarationsResolved aggregate- ]- shapeModules =- [ structuralShapeModuleName (aContext aggregate) (sdName declaration)- | ResolvedStructural declaration _ <- mappedHarnessDeclarationsResolved aggregate- ]- consumerModules =- [ hsModule (sdHaskell declaration)- | ResolvedStructural declaration _ <- mappedHarnessDeclarationsResolved aggregate- ]--mappedCodecHarnessExports :: Agg -> Text-mappedCodecHarnessExports aggregate =- T.concat- [ ", encode" <> sdName declaration <> "Mapped, decode" <> sdName declaration <> "Mapped"- | ResolvedStructural declaration _ <- codecMappedDeclarations aggregate- ]--fixtureSample :: QualifiedValueName -> Text-fixtureSample qualified =- "(snd (NonEmpty.head (fixtureCases " <> unQualifiedValueName qualified <> ")))"--splitQualifiedHarness :: Text -> (Text, Text)-splitQualifiedHarness value =- let (prefix, name) = T.breakOnEnd "." value- in (T.dropEnd 1 prefix, name)--unique :: (Eq value) => [value] -> [value]-unique = foldr (\value values -> if value `elem` values then values else value : values) []--hasMappedHarness :: Agg -> Bool-hasMappedHarness = not . null . mappedHarnessDeclarationsResolved--mappedHarnessDeclarationsResolved :: Agg -> [ResolvedMappedDecl]-mappedHarnessDeclarationsResolved aggregate = case aTypeGraph aggregate of- Nothing -> []- Just graph -> Map.elems (tgDeclarations graph)--mappedProjectionSpecs :: Agg -> [StructuralProjection]-mappedProjectionSpecs aggregate = case aTypeGraph aggregate of- Nothing -> []- Just graph -> map (resolveProjectionModules (aContext aggregate)) (projectionSpecs graph)--structuralShapeModuleName :: Context -> Name -> Text-structuralShapeModuleName context name = case placement context of- GeneratedPrefix -> root <> "Generated." <> contextSegment <> ".Structural.Shape." <> name- CollocatedLeaf -> root <> contextSegment <> ".Generated.Structural.Shape." <> name- where- root = if T.null (moduleRoot context) then "" else moduleRoot context <> "."- contextSegment = pascalFromKebab (contextName context)--structuralProjectionModuleName :: Context -> Text-structuralProjectionModuleName context = case placement context of- GeneratedPrefix -> root <> "Generated." <> contextSegment <> ".StructuralProjections"- CollocatedLeaf -> root <> contextSegment <> ".Generated.StructuralProjections"- where- root = if T.null (moduleRoot context) then "" else moduleRoot context <> "."- contextSegment = pascalFromKebab (contextName context)--mappedHarnessDeclarations :: Agg -> [Text]-mappedHarnessDeclarations aggregate- | not (hasMappedHarness aggregate) = []- | otherwise =- [ ""- , "mappedConformanceAssertions :: [(String, Bool)]"- , "mappedConformanceAssertions ="- , " concat"- , " [ " <> T.intercalate "\n , " assertionLists- , " ]"- , ""- , "validFixtureLabels :: NonEmpty.NonEmpty (T.Text, value) -> Bool"- , "validFixtureLabels cases ="- , " all (not . T.null) labels && length labels == length (nub labels)"- , " where"- , " labels = map fst (NonEmpty.toList cases)"- ]- ++ concatMap (bindingAssertionDecl aggregate) structural- ++ concatMap (opaqueAssertionDecl aggregate) opaque- ++ concatMap (coverageDecl aggregate) structural- ++ concatMap (mappedEventAssertionDecl aggregate) mappedEventFields- ++ wirePolicyAssertionDecls aggregate structuralWire- ++ projectionAssertionDecls aggregate structural- ++ wirePolicyHelpers structuralWire- where- declarations = mappedHarnessDeclarationsResolved aggregate- structural = [(declaration, shape) | ResolvedStructural declaration shape <- declarations]- opaque = [declaration | ResolvedOpaque declaration <- declarations]- structuralWire = [(declaration, shape) | ResolvedStructural declaration shape <- codecMappedDeclarations aggregate]- mappedEventFields =- [ (event, fieldName, fieldType, declaration)- | event <- aEvents aggregate- , (fieldName, fieldType) <- rcFields event- , declaration <- maybeToListHarness (mappedDeclaration aggregate fieldType)- ]- assertionLists =- [lowerFirst (sdName declaration) <> "BindingAssertions" | (declaration, _) <- structural]- <> [lowerFirst (odName declaration) <> "OpaqueAssertions" | declaration <- opaque]- <> [ "[(\"fixture coverage: "- <> unCanonicalTypeId (sdCanonical declaration)- <> "\", coverage"- <> sdName declaration- <> ")]"- | (declaration, _) <- structural- ]- <> [ mappedEventAssertionName event fieldName <> "Assertions"- | (event, fieldName, _, _) <- mappedEventFields- ]- <> ["structuralWirePolicyAssertions" | not (null structuralWire)]- <> ["structuralProjectionAssertions" | not (null (mappedProjectionSpecs aggregate))]--mappedDeclaration :: Agg -> Text -> Maybe ResolvedMappedDecl-mappedDeclaration aggregate name = do- graph <- aTypeGraph aggregate- Map.lookup (MappedKey name) (tgDeclarations graph)--bindingAssertionDecl :: Agg -> (StructuralDecl, ResolvedMappedShape) -> [Text]-bindingAssertionDecl _aggregate (declaration, _shape) =- [ ""- , valueName <> " :: [(String, Bool)]"- , valueName <> " ="- , " (\"fixture labels: " <> canonical <> "\", validFixtureLabels cases) :"- , " (\"canonical identity: " <> canonical <> "\", canonicalTypeName (Proxy @" <> consumerType <> ") == " <> tshow canonical <> ") :"- , " concat"- , " [ [ (\"binding domain round-trip: " <> canonical <> "/\" <> T.unpack label, bindingDomainRoundTrip " <> binding <> " value)"- , " , (\"binding shape round-trip: " <> canonical <> "/\" <> T.unpack label, bindingShapeRoundTrip " <> binding <> " (bindingToShape " <> binding <> " value))"- , " ]"- , " | (label, value) <- NonEmpty.toList cases"- , " ]"- , " where"- , " cases = fixtureCases " <> fixtures- ]- where- valueName = lowerFirst (sdName declaration) <> "BindingAssertions"- canonical = unCanonicalTypeId (sdCanonical declaration)- consumerType = hsModule (sdHaskell declaration) <> "." <> hsType (sdHaskell declaration)- binding = unQualifiedValueName (sdBinding declaration)- fixtures = unQualifiedValueName (sdFixtures declaration)--opaqueAssertionDecl :: Agg -> OpaqueDecl -> [Text]-opaqueAssertionDecl _aggregate declaration =- [ ""- , valueName <> " :: [(String, Bool)]"- , valueName <> " ="- , " (\"opaque boundary fixtures: " <> label <> "\", validFixtureLabels cases) :"- , " [ (\"opaque codec round-trip: " <> label <> "/\" <> T.unpack caseLabel, case Aeson.fromJSON (Aeson.toJSON value) of Aeson.Success decoded -> decoded == value; Aeson.Error _ -> False)"- , " | (caseLabel, value) <- NonEmpty.toList cases"- , " ]"- , " where"- , " cases = fixtureCases " <> fixtures- ]- where- valueName = lowerFirst (odName declaration) <> "OpaqueAssertions"- label = unCodecIdentity (odCodecIdentity declaration) <> "@" <> unCodecVersion (odCodecVersion declaration)- fixtures = unQualifiedValueName (odFixtures declaration)--coverageDecl :: Agg -> (StructuralDecl, ResolvedMappedShape) -> [Text]-coverageDecl aggregate (declaration, shape) =- [ ""- , "coverage" <> sdName declaration <> " :: Bool"- , "coverage" <> sdName declaration <> " = " <> coverageExpression aggregate declaration shape- ]--coverageExpression :: Agg -> StructuralDecl -> ResolvedMappedShape -> Text-coverageExpression aggregate declaration shape = case obligations of- [] -> "True"- _ -> T.intercalate " && " obligations <> "\n where\n shapes = map (bindingToShape " <> binding <> " . snd) (NonEmpty.toList (fixtureCases " <> fixtures <> "))"- where- shapeModule = structuralShapeModuleName (aContext aggregate) (sdName declaration)- binding = unQualifiedValueName (sdBinding declaration)- fixtures = unQualifiedValueName (sdFixtures declaration)- obligations = case shape of- RRecord _ _ fields -> concatMap (recordFieldObligation shapeModule) fields- REnum entries ->- [ "any (\\case " <> shapeModule <> "." <> weCtor entry <> " -> True; _ -> False) shapes"- | entry <- entries- ]- RUnion _ arms -> concatMap (unionArmObligations shapeModule) arms--recordFieldObligation :: Text -> ResolvedWireField -> [Text]-recordFieldObligation shapeModule field = case rwfType field of- ROptional _ ->- [ "any (isNothing . " <> selector <> ") shapes"- , "any (isJust . " <> selector <> ") shapes"- ]- _ -> []- where- selector = shapeModule <> "." <> rwfHaskell field--unionArmObligations :: Text -> ResolvedWireArm -> [Text]-unionArmObligations shapeModule arm =- ["any (\\case " <> patternText <> " -> True; _ -> False) shapes"] <> optionalPayload- where- constructor = shapeModule <> "." <> rwaCtor arm- patternText = constructor <> maybe "" (const "{}") (rwaPayload arm)- optionalPayload = case rwaPayload arm of- Just (ROptional _) ->- [ "any (\\case " <> constructor <> " Nothing -> True; _ -> False) shapes"- , "any (\\case " <> constructor <> " (Just _) -> True; _ -> False) shapes"- ]- _ -> []--mappedEventAssertionDecl :: Agg -> (ResolvedCtor, Text, Text, ResolvedMappedDecl) -> [Text]-mappedEventAssertionDecl aggregate (event, fieldName, _fieldType, declaration) =- [ ""- , valueName <> "Assertions :: [(String, Bool)]"- , valueName <> "Assertions ="- , " [ (\"mapped codec round-trip: " <> rcName event <> "/" <> fieldName <> "/\" <> T.unpack label, roundTrips " <> eventExpression <> ")"- , " | (label, mappedValue) <- NonEmpty.toList (fixtureCases " <> fixtures <> ")"- , " ]"- ]- where- valueName = mappedEventAssertionName event fieldName- fixtures = unQualifiedValueName (mappedFixtures declaration)- eventExpression = ctorExprWithOverride aggregate event fieldName "mappedValue"--mappedEventAssertionName :: ResolvedCtor -> Text -> Text-mappedEventAssertionName event fieldName = lowerFirst (rcName event) <> pascal fieldName--wirePolicyAssertionDecls :: Agg -> [(StructuralDecl, ResolvedMappedShape)] -> [Text]-wirePolicyAssertionDecls _aggregate [] = []-wirePolicyAssertionDecls aggregate declarations =- [ ""- , "structuralWirePolicyAssertions :: [(String, Bool)]"- , "structuralWirePolicyAssertions ="- , " [ " <> T.intercalate "\n , " assertions- , " ]"- ]- where- assertions = concatMap (wirePolicyAssertions aggregate) declarations--wirePolicyAssertions :: Agg -> (StructuralDecl, ResolvedMappedShape) -> [Text]-wirePolicyAssertions aggregate (declaration, shape) = case shape of- RRecord _ unknownFields fields ->- concatMap (recordMissingAssertions aggregate declaration) [field | field <- fields, rwfPresence field == POptional]- <> [unknownFieldAssertion declaration unknownFields]- REnum entries -> map (enumArmAssertion declaration) entries <> [enumUnknownAssertion declaration]- RUnion encoding arms ->- map (unionArmAssertion declaration encoding) arms- <> [unknownFieldAssertion declaration (ueUnknownFields encoding)]--recordMissingAssertions :: Agg -> StructuralDecl -> ResolvedWireField -> [Text]-recordMissingAssertions aggregate declaration field =- [ "(\"wire policy missing default: "- <> canonical- <> "/"- <> rwfKey field- <> "\", case "- <> decoder- <> " (deleteObjectField "- <> tshow (rwfKey field)- <> " ("- <> encodedSample- <> ")) of Left _ -> False; Right decoded -> objectField "- <> tshow (rwfKey field)- <> " ("- <> encoder- <> " decoded) == Just ("- <> missingExpectedValue aggregate field- <> "))"- , "(\"wire policy explicit null: "- <> canonical- <> "/"- <> rwfKey field- <> "\", "- <> nullExpectation- <> " ("- <> decoder- <> " (insertObjectField "- <> tshow (rwfKey field)- <> " Aeson.Null ("- <> encodedSample- <> "))))"- ]- where- canonical = unCanonicalTypeId (sdCanonical declaration)- encoder = "encode" <> sdName declaration <> "Mapped"- decoder = "decode" <> sdName declaration <> "Mapped"- fixtures = unQualifiedValueName (sdFixtures declaration)- encodedSample = encoder <> " (snd (NonEmpty.head (fixtureCases " <> fixtures <> ")))"- nullExpectation = case rwfType field of- ROptional _ -> "isRight"- _ -> "isLeft"--missingExpectedValue :: Agg -> ResolvedWireField -> Text-missingExpectedValue aggregate field = case rwfOnMissing field of- Just OmNull -> "Aeson.Null"- Just (OmText value) -> "Aeson.String " <> tshow value- Just (OmInt value) -> "Aeson.toJSON (" <> T.pack (show value) <> " :: Int)"- Just (OmBool value) -> if value then "Aeson.Bool True" else "Aeson.Bool False"- Just OmEmptyList -> "Aeson.toJSON ([] :: [Aeson.Value])"- Just OmEmptyMap -> "Aeson.Object mempty"- Just (OmCtor constructor) -> case (aTypeGraph aggregate, rwfType field) of- (Just graph, RRef key) -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural _ (REnum entries)) -> case find ((== constructor) . weCtor) entries of- Just entry -> "Aeson.String " <> tshow (weTag entry)- Nothing -> "error \"missing enum default constructor\""- _ -> "error \"non-enum constructor default\""- _ -> "error \"non-reference constructor default\""- Nothing -> "error \"optional field lacks on-missing policy\""--unknownFieldAssertion :: StructuralDecl -> UnknownFields -> Text-unknownFieldAssertion declaration policy =- "(\"wire policy unknown fields: "- <> unCanonicalTypeId (sdCanonical declaration)- <> "\", all (\\(_, value) -> "- <> expectation- <> " (decode"- <> sdName declaration- <> "Mapped (insertObjectField \"__keiro_unknown\" (Aeson.Bool True) (encode"- <> sdName declaration- <> "Mapped value)))) (NonEmpty.toList (fixtureCases "- <> unQualifiedValueName (sdFixtures declaration)- <> ")))"- where- expectation = case policy of- RejectUnknown -> "isLeft"- IgnoreUnknown -> "isRight"--enumArmAssertion :: StructuralDecl -> WireEnum -> Text-enumArmAssertion declaration entry =- "(\"wire enum arm: "- <> unCanonicalTypeId (sdCanonical declaration)- <> "/"- <> weTag entry- <> "\", any (\\(_, value) -> encode"- <> sdName declaration- <> "Mapped value == Aeson.String "- <> tshow (weTag entry)- <> " && decode"- <> sdName declaration- <> "Mapped (Aeson.String "- <> tshow (weTag entry)- <> ") == Right value) (NonEmpty.toList (fixtureCases "- <> unQualifiedValueName (sdFixtures declaration)- <> ")))"--enumUnknownAssertion :: StructuralDecl -> Text-enumUnknownAssertion declaration =- "(\"wire enum unknown tag: "- <> unCanonicalTypeId (sdCanonical declaration)- <> "\", isLeft (decode"- <> sdName declaration- <> "Mapped (Aeson.String \"__keiro_unknown\")))"--unionArmAssertion :: StructuralDecl -> UnionEncoding -> ResolvedWireArm -> Text-unionArmAssertion declaration encoding arm =- "(\"wire union arm: "- <> unCanonicalTypeId (sdCanonical declaration)- <> "/"- <> rwaTag arm- <> "\", any (\\(_, value) -> objectField "- <> tshow (ueTagField encoding)- <> " (encode"- <> sdName declaration- <> "Mapped value) == Just (Aeson.String "- <> tshow (rwaTag arm)- <> ") && decode"- <> sdName declaration- <> "Mapped (encode"- <> sdName declaration- <> "Mapped value) == Right value) (NonEmpty.toList (fixtureCases "- <> unQualifiedValueName (sdFixtures declaration)- <> ")))"--wirePolicyHelpers :: [(StructuralDecl, ResolvedMappedShape)] -> [Text]-wirePolicyHelpers [] = []-wirePolicyHelpers _ =- [ ""- , "deleteObjectField :: T.Text -> Aeson.Value -> Aeson.Value"- , "deleteObjectField key (Aeson.Object objectValue) = Aeson.Object (AesonKeyMap.delete (AesonKey.fromText key) objectValue)"- , "deleteObjectField _ value = value"- , ""- , "insertObjectField :: T.Text -> Aeson.Value -> Aeson.Value -> Aeson.Value"- , "insertObjectField key inserted (Aeson.Object objectValue) = Aeson.Object (AesonKeyMap.insert (AesonKey.fromText key) inserted objectValue)"- , "insertObjectField _ _ value = value"- , ""- , "objectField :: T.Text -> Aeson.Value -> Maybe Aeson.Value"- , "objectField key (Aeson.Object objectValue) = AesonKeyMap.lookup (AesonKey.fromText key) objectValue"- , "objectField _ _ = Nothing"- ]--mappedFixtures :: ResolvedMappedDecl -> QualifiedValueName-mappedFixtures (ResolvedStructural declaration _) = sdFixtures declaration-mappedFixtures (ResolvedOpaque declaration) = odFixtures declaration--ctorExprWithOverride :: Agg -> ResolvedCtor -> Text -> Text -> Text-ctorExprWithOverride aggregate constructor target replacement =- "(" <> rcName constructor <> " (" <> rcName constructor <> "Data" <> arguments <> "))"- where- arguments =- T.concat- [ " " <> if fieldName == target then replacement else sampleValue aggregate fieldName fieldType- | (fieldName, fieldType) <- rcFields constructor- ]--projectionAssertionDecls :: Agg -> [(StructuralDecl, ResolvedMappedShape)] -> [Text]-projectionAssertionDecls aggregate structural- | null specs = []- | otherwise =- [ ""- , "structuralProjectionAssertions :: [(String, Bool)]"- , "structuralProjectionAssertions ="- , " [ " <> T.intercalate "\n , " (map assertion specs)- , " ]"- ]- where- specs = mappedProjectionSpecs aggregate- assertion spec =- "(\"projection witness agreement: "- <> unCanonicalTypeId (spCanonical spec)- <> spPointer spec- <> "\", all (\\(_, owner) -> fieldWitnessAgrees StructuralProjections."- <> spWitness spec- <> " (\\referenceOwner -> "- <> projectionGetter "referenceOwner" spec- <> ") owner) (NonEmpty.toList (fixtureCases "- <> ownerFixtures spec- <> ")))"- ownerFixtures spec = case find (\(declaration, _) -> sdCanonical declaration == spCanonical spec) structural of- Just (declaration, _) -> unQualifiedValueName (sdFixtures declaration)- Nothing -> "error \"projection owner fixtures missing\""--projectionGetter :: Text -> StructuralProjection -> Text-projectionGetter owner spec =- foldl- (\value (shapeModule, selector) -> shapeModule <> "." <> selector <> " (" <> value <> ")")- ("bindingToShape " <> unQualifiedValueName (spBinding spec) <> " " <> owner)- (spSelectors spec)+-- | The harness engine. From an aggregate spec it emits a @-- \@generated@ test+-- module that __pins the filled holes' behaviour__ — the project's actual+-- determinism guarantee, since the scaffolder no longer produces the transducer+-- body by construction. The emitted module exposes @harnessAssertions ::+-- [(String, Bool)]@, a list of labelled checks a driver runs (failing on any+-- @False@, naming the assertion). The checks are:+--+-- 1. keiki's @validateTransducer defaultValidationOptions@ on the filled+-- transducer is empty (no hidden inputs / nondeterminism / dead edges);+-- 2. a /clock-free/ assertion baked from the spec (TIME IS INJECTED, NOT+-- SAMPLED) — @False@ would mean a guard\/write sampled a wall clock;+-- 3. a golden wire round-trip per event (@decode . encode == id@);+-- 4. a behavioural /accept/ check per transition out of the initial state:+-- stepping a sample command lands on the declared @goto@ vertex. This is the+-- check a wrong guard fails — flipping @./=@ to @.==@ in the filled body turns+-- it red while leaving the scaffold untouched.+-- 5. a forward/replay equality check per live, event-emitting transition out of+-- the initial state: emitted events cross the generated codec boundary, then+-- replay must reconstruct the forward vertex and every declared register.+--+-- @Text@ samples include their field name so same-typed field swaps remain visible+-- to the replay check. Other sample kinds remain uniform until fixture bindings can+-- supply a wider, consumer-owned corpus.+module Keiro.Dsl.Harness+ ( harnessFor,+ harnessForWithGoldens,+ harnessProcess,+ harnessRouter,+ harnessReadModel,+ harnessWorkflow,+ )+where++import Data.List (find)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Keiro.Dsl.AggregateType+import Keiro.Dsl.Goldens (GoldenPayload (..))+import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType+import Keiro.Dsl.ReadModelShape (deriveShapeHash, registryNameFor, subscriptionNameFor)+import Keiro.Dsl.Scaffold+import Keiro.Dsl.TypeGraph++-- | Emit the harness test module for one aggregate. Like 'scaffoldAggregate',+-- it takes the 'Spec' for the shared id\/enum declarations.+harnessFor :: Context -> Spec -> Aggregate -> [ScaffoldModule]+harnessFor = harnessForWithGoldens []++-- | Emit an aggregate harness with checked-in old-payload fixtures embedded+-- as string literals. Embedding keeps the generated test independent of runtime+-- file paths while retaining the golden file as regeneration source of truth.+harnessForWithGoldens :: [GoldenPayload] -> Context -> Spec -> Aggregate -> [ScaffoldModule]+harnessForWithGoldens goldens ctx spec agg =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" (aGenPrefix a) <> "/Harness.hs"),+ moduleText = emitHarness relevantGoldens a,+ kind = Generated,+ origin = "aggregate " <> aggName agg <> locSuffix (aggLoc agg)+ }+ ]+ where+ a = resolveAgg ctx spec agg+ relevantGoldens =+ [ golden+ | golden <- goldens,+ goldenContext golden == specContext spec,+ goldenAggregate golden == aggName agg+ ]++-- | Emit a self-contained, firewall-clean facts harness for a process manager,+-- pinning the spec's deterministic decisions: the time-injection formula, the+-- deterministic timer-id and fired-event-id derivation strings, the runtime-owned+-- dispatch-id (no user id), and the dispatch\/fire disposition tables (incl. the+-- @on-reject => Fired@ benign inversion). It exposes+-- @processHarnessFacts :: [(String, Bool)]@ over pure values, so it compiles and+-- runs without the effectful\/hasql runtime. (Behavioural conformance of the+-- /filled/ ProcessManager against the live runtime is the M5 step.)+harnessProcess :: Context -> ProcessNode -> [ScaffoldModule]+harnessProcess ctx p =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/ProcessHarness.hs"),+ moduleText = emitProcessHarness genPrefix p,+ kind = Generated,+ origin = "process " <> procId p <> locSuffix (procLoc p)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (procId p)++-- | Emit runtime-free facts for a router's identity, resolution, dispatch,+-- and worker-policy decisions. A hand-written conformance driver owns the+-- expected values so a spec mutation turns one focused assertion red.+harnessRouter :: Context -> RouterNode -> [ScaffoldModule]+harnessRouter ctx router =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/RouterHarness.hs"),+ moduleText = emitRouterHarness genPrefix router,+ kind = Generated,+ origin = "router " <> rtId router <> locSuffix (rtLoc router)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (rtId router)++emitRouterHarness :: Text -> RouterNode -> Text+emitRouterHarness genPrefix router =+ nl+ [ generatedBanner,+ "module " <> genPrefix <> ".RouterHarness (routerHarnessValues) where",+ "",+ "routerHarnessValues :: [(String, String)]",+ "routerHarnessValues =",+ " [ (\"routerName\", " <> hs (rtName router) <> ")",+ " , (\"keyField\", " <> hs (corrField (rtKey router)) <> ")",+ " , (\"resolveSource\", " <> hs resolveSource <> ")",+ " , (\"resolveRow\", " <> hs (T.intercalate "," (rvRow (rtResolve router))) <> ")",+ " , (\"dispatchCommand\", " <> hs (rdCommand dispatch) <> ")",+ " , (\"dispatchIdInputs\", \"(name, key, sourceEventId, targetStreamName, occurrence)\")",+ " , (\"onDuplicate\", " <> hs (showDisp (onDuplicate disposition)) <> ")",+ " , (\"onFailed\", " <> hs (showDisp (onFailed disposition)) <> ")",+ " , (\"rejectedPolicy\", " <> hs (showPolicy (rtRejected router)) <> ")",+ " , (\"poisonPolicy\", " <> hs (showPolicy (rtPoison router)) <> ")",+ " ]"+ ]+ where+ hs = tshow+ dispatch = rtDispatch router+ disposition = rdDisposition dispatch+ resolveSource = case rvSource (rtResolve router) of+ ResolveReadModel name -> "read-model " <> name+ ResolveHole -> "hole"++-- | Emit runtime-free facts for a read-model node. Each row records the value+-- expected directly from the notation next to the value produced by the shared+-- derivation helpers. Committed conformance expectations pin the lowered values,+-- while a shape-fixture drift makes the generated harness itself fail.+harnessReadModel :: Context -> ReadModelNode -> [ScaffoldModule]+harnessReadModel ctx readModel =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/ReadModelHarness.hs"),+ moduleText = emitReadModelHarness genPrefix ctx readModel,+ kind = Generated,+ origin = "readmodel " <> rmName readModel <> locSuffix (rmLoc readModel)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (pascal (rmName readModel))++emitReadModelHarness :: Text -> Context -> ReadModelNode -> Text+emitReadModelHarness genPrefix ctx readModel =+ nl+ [ generatedBanner,+ "module " <> genPrefix <> ".ReadModelHarness (readModelFacts, runReadModelFacts) where",+ "",+ "-- | (fact, expected from notation, actual shared derivation/lowering).",+ "readModelFacts :: [(String, String, String)]",+ "readModelFacts =",+ " [ (\"registryName\", " <> tshow expectedRegistry <> ", " <> tshow actualRegistry <> ")",+ " , (\"subscriptionName\", " <> tshow expectedSubscription <> ", " <> tshow actualSubscription <> ")",+ " , (\"shapeHash\", " <> tshow (rmShape readModel) <> ", " <> tshow (deriveShapeHash readModel) <> ")",+ " , (\"asyncProjectionName\", " <> tshow expectedAsync <> ", " <> tshow actualAsync <> ")",+ " , (\"consistency\", " <> tshow consistency <> ", " <> tshow consistency <> ")",+ " , (\"strongScope\", " <> tshow scope <> ", " <> tshow scope <> ")",+ " ]",+ "",+ "runReadModelFacts :: IO Bool",+ "runReadModelFacts = do",+ " let failures = [(fact, expected, actual) | (fact, expected, actual) <- readModelFacts, expected /= actual]",+ " mapM_ (\\(fact, expected, actual) -> putStrLn (\"FAIL \" <> fact <> \" expected=\" <> show expected <> \" actual=\" <> show actual)) failures",+ " pure (null failures)"+ ]+ where+ expectedRegistry = contextName ctx <> "-" <> T.replace "_" "-" (rmName readModel)+ actualRegistry = registryNameFor (contextName ctx) readModel+ expectedSubscription = case rmSubscription readModel of+ Just name -> name+ Nothing -> expectedRegistry <> "-sub"+ actualSubscription = subscriptionNameFor (contextName ctx) readModel+ expectedAsync = case rmFeed readModel of+ RmInline -> "none"+ RmSubscription -> expectedRegistry <> "-async"+ actualAsync = case rmFeed readModel of+ RmInline -> "none"+ RmSubscription -> actualRegistry <> "-async"+ consistency = case rmConsistency readModel of+ Strong -> "Strong"+ Eventual -> "Eventual"+ scope = case rmScope readModel of+ Nothing -> "EntireLog"+ Just RmEntireLog -> "EntireLog"+ Just (RmCategory categoryName) -> "CategoryHead " <> categoryName++emitProcessHarness :: Text -> ProcessNode -> Text+emitProcessHarness genPrefix p =+ nl+ [ generatedBanner,+ "module " <> genPrefix <> ".ProcessHarness (processHarnessValues) where",+ "",+ "{- | (label, value): the spec's deterministic process/timer decisions,",+ "lowered to plain values so a driver can assert them against a committed",+ "expectation. The driver's expectation is hand-written (not generated), so a",+ "spec change that alters a decision diverges from it and turns a specific",+ "assertion red — the spec->behaviour pin. (Live-runtime behavioural",+ "conformance of the filled ProcessManager is the M5 step.)",+ "-}",+ "processHarnessValues :: [(String, String)]",+ "processHarnessValues =",+ " [ (\"fireAtField\", " <> hs (faField (tmFireAt timer)) <> ")",+ " , (\"timerIdPrefix\", " <> hs (idePrefix (tmId timer)) <> ")",+ " , (\"firedEventIdPrefix\", " <> hs (idePrefix (fireFiredEventId timer')) <> ")",+ " , (\"dispatchIdUserField\", \"none\")",+ " , (\"onReject\", " <> hs (showFireOutcome (onReject fd)) <> ")",+ " , (\"onAmbiguous\", " <> hs (showFireOutcome (onAmbiguous fd)) <> ")",+ " , (\"onFailed\", " <> hs (showDisp (onFailed (firstDispDisposition p))) <> ")",+ " , (\"rejectedPolicy\", " <> hs (showPolicy (procRejected p)) <> ")",+ " , (\"poisonPolicy\", " <> hs (showPolicy (procPoison p)) <> ")",+ " , (\"maxAttempts\", " <> hs (tInt (tmMaxAttempts timer)) <> ")",+ " ]"+ ]+ where+ timer = procTimer p+ timer' = tmFire timer+ fd = fireDisposition timer'+ hs = tshow++firstDispDisposition :: ProcessNode -> DispatchDisposition+firstDispDisposition p = case hDispatch (procHandle p) of+ (d : _) -> dispDisposition d+ [] -> DispatchDisposition DAckOk DAckOk DRetry++showFireOutcome :: FireOutcome -> Text+showFireOutcome OFired = "Fired"+showFireOutcome ORetry = "Retry"++showDisp :: Disp -> Text+showDisp DAckOk = "AckOk"+showDisp DRetry = "Retry"+showDisp (DDeadLetter _) = "DeadLetter"++showPolicy :: PolicyChoice -> Text+showPolicy PolHalt = "halt"+showPolicy PolDeadLetter = "deadLetter"+showPolicy PolSkip = "skip"++-- | A self-contained, firewall-clean facts harness for a durable workflow,+-- pinning the spec's deterministic decisions: the stable name, the WorkflowId+-- derivation, the ordered body (step/await/sleep/child by label), and the await+-- labels (whose ids the signal operations must match). Exposes+-- @workflowFacts :: [(String, String)]@ so a driver asserts them against a+-- hand-written expectation — a spec change (e.g. renaming an await label) diverges+-- and reddens a specific assertion. Workflows intentionally have no domain scaffold+-- or hole stub: their behaviour-bearing body remains hand-written, while these facts+-- and the live-runtime module pin its declared structure.+harnessWorkflow :: Context -> WorkflowNode -> [ScaffoldModule]+harnessWorkflow ctx w =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/WorkflowFacts.hs"),+ moduleText = emitWorkflowFacts genPrefix w,+ kind = Generated,+ origin = "workflow " <> wfId w <> locSuffix (workflowNodeLoc w)+ },+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/WorkflowRuntime.hs"),+ moduleText = emitWorkflowRuntime genPrefix w,+ kind = Generated,+ origin = "workflow " <> wfId w <> locSuffix (workflowNodeLoc w)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (wfId w)++locSuffix :: Loc -> Text+locSuffix loc = case unLoc loc of+ 0 -> ""+ line -> " (line " <> tInt line <> ")"++emitWorkflowFacts :: Text -> WorkflowNode -> Text+emitWorkflowFacts genPrefix w =+ nl+ [ generatedBanner,+ "module " <> genPrefix <> ".WorkflowFacts (workflowFacts) where",+ "",+ "{- | (label, value): the workflow's deterministic decisions, pinned as pure",+ "facts. A driver asserts them against a hand-written expectation, so a spec",+ "change (e.g. renaming an await) reddens a specific assertion.",+ "-}",+ "workflowFacts :: [(String, String)]",+ "workflowFacts =",+ " [ (\"name\", " <> hs (wfStable w) <> ")",+ " , (\"idVia\", " <> hs (wfIdVia w) <> ")",+ " , (\"idField\", " <> hs (maybe "input" id (wfIdField w)) <> ")",+ " , (\"body\", " <> hs (T.intercalate "," (map bodyTag (wfBody w))) <> ")",+ " , (\"awaits\", " <> hs (T.intercalate "," (workflowAwaitLabels (wfBody w))) <> ")",+ " , (\"patches\", " <> hs (T.intercalate "," (workflowPatchIds (wfBody w))) <> ")",+ " ]"+ ]+ where+ hs = tshow+ bodyTag (WfStep l _ _) = "step:" <> l+ bodyTag (WfAwait l _ _) = "await:" <> l+ bodyTag (WfSleep l _ _) = "sleep:" <> l+ bodyTag (WfChild l _ _ _) = "child:" <> l+ bodyTag (WfPatch patchId items _) = "patch:" <> patchId <> "(" <> T.intercalate "," (map bodyTag items) <> ")"+ bodyTag (WfContinueAsNew seedType _) = "continueAsNew:" <> seedType++-- | Emit the workflow's deterministic id derivation compiled against the LIVE+-- @Keiro.Workflow@: the 'WorkflowName' and the awakeable-id function (the actual+-- 'deterministicAwakeableId'). A signal operation deriving the SAME (name, id,+-- label) lands on the same 'AwakeableId' — so this module compiling + the+-- conformance comparing the two sides proves the await↔signal coupling holds over+-- the real runtime function, not just by label-string equality.+emitWorkflowRuntime :: Text -> WorkflowNode -> Text+emitWorkflowRuntime genPrefix w =+ nl $+ [ generatedBanner,+ "module " <> genPrefix <> ".WorkflowRuntime",+ " ( workflowName",+ " , awaitAwakeableId",+ " , awaitLabels",+ " , declaredPatches",+ " , declaredPatchStepNames",+ " , withDeclaredPatches",+ " ) where",+ "",+ "import Data.Set (Set)",+ "import Data.Set qualified as Set",+ "import Data.Text (Text)",+ "import Keiro.Workflow (WorkflowRunOptions (..))",+ "import Keiro.Workflow.Awakeable (AwakeableId, deterministicAwakeableId)",+ "import Keiro.Workflow.Types (PatchId (..), WorkflowId, WorkflowName (..), patchStepName)",+ "",+ "workflowName :: WorkflowName",+ "workflowName = WorkflowName " <> tshow (wfStable w),+ "",+ "-- The awakeable id an await allocates — the real deterministicAwakeableId.",+ "-- A signal op deriving the same (name, id, label) gets the same id.",+ "awaitAwakeableId :: WorkflowId -> Text -> AwakeableId",+ "awaitAwakeableId wid label = deterministicAwakeableId workflowName wid label",+ "",+ "awaitLabels :: [Text]",+ "awaitLabels = [" <> T.intercalate ", " (map tshow (workflowAwaitLabels (wfBody w))) <> "]",+ "",+ "declaredPatches :: Set PatchId",+ "declaredPatches = Set.fromList [" <> T.intercalate ", " ["PatchId " <> tshow patchId | patchId <- workflowPatchIds (wfBody w)] <> "]",+ "",+ "-- The journal keys the runtime records patch decisions under.",+ "declaredPatchStepNames :: [Text]",+ "declaredPatchStepNames = map patchStepName (Set.toList declaredPatches)",+ "",+ "-- Activate exactly the patches declared by this spec for a workflow run.",+ "withDeclaredPatches :: WorkflowRunOptions -> WorkflowRunOptions",+ "withDeclaredPatches opts = opts{activePatches = declaredPatches}"+ ]++workflowAwaitLabels :: [WfBodyItem] -> [Name]+workflowAwaitLabels = concatMap go+ where+ go (WfAwait label _ _) = [label]+ go (WfPatch _ items _) = workflowAwaitLabels items+ go _ = []++workflowPatchIds :: [WfBodyItem] -> [Name]+workflowPatchIds = concatMap go+ where+ go (WfPatch patchId items _) = patchId : workflowPatchIds items+ go _ = []++emitHarness :: [GoldenPayload] -> Agg -> Text+emitHarness goldens a =+ nl $+ [ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE OverloadedLabels #-}"+ ]+ ++ ["{-# LANGUAGE TypeApplications #-}" | hasMappedHarness a]+ ++ [ generatedBanner,+ "module " <> aGenPrefix a <> ".Harness (harnessAssertions) where",+ "",+ "import " <> aGenPrefix a <> ".Domain",+ "import " <> aGenPrefix a <> ".Codec (encode" <> nm <> "Event, parse" <> nm <> "Event" <> codecValueImport <> mappedCodecHarnessExports a <> ")",+ transducerImport a,+ "import Keiki.Core (" <> T.intercalate ", " coreImports <> ")",+ codecDecodeRawImport+ ]+ ++ mappedHarnessImports a+ ++ nominalHarnessImports a+ ++ aggregateHarnessImports a+ ++ goldenImports+ ++ [ "",+ "{- | (label, passed). A driver runs these and exits non-zero on any False,",+ "naming the failing assertion. Filling a hole wrongly turns a specific",+ "entry False; the scaffold cannot.",+ "-}",+ "harnessAssertions :: [(String, Bool)]",+ "harnessAssertions =",+ " [ (\"validateTransducer is empty\", null (validateTransducer defaultValidationOptions " <> lowerFirst nm <> "Transducer))",+ " , (\"clock-free: spec samples no wall clock\", " <> clockFreeLit <> ")"+ ]+ ++ [ " , (\"golden round-trip: " <> rcName e <> "\", roundTrips sampleEvent" <> rcName e <> ")"+ | e <- aEvents a+ ]+ ++ [ " , (\"accepts " <> tCommand t <> " from " <> initialVertex a <> "\", accept" <> tCommand t <> ")"+ | t <- initialTransitions a+ ]+ ++ [ " ]"+ ]+ ++ [" ++ mappedConformanceAssertions" | hasMappedHarness a]+ ++ [" ++ nominalConformanceAssertions" | hasNominalHarness a]+ ++ [ " ++ forwardReplay" <> tCommand t+ | t <- replayTransitions+ ]+ ++ ( if null upcastEvents+ then []+ else+ [ " ++ [ " <> T.intercalate "\n , " upcastAssertions,+ " ]"+ ]+ )+ ++ [ "",+ "roundTrips :: " <> nm <> "Event -> Bool",+ "roundTrips e = parse" <> nm <> "Event (eventType " <> lowerFirst nm <> "Codec e) (encode" <> nm <> "Event e) == Right e"+ ]+ ++ concatMap (sampleEventDecl a) (aEvents a)+ ++ concatMap (acceptDecl a) (initialTransitions a)+ ++ concatMap (forwardReplayDecl a) replayTransitions+ ++ concatMap (upcastDecl goldens a) upcastEvents+ ++ mappedHarnessDeclarations a+ ++ nominalHarnessDeclarations a+ where+ nm = aName a+ -- Bake the clock-free result computed from the spec at scaffold time.+ clockFreeLit = if specIsClockFree a then "True" else "False"+ upcastEvents = [e | e <- aEvents a, rcUpcastFrom e /= Nothing]+ replayTransitions =+ [ t+ | t <- initialTransitions a,+ tMode t == TmLive,+ not (null (tEmits t))+ ]+ coreImports =+ ["applyEventsEither" | not (null replayTransitions)]+ ++ ["defaultValidationOptions", "step", "validateTransducer"]+ ++ ["fieldWitnessAgrees" | not (null (mappedProjectionSpecs a)) || not (null (nominalScalarHarnessTypes a))]+ ++ ["(!)" | not (null replayTransitions) && not (null (aRegs a))]+ upcastAssertions =+ [ "(" <> tshow (upcastLabel e m) <> ", upcasts" <> rcName e <> ")"+ | e <- upcastEvents,+ Just m <- [rcUpcastFrom e]+ ]+ codecValueImport = ", " <> lowerFirst nm <> "Codec"+ codecDecodeRawImport =+ if null upcastEvents+ then "import Keiro.Codec (eventType)"+ else "import Keiro.Codec (EventType (..), decodeRaw, eventType)"+ goldenImports =+ if any (hasGolden goldens) upcastEvents+ then+ [ "import Data.Aeson (eitherDecodeStrict)",+ "import Data.Text.Encoding (encodeUtf8)"+ ]+ else []++ upcastLabel event source =+ case goldenFor goldens event of+ Just _ -> "golden " <> rcName event <> ".v" <> tInt source <> " decodes"+ Nothing ->+ "upcast "+ <> rcName event+ <> " chain wired (current-shape stand-in; add a golden payload)"++transducerImport :: Agg -> Text+transducerImport aggregate+ | usesGeneratedTransducer aggregate =+ "import "+ <> aGenPrefix aggregate+ <> ".Transducer ("+ <> lowerFirst (aName aggregate)+ <> "Transducer)"+ | otherwise =+ "import "+ <> aHolePrefix aggregate+ <> ".Holes ("+ <> lowerFirst (aName aggregate)+ <> "Transducer)"++usesGeneratedTransducer :: Agg -> Bool+usesGeneratedTransducer = any ((/= LegacyHoleImplementation) . tImplementation) . aTransitions++-- | Decode a genuine embedded old payload when available. Without a golden,+-- retain the weaker current-shape wiring assertion and label it honestly.+upcastDecl :: [GoldenPayload] -> Agg -> ResolvedCtor -> [Text]+upcastDecl goldens a e = case rcUpcastFrom e of+ Nothing -> []+ Just m -> case goldenFor goldens e of+ Just golden ->+ [ "",+ "upcasts" <> rcName e <> " :: Bool",+ "upcasts" <> rcName e <> " =",+ " case eitherDecodeStrict (encodeUtf8 " <> tshow (goldenJson golden) <> ") of",+ " Left _ -> False",+ " Right payload ->",+ " either (const False) (const True)",+ " (decodeRaw " <> lowerFirst (aName a) <> "Codec (EventType " <> tshow (rcName e) <> ") " <> tInt m <> " payload)"+ ]+ Nothing ->+ [ "",+ "upcasts" <> rcName e <> " :: Bool",+ "upcasts" <> rcName e <> " =",+ " either (const False) (const True)",+ " (decodeRaw " <> lowerFirst (aName a) <> "Codec (EventType " <> tshow (rcName e) <> ") " <> tInt m <> " (encode" <> aName a <> "Event sampleEvent" <> rcName e <> "))"+ ]++hasGolden :: [GoldenPayload] -> ResolvedCtor -> Bool+hasGolden goldens event = case goldenFor goldens event of+ Just _ -> True+ Nothing -> False++goldenFor :: [GoldenPayload] -> ResolvedCtor -> Maybe GoldenPayload+goldenFor goldens event = do+ source <- rcUpcastFrom event+ find+ (\golden -> goldenEvent golden == rcName event && goldenVersion golden == source)+ goldens++tInt :: Int -> Text+tInt = T.pack . show++-- | Render a Text as a Haskell string literal (quoted, escaped).+tshow :: Text -> Text+tshow = T.pack . show++nl :: [Text] -> Text+nl = T.intercalate "\n"++specIsClockFree :: Agg -> Bool+specIsClockFree a = not (any transitionSamplesClock (aTransitions a))+ where+ clockAtoms = ["now", "currentTime", "wallClock", "today", "utcNow"]+ transitionSamplesClock t =+ let exprs = maybe [] pure (tGuard t) ++ map snd (tWrites t)+ in any (\e -> any (`elem` clockAtoms) (exprNames e)) exprs++exprNames :: Expr -> [Text]+exprNames (EOr x y) = exprNames x ++ exprNames y+exprNames (EAnd x y) = exprNames x ++ exprNames y+exprNames (ECmp _ x y) = exprNames x ++ exprNames y+exprNames (EAdd _ x y) = exprNames x ++ exprNames y+exprNames (ESubtract _ x y) = exprNames x ++ exprNames y+exprNames (EMultiply _ x y) = exprNames x ++ exprNames y+exprNames (EPath _ _ (name : _)) = [name]+exprNames (EPath _ _ []) = []+exprNames ELiteral {} = []+exprNames (EAtom (AName n)) = [n]+exprNames (EAtom (ABool _)) = []++initialTransitions :: Agg -> [Transition]+initialTransitions a = case map stName (aStates a) of+ (s0 : _) -> [t | t <- aTransitions a, tSource t == s0]+ [] -> []++-- | @sampleEvent<Ctor> :: <Agg>Event@ — a sample built from per-field sample+-- values (enum→first constructor, Bool→False, id→placeholder,+-- Text→\"sample-<fieldName>\").+sampleEventDecl :: Agg -> ResolvedCtor -> [Text]+sampleEventDecl a e =+ [ "",+ "sampleEvent" <> rcName e <> " :: " <> aName a <> "Event",+ "sampleEvent" <> rcName e <> " = " <> ctorExpr a e+ ]++acceptDecl :: Agg -> Transition -> [Text]+acceptDecl a t =+ [ "",+ "accept" <> tCommand t <> " :: Bool",+ "accept" <> tCommand t <> " =",+ " case step " <> lowerFirst (aName a) <> "Transducer (" <> initialVertex a <> ", initial" <> aName a <> "Regs) " <> cmdSample <> " of",+ " Just (v, _, _) -> v == " <> vertexCtor a (tGoto t),+ " Nothing -> False"+ ]+ where+ cmdSample = case [c | c <- aCommands a, rcName c == tCommand t] of+ (c : _) -> "(" <> ctorExpr a c <> ")"+ [] -> "(error \"no command\")"++forwardReplayDecl :: Agg -> Transition -> [Text]+forwardReplayDecl a t =+ [ "",+ "-- forward/replay equality (plan 147): cross the persisted codec boundary,",+ "-- replay the emitted chain, and compare the final vertex and every register.",+ helperName <> " :: [(String, Bool)]",+ helperName <> " =",+ " case step " <> transducer <> " (" <> initial <> ", " <> initialRegs <> ") " <> cmdSample <> " of",+ " Nothing -> [(prefix <> \"forward step accepted\", False)]",+ " Just (forwardVertex, " <> forwardRegsName <> ", emitted) ->",+ " case mapM (\\event -> parse" <> nm <> "Event (eventType " <> codec <> " event) (encode" <> nm <> "Event event)) emitted of",+ " Left _ -> [(prefix <> \"emitted chain decodes\", False)]",+ " Right decodedEvents ->",+ " case applyEventsEither " <> transducer <> " (" <> initial <> ", " <> initialRegs <> ") decodedEvents of",+ " Left _ -> [(prefix <> \"replay succeeds\", False)]",+ " Right (replayVertex, " <> replayRegsName <> ") ->",+ " [ (prefix <> \"final vertex\", replayVertex == forwardVertex)"+ ]+ ++ [ " , (prefix <> \"register " <> rrName reg <> "\", (replayRegs ! #" <> rrName reg <> ") == (forwardRegs ! #" <> rrName reg <> "))"+ | reg <- aRegs a+ ]+ ++ [ " ]",+ " where",+ " prefix = \"forward/replay equality: " <> tCommand t <> " from " <> initial <> " -- \""+ ]+ where+ nm = aName a+ helperName = "forwardReplay" <> tCommand t+ transducer = lowerFirst nm <> "Transducer"+ codec = lowerFirst nm <> "Codec"+ initial = initialVertex a+ initialRegs = "initial" <> nm <> "Regs"+ forwardRegsName = if null (aRegs a) then "_forwardRegs" else "forwardRegs"+ replayRegsName = if null (aRegs a) then "_replayRegs" else "replayRegs"+ cmdSample = case [c | c <- aCommands a, rcName c == tCommand t] of+ (c : _) -> "(" <> ctorExpr a c <> ")"+ [] -> "(error \"no command\")"++-- | @(<Ctor> (<Ctor>Data v1 v2 …))@ with positional sample field values.+ctorExpr :: Agg -> ResolvedCtor -> Text+ctorExpr a rc =+ "(" <> rcName rc <> " (" <> rcName rc <> "Data" <> args <> "))"+ where+ args = T.concat [" " <> sampleValue a fieldName ty | (fieldName, ty) <- rcFields rc]++sampleValue :: Agg -> Text -> ResolvedAggregateType -> Text+sampleValue a fieldName ty = case fieldCat a ty of+ IdCat -> aggregateSampleHaskell (aSymbols a) fieldName ty+ EnumCat -> aggregateSampleHaskell (aSymbols a) fieldName ty+ MappedStructuralCat declaration _ -> fixtureSample (sdFixtures declaration)+ MappedOpaqueCat declaration -> fixtureSample (odFixtures declaration)+ OtherCat -> case ty of+ AggregateVertex vertexType+ | vertexType == aVertexType a -> initialVertex a+ _ -> aggregateSampleHaskell (aSymbols a) fieldName ty++aggregateHarnessImports :: Agg -> [Text]+aggregateHarnessImports aggregate =+ unique+ [ "import " <> imported+ | resolvedType <- map snd (concatMap rcFields (aCommands aggregate <> aEvents aggregate)),+ AggregateTime <- [resolvedType],+ imported <- Set.toAscList (aggregateImports (aSymbols aggregate) resolvedType)+ ]++nominalHarnessImports :: Agg -> [Text]+nominalHarnessImports aggregate+ | null nominals = []+ | otherwise =+ [ "import Data.List.NonEmpty qualified as NonEmpty",+ "import Keiro.Codec.Nominal (nominalDomainRoundTrip, nominalFixtureCases, nominalFixtureDomain, nominalRepresentationRoundTrip, nominalToRepresentation)"+ ]+ <> ["import " <> moduleName <> " qualified" | moduleName <- unique (fixtureModules <> bindingModules)]+ <> ["import " <> nominalProjectionModule (aContext aggregate) <> " qualified as NominalProjections" | not (null (nominalScalarHarnessTypes aggregate))]+ where+ nominals = consumerNominalHarnessTypes aggregate+ bindings = [binding | nominal <- nominals, ConsumerNominal binding <- [resolvedNominalOwnership nominal]]+ fixtureModules =+ [ fst (splitQualifiedHarness (unQualifiedValueName (consumerNominalFixtures binding)))+ | binding <- bindings+ ]+ bindingModules =+ [ fst (splitQualifiedHarness (unQualifiedValueName (consumerNominalBinding binding)))+ | binding <- bindings+ ]++hasNominalHarness :: Agg -> Bool+hasNominalHarness = not . null . consumerNominalHarnessTypes++consumerNominalHarnessTypes :: Agg -> [ResolvedNominalType]+consumerNominalHarnessTypes aggregate =+ Map.elems . Map.fromList $+ [ (resolvedNominalName nominal, nominal)+ | resolvedType <- map snd (concatMap rcFields (aCommands aggregate <> aEvents aggregate)) <> map rrType (aRegs aggregate),+ AggregateNominal nominal <- [resolvedType],+ ConsumerNominal {} <- [resolvedNominalOwnership nominal]+ ]++nominalScalarHarnessTypes :: Agg -> [ResolvedNominalType]+nominalScalarHarnessTypes aggregate =+ [ nominal+ | nominal <- consumerNominalHarnessTypes aggregate,+ ScalarRepresentation {} <- [resolvedNominalRepresentation nominal]+ ]++nominalHarnessDeclarations :: Agg -> [Text]+nominalHarnessDeclarations aggregate+ | null nominals = []+ | otherwise =+ [ "",+ "nominalConformanceAssertions :: [(String, Bool)]",+ "nominalConformanceAssertions ="+ ]+ <> renderList assertions+ where+ nominals = consumerNominalHarnessTypes aggregate+ assertions = concatMap assertionsFor nominals+ assertionsFor nominal = case resolvedNominalOwnership nominal of+ GeneratedNominal -> []+ ConsumerNominal binding ->+ [ ( "nominal domain law: " <> name,+ "all (\\fixture -> nominalDomainRoundTrip " <> bindingName <> " (nominalFixtureDomain fixture)) " <> fixtures+ ),+ ( "nominal representation law: " <> name,+ "all (\\fixture -> let domainValue = nominalFixtureDomain fixture in nominalRepresentationRoundTrip " <> bindingName <> " (nominalToRepresentation " <> bindingName <> " domainValue)) " <> fixtures+ )+ ]+ <> [ ( "nominal projection agreement: " <> name,+ "all (\\fixture -> fieldWitnessAgrees NominalProjections."+ <> lowerFirst name+ <> "Witness (nominalToRepresentation "+ <> bindingName+ <> ") (nominalFixtureDomain fixture)) "+ <> fixtures+ )+ | ScalarRepresentation {} <- [resolvedNominalRepresentation nominal]+ ]+ where+ name = resolvedNominalName nominal+ bindingName = unQualifiedValueName (consumerNominalBinding binding)+ fixtureName = unQualifiedValueName (consumerNominalFixtures binding)+ fixtures = "(NonEmpty.toList (nominalFixtureCases " <> fixtureName <> "))"+ renderList values =+ [ (if index == (0 :: Int) then " [ " else " , ") <> "(" <> tshow labelText <> ", " <> expression <> ")"+ | (index, (labelText, expression)) <- zip [0 ..] values+ ]+ <> [" ]"]++mappedHarnessImports :: Agg -> [Text]+mappedHarnessImports aggregate+ | null fixtures = []+ | otherwise =+ [ "import Data.Aeson qualified as Aeson",+ "import Data.Aeson.Key qualified as AesonKey",+ "import Data.Aeson.KeyMap qualified as AesonKeyMap",+ "import Data.Either (isLeft, isRight)",+ "import Data.List (nub)",+ "import Data.List.NonEmpty qualified as NonEmpty",+ "import Data.Maybe (isJust, isNothing)",+ "import Data.Proxy (Proxy (..))",+ "import Data.Text qualified as T",+ "import Keiki.Shape (CanonicalTypeName (..))",+ "import Keiro.Codec.Structural (FixtureCases (..), bindingDomainRoundTrip, bindingShapeRoundTrip, bindingToShape)"+ ]+ ++ map (\moduleName -> "import " <> moduleName <> " qualified") (unique (modules <> bindingModules <> shapeModules <> consumerModules))+ ++ ["import " <> structuralProjectionModuleName (aContext aggregate) <> " qualified as StructuralProjections" | not (null (mappedProjectionSpecs aggregate))]+ where+ fixtures = [mappedFixtures declaration | declaration <- mappedHarnessDeclarationsResolved aggregate]+ modules = unique [fst (splitQualifiedHarness (unQualifiedValueName qualified)) | qualified <- fixtures]+ bindingModules =+ [ fst (splitQualifiedHarness (unQualifiedValueName (sdBinding declaration)))+ | ResolvedStructural declaration _ <- mappedHarnessDeclarationsResolved aggregate+ ]+ shapeModules =+ [ structuralShapeModuleName (aContext aggregate) (sdName declaration)+ | ResolvedStructural declaration _ <- mappedHarnessDeclarationsResolved aggregate+ ]+ consumerModules =+ [ hsModule (sdHaskell declaration)+ | ResolvedStructural declaration _ <- mappedHarnessDeclarationsResolved aggregate+ ]++mappedCodecHarnessExports :: Agg -> Text+mappedCodecHarnessExports aggregate =+ T.concat+ [ ", encode" <> sdName declaration <> "Mapped, decode" <> sdName declaration <> "Mapped"+ | ResolvedStructural declaration _ <- codecMappedDeclarations aggregate+ ]++fixtureSample :: QualifiedValueName -> Text+fixtureSample qualified =+ "(snd (NonEmpty.head (fixtureCases " <> unQualifiedValueName qualified <> ")))"++splitQualifiedHarness :: Text -> (Text, Text)+splitQualifiedHarness value =+ let (prefix, name) = T.breakOnEnd "." value+ in (T.dropEnd 1 prefix, name)++unique :: (Eq value) => [value] -> [value]+unique = foldr (\value values -> if value `elem` values then values else value : values) []++hasMappedHarness :: Agg -> Bool+hasMappedHarness = not . null . mappedHarnessDeclarationsResolved++mappedHarnessDeclarationsResolved :: Agg -> [ResolvedMappedDecl]+mappedHarnessDeclarationsResolved aggregate = case aTypeGraph aggregate of+ Nothing -> []+ Just graph -> Map.elems (tgDeclarations graph)++mappedProjectionSpecs :: Agg -> [StructuralProjection]+mappedProjectionSpecs aggregate = case aTypeGraph aggregate of+ Nothing -> []+ Just graph -> map (resolveProjectionModules (aContext aggregate)) (projectionSpecs graph)++structuralShapeModuleName :: Context -> Name -> Text+structuralShapeModuleName context name = case placement context of+ GeneratedPrefix -> root <> "Generated." <> contextSegment <> ".Structural.Shape." <> name+ CollocatedLeaf -> root <> contextSegment <> ".Generated.Structural.Shape." <> name+ where+ root = if T.null (moduleRoot context) then "" else moduleRoot context <> "."+ contextSegment = pascalFromKebab (contextName context)++structuralProjectionModuleName :: Context -> Text+structuralProjectionModuleName context = case placement context of+ GeneratedPrefix -> root <> "Generated." <> contextSegment <> ".StructuralProjections"+ CollocatedLeaf -> root <> contextSegment <> ".Generated.StructuralProjections"+ where+ root = if T.null (moduleRoot context) then "" else moduleRoot context <> "."+ contextSegment = pascalFromKebab (contextName context)++mappedHarnessDeclarations :: Agg -> [Text]+mappedHarnessDeclarations aggregate+ | not (hasMappedHarness aggregate) = []+ | otherwise =+ [ "",+ "mappedConformanceAssertions :: [(String, Bool)]",+ "mappedConformanceAssertions =",+ " concat",+ " [ " <> T.intercalate "\n , " assertionLists,+ " ]",+ "",+ "validFixtureLabels :: NonEmpty.NonEmpty (T.Text, value) -> Bool",+ "validFixtureLabels cases =",+ " all (not . T.null) labels && length labels == length (nub labels)",+ " where",+ " labels = map fst (NonEmpty.toList cases)"+ ]+ ++ concatMap (bindingAssertionDecl aggregate) structural+ ++ concatMap (opaqueAssertionDecl aggregate) opaque+ ++ concatMap (coverageDecl aggregate) structural+ ++ concatMap (mappedEventAssertionDecl aggregate) mappedEventFields+ ++ wirePolicyAssertionDecls aggregate structuralWire+ ++ projectionAssertionDecls aggregate structural+ ++ wirePolicyHelpers structuralWire+ where+ declarations = mappedHarnessDeclarationsResolved aggregate+ structural = [(declaration, shape) | ResolvedStructural declaration shape <- declarations]+ opaque = [declaration | ResolvedOpaque declaration <- declarations]+ structuralWire = [(declaration, shape) | ResolvedStructural declaration shape <- codecMappedDeclarations aggregate]+ mappedEventFields =+ [ (event, fieldName, fieldType, declaration)+ | event <- aEvents aggregate,+ (fieldName, fieldType) <- rcFields event,+ declaration <- maybeToListHarness (mappedDeclaration aggregate fieldType)+ ]+ assertionLists =+ [lowerFirst (sdName declaration) <> "BindingAssertions" | (declaration, _) <- structural]+ <> [lowerFirst (odName declaration) <> "OpaqueAssertions" | declaration <- opaque]+ <> [ "[(\"fixture coverage: "+ <> unCanonicalTypeId (sdCanonical declaration)+ <> "\", coverage"+ <> sdName declaration+ <> ")]"+ | (declaration, _) <- structural+ ]+ <> [ mappedEventAssertionName event fieldName <> "Assertions"+ | (event, fieldName, _, _) <- mappedEventFields+ ]+ <> ["structuralWirePolicyAssertions" | not (null structuralWire)]+ <> ["structuralProjectionAssertions" | not (null (mappedProjectionSpecs aggregate))]++mappedDeclaration :: Agg -> ResolvedAggregateType -> Maybe ResolvedMappedDecl+mappedDeclaration aggregate resolvedType = do+ key <- case resolvedType of+ AggregateMapped mappedKey -> Just mappedKey+ _ -> Nothing+ graph <- aTypeGraph aggregate+ Map.lookup key (tgDeclarations graph)++bindingAssertionDecl :: Agg -> (StructuralDecl, ResolvedMappedShape) -> [Text]+bindingAssertionDecl _aggregate (declaration, _shape) =+ [ "",+ valueName <> " :: [(String, Bool)]",+ valueName <> " =",+ " (\"fixture labels: " <> canonical <> "\", validFixtureLabels cases) :",+ " (\"canonical identity: " <> canonical <> "\", canonicalTypeName (Proxy @" <> consumerType <> ") == " <> tshow canonical <> ") :",+ " concat",+ " [ [ (\"binding domain round-trip: " <> canonical <> "/\" <> T.unpack label, bindingDomainRoundTrip " <> binding <> " value)",+ " , (\"binding shape round-trip: " <> canonical <> "/\" <> T.unpack label, bindingShapeRoundTrip " <> binding <> " (bindingToShape " <> binding <> " value))",+ " ]",+ " | (label, value) <- NonEmpty.toList cases",+ " ]",+ " where",+ " cases = fixtureCases " <> fixtures+ ]+ where+ valueName = lowerFirst (sdName declaration) <> "BindingAssertions"+ canonical = unCanonicalTypeId (sdCanonical declaration)+ consumerType = hsModule (sdHaskell declaration) <> "." <> hsType (sdHaskell declaration)+ binding = unQualifiedValueName (sdBinding declaration)+ fixtures = unQualifiedValueName (sdFixtures declaration)++opaqueAssertionDecl :: Agg -> OpaqueDecl -> [Text]+opaqueAssertionDecl _aggregate declaration =+ [ "",+ valueName <> " :: [(String, Bool)]",+ valueName <> " =",+ " (\"opaque boundary fixtures: " <> label <> "\", validFixtureLabels cases) :",+ " [ (\"opaque codec round-trip: " <> label <> "/\" <> T.unpack caseLabel, case Aeson.fromJSON (Aeson.toJSON value) of Aeson.Success decoded -> decoded == value; Aeson.Error _ -> False)",+ " | (caseLabel, value) <- NonEmpty.toList cases",+ " ]",+ " where",+ " cases = fixtureCases " <> fixtures+ ]+ where+ valueName = lowerFirst (odName declaration) <> "OpaqueAssertions"+ label = unCodecIdentity (odCodecIdentity declaration) <> "@" <> unCodecVersion (odCodecVersion declaration)+ fixtures = unQualifiedValueName (odFixtures declaration)++coverageDecl :: Agg -> (StructuralDecl, ResolvedMappedShape) -> [Text]+coverageDecl aggregate (declaration, shape) =+ [ "",+ "coverage" <> sdName declaration <> " :: Bool",+ "coverage" <> sdName declaration <> " = " <> coverageExpression aggregate declaration shape+ ]++coverageExpression :: Agg -> StructuralDecl -> ResolvedMappedShape -> Text+coverageExpression aggregate declaration shape = case obligations of+ [] -> "True"+ _ -> T.intercalate " && " obligations <> "\n where\n shapes = map (bindingToShape " <> binding <> " . snd) (NonEmpty.toList (fixtureCases " <> fixtures <> "))"+ where+ shapeModule = structuralShapeModuleName (aContext aggregate) (sdName declaration)+ binding = unQualifiedValueName (sdBinding declaration)+ fixtures = unQualifiedValueName (sdFixtures declaration)+ obligations = case shape of+ RRecord _ _ fields -> concatMap (recordFieldObligation shapeModule) fields+ REnum entries ->+ [ "any (\\case " <> shapeModule <> "." <> weCtor entry <> " -> True; _ -> False) shapes"+ | entry <- entries+ ]+ RUnion _ arms -> concatMap (unionArmObligations shapeModule) arms++recordFieldObligation :: Text -> ResolvedWireField -> [Text]+recordFieldObligation shapeModule field = case rwfType field of+ ROptional _ ->+ [ "any (isNothing . " <> selector <> ") shapes",+ "any (isJust . " <> selector <> ") shapes"+ ]+ _ -> []+ where+ selector = shapeModule <> "." <> rwfHaskell field++unionArmObligations :: Text -> ResolvedWireArm -> [Text]+unionArmObligations shapeModule arm =+ ["any (\\case " <> patternText <> " -> True; _ -> False) shapes"] <> optionalPayload+ where+ constructor = shapeModule <> "." <> rwaCtor arm+ patternText = constructor <> maybe "" (const "{}") (rwaPayload arm)+ optionalPayload = case rwaPayload arm of+ Just (ROptional _) ->+ [ "any (\\case " <> constructor <> " Nothing -> True; _ -> False) shapes",+ "any (\\case " <> constructor <> " (Just _) -> True; _ -> False) shapes"+ ]+ _ -> []++mappedEventAssertionDecl :: Agg -> (ResolvedCtor, Text, ResolvedAggregateType, ResolvedMappedDecl) -> [Text]+mappedEventAssertionDecl aggregate (event, fieldName, _fieldType, declaration) =+ [ "",+ valueName <> "Assertions :: [(String, Bool)]",+ valueName <> "Assertions =",+ " [ (\"mapped codec round-trip: " <> rcName event <> "/" <> fieldName <> "/\" <> T.unpack label, roundTrips " <> eventExpression <> ")",+ " | (label, mappedValue) <- NonEmpty.toList (fixtureCases " <> fixtures <> ")",+ " ]"+ ]+ where+ valueName = mappedEventAssertionName event fieldName+ fixtures = unQualifiedValueName (mappedFixtures declaration)+ eventExpression = ctorExprWithOverride aggregate event fieldName "mappedValue"++mappedEventAssertionName :: ResolvedCtor -> Text -> Text+mappedEventAssertionName event fieldName = lowerFirst (rcName event) <> pascal fieldName++wirePolicyAssertionDecls :: Agg -> [(StructuralDecl, ResolvedMappedShape)] -> [Text]+wirePolicyAssertionDecls _aggregate [] = []+wirePolicyAssertionDecls aggregate declarations =+ [ "",+ "structuralWirePolicyAssertions :: [(String, Bool)]",+ "structuralWirePolicyAssertions =",+ " [ " <> T.intercalate "\n , " assertions,+ " ]"+ ]+ where+ assertions = concatMap (wirePolicyAssertions aggregate) declarations++wirePolicyAssertions :: Agg -> (StructuralDecl, ResolvedMappedShape) -> [Text]+wirePolicyAssertions aggregate (declaration, shape) = case shape of+ RRecord _ unknownFields fields ->+ concatMap (recordMissingAssertions aggregate declaration) [field | field <- fields, rwfPresence field == POptional]+ <> [unknownFieldAssertion declaration unknownFields]+ REnum entries -> map (enumArmAssertion declaration) entries <> [enumUnknownAssertion declaration]+ RUnion encoding arms ->+ map (unionArmAssertion declaration encoding) arms+ <> [unknownFieldAssertion declaration (ueUnknownFields encoding)]++recordMissingAssertions :: Agg -> StructuralDecl -> ResolvedWireField -> [Text]+recordMissingAssertions aggregate declaration field =+ [ "(\"wire policy missing default: "+ <> canonical+ <> "/"+ <> rwfKey field+ <> "\", case "+ <> decoder+ <> " (deleteObjectField "+ <> tshow (rwfKey field)+ <> " ("+ <> encodedSample+ <> ")) of Left _ -> False; Right decoded -> objectField "+ <> tshow (rwfKey field)+ <> " ("+ <> encoder+ <> " decoded) == Just ("+ <> missingExpectedValue aggregate field+ <> "))",+ "(\"wire policy explicit null: "+ <> canonical+ <> "/"+ <> rwfKey field+ <> "\", "+ <> nullExpectation+ <> " ("+ <> decoder+ <> " (insertObjectField "+ <> tshow (rwfKey field)+ <> " Aeson.Null ("+ <> encodedSample+ <> "))))"+ ]+ where+ canonical = unCanonicalTypeId (sdCanonical declaration)+ encoder = "encode" <> sdName declaration <> "Mapped"+ decoder = "decode" <> sdName declaration <> "Mapped"+ fixtures = unQualifiedValueName (sdFixtures declaration)+ encodedSample = encoder <> " (snd (NonEmpty.head (fixtureCases " <> fixtures <> ")))"+ nullExpectation = case rwfType field of+ ROptional _ -> "isRight"+ _ -> "isLeft"++missingExpectedValue :: Agg -> ResolvedWireField -> Text+missingExpectedValue aggregate field = case rwfOnMissing field of+ Just OmNull -> "Aeson.Null"+ Just (OmText value) -> "Aeson.String " <> tshow value+ Just (OmInt value) -> "Aeson.toJSON (" <> T.pack (show value) <> " :: Int)"+ Just (OmBool value) -> if value then "Aeson.Bool True" else "Aeson.Bool False"+ Just OmEmptyList -> "Aeson.toJSON ([] :: [Aeson.Value])"+ Just OmEmptyMap -> "Aeson.Object mempty"+ Just (OmCtor constructor) -> case (aTypeGraph aggregate, rwfType field) of+ (Just graph, RRef key) -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural _ (REnum entries)) -> case find ((== constructor) . weCtor) entries of+ Just entry -> "Aeson.String " <> tshow (weTag entry)+ Nothing -> "error \"missing enum default constructor\""+ _ -> "error \"non-enum constructor default\""+ _ -> "error \"non-reference constructor default\""+ Nothing -> "error \"optional field lacks on-missing policy\""++unknownFieldAssertion :: StructuralDecl -> UnknownFields -> Text+unknownFieldAssertion declaration policy =+ "(\"wire policy unknown fields: "+ <> unCanonicalTypeId (sdCanonical declaration)+ <> "\", all (\\(_, value) -> "+ <> expectation+ <> " (decode"+ <> sdName declaration+ <> "Mapped (insertObjectField \"__keiro_unknown\" (Aeson.Bool True) (encode"+ <> sdName declaration+ <> "Mapped value)))) (NonEmpty.toList (fixtureCases "+ <> unQualifiedValueName (sdFixtures declaration)+ <> ")))"+ where+ expectation = case policy of+ RejectUnknown -> "isLeft"+ IgnoreUnknown -> "isRight"++enumArmAssertion :: StructuralDecl -> WireEnum -> Text+enumArmAssertion declaration entry =+ "(\"wire enum arm: "+ <> unCanonicalTypeId (sdCanonical declaration)+ <> "/"+ <> weTag entry+ <> "\", any (\\(_, value) -> encode"+ <> sdName declaration+ <> "Mapped value == Aeson.String "+ <> tshow (weTag entry)+ <> " && decode"+ <> sdName declaration+ <> "Mapped (Aeson.String "+ <> tshow (weTag entry)+ <> ") == Right value) (NonEmpty.toList (fixtureCases "+ <> unQualifiedValueName (sdFixtures declaration)+ <> ")))"++enumUnknownAssertion :: StructuralDecl -> Text+enumUnknownAssertion declaration =+ "(\"wire enum unknown tag: "+ <> unCanonicalTypeId (sdCanonical declaration)+ <> "\", isLeft (decode"+ <> sdName declaration+ <> "Mapped (Aeson.String \"__keiro_unknown\")))"++unionArmAssertion :: StructuralDecl -> UnionEncoding -> ResolvedWireArm -> Text+unionArmAssertion declaration encoding arm =+ "(\"wire union arm: "+ <> unCanonicalTypeId (sdCanonical declaration)+ <> "/"+ <> rwaTag arm+ <> "\", any (\\(_, value) -> objectField "+ <> tshow (ueTagField encoding)+ <> " (encode"+ <> sdName declaration+ <> "Mapped value) == Just (Aeson.String "+ <> tshow (rwaTag arm)+ <> ") && decode"+ <> sdName declaration+ <> "Mapped (encode"+ <> sdName declaration+ <> "Mapped value) == Right value) (NonEmpty.toList (fixtureCases "+ <> unQualifiedValueName (sdFixtures declaration)+ <> ")))"++wirePolicyHelpers :: [(StructuralDecl, ResolvedMappedShape)] -> [Text]+wirePolicyHelpers [] = []+wirePolicyHelpers _ =+ [ "",+ "deleteObjectField :: T.Text -> Aeson.Value -> Aeson.Value",+ "deleteObjectField key (Aeson.Object objectValue) = Aeson.Object (AesonKeyMap.delete (AesonKey.fromText key) objectValue)",+ "deleteObjectField _ value = value",+ "",+ "insertObjectField :: T.Text -> Aeson.Value -> Aeson.Value -> Aeson.Value",+ "insertObjectField key inserted (Aeson.Object objectValue) = Aeson.Object (AesonKeyMap.insert (AesonKey.fromText key) inserted objectValue)",+ "insertObjectField _ _ value = value",+ "",+ "objectField :: T.Text -> Aeson.Value -> Maybe Aeson.Value",+ "objectField key (Aeson.Object objectValue) = AesonKeyMap.lookup (AesonKey.fromText key) objectValue",+ "objectField _ _ = Nothing"+ ]++mappedFixtures :: ResolvedMappedDecl -> QualifiedValueName+mappedFixtures (ResolvedStructural declaration _) = sdFixtures declaration+mappedFixtures (ResolvedOpaque declaration) = odFixtures declaration++ctorExprWithOverride :: Agg -> ResolvedCtor -> Text -> Text -> Text+ctorExprWithOverride aggregate constructor target replacement =+ "(" <> rcName constructor <> " (" <> rcName constructor <> "Data" <> arguments <> "))"+ where+ arguments =+ T.concat+ [ " " <> if fieldName == target then replacement else sampleValue aggregate fieldName fieldType+ | (fieldName, fieldType) <- rcFields constructor+ ]++projectionAssertionDecls :: Agg -> [(StructuralDecl, ResolvedMappedShape)] -> [Text]+projectionAssertionDecls aggregate structural+ | null specs = []+ | otherwise =+ [ "",+ "structuralProjectionAssertions :: [(String, Bool)]",+ "structuralProjectionAssertions =",+ " [ " <> T.intercalate "\n , " (map assertion specs),+ " ]"+ ]+ where+ specs = mappedProjectionSpecs aggregate+ assertion spec =+ "(\"projection witness agreement: "+ <> unCanonicalTypeId (spCanonical spec)+ <> spPointer spec+ <> "\", all (\\(_, owner) -> fieldWitnessAgrees StructuralProjections."+ <> spWitness spec+ <> " (\\referenceOwner -> "+ <> projectionGetter "referenceOwner" spec+ <> ") owner) (NonEmpty.toList (fixtureCases "+ <> ownerFixtures spec+ <> ")))"+ ownerFixtures spec = case find (\(declaration, _) -> sdCanonical declaration == spCanonical spec) structural of+ Just (declaration, _) -> unQualifiedValueName (sdFixtures declaration)+ Nothing -> "error \"projection owner fixtures missing\""++projectionGetter :: Text -> StructuralProjection -> Text+projectionGetter owner spec =+ foldl+ (\value (shapeModule, selector) -> shapeModule <> "." <> selector <> " (" <> value <> ")")+ ("bindingToShape " <> unQualifiedValueName (spBinding spec) <> " " <> owner)+ (spSelectors spec) maybeToListHarness :: Maybe value -> [value] maybeToListHarness = maybe [] pure
+ src/Keiro/Dsl/LanguageVersion.hs view
@@ -0,0 +1,218 @@+-- | The released-language contract selected by a @.keiro@ source.+--+-- Source-language provenance deliberately wraps the semantic 'Spec' rather than+-- becoming part of it. A workspace can therefore merge semantically equivalent+-- legacy and explicitly versioned members without inventing one declaration for+-- the merged graph.+module Keiro.Dsl.LanguageVersion+ ( LanguageVersion,+ languageVersion,+ languageVersionNumber,+ languageVersionText,+ SourceLanguage (..),+ sourceFormText,+ declaredLanguageVersionMaybe,+ effectiveLanguageVersion,+ LanguageBodyParser (..),+ LanguageDefinition (..),+ languageRegistry,+ supportedLanguageVersions,+ lookupLanguageDefinition,+ SourceLanguageErrorCode (..),+ sourceLanguageErrorCodeText,+ SourceLanguageDiagnostic (..),+ renderSourceLanguageDiagnostic,+ ParsedSource (..),+ ParseFailure (..),+ renderParseFailure,+ )+where++import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))+import Data.List (find)+import Data.List.NonEmpty (NonEmpty (..))+import Data.List.NonEmpty qualified as NE+import Data.Text (Text)+import Data.Text qualified as T+import Keiro.Dsl.Grammar (Loc (..), Spec, noLoc)+import Numeric.Natural (Natural)++-- | A positive released Keiro DSL language version.+newtype LanguageVersion = LanguageVersion Natural+ deriving stock (Eq, Ord)++instance Show LanguageVersion where+ show = T.unpack . languageVersionText++instance ToJSON LanguageVersion where+ toJSON = toJSON . languageVersionNumber++instance FromJSON LanguageVersion where+ parseJSON value = do+ raw <- parseJSON value+ maybe (fail "language version must be a positive decimal") pure (languageVersion raw)++-- | Construct a version, rejecting zero because released versions are positive.+languageVersion :: Natural -> Maybe LanguageVersion+languageVersion 0 = Nothing+languageVersion value = Just (LanguageVersion value)++-- | Extract the positive decimal value.+languageVersionNumber :: LanguageVersion -> Natural+languageVersionNumber (LanguageVersion value) = value++languageVersionText :: LanguageVersion -> Text+languageVersionText = T.pack . show . languageVersionNumber++-- | Whether a source declared a contract or entered through the legacy bridge.+data SourceLanguage+ = LegacyUnversioned+ | DeclaredLanguage+ { declaredLanguageVersion :: !LanguageVersion,+ languageVersionLoc :: !Loc+ }+ deriving stock (Eq, Show)++sourceFormText :: SourceLanguage -> Text+sourceFormText LegacyUnversioned = "legacy-unversioned"+sourceFormText DeclaredLanguage {} = "declared"++declaredLanguageVersionMaybe :: SourceLanguage -> Maybe LanguageVersion+declaredLanguageVersionMaybe LegacyUnversioned = Nothing+declaredLanguageVersionMaybe DeclaredLanguage {declaredLanguageVersion = version} = Just version++-- | The body-parser configuration selected by a released version.+data LanguageBodyParser+ = LanguageBodyParserV1+ | LanguageBodyParserV2+ deriving stock (Eq, Show)++-- | One append-only released-language registry entry.+data LanguageDefinition = LanguageDefinition+ { definitionVersion :: !LanguageVersion,+ definitionPredecessor :: !(Maybe LanguageVersion),+ definitionBodyParser :: !LanguageBodyParser+ }+ deriving stock (Eq, Show)++version1 :: LanguageVersion+version1 = LanguageVersion 1++version2 :: LanguageVersion+version2 = LanguageVersion 2++-- | The authoritative, append-only registry of released language contracts.+languageRegistry :: NonEmpty LanguageDefinition+languageRegistry =+ LanguageDefinition version1 Nothing LanguageBodyParserV1+ :| [LanguageDefinition version2 (Just version1) LanguageBodyParserV2]++-- | Supported versions, derived from 'languageRegistry'.+supportedLanguageVersions :: NonEmpty LanguageVersion+supportedLanguageVersions = definitionVersion <$> languageRegistry++lookupLanguageDefinition :: LanguageVersion -> Maybe LanguageDefinition+lookupLanguageDefinition version =+ find ((== version) . definitionVersion) (NE.toList languageRegistry)++effectiveLanguageVersion :: SourceLanguage -> LanguageVersion+effectiveLanguageVersion LegacyUnversioned = version1+effectiveLanguageVersion DeclaredLanguage {declaredLanguageVersion = version} = version++instance ToJSON SourceLanguage where+ toJSON sourceLanguage =+ object+ [ "sourceForm" .= sourceFormText sourceLanguage,+ "declaredLanguageVersion" .= declaredLanguageVersionMaybe sourceLanguage,+ "effectiveLanguageVersion" .= effectiveLanguageVersion sourceLanguage+ ]++instance FromJSON SourceLanguage where+ parseJSON = withObject "SourceLanguage" $ \fields -> do+ sourceForm <- fields .: "sourceForm"+ declared <- fields .:? "declaredLanguageVersion"+ effective <- fields .: "effectiveLanguageVersion"+ case (sourceForm :: Text, declared) of+ ("legacy-unversioned", Nothing)+ | effective == effectiveLanguageVersion LegacyUnversioned -> pure LegacyUnversioned+ | otherwise -> fail "legacy-unversioned source must select effective language version 1"+ ("declared", Just version)+ | effective == version -> pure (DeclaredLanguage version noLoc)+ | otherwise -> fail "declared and effective language versions must match"+ ("legacy-unversioned", Just _) -> fail "legacy-unversioned source cannot declare a language version"+ ("declared", Nothing) -> fail "declared source must include declaredLanguageVersion"+ (other, _) -> fail ("unknown source form: " <> T.unpack other)++-- | Stable codes for failures detected before a body grammar is selected.+data SourceLanguageErrorCode+ = InvalidLanguageVersion+ | UnsupportedLanguageVersion+ | DuplicateLanguagePreamble+ | MisplacedLanguagePreamble+ | LanguageFeatureRequiresVersion+ deriving stock (Eq, Ord, Show)++sourceLanguageErrorCodeText :: SourceLanguageErrorCode -> Text+sourceLanguageErrorCodeText = T.pack . show++-- | A source-selection failure with the original member-local source line.+data SourceLanguageDiagnostic = SourceLanguageDiagnostic+ { sourceLanguageErrorCode :: !SourceLanguageErrorCode,+ sourceLanguageSource :: !FilePath,+ sourceLanguageLoc :: !Loc,+ sourceLanguageToken :: !(Maybe Text),+ sourceLanguageDeclaredVersion :: !(Maybe LanguageVersion),+ sourceLanguageSupportedVersions :: !(NonEmpty LanguageVersion)+ }+ deriving stock (Eq, Show)++renderSourceLanguageDiagnostic :: SourceLanguageDiagnostic -> Text+renderSourceLanguageDiagnostic diagnostic =+ T.pack (sourceLanguageSource diagnostic)+ <> ":"+ <> T.pack (show line)+ <> ":1: error ["+ <> sourceLanguageErrorCodeText code+ <> "]: "+ <> detail+ where+ Loc line = sourceLanguageLoc diagnostic+ code = sourceLanguageErrorCode diagnostic+ supported = T.intercalate ", " (map languageVersionText (NE.toList (sourceLanguageSupportedVersions diagnostic)))+ token = maybe "<missing>" id (sourceLanguageToken diagnostic)+ detail = case code of+ InvalidLanguageVersion ->+ "invalid language preamble; expected `language keiro-dsl <positive-decimal>`, found `"+ <> token+ <> "`"+ UnsupportedLanguageVersion ->+ "declared keiro-dsl language version "+ <> maybe token languageVersionText (sourceLanguageDeclaredVersion diagnostic)+ <> " is unsupported; supported versions: "+ <> supported+ DuplicateLanguagePreamble ->+ "duplicate language preamble; exactly one may appear before `context`"+ MisplacedLanguagePreamble ->+ "misplaced language preamble; it must be the first significant clause before `context`"+ LanguageFeatureRequiresVersion ->+ "selected syntax requires keiro-dsl language version "+ <> languageVersionText (NE.last (sourceLanguageSupportedVersions diagnostic))+ <> "; selected version "+ <> maybe token languageVersionText (sourceLanguageDeclaredVersion diagnostic)++-- | A parsed document with its source declaration preserved beside its graph.+data ParsedSource = ParsedSource+ { parsedSourceLanguage :: !SourceLanguage,+ parsedSpec :: !Spec+ }+ deriving stock (Eq, Show)++-- | The parse boundary distinguishes source selection from body grammar errors.+data ParseFailure+ = SourceLanguageFailure !SourceLanguageDiagnostic+ | BodyGrammarFailure !Text+ deriving stock (Eq, Show)++renderParseFailure :: ParseFailure -> Text+renderParseFailure (SourceLanguageFailure diagnostic) = renderSourceLanguageDiagnostic diagnostic+renderParseFailure (BodyGrammarFailure message) = message
src/Keiro/Dsl/Manifest.hs view
@@ -1,105 +1,139 @@-{- | The build-wiring __manifest__: a Cabal-pasteable summary of what a-@scaffold@ run produced. @scaffold@ writes @.hs@ files but the consumer still-has to wire them into a Cabal stanza by hand — the @other-modules@ list and the-@build-depends@ implied by the node kinds. This module renders both as plain-text a human pastes into a @.cabal@ file (see @keiro-dsl/keiro-dsl.cabal@'s-conformance stanzas for the hand-maintained version this replaces).--The dependency set is a pure function of which 'Node' constructors occur in the-spec. The mapping is grounded in the existing per-suite @build-depends@ in-@keiro-dsl/keiro-dsl.cabal@:-- * aggregate => aeson, keiki, keiro, text (keiro-dsl-conformance)- * process => aeson, keiki, keiro, shibuya-core, text, time, uuid- (…-process-runtime)- * contract => aeson, text (…-contract)- * intake/emit/publisher (full integration path)- => effectful-core, hasql-transaction, keiro, kiroku-store- (…-intake-full)- * workqueue => aeson, keiro-core, keiro-pgmq, text- (…-queue, …-queue-runtime)- * dispatch => aeson, effectful-core, keiro-pgmq, text- (…-dispatch-full)- * workflow/operation => containers, effectful-core, keiro, text- (…-workflow-full; facts and runtime wiring only,- with the body hand-owned)--@base@ is always present.--}-module Keiro.Dsl.Manifest (- renderManifest,+-- | The build-wiring __manifest__: a Cabal-pasteable summary of what a+-- @scaffold@ run produced. @scaffold@ writes @.hs@ files but the consumer still+-- has to wire them into a Cabal stanza by hand — the @other-modules@ list and the+-- @build-depends@ implied by the node kinds. This module renders both as plain+-- text a human pastes into a @.cabal@ file (see @keiro-dsl/keiro-dsl.cabal@'s+-- conformance stanzas for the hand-maintained version this replaces).+--+-- The dependency set is a pure function of which 'Node' constructors occur in the+-- spec. The mapping is grounded in the existing per-suite @build-depends@ in+-- @keiro-dsl/keiro-dsl.cabal@:+--+-- * aggregate => aeson, keiki, keiro, text, and time only when a direct+-- aggregate surface uses Time (keiro-dsl-conformance)+-- * process => aeson, keiki, keiro, shibuya-core, text, time, uuid+-- (…-process-runtime)+-- * contract => aeson, text (…-contract)+-- * intake/emit/publisher (full integration path)+-- => effectful-core, hasql-transaction, keiro, kiroku-store+-- (…-intake-full)+-- * workqueue => aeson, keiro-core, keiro-pgmq, text+-- (…-queue, …-queue-runtime)+-- * dispatch => aeson, effectful-core, keiro-pgmq, text+-- (…-dispatch-full)+-- * workflow/operation => containers, effectful-core, keiro, text+-- (…-workflow-full; facts and runtime wiring only,+-- with the body hand-owned)+--+-- @base@ is always present.+module Keiro.Dsl.Manifest+ ( renderManifest, manifestDependencies, moduleNameOf,-) where+ )+where import Data.List (nub, sort)+import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as T+import Keiro.Dsl.AggregateType import Keiro.Dsl.Grammar import Keiro.Dsl.MappedConsumer (ConsumerPlan (..), consumerPlan)+import Keiro.Dsl.NominalType import Keiro.Dsl.Scaffold (ScaffoldModule (..)) -{- | Render a Cabal-pasteable manifest from the modules a scaffold run produced-plus the node kinds present (which imply the dependency set). The first argument-names the source spec (for the header comment).--}+-- | Render a Cabal-pasteable manifest from the modules a scaffold run produced+-- plus the node kinds present (which imply the dependency set). The first argument+-- names the source spec (for the header comment). renderManifest :: Text -> [ScaffoldModule] -> Spec -> Text renderManifest specName mods spec =- T.unlines $- [ "-- keiro-dsl build manifest for " <> specName- , "-- Paste the two blocks below into the consuming Cabal stanza."- , "-- The generated layer is overwritten on every scaffold; hole modules are"- , "-- create-if-absent (filled by hand)."- , ""- , "other-modules:"- ]- ++ map (" " <>) (sort (map (moduleNameOf . modulePath) mods))- ++ [ ""- , "build-depends:"- ]- ++ map (" , " <>) (manifestDependencies spec)- ++ consumerBlocks+ T.unlines $+ [ "-- keiro-dsl build manifest for " <> specName,+ "-- Paste the two blocks below into the consuming Cabal stanza.",+ "-- The generated layer is overwritten on every scaffold; hole modules are",+ "-- create-if-absent (filled by hand).",+ "",+ "other-modules:"+ ]+ ++ map (" " <>) (sort (map (moduleNameOf . modulePath) mods))+ ++ [ "",+ "build-depends:"+ ]+ ++ map (" , " <>) (manifestDependencies spec)+ ++ consumerBlocks where plan = consumerPlan spec consumerBlocks- | null (consumerMappings plan) = []- | otherwise =- [ ""- , "consumer-packages:"- ]- ++ map (" " <>) (consumerPackages plan)- ++ [ ""- , "consumer-modules:"- ]- ++ map (" " <>) (consumerModules plan)+ | null (consumerMappings plan) = []+ | otherwise =+ [ "",+ "consumer-packages:"+ ]+ ++ map (" " <>) (consumerPackages plan)+ ++ [ "",+ "consumer-modules:"+ ]+ ++ map (" " <>) (consumerModules plan) -{- | The dotted module name recovered from a 'ScaffoldModule' path: drop the-trailing @.hs@ and replace @/@ with @.@.--}+-- | The dotted module name recovered from a 'ScaffoldModule' path: drop the+-- trailing @.hs@ and replace @/@ with @.@. moduleNameOf :: FilePath -> Text moduleNameOf p = T.replace "/" "." (T.dropEnd 3 (T.pack p)) -{- | The sorted, deduplicated dependency set implied by the node kinds present-in the spec. @base@ is always included.--}+-- | The sorted, deduplicated dependency set implied by the node kinds present+-- in the spec. @base@ is always included. manifestDependencies :: Spec -> [Text] manifestDependencies spec =- sort (nub ("base" : consumerPackages (consumerPlan spec) <> concatMap depsForNode (specNodes spec)))+ sort (nub ("base" : consumerPackages (consumerPlan spec) <> concatMap (depsForNode spec) (specNodes spec))) -- | The dependencies a single node kind implies (see the module header table).-depsForNode :: Node -> [Text]-depsForNode n = case n of- NAggregate{} -> ["aeson", "keiki", "keiro", "text"]- NProcess{} -> ["aeson", "keiki", "keiro", "shibuya-core", "text", "time", "uuid"]- NRouter{} -> ["effectful-core", "keiro", "shibuya-core", "text"]- NContract{} -> ["aeson", "text"]- NIntake{} -> integration- NEmit{} -> integration- NPublisher{} -> integration- NWorkqueue{} -> ["aeson", "keiro-core", "keiro-pgmq", "text"]- NPgmqDispatch{} -> ["aeson", "effectful-core", "keiro-pgmq", "text"]- NReadModel{} -> ["effectful-core", "hasql-transaction", "keiro", "kiroku-store", "text"]- NWorkflow{} -> ["containers", "effectful-core", "keiro", "text"]- NOperation{} -> ["effectful-core", "keiro", "text"]+depsForNode :: Spec -> Node -> [Text]+depsForNode spec n = case n of+ NAggregate aggregate -> ["aeson", "keiki", "keiro", "text"] <> aggregateDependencies spec aggregate+ NProcess {} -> ["aeson", "keiki", "keiro", "shibuya-core", "text", "time", "uuid"]+ NRouter {} -> ["effectful-core", "keiro", "shibuya-core", "text"]+ NContract {} -> ["aeson", "text"]+ NIntake {} -> integration+ NEmit {} -> integration+ NPublisher {} -> integration+ NWorkqueue {} -> ["aeson", "keiro-core", "keiro-pgmq", "text"]+ NPgmqDispatch {} -> ["aeson", "effectful-core", "keiro-pgmq", "text"]+ NReadModel {} -> ["effectful-core", "hasql-transaction", "keiro", "kiroku-store", "text"]+ NWorkflow {} -> ["containers", "effectful-core", "keiro", "text"]+ NOperation {} -> ["effectful-core", "keiro", "text"] where integration = ["effectful-core", "hasql-transaction", "keiro", "kiroku-store"]++aggregateDependencies :: Spec -> Aggregate -> [Text]+aggregateDependencies spec aggregate =+ Set.toAscList+ ( Set.unions+ [ aggregatePackages symbols resolvedType+ | resolvedType <- resolvedTypes+ ]+ <> Set.fromList+ [ "mmzk-typeid"+ | AggregateNominal nominal <- resolvedTypes,+ IdRepresentation {} <- [resolvedNominalRepresentation nominal],+ ConsumerNominal {} <- [resolvedNominalOwnership nominal]+ ]+ )+ where+ symbols = aggregateSymbols spec+ resolvedTypes =+ [ resolvedType+ | register <- aggRegs aggregate,+ Right resolvedType <- [resolveAggregateType symbols (regLoc register) RegisterUse (regType register)]+ ]+ <> [ resolvedType+ | command <- aggCommands aggregate,+ field <- cmdFields command,+ Right resolvedType <- [inferAggregateFieldType symbols aggregate CommandFieldUse field]+ ]+ <> [ resolvedType+ | event <- aggEvents aggregate,+ EventFields fields <- [evBody event],+ field <- fields,+ Right resolvedType <- [inferAggregateFieldType symbols aggregate EventFieldUse field]+ ]
src/Keiro/Dsl/MappedConsumer.hs view
@@ -1,112 +1,161 @@-{- | One checked projection of mapped declarations for every scaffold-integration surface. Keeping dependency requirements and persisted identities-together prevents the manifest, preflight report, and scaffold record from-silently disagreeing.--}-module Keiro.Dsl.MappedConsumer (- ConsumerPlan (..),+-- | One checked projection of mapped declarations for every scaffold+-- integration surface. Keeping dependency requirements and persisted identities+-- together prevents the manifest, preflight report, and scaffold record from+-- silently disagreeing.+module Keiro.Dsl.MappedConsumer+ ( ConsumerPlan (..), MappingIdentity (..), consumerPlan,-) where+ )+where import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.=)) import Data.List (nub, sort)+import Data.List.NonEmpty qualified as NE import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as T import Keiro.Dsl.Grammar (HaskellSource (..), Spec)+import Keiro.Dsl.NominalType import Keiro.Dsl.TypeGraph data ConsumerPlan = ConsumerPlan- { consumerPackages :: ![Text]- , consumerModules :: ![Text]- , consumerMappings :: ![MappingIdentity]- }- deriving stock (Eq, Show)+ { consumerPackages :: ![Text],+ consumerModules :: ![Text],+ consumerMappings :: ![MappingIdentity]+ }+ deriving stock (Eq, Show) data MappingIdentity- = StructuralMapping- { mappingSpecName :: !Text- , mappingCanonicalType :: !Text- , mappingPackage :: !Text- , mappingModule :: !Text- , mappingType :: !Text- , mappingBindingSymbol :: !Text- , mappingBindingVersion :: !Text- }- | OpaqueMapping- { mappingSpecName :: !Text- , mappingPackage :: !Text- , mappingModule :: !Text- , mappingType :: !Text- , mappingCodecIdentity :: !Text- , mappingCodecVersion :: !Text- }- deriving stock (Eq, Show)+ = StructuralMapping+ { mappingSpecName :: !Text,+ mappingCanonicalType :: !Text,+ mappingPackage :: !Text,+ mappingModule :: !Text,+ mappingType :: !Text,+ mappingBindingSymbol :: !Text,+ mappingBindingVersion :: !Text+ }+ | OpaqueMapping+ { mappingSpecName :: !Text,+ mappingPackage :: !Text,+ mappingModule :: !Text,+ mappingType :: !Text,+ mappingCodecIdentity :: !Text,+ mappingCodecVersion :: !Text+ }+ | NominalMapping+ { mappingSpecName :: !Text,+ mappingNominalCategory :: !Text,+ mappingNominalRepresentation :: !Text,+ mappingCanonicalType :: !Text,+ mappingPackage :: !Text,+ mappingModule :: !Text,+ mappingType :: !Text,+ mappingBindingSymbol :: !Text,+ mappingBindingVersion :: !Text,+ mappingFixtureSymbol :: !Text,+ mappingInitialSymbol :: !(Maybe Text)+ }+ deriving stock (Eq, Show) instance ToJSON MappingIdentity where- toJSON StructuralMapping{mappingSpecName, mappingCanonicalType, mappingPackage, mappingModule, mappingType, mappingBindingSymbol, mappingBindingVersion} =- object- [ "schema" .= (1 :: Int)- , "mode" .= ("structural" :: Text)- , "specName" .= mappingSpecName- , "canonicalType" .= mappingCanonicalType- , "package" .= mappingPackage- , "module" .= mappingModule- , "type" .= mappingType- , "bindingSymbol" .= mappingBindingSymbol- , "bindingVersion" .= mappingBindingVersion- ]- toJSON OpaqueMapping{mappingSpecName, mappingPackage, mappingModule, mappingType, mappingCodecIdentity, mappingCodecVersion} =- object- [ "schema" .= (1 :: Int)- , "mode" .= ("opaque" :: Text)- , "specName" .= mappingSpecName- , "package" .= mappingPackage- , "module" .= mappingModule- , "type" .= mappingType- , "codecIdentity" .= mappingCodecIdentity- , "codecVersion" .= mappingCodecVersion- ]+ toJSON StructuralMapping {mappingSpecName, mappingCanonicalType, mappingPackage, mappingModule, mappingType, mappingBindingSymbol, mappingBindingVersion} =+ object+ [ "schema" .= (1 :: Int),+ "mode" .= ("structural" :: Text),+ "specName" .= mappingSpecName,+ "canonicalType" .= mappingCanonicalType,+ "package" .= mappingPackage,+ "module" .= mappingModule,+ "type" .= mappingType,+ "bindingSymbol" .= mappingBindingSymbol,+ "bindingVersion" .= mappingBindingVersion+ ]+ toJSON OpaqueMapping {mappingSpecName, mappingPackage, mappingModule, mappingType, mappingCodecIdentity, mappingCodecVersion} =+ object+ [ "schema" .= (1 :: Int),+ "mode" .= ("opaque" :: Text),+ "specName" .= mappingSpecName,+ "package" .= mappingPackage,+ "module" .= mappingModule,+ "type" .= mappingType,+ "codecIdentity" .= mappingCodecIdentity,+ "codecVersion" .= mappingCodecVersion+ ]+ toJSON NominalMapping {mappingSpecName, mappingNominalCategory, mappingNominalRepresentation, mappingCanonicalType, mappingPackage, mappingModule, mappingType, mappingBindingSymbol, mappingBindingVersion, mappingFixtureSymbol, mappingInitialSymbol} =+ object+ [ "schema" .= (1 :: Int),+ "mode" .= ("nominal" :: Text),+ "specName" .= mappingSpecName,+ "category" .= mappingNominalCategory,+ "representation" .= mappingNominalRepresentation,+ "canonicalType" .= mappingCanonicalType,+ "package" .= mappingPackage,+ "module" .= mappingModule,+ "type" .= mappingType,+ "bindingSymbol" .= mappingBindingSymbol,+ "bindingVersion" .= mappingBindingVersion,+ "fixtureSymbol" .= mappingFixtureSymbol,+ "initialSymbol" .= mappingInitialSymbol+ ] instance FromJSON MappingIdentity where- parseJSON = withObject "keiro-dsl mapping identity" $ \value -> do- schema <- value .: "schema"- if schema /= (1 :: Int)- then fail "unsupported mapping identity schema"- else do- mode <- value .: "mode"- case (mode :: Text) of- "structural" ->- StructuralMapping- <$> value .: "specName"- <*> value .: "canonicalType"- <*> value .: "package"- <*> value .: "module"- <*> value .: "type"- <*> value .: "bindingSymbol"- <*> value .: "bindingVersion"- "opaque" ->- OpaqueMapping- <$> value .: "specName"- <*> value .: "package"- <*> value .: "module"- <*> value .: "type"- <*> value .: "codecIdentity"- <*> value .: "codecVersion"- _ -> fail "unknown mapping identity mode"+ parseJSON = withObject "keiro-dsl mapping identity" $ \value -> do+ schema <- value .: "schema"+ if schema /= (1 :: Int)+ then fail "unsupported mapping identity schema"+ else do+ mode <- value .: "mode"+ case (mode :: Text) of+ "structural" ->+ StructuralMapping+ <$> value .: "specName"+ <*> value .: "canonicalType"+ <*> value .: "package"+ <*> value .: "module"+ <*> value .: "type"+ <*> value .: "bindingSymbol"+ <*> value .: "bindingVersion"+ "opaque" ->+ OpaqueMapping+ <$> value .: "specName"+ <*> value .: "package"+ <*> value .: "module"+ <*> value .: "type"+ <*> value .: "codecIdentity"+ <*> value .: "codecVersion"+ "nominal" ->+ NominalMapping+ <$> value .: "specName"+ <*> value .: "category"+ <*> value .: "representation"+ <*> value .: "canonicalType"+ <*> value .: "package"+ <*> value .: "module"+ <*> value .: "type"+ <*> value .: "bindingSymbol"+ <*> value .: "bindingVersion"+ <*> value .: "fixtureSymbol"+ <*> value .: "initialSymbol"+ _ -> fail "unknown mapping identity mode" consumerPlan :: Spec -> ConsumerPlan-consumerPlan spec = case resolveTypeGraph spec of- Left _ -> ConsumerPlan [] [] []- Right graph ->- ConsumerPlan- { consumerPackages = uniqueSorted [hsPackage (mappedSource declaration) | declaration <- declarations]- , consumerModules = uniqueSorted (concatMap mappedModules declarations)- , consumerMappings = sortMappings (map mappingIdentity declarations)- }- where- declarations = Map.elems (tgDeclarations graph)+consumerPlan spec = case (resolveTypeGraph spec, resolveNominalTypes spec) of+ (Right graph, Right nominalRegistry) ->+ ConsumerPlan+ { consumerPackages = uniqueSorted ([hsPackage (mappedSource declaration) | declaration <- declarations] <> map nominalPackage nominalBindings),+ consumerModules = uniqueSorted (concatMap mappedModules declarations <> concatMap nominalModules nominalBindings),+ consumerMappings = sortMappings (map mappingIdentity declarations <> map nominalMappingIdentity nominalBindings)+ }+ where+ declarations = Map.elems (tgDeclarations graph)+ nominalBindings =+ [ (nominal, binding)+ | nominal <- Map.elems (nominalTypes nominalRegistry),+ ConsumerNominal binding <- [resolvedNominalOwnership nominal]+ ]+ _ -> ConsumerPlan [] [] [] mappedSource :: ResolvedMappedDecl -> HaskellSource mappedSource (ResolvedStructural declaration _) = sdHaskell declaration@@ -114,36 +163,82 @@ mappedModules :: ResolvedMappedDecl -> [Text] mappedModules (ResolvedStructural declaration _) =- hsModule (sdHaskell declaration)- : qualifiedModule (sdBinding declaration)- : qualifiedModule (sdFixtures declaration)- : maybe [] (pure . qualifiedModule) (sdInitial declaration)+ hsModule (sdHaskell declaration)+ : qualifiedModule (sdBinding declaration)+ : qualifiedModule (sdFixtures declaration)+ : maybe [] (pure . qualifiedModule) (sdInitial declaration) mappedModules (ResolvedOpaque declaration) =- hsModule (odHaskell declaration)- : qualifiedModule (odFixtures declaration)- : maybe [] (pure . qualifiedModule) (odInitial declaration)+ hsModule (odHaskell declaration)+ : qualifiedModule (odFixtures declaration)+ : maybe [] (pure . qualifiedModule) (odInitial declaration) mappingIdentity :: ResolvedMappedDecl -> MappingIdentity mappingIdentity (ResolvedStructural declaration _) =- StructuralMapping- { mappingSpecName = sdName declaration- , mappingCanonicalType = unCanonicalTypeId (sdCanonical declaration)- , mappingPackage = hsPackage (sdHaskell declaration)- , mappingModule = hsModule (sdHaskell declaration)- , mappingType = hsType (sdHaskell declaration)- , mappingBindingSymbol = unQualifiedValueName (sdBinding declaration)- , mappingBindingVersion = unBindingVersion (sdBindingVersion declaration)- }+ StructuralMapping+ { mappingSpecName = sdName declaration,+ mappingCanonicalType = unCanonicalTypeId (sdCanonical declaration),+ mappingPackage = hsPackage (sdHaskell declaration),+ mappingModule = hsModule (sdHaskell declaration),+ mappingType = hsType (sdHaskell declaration),+ mappingBindingSymbol = unQualifiedValueName (sdBinding declaration),+ mappingBindingVersion = unBindingVersion (sdBindingVersion declaration)+ } mappingIdentity (ResolvedOpaque declaration) =- OpaqueMapping- { mappingSpecName = odName declaration- , mappingPackage = hsPackage (odHaskell declaration)- , mappingModule = hsModule (odHaskell declaration)- , mappingType = hsType (odHaskell declaration)- , mappingCodecIdentity = unCodecIdentity (odCodecIdentity declaration)- , mappingCodecVersion = unCodecVersion (odCodecVersion declaration)- }+ OpaqueMapping+ { mappingSpecName = odName declaration,+ mappingPackage = hsPackage (odHaskell declaration),+ mappingModule = hsModule (odHaskell declaration),+ mappingType = hsType (odHaskell declaration),+ mappingCodecIdentity = unCodecIdentity (odCodecIdentity declaration),+ mappingCodecVersion = unCodecVersion (odCodecVersion declaration)+ } +nominalMappingIdentity :: (ResolvedNominalType, ConsumerNominalBinding) -> MappingIdentity+nominalMappingIdentity (nominal, binding) =+ NominalMapping+ { mappingSpecName = resolvedNominalName nominal,+ mappingNominalCategory = nominalCategory nominal,+ mappingNominalRepresentation = nominalRepresentationIdentity nominal,+ mappingCanonicalType = unCanonicalTypeId (consumerNominalCanonical binding),+ mappingPackage = hsPackage source,+ mappingModule = hsModule source,+ mappingType = hsType source,+ mappingBindingSymbol = unQualifiedValueName (consumerNominalBinding binding),+ mappingBindingVersion = unBindingVersion (consumerNominalBindingVersion binding),+ mappingFixtureSymbol = unQualifiedValueName (consumerNominalFixtures binding),+ mappingInitialSymbol = unQualifiedValueName <$> consumerNominalInitial binding+ }+ where+ source = consumerNominalHaskell binding++nominalPackage :: (ResolvedNominalType, ConsumerNominalBinding) -> Text+nominalPackage (_, binding) = hsPackage (consumerNominalHaskell binding)++nominalModules :: (ResolvedNominalType, ConsumerNominalBinding) -> [Text]+nominalModules (_, binding) =+ hsModule (consumerNominalHaskell binding)+ : qualifiedModule (consumerNominalBinding binding)+ : qualifiedModule (consumerNominalFixtures binding)+ : maybe [] (pure . qualifiedModule) (consumerNominalInitial binding)++nominalCategory :: ResolvedNominalType -> Text+nominalCategory nominal = case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> "id"+ EnumRepresentation {} -> "enum"+ ScalarRepresentation {} -> "scalar"++nominalRepresentationIdentity :: ResolvedNominalType -> Text+nominalRepresentationIdentity nominal = case resolvedNominalRepresentation nominal of+ IdRepresentation prefix -> "KindID:" <> prefix+ EnumRepresentation constructors ->+ "enum:" <> T.intercalate "," [constructor <> "=" <> wire | (constructor, wire) <- NE.toList constructors]+ ScalarRepresentation representation -> case representation of+ NominalText -> "Text"+ NominalInt -> "Int"+ NominalNatural -> "Natural"+ NominalBool -> "Bool"+ NominalTime -> "Time"+ qualifiedModule :: QualifiedValueName -> Text qualifiedModule qualified = T.dropEnd 1 (fst (T.breakOnEnd "." (unQualifiedValueName qualified))) @@ -152,11 +247,11 @@ where sortOnName [] = [] sortOnName mappings =- [ mapping- | name <- sort (map mappingSpecName mappings)- , mapping <- mappings- , mappingSpecName mapping == name- ]+ [ mapping+ | name <- sort (map mappingSpecName mappings),+ mapping <- mappings,+ mappingSpecName mapping == name+ ] uniqueSorted :: [Text] -> [Text] uniqueSorted = sort . nub
src/Keiro/Dsl/MappedDiff.hs view
@@ -1,18 +1,18 @@ {-# OPTIONS_GHC -Werror=incomplete-patterns #-} -{- | Recursive, wire-aware differences for consumer-owned mapped types.--This module deliberately returns mapped findings rather than importing the-ordinary 'Change' type: 'Keiro.Dsl.Diff' owns compatibility vectors and turns-each complete mapped use path into the appropriate event, snapshot, or build-finding. Keeping that seam acyclic also makes the recursive comparison usable-by mutation coverage without rendering a report.--}-module Keiro.Dsl.MappedDiff (- MappedFinding (..),+-- | Recursive, wire-aware differences for consumer-owned mapped types.+--+-- This module deliberately returns mapped findings rather than importing the+-- ordinary 'Change' type: 'Keiro.Dsl.Diff' owns compatibility vectors and turns+-- each complete mapped use path into the appropriate event, snapshot, or build+-- finding. Keeping that seam acyclic also makes the recursive comparison usable+-- by mutation coverage without rendering a report.+module Keiro.Dsl.MappedDiff+ ( MappedFinding (..), diffMapped, renderMappedSubject,-) where+ )+where import Data.List (find, nubBy, sortOn) import Data.Map.Strict qualified as Map@@ -24,294 +24,295 @@ import Keiro.Dsl.Validate (DiagnosticCode (..)) data MappedFinding = MappedFinding- { mfDeclaration :: !Name- , mfLeaf :: !Text- , mfCode :: !DiagnosticCode- , mfDetail :: !Text- , mfUsePaths :: ![UsePath]- , mfOldUnknownFields :: !(Maybe UnknownFields)- }- deriving stock (Eq, Show)+ { mfDeclaration :: !Name,+ mfLeaf :: !Text,+ mfCode :: !DiagnosticCode,+ mfDetail :: !Text,+ mfUsePaths :: ![UsePath],+ mfOldUnknownFields :: !(Maybe UnknownFields)+ }+ deriving stock (Eq, Show) -{- | Compare valid old/new mapped graphs. A spec that cannot resolve has-already failed @check@; the ordinary differ therefore emits no speculative-mapped compatibility claim for it.--}+-- | Compare valid old/new mapped graphs. A spec that cannot resolve has+-- already failed @check@; the ordinary differ therefore emits no speculative+-- mapped compatibility claim for it. diffMapped :: Spec -> Spec -> [MappedFinding] diffMapped oldSpec newSpec = case (resolveTypeGraph oldSpec, resolveTypeGraph newSpec) of- (Right oldGraph, Right newGraph) ->- concatMap (uncurry (diffDeclaration oldGraph newGraph)) matched- ++ map (addedDeclaration newGraph) added- ++ map (removedDeclaration oldGraph) removed- where- oldDeclarations = tgDeclarations oldGraph- newDeclarations = tgDeclarations newGraph- matched =- [ (oldDeclaration, newDeclaration)- | (key, newDeclaration) <- Map.toList newDeclarations- , oldDeclaration <- maybeToList (Map.lookup key oldDeclarations)- ]- added =- [ (key, declaration)- | (key, declaration) <- Map.toList newDeclarations- , Map.notMember key oldDeclarations- ]- removed =- [ (key, declaration)- | (key, declaration) <- Map.toList oldDeclarations- , Map.notMember key newDeclarations- ]- _ -> []+ (Right oldGraph, Right newGraph) ->+ concatMap (uncurry (diffDeclaration oldGraph newGraph)) matched+ ++ map (addedDeclaration newGraph) added+ ++ map (removedDeclaration oldGraph) removed+ where+ oldDeclarations = tgDeclarations oldGraph+ newDeclarations = tgDeclarations newGraph+ matched =+ [ (oldDeclaration, newDeclaration)+ | (key, newDeclaration) <- Map.toList newDeclarations,+ oldDeclaration <- maybeToList (Map.lookup key oldDeclarations)+ ]+ added =+ [ (key, declaration)+ | (key, declaration) <- Map.toList newDeclarations,+ Map.notMember key oldDeclarations+ ]+ removed =+ [ (key, declaration)+ | (key, declaration) <- Map.toList oldDeclarations,+ Map.notMember key newDeclarations+ ]+ _ -> [] renderMappedSubject :: UsePath -> Text -> Text renderMappedSubject path leaf =- renderUsePath path <> if T.null leaf then "" else " " <> leaf+ renderUsePath path <> if T.null leaf then "" else " " <> leaf data DeclView- = StructuralView !StructuralDecl !ShapeView- | OpaqueView !OpaqueDecl+ = StructuralView !StructuralDecl !ShapeView+ | OpaqueView !OpaqueDecl data ShapeView- = RecordView !Name !UnknownFields ![ResolvedWireField]- | EnumView ![WireEnum]- | UnionView !UnionEncoding ![ResolvedWireArm]+ = RecordView !Name !UnknownFields ![ResolvedWireField]+ | EnumView ![WireEnum]+ | UnionView !UnionEncoding ![ResolvedWireArm] data ExprView- = ExprText- | ExprInt- | ExprBool- | ExprNatural- | ExprTime- | ExprJson- | ExprOptional !ExprView- | ExprList !ExprView- | ExprMap !ExprView- | ExprRef !MappedKey- deriving stock (Eq, Show)+ = ExprText+ | ExprInt+ | ExprInteger+ | ExprBool+ | ExprNatural+ | ExprTime+ | ExprJson+ | ExprOptional !ExprView+ | ExprList !ExprView+ | ExprMap !ExprView+ | ExprRef !MappedKey+ deriving stock (Eq, Show) declView :: ResolvedMappedDecl -> DeclView declView =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \declaration shape -> StructuralView declaration (shapeView shape)- , onOpaqueDecl = OpaqueView- }+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \declaration shape -> StructuralView declaration (shapeView shape),+ onOpaqueDecl = OpaqueView+ } shapeView :: ResolvedMappedShape -> ShapeView shapeView =- foldMappedShape- MappedShapeAlgebra- { onRecord = RecordView- , onEnum = EnumView- , onUnion = UnionView- }+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = RecordView,+ onEnum = EnumView,+ onUnion = UnionView+ } exprView :: ResolvedTypeExpr -> ExprView exprView =- foldTypeExpr- TypeExprAlgebra- { onText = ExprText- , onInt = ExprInt- , onBool = ExprBool- , onNatural = ExprNatural- , onTime = ExprTime- , onJson = ExprJson- , onOptional = ExprOptional- , onList = ExprList- , onMap = ExprMap- , onRef = ExprRef- }+ foldTypeExpr+ TypeExprAlgebra+ { onText = ExprText,+ onInt = ExprInt,+ onInteger = ExprInteger,+ onBool = ExprBool,+ onNatural = ExprNatural,+ onTime = ExprTime,+ onJson = ExprJson,+ onOptional = ExprOptional,+ onList = ExprList,+ onMap = ExprMap,+ onRef = ExprRef+ } diffDeclaration :: TypeGraph -> TypeGraph -> ResolvedMappedDecl -> ResolvedMappedDecl -> [MappedFinding] diffDeclaration oldGraph newGraph oldResolved newResolved =- case (declView oldResolved, declView newResolved) of- (StructuralView oldDeclaration oldShape, StructuralView newDeclaration newShape) ->- metadataDiff paths oldDeclaration newDeclaration- ++ diffShape paths name oldShape newShape- (OpaqueView oldDeclaration, OpaqueView newDeclaration) ->- opaqueMetadataDiff paths oldDeclaration newDeclaration- _ ->- [ finding- paths- name- ""- MappedModeCrossed- "mapped declaration crossed the structural/opaque boundary; no structural proof can establish codec parity"- ]+ case (declView oldResolved, declView newResolved) of+ (StructuralView oldDeclaration oldShape, StructuralView newDeclaration newShape) ->+ metadataDiff paths oldDeclaration newDeclaration+ ++ diffShape paths name oldShape newShape+ (OpaqueView oldDeclaration, OpaqueView newDeclaration) ->+ opaqueMetadataDiff paths oldDeclaration newDeclaration+ _ ->+ [ finding+ paths+ name+ ""+ MappedModeCrossed+ "mapped declaration crossed the structural/opaque boundary; no structural proof can establish codec parity"+ ] where name = resolvedName newResolved paths = pathsFor oldGraph newGraph name metadataDiff :: [UsePath] -> StructuralDecl -> StructuralDecl -> [MappedFinding] metadataDiff paths oldDeclaration newDeclaration =- [ finding- paths- name- "haskell"- MappedHaskellSourceChanged- "consumer package, module, or type changed without changing declared wire identity; recompile every affected consumer"- | sdHaskell oldDeclaration /= sdHaskell newDeclaration- ]- ++ [ finding- paths- name- "binding"- MappedBindingChanged- "binding symbol or binding-version changed; diff cannot inspect binding behavior, so run the two-law, codec, and historical-fixture conformance suite"- | (sdBinding oldDeclaration, sdBindingVersion oldDeclaration)- /= (sdBinding newDeclaration, sdBindingVersion newDeclaration)- ]- ++ [ finding- paths- name- "fixtures"- MappedFixturesChanged- "fixture evidence symbol changed; runtime wire policy is unchanged, but the complete conformance suite must run"- | sdFixtures oldDeclaration /= sdFixtures newDeclaration- ]- ++ [ finding- paths- name- "initial"- MappedInitialChanged- "mapped initial symbol changed; new streams and snapshot fingerprints may change while historical event decoding does not"- | sdInitial oldDeclaration /= sdInitial newDeclaration- ]- ++ [ finding- paths- name- "canonical-type"- MappedCanonicalTypeChanged- "canonical type identity changed; rebuild generated projections and invalidate mapped snapshots while declared event bytes remain unchanged"- | sdCanonical oldDeclaration /= sdCanonical newDeclaration- ]+ [ finding+ paths+ name+ "haskell"+ MappedHaskellSourceChanged+ "consumer package, module, or type changed without changing declared wire identity; recompile every affected consumer"+ | sdHaskell oldDeclaration /= sdHaskell newDeclaration+ ]+ ++ [ finding+ paths+ name+ "binding"+ MappedBindingChanged+ "binding symbol or binding-version changed; diff cannot inspect binding behavior, so run the two-law, codec, and historical-fixture conformance suite"+ | (sdBinding oldDeclaration, sdBindingVersion oldDeclaration)+ /= (sdBinding newDeclaration, sdBindingVersion newDeclaration)+ ]+ ++ [ finding+ paths+ name+ "fixtures"+ MappedFixturesChanged+ "fixture evidence symbol changed; runtime wire policy is unchanged, but the complete conformance suite must run"+ | sdFixtures oldDeclaration /= sdFixtures newDeclaration+ ]+ ++ [ finding+ paths+ name+ "initial"+ MappedInitialChanged+ "mapped initial symbol changed; new streams and snapshot fingerprints may change while historical event decoding does not"+ | sdInitial oldDeclaration /= sdInitial newDeclaration+ ]+ ++ [ finding+ paths+ name+ "canonical-type"+ MappedCanonicalTypeChanged+ "canonical type identity changed; rebuild generated projections and invalidate mapped snapshots while declared event bytes remain unchanged"+ | sdCanonical oldDeclaration /= sdCanonical newDeclaration+ ] where name = sdName newDeclaration opaqueMetadataDiff :: [UsePath] -> OpaqueDecl -> OpaqueDecl -> [MappedFinding] opaqueMetadataDiff paths oldDeclaration newDeclaration =- [ finding- paths- name- "haskell"- MappedHaskellSourceChanged- "consumer package, module, or type changed without changing the opaque codec claim; recompile every affected consumer"- | odHaskell oldDeclaration /= odHaskell newDeclaration- ]- ++ [ finding- paths- name- "codec"- MappedOpaqueCodecChanged- "opaque codec identity or version changed; Keiro cannot inspect the codec and historical payload compatibility is unproven"- | (odCodecIdentity oldDeclaration, odCodecVersion oldDeclaration)- /= (odCodecIdentity newDeclaration, odCodecVersion newDeclaration)- ]- ++ [ finding- paths- name- "fixtures"- MappedFixturesChanged- "fixture evidence symbol changed; runtime codec identity is unchanged, but the complete conformance suite must run"- | odFixtures oldDeclaration /= odFixtures newDeclaration- ]- ++ [ finding- paths- name- "initial"- MappedInitialChanged- "mapped initial symbol changed; new streams and snapshot fingerprints may change while historical event decoding does not"- | odInitial oldDeclaration /= odInitial newDeclaration- ]+ [ finding+ paths+ name+ "haskell"+ MappedHaskellSourceChanged+ "consumer package, module, or type changed without changing the opaque codec claim; recompile every affected consumer"+ | odHaskell oldDeclaration /= odHaskell newDeclaration+ ]+ ++ [ finding+ paths+ name+ "codec"+ MappedOpaqueCodecChanged+ "opaque codec identity or version changed; Keiro cannot inspect the codec and historical payload compatibility is unproven"+ | (odCodecIdentity oldDeclaration, odCodecVersion oldDeclaration)+ /= (odCodecIdentity newDeclaration, odCodecVersion newDeclaration)+ ]+ ++ [ finding+ paths+ name+ "fixtures"+ MappedFixturesChanged+ "fixture evidence symbol changed; runtime codec identity is unchanged, but the complete conformance suite must run"+ | odFixtures oldDeclaration /= odFixtures newDeclaration+ ]+ ++ [ finding+ paths+ name+ "initial"+ MappedInitialChanged+ "mapped initial symbol changed; new streams and snapshot fingerprints may change while historical event decoding does not"+ | odInitial oldDeclaration /= odInitial newDeclaration+ ] where name = odName newDeclaration diffShape :: [UsePath] -> Name -> ShapeView -> ShapeView -> [MappedFinding] diffShape paths declaration oldShape newShape = case (oldShape, newShape) of- (RecordView oldConstructor oldUnknown oldFields, RecordView newConstructor newUnknown newFields) ->- [ finding- paths- declaration- "constructor"- MappedRecordConstructorChanged- "record constructor changed without changing the JSON wire identity; recompile affected consumers"- | oldConstructor /= newConstructor- ]- ++ [ finding- paths- declaration- "unknown-fields"- MappedUnionEncodingChanged- "record unknown-fields policy changed; historical and mixed-version decoding posture is no longer the same"- | oldUnknown /= newUnknown- ]- ++ diffRecord paths declaration oldUnknown oldFields newFields- (EnumView oldEntries, EnumView newEntries) -> diffEnum paths declaration oldEntries newEntries- (UnionView oldEncoding oldArms, UnionView newEncoding newArms) ->- [ finding- paths- declaration- "encoding"- MappedUnionEncodingChanged- "tagged-object encoding changed; version and upcast every affected private event root"- | oldEncoding /= newEncoding- ]- ++ diffUnion paths declaration oldArms newArms- _ ->- [ finding- paths- declaration- "shape"- MappedUnionEncodingChanged- "structural shape kind changed; version and upcast every affected private event root"- ]+ (RecordView oldConstructor oldUnknown oldFields, RecordView newConstructor newUnknown newFields) ->+ [ finding+ paths+ declaration+ "constructor"+ MappedRecordConstructorChanged+ "record constructor changed without changing the JSON wire identity; recompile affected consumers"+ | oldConstructor /= newConstructor+ ]+ ++ [ finding+ paths+ declaration+ "unknown-fields"+ MappedUnionEncodingChanged+ "record unknown-fields policy changed; historical and mixed-version decoding posture is no longer the same"+ | oldUnknown /= newUnknown+ ]+ ++ diffRecord paths declaration oldUnknown oldFields newFields+ (EnumView oldEntries, EnumView newEntries) -> diffEnum paths declaration oldEntries newEntries+ (UnionView oldEncoding oldArms, UnionView newEncoding newArms) ->+ [ finding+ paths+ declaration+ "encoding"+ MappedUnionEncodingChanged+ "tagged-object encoding changed; version and upcast every affected private event root"+ | oldEncoding /= newEncoding+ ]+ ++ diffUnion paths declaration oldArms newArms+ _ ->+ [ finding+ paths+ declaration+ "shape"+ MappedUnionEncodingChanged+ "structural shape kind changed; version and upcast every affected private event root"+ ] diffRecord :: [UsePath] -> Name -> UnknownFields -> [ResolvedWireField] -> [ResolvedWireField] -> [MappedFinding] diffRecord paths declaration oldUnknown oldFields newFields =- concatMap (uncurry (diffField paths declaration)) matched- ++ map addedFinding added- ++ map removedFinding removed+ concatMap (uncurry (diffField paths declaration)) matched+ ++ map addedFinding added+ ++ map removedFinding removed where (matched, added, removed) = pairFields oldFields newFields addedFinding field =- (findingWithUnknown paths declaration (fieldLeaf field) code detail (Just oldUnknown))+ (findingWithUnknown paths declaration (fieldLeaf field) code detail (Just oldUnknown)) where hasDefault = isJustValue (rwfOnMissing field) code- | hasDefault = MappedFieldAddedWithDefault- | otherwise = MappedFieldAddedNoDefault+ | hasDefault = MappedFieldAddedWithDefault+ | otherwise = MappedFieldAddedNoDefault oldPolicy = case oldUnknown of RejectUnknown -> "reject"; IgnoreUnknown -> "ignore" detail- | hasDefault =- "field added with an explicit on-missing default; new readers preserve old meaning, while old readers use unknown-fields="- <> oldPolicy- | otherwise =- "field added without an on-missing default; old payloads do not contain it, so version and upcast every affected private event root"+ | hasDefault =+ "field added with an explicit on-missing default; new readers preserve old meaning, while old readers use unknown-fields="+ <> oldPolicy+ | otherwise =+ "field added without an on-missing default; old payloads do not contain it, so version and upcast every affected private event root" removedFinding field =- finding- paths- declaration- (fieldLeaf field)- MappedFieldRemoved- "field removed; replay-relevant removal remains breaking even when a tolerant decoder would ignore the historical key"+ finding+ paths+ declaration+ (fieldLeaf field)+ MappedFieldRemoved+ "field removed; replay-relevant removal remains breaking even when a tolerant decoder would ignore the historical key" pairFields :: [ResolvedWireField] -> [ResolvedWireField] -> ([(ResolvedWireField, ResolvedWireField)], [ResolvedWireField], [ResolvedWireField]) pairFields oldFields newFields = (exact <> fallback, added, removed) where exact =- [ (oldField, newField)- | newField <- newFields- , oldField <- maybeToList (find ((== rwfHaskell newField) . rwfHaskell) oldFields)- ]+ [ (oldField, newField)+ | newField <- newFields,+ oldField <- maybeToList (find ((== rwfHaskell newField) . rwfHaskell) oldFields)+ ] matchedOld = map (rwfHaskell . fst) exact matchedNew = map (rwfHaskell . snd) exact unmatchedOld = [field | field <- oldFields, rwfHaskell field `notElem` matchedOld] unmatchedNew = [field | field <- newFields, rwfHaskell field `notElem` matchedNew] fallback =- [ (oldField, newField)- | newField <- unmatchedNew- , oldField <- maybeToList (find ((== rwfKey newField) . rwfKey) unmatchedOld)- ]+ [ (oldField, newField)+ | newField <- unmatchedNew,+ oldField <- maybeToList (find ((== rwfKey newField) . rwfKey) unmatchedOld)+ ] fallbackOld = map (rwfHaskell . fst) fallback fallbackNew = map (rwfHaskell . snd) fallback removed = [field | field <- unmatchedOld, rwfHaskell field `notElem` fallbackOld]@@ -319,138 +320,138 @@ diffField :: [UsePath] -> Name -> ResolvedWireField -> ResolvedWireField -> [MappedFinding] diffField paths declaration oldField newField =- [ finding- paths- declaration- leaf- MappedWireKeyChanged- ("wire key changed '" <> rwfKey oldField <> "' -> '" <> rwfKey newField <> "'; version and upcast every affected private event root")- | rwfKey oldField /= rwfKey newField- ]- ++ [ finding- paths- declaration- leaf- MappedPresenceChanged- "field presence changed between required and optional; historical decode policy changed"- | rwfPresence oldField /= rwfPresence newField- ]- ++ defaultChanges- ++ diffExpr paths declaration (leaf <> ".type") (rwfType oldField) (rwfType newField)+ [ finding+ paths+ declaration+ leaf+ MappedWireKeyChanged+ ("wire key changed '" <> rwfKey oldField <> "' -> '" <> rwfKey newField <> "'; version and upcast every affected private event root")+ | rwfKey oldField /= rwfKey newField+ ]+ ++ [ finding+ paths+ declaration+ leaf+ MappedPresenceChanged+ "field presence changed between required and optional; historical decode policy changed"+ | rwfPresence oldField /= rwfPresence newField+ ]+ ++ defaultChanges+ ++ diffExpr paths declaration (leaf <> ".type") (rwfType oldField) (rwfType newField) where leaf = fieldLeaf newField defaultChanges = case (rwfOnMissing oldField, rwfOnMissing newField) of- (Just _, Nothing) ->+ (Just _, Nothing) ->+ [ finding+ paths+ declaration+ leaf+ MappedDefaultRemoved+ "on-missing default was removed; old payloads may no longer decode with preserved meaning"+ ]+ (oldDefault, newDefault)+ | oldDefault /= newDefault -> [ finding paths declaration leaf- MappedDefaultRemoved- "on-missing default was removed; old payloads may no longer decode with preserved meaning"+ MappedDefaultChanged+ "on-missing default changed; the same historical bytes now construct a different consumer value" ]- (oldDefault, newDefault)- | oldDefault /= newDefault ->- [ finding- paths- declaration- leaf- MappedDefaultChanged- "on-missing default changed; the same historical bytes now construct a different consumer value"- ]- _ -> []+ _ -> [] diffExpr :: [UsePath] -> Name -> Text -> ResolvedTypeExpr -> ResolvedTypeExpr -> [MappedFinding] diffExpr paths declaration leaf oldExpression newExpression =- case (exprView oldExpression, exprView newExpression) of- (oldView, newView)- | oldView == newView -> []- (ExprOptional oldValue, ExprOptional newValue) -> recurse ".optional" oldValue newValue- (ExprList oldValue, ExprList newValue) -> recurse "[]" oldValue newValue- (ExprMap oldValue, ExprMap newValue) -> recurse "{}" oldValue newValue- (ExprOptional _, _) -> nullability- (_, ExprOptional _) -> nullability- _ ->- [ finding- paths- declaration- leaf- MappedFieldTypeChanged- "wire type changed; version and upcast every affected private event root"- ]+ case (exprView oldExpression, exprView newExpression) of+ (oldView, newView)+ | oldView == newView -> []+ (ExprOptional oldValue, ExprOptional newValue) -> recurse ".optional" oldValue newValue+ (ExprList oldValue, ExprList newValue) -> recurse "[]" oldValue newValue+ (ExprMap oldValue, ExprMap newValue) -> recurse "{}" oldValue newValue+ (ExprOptional _, _) -> nullability+ (_, ExprOptional _) -> nullability+ _ ->+ [ finding+ paths+ declaration+ leaf+ MappedFieldTypeChanged+ "wire type changed; version and upcast every affected private event root"+ ] where recurse suffix oldView newView = diffExprViews paths declaration (leaf <> suffix) oldView newView nullability =- [ finding- paths- declaration- leaf- MappedNullabilityChanged- "Optional nullability changed; historical null and non-null meanings are no longer stable"- ]+ [ finding+ paths+ declaration+ leaf+ MappedNullabilityChanged+ "Optional nullability changed; historical null and non-null meanings are no longer stable"+ ] diffExprViews :: [UsePath] -> Name -> Text -> ExprView -> ExprView -> [MappedFinding] diffExprViews paths declaration leaf oldView newView = case (oldView, newView) of- _ | oldView == newView -> []- (ExprOptional oldValue, ExprOptional newValue) -> diffExprViews paths declaration (leaf <> ".optional") oldValue newValue- (ExprList oldValue, ExprList newValue) -> diffExprViews paths declaration (leaf <> "[]") oldValue newValue- (ExprMap oldValue, ExprMap newValue) -> diffExprViews paths declaration (leaf <> "{}") oldValue newValue- (ExprOptional _, _) -> nullability- (_, ExprOptional _) -> nullability- _ -> [finding paths declaration leaf MappedFieldTypeChanged "wire type changed; version and upcast every affected private event root"]+ _ | oldView == newView -> []+ (ExprOptional oldValue, ExprOptional newValue) -> diffExprViews paths declaration (leaf <> ".optional") oldValue newValue+ (ExprList oldValue, ExprList newValue) -> diffExprViews paths declaration (leaf <> "[]") oldValue newValue+ (ExprMap oldValue, ExprMap newValue) -> diffExprViews paths declaration (leaf <> "{}") oldValue newValue+ (ExprOptional _, _) -> nullability+ (_, ExprOptional _) -> nullability+ _ -> [finding paths declaration leaf MappedFieldTypeChanged "wire type changed; version and upcast every affected private event root"] where nullability = [finding paths declaration leaf MappedNullabilityChanged "Optional nullability changed; historical null and non-null meanings are no longer stable"] diffEnum :: [UsePath] -> Name -> [WireEnum] -> [WireEnum] -> [MappedFinding] diffEnum paths declaration oldEntries newEntries =- [ finding- paths- declaration- (enumLeaf newEntry)- MappedEnumSpellingChanged- ("enum wire spelling changed '" <> weTag oldEntry <> "' -> '" <> weTag newEntry <> "'")- | newEntry <- newEntries- , oldEntry <- maybeToList (find ((== weCtor newEntry) . weCtor) oldEntries)- , weTag oldEntry /= weTag newEntry- ]- ++ [ finding- paths- declaration- (enumLeaf entry)- MappedEnumValueAdded- "enum value added; existing history remains readable, but deploy readers before writers emit the new spelling; a future public surface exposing this closed enum would classify the addition as consumer-breaking"- | entry <- newEntries- , isNothing (find ((== weCtor entry) . weCtor) oldEntries)- ]- ++ [ finding- paths- declaration- (enumLeaf entry)- MappedEnumValueRemoved- "enum value removed; historical payloads carrying its wire spelling no longer decode"- | entry <- oldEntries- , isNothing (find ((== weCtor entry) . weCtor) newEntries)- ]+ [ finding+ paths+ declaration+ (enumLeaf newEntry)+ MappedEnumSpellingChanged+ ("enum wire spelling changed '" <> weTag oldEntry <> "' -> '" <> weTag newEntry <> "'")+ | newEntry <- newEntries,+ oldEntry <- maybeToList (find ((== weCtor newEntry) . weCtor) oldEntries),+ weTag oldEntry /= weTag newEntry+ ]+ ++ [ finding+ paths+ declaration+ (enumLeaf entry)+ MappedEnumValueAdded+ "enum value added; existing history remains readable, but deploy readers before writers emit the new spelling; a future public surface exposing this closed enum would classify the addition as consumer-breaking"+ | entry <- newEntries,+ isNothing (find ((== weCtor entry) . weCtor) oldEntries)+ ]+ ++ [ finding+ paths+ declaration+ (enumLeaf entry)+ MappedEnumValueRemoved+ "enum value removed; historical payloads carrying its wire spelling no longer decode"+ | entry <- oldEntries,+ isNothing (find ((== weCtor entry) . weCtor) newEntries)+ ] diffUnion :: [UsePath] -> Name -> [ResolvedWireArm] -> [ResolvedWireArm] -> [MappedFinding] diffUnion paths declaration oldArms newArms =- concatMap (uncurry pairedArm) matched- ++ map addedArm added- ++ map removedArm removed+ concatMap (uncurry pairedArm) matched+ ++ map addedArm added+ ++ map removedArm removed where (matched, added, removed) = pairArms oldArms newArms pairedArm oldArm newArm =- [ finding- paths- declaration- (armLeaf newArm)- MappedArmTagChanged- ("union arm tag changed '" <> rwaTag oldArm <> "' -> '" <> rwaTag newArm <> "'")- | rwaTag oldArm /= rwaTag newArm- ]- ++ case (rwaPayload oldArm, rwaPayload newArm) of- (Nothing, Nothing) -> []- (Just oldPayload, Just newPayload) -> diffExpr paths declaration (armLeaf newArm <> ".payload") oldPayload newPayload- _ -> [finding paths declaration (armLeaf newArm) MappedFieldTypeChanged "union arm payload presence changed; historical tagged objects no longer share one wire shape"]+ [ finding+ paths+ declaration+ (armLeaf newArm)+ MappedArmTagChanged+ ("union arm tag changed '" <> rwaTag oldArm <> "' -> '" <> rwaTag newArm <> "'")+ | rwaTag oldArm /= rwaTag newArm+ ]+ ++ case (rwaPayload oldArm, rwaPayload newArm) of+ (Nothing, Nothing) -> []+ (Just oldPayload, Just newPayload) -> diffExpr paths declaration (armLeaf newArm <> ".payload") oldPayload newPayload+ _ -> [finding paths declaration (armLeaf newArm) MappedFieldTypeChanged "union arm payload presence changed; historical tagged objects no longer share one wire shape"] addedArm arm = finding paths declaration (armLeaf arm) MappedArmAdded "union arm added; existing history remains readable, but older binaries cannot read the new arm once emitted, so deploy readers before writers; a future public surface exposing this closed union would classify the addition as consumer-breaking" removedArm arm = finding paths declaration (armLeaf arm) MappedArmRemoved "union arm removed; historical tagged objects carrying that tag no longer decode" @@ -458,19 +459,19 @@ pairArms oldArms newArms = (exact <> fallback, added, removed) where exact =- [ (oldArm, newArm)- | newArm <- newArms- , oldArm <- maybeToList (find ((== rwaCtor newArm) . rwaCtor) oldArms)- ]+ [ (oldArm, newArm)+ | newArm <- newArms,+ oldArm <- maybeToList (find ((== rwaCtor newArm) . rwaCtor) oldArms)+ ] matchedOld = map (rwaCtor . fst) exact matchedNew = map (rwaCtor . snd) exact unmatchedOld = [arm | arm <- oldArms, rwaCtor arm `notElem` matchedOld] unmatchedNew = [arm | arm <- newArms, rwaCtor arm `notElem` matchedNew] fallback =- [ (oldArm, newArm)- | newArm <- unmatchedNew- , oldArm <- maybeToList (find ((== rwaTag newArm) . rwaTag) unmatchedOld)- ]+ [ (oldArm, newArm)+ | newArm <- unmatchedNew,+ oldArm <- maybeToList (find ((== rwaTag newArm) . rwaTag) unmatchedOld)+ ] fallbackOld = map (rwaCtor . fst) fallback fallbackNew = map (rwaCtor . snd) fallback removed = [arm | arm <- unmatchedOld, rwaCtor arm `notElem` fallbackOld]@@ -478,35 +479,35 @@ addedDeclaration :: TypeGraph -> (MappedKey, ResolvedMappedDecl) -> MappedFinding addedDeclaration _ (key, _) =- finding- []- (unMappedKey key)- ""- MappedDeclAdded- "new mapped declaration; use-site changes retain their own compatibility classification"+ finding+ []+ (unMappedKey key)+ ""+ MappedDeclAdded+ "new mapped declaration; use-site changes retain their own compatibility classification" removedDeclaration :: TypeGraph -> (MappedKey, ResolvedMappedDecl) -> MappedFinding removedDeclaration graph (key, _) =- finding- (usePaths graph (unMappedKey key))- (unMappedKey key)- ""- MappedDeclRemoved- "mapped declaration removed; persisted roots using its historical decoder require migration, while an unused source-only declaration requires consumer rebuild only"+ finding+ (usePaths graph (unMappedKey key))+ (unMappedKey key)+ ""+ MappedDeclRemoved+ "mapped declaration removed; persisted roots using its historical decoder require migration, while an unused source-only declaration requires consumer rebuild only" pathsFor :: TypeGraph -> TypeGraph -> Name -> [UsePath] pathsFor oldGraph newGraph name =- nubBy sameRendered . sortOn renderUsePath $ usePaths oldGraph name <> usePaths newGraph name+ nubBy sameRendered . sortOn renderUsePath $ usePaths oldGraph name <> usePaths newGraph name where sameRendered left right = renderUsePath left == renderUsePath right resolvedName :: ResolvedMappedDecl -> Name resolvedName =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \declaration _ -> sdName declaration- , onOpaqueDecl = odName- }+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \declaration _ -> sdName declaration,+ onOpaqueDecl = odName+ } fieldLeaf :: ResolvedWireField -> Text fieldLeaf field = ".field " <> rwfHaskell field <> "[\"" <> rwfKey field <> "\"]"@@ -519,18 +520,18 @@ finding :: [UsePath] -> Name -> Text -> DiagnosticCode -> Text -> MappedFinding finding paths declaration leaf code detail =- findingWithUnknown paths declaration leaf code detail Nothing+ findingWithUnknown paths declaration leaf code detail Nothing findingWithUnknown :: [UsePath] -> Name -> Text -> DiagnosticCode -> Text -> Maybe UnknownFields -> MappedFinding findingWithUnknown paths declaration leaf code detail unknownFields =- MappedFinding- { mfDeclaration = declaration- , mfLeaf = leaf- , mfCode = code- , mfDetail = detail- , mfUsePaths = paths- , mfOldUnknownFields = unknownFields- }+ MappedFinding+ { mfDeclaration = declaration,+ mfLeaf = leaf,+ mfCode = code,+ mfDetail = detail,+ mfUsePaths = paths,+ mfOldUnknownFields = unknownFields+ } isJustValue :: Maybe a -> Bool isJustValue = not . isNothing
+ src/Keiro/Dsl/NominalType.hs view
@@ -0,0 +1,344 @@+{-# OPTIONS_GHC -Werror=incomplete-patterns #-}++-- | Checked nominal declarations shared by validation, aggregate lowering,+-- generation, compatibility analysis, and scaffold records.+--+-- Parser declarations deliberately retain optional facts so diagnostics can be+-- located at their owner. This module is the phase boundary after which every+-- consumer binding is complete, every representation is closed, and every name+-- has one declaration category.+module Keiro.Dsl.NominalType+ ( NominalScalarRepresentation (..),+ NominalRepresentation (..),+ NominalOwnership (..),+ ConsumerNominalBinding (..),+ ResolvedNominalType (..),+ NominalTypeRegistry,+ nominalTypes,+ lookupNominalType,+ NominalTypeError (..),+ resolveNominalTypes,+ )+where++import Data.Char (isAscii, isDigit, isLower, isUpper, ord)+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.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.TypeID qualified as TypeID+import GHC.Generics (Generic)+import Keiro.Dsl.Grammar+import Keiro.Dsl.TypeGraph++data NominalScalarRepresentation+ = NominalText+ | NominalInt+ | NominalNatural+ | NominalBool+ | NominalTime+ deriving stock (Eq, Ord, Show, Generic)++data NominalRepresentation+ = IdRepresentation !Text+ | EnumRepresentation !(NonEmpty (Name, Text))+ | ScalarRepresentation !NominalScalarRepresentation+ deriving stock (Eq, Ord, Show, Generic)++data ConsumerNominalBinding = ConsumerNominalBinding+ { consumerNominalHaskell :: !HaskellSource,+ consumerNominalBinding :: !QualifiedValueName,+ consumerNominalBindingVersion :: !BindingVersion,+ consumerNominalCanonical :: !CanonicalTypeId,+ consumerNominalFixtures :: !QualifiedValueName,+ consumerNominalInitial :: !(Maybe QualifiedValueName)+ }+ deriving stock (Eq, Ord, Show, Generic)++data NominalOwnership+ = GeneratedNominal+ | ConsumerNominal !ConsumerNominalBinding+ deriving stock (Eq, Ord, Show, Generic)++data ResolvedNominalType = ResolvedNominalType+ { resolvedNominalName :: !Name,+ resolvedNominalRepresentation :: !NominalRepresentation,+ resolvedNominalOwnership :: !NominalOwnership,+ resolvedNominalLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic)++instance Ord ResolvedNominalType where+ compare left right =+ compare+ (resolvedNominalName left, resolvedNominalRepresentation left, resolvedNominalOwnership left)+ (resolvedNominalName right, resolvedNominalRepresentation right, resolvedNominalOwnership right)++newtype NominalTypeRegistry = NominalTypeRegistry+ { nominalTypes :: Map Name ResolvedNominalType+ }+ deriving stock (Eq, Show, Generic)++lookupNominalType :: Name -> NominalTypeRegistry -> Maybe ResolvedNominalType+lookupNominalType name = Map.lookup name . nominalTypes++data NominalTypeError+ = NominalMissingIngredient !Name !Loc !Text+ | NominalInvalidHaskellSource !Name !Loc !Text+ | NominalInvalidQualifiedValue !Name !Loc !Text !Text+ | NominalInvalidIdentity !Name !Loc !Text !Text+ | NominalInvalidIdPrefix !Name !Loc !Text !Text+ | NominalUnsupportedScalar !Name !Loc !Name+ | NominalEmptyEnum !Name !Loc+ | NominalMissingRegisterInitial !Name !Loc !Name+ | NominalDeclarationCollision !Name !Loc ![Text]+ deriving stock (Eq, Show, Generic)++resolveNominalTypes :: Spec -> Either (NonEmpty NominalTypeError) NominalTypeRegistry+resolveNominalTypes spec = do+ resolved <- rejectErrors declarationErrors resolvedDeclarations+ rejectMany collisionErrors+ let registry = NominalTypeRegistry (Map.fromList [(resolvedNominalName value, value) | value <- resolved])+ rejectMany (registerInitialErrors registry)+ pure registry+ where+ declarationResults =+ map resolveId (specIds spec)+ <> map resolveEnum (specEnums spec)+ <> map resolveScalar (specNominalScalars spec)+ declarationErrors = concatMap fst declarationResults+ resolvedDeclarations = [value | (_, Just value) <- declarationResults]++ resolveId declaration =+ let name = idName declaration+ loc = idLoc declaration+ prefixErrors =+ case idBinding declaration >>= const (TypeID.checkPrefix (idPrefix declaration)) of+ Nothing -> []+ Just err -> [NominalInvalidIdPrefix name loc (idPrefix declaration) (T.pack (show err))]+ (bindingErrors, ownership) = resolveOwnership name loc (idBinding declaration)+ errors = prefixErrors <> bindingErrors+ value = ResolvedNominalType name (IdRepresentation (idPrefix declaration)) <$> ownership <*> pure loc+ in (errors, value <* guardNoErrors errors)++ resolveEnum declaration =+ let name = enumName declaration+ loc = enumLoc declaration+ representation = NE.nonEmpty (enumCtors declaration)+ representationErrors = [NominalEmptyEnum name loc | representation == Nothing]+ (bindingErrors, ownership) = resolveOwnership name loc (enumBinding declaration)+ errors = representationErrors <> bindingErrors+ value = ResolvedNominalType name <$> (EnumRepresentation <$> representation) <*> ownership <*> pure loc+ in (errors, value <* guardNoErrors errors)++ resolveScalar declaration =+ let name = nominalScalarName declaration+ loc = nominalScalarLoc declaration+ representation = scalarRepresentation (nominalScalarRepresentation declaration)+ representationErrors = [NominalUnsupportedScalar name loc (nominalScalarRepresentation declaration) | representation == Nothing]+ (bindingErrors, ownership) = resolveRequiredOwnership name loc (nominalScalarBinding declaration)+ errors = representationErrors <> bindingErrors+ value = ResolvedNominalType name <$> (ScalarRepresentation <$> representation) <*> ownership <*> pure loc+ in (errors, value <* guardNoErrors errors)++ collisionErrors =+ [ NominalDeclarationCollision name loc categories+ | (name, occurrences) <- Map.toList originsByName,+ let categories = map fst occurrences,+ Set.size (Set.fromList categories) > 1,+ (_, loc) <- occurrences+ ]+ originsByName = Map.fromListWith (<>) [(name, [(category, loc)]) | (name, category, loc) <- origins]+ origins =+ [(idName value, "id", idLoc value) | value <- specIds spec]+ <> [(enumName value, "enum", enumLoc value) | value <- specEnums spec]+ <> [(nominalScalarName value, "nominal scalar", nominalScalarLoc value) | value <- specNominalScalars spec]+ <> [(mappedName value, "mapped", mappedLoc value) | value <- specMapped spec]+ <> [(ruleName value, "rule", ruleLoc value) | value <- specRules spec]+ <> [(name, kind <> " node", loc) | node <- specNodes spec, let (kind, name, loc) = nodeIdentityLocal node]++ registerInitialErrors registry =+ [ NominalMissingRegisterInitial typeName (regLoc register) (regName register)+ | aggregate <- [value | NAggregate value <- specNodes spec],+ register <- aggRegs aggregate,+ TRef typeName <- [regType register],+ Just resolved <- [lookupNominalType typeName registry],+ ConsumerNominal binding <- [resolvedNominalOwnership resolved],+ consumerNominalInitial binding == Nothing+ ]++resolveOwnership :: Name -> Loc -> Maybe NominalBindingDecl -> ([NominalTypeError], Maybe NominalOwnership)+resolveOwnership _ _ Nothing = ([], Just GeneratedNominal)+resolveOwnership name loc (Just binding) = resolveRequiredOwnership name loc binding++resolveRequiredOwnership :: Name -> Loc -> NominalBindingDecl -> ([NominalTypeError], Maybe NominalOwnership)+resolveRequiredOwnership name loc binding =+ (errors, ConsumerNominal <$> checkedBinding <* guardNoErrors errors)+ where+ requiredErrors =+ [NominalMissingIngredient name loc label | (label, missing) <- missingFacts, missing]+ missingFacts =+ [ ("haskell", nominalHaskell binding == Nothing),+ ("binding", nominalBinding binding == Nothing),+ ("binding-version", nominalBindingVersion binding == Nothing),+ ("canonical-type", nominalCanonicalType binding == Nothing),+ ("fixtures", nominalFixtures binding == Nothing)+ ]+ haskellErrors = maybe [] (validateHaskellSource name loc) (nominalHaskell binding)+ (bindingErrors, checkedBindingName) = validateQualified name loc "binding" (nominalBinding binding)+ (fixtureErrors, checkedFixtures) = validateQualified name loc "fixtures" (nominalFixtures binding)+ (initialErrors, checkedInitial) = validateOptionalQualified name loc "initial" (nominalInitial binding)+ (bindingVersionErrors, checkedBindingVersion) = validateBindingVersion name loc (nominalBindingVersion binding)+ (canonicalErrors, checkedCanonical) = validateCanonical name loc (nominalCanonicalType binding)+ errors = requiredErrors <> haskellErrors <> bindingErrors <> fixtureErrors <> initialErrors <> bindingVersionErrors <> canonicalErrors+ checkedBinding =+ ConsumerNominalBinding+ <$> nominalHaskell binding+ <*> checkedBindingName+ <*> checkedBindingVersion+ <*> checkedCanonical+ <*> checkedFixtures+ <*> pure checkedInitial++validateHaskellSource :: Name -> Loc -> HaskellSource -> [NominalTypeError]+validateHaskellSource name loc source =+ [NominalInvalidHaskellSource name loc "package" | not (cabalPackageName (hsPackage source))]+ <> [NominalInvalidHaskellSource name loc "module" | not (moduleNameSafe (hsModule source))]+ <> [NominalInvalidHaskellSource name loc "type" | not (constructorSafe (hsType source))]++validateQualified :: Name -> Loc -> Text -> Maybe Text -> ([NominalTypeError], Maybe QualifiedValueName)+validateQualified _ _ _ Nothing = ([], Nothing)+validateQualified name loc category (Just value) =+ case mkQualifiedValueName value of+ Right checked | qualifiedValueSafe value -> ([], Just checked)+ _ -> ([NominalInvalidQualifiedValue name loc category value], Nothing)++validateOptionalQualified :: Name -> Loc -> Text -> Maybe Text -> ([NominalTypeError], Maybe QualifiedValueName)+validateOptionalQualified = validateQualified++validateBindingVersion :: Name -> Loc -> Maybe Text -> ([NominalTypeError], Maybe BindingVersion)+validateBindingVersion _ _ Nothing = ([], Nothing)+validateBindingVersion name loc (Just value) =+ case mkBindingVersion value of+ Right checked | identitySafe value -> ([], Just checked)+ _ -> ([NominalInvalidIdentity name loc "binding-version" value], Nothing)++validateCanonical :: Name -> Loc -> Maybe Text -> ([NominalTypeError], Maybe CanonicalTypeId)+validateCanonical _ _ Nothing = ([], Nothing)+validateCanonical name loc (Just value) =+ case mkCanonicalTypeId value of+ Right checked | identitySafe value -> ([], Just checked)+ _ -> ([NominalInvalidIdentity name loc "canonical-type" value], Nothing)++scalarRepresentation :: Name -> Maybe NominalScalarRepresentation+scalarRepresentation = \case+ "Text" -> Just NominalText+ "Int" -> Just NominalInt+ "Natural" -> Just NominalNatural+ "Bool" -> Just NominalBool+ "Time" -> Just NominalTime+ "UTCTime" -> Just NominalTime+ _ -> Nothing++rejectErrors :: [e] -> [a] -> Either (NonEmpty e) [a]+rejectErrors errors values = maybe (Right values) Left (NE.nonEmpty errors)++rejectMany :: [e] -> Either (NonEmpty e) ()+rejectMany errors = maybe (Right ()) Left (NE.nonEmpty errors)++guardNoErrors :: [e] -> Maybe ()+guardNoErrors [] = Just ()+guardNoErrors _ = Nothing++mappedName :: MappedDecl -> Name+mappedName MappedStructural {msName = name} = name+mappedName MappedOpaque {moName = name} = name++mappedLoc :: MappedDecl -> Loc+mappedLoc MappedStructural {msLoc = loc} = loc+mappedLoc MappedOpaque {moLoc = loc} = loc++nodeIdentityLocal :: Node -> (Text, Name, Loc)+nodeIdentityLocal = \case+ NAggregate value -> ("aggregate", aggName value, aggLoc value)+ NProcess value -> ("process", procId value, procLoc value)+ NRouter value -> ("router", rtId value, rtLoc value)+ NContract value -> ("contract", ctrName value, ctrLoc value)+ NIntake value -> ("intake", inkName value, inkLoc value)+ NEmit value -> ("emit", emName value, emLoc value)+ NPublisher value -> ("publisher", pubName value, pubLoc value)+ NWorkqueue value -> ("workqueue", wqName value, wqLoc value)+ NPgmqDispatch value -> ("dispatch", pdName value, pdLoc value)+ NReadModel value -> ("readmodel", rmName value, rmLoc value)+ NWorkflow value -> ("workflow", wfId value, workflowNodeLoc value)+ NOperation value -> ("operation", opName value, opLoc value)++cabalPackageName :: Text -> Bool+cabalPackageName packageName = not (null components) && all validComponent components+ where+ components = T.splitOn "-" packageName+ validComponent component = not (T.null component) && T.all asciiAlphaNum component && T.any asciiLetter component++moduleNameSafe :: Text -> Bool+moduleNameSafe moduleName = not (null components) && all constructorSafe components+ where+ components = T.splitOn "." moduleName++qualifiedValueSafe :: Text -> Bool+qualifiedValueSafe qualified = case reverse (T.splitOn "." qualified) of+ value : reversedModule -> not (null reversedModule) && lowerIdentifierSafe value && all constructorSafe reversedModule+ [] -> False++constructorSafe :: Text -> Bool+constructorSafe name = case T.uncons name of+ Just (first, rest) -> asciiUpper first && T.all asciiAlphaNumOrUnderscore rest+ Nothing -> False++lowerIdentifierSafe :: Text -> Bool+lowerIdentifierSafe name = case T.uncons name of+ Just (first, rest) -> asciiLower first && T.all asciiAlphaNumOrUnderscore rest && name `Set.notMember` haskellKeywords+ Nothing -> False++identitySafe :: Text -> Bool+identitySafe value = not (T.null (T.strip value)) && not (T.any asciiControl value)++asciiUpper, asciiLower, asciiLetter, asciiAlphaNum, asciiAlphaNumOrUnderscore, asciiControl :: Char -> Bool+asciiUpper c = isAscii c && isUpper c+asciiLower c = isAscii c && isLower c+asciiLetter c = asciiUpper c || asciiLower c+asciiAlphaNum c = asciiLetter c || (isAscii c && isDigit c)+asciiAlphaNumOrUnderscore c = asciiAlphaNum c || c == '_'+asciiControl c = ord c < 32 || ord c == 127++haskellKeywords :: Set.Set Text+haskellKeywords =+ Set.fromList+ [ "case",+ "class",+ "data",+ "default",+ "deriving",+ "do",+ "else",+ "foreign",+ "if",+ "import",+ "in",+ "infix",+ "infixl",+ "infixr",+ "instance",+ "let",+ "module",+ "newtype",+ "of",+ "then",+ "type",+ "where",+ "mdo",+ "rec",+ "proc"+ ]
src/Keiro/Dsl/Parser.hs view
@@ -1,1795 +1,2108 @@-{- | The megaparsec parser for the keiro DSL. Turns @.keiro@ text into the typed-'Spec' AST. The notation is keyword-driven: newlines and @#@-comments are-whitespace, structure comes from keywords (@aggregate@, @regs@, @states@,-@command@, @event@, @wire@, @projection@) and the transition arrow-@Src -- Command --> clauses@. Guards and write right-hand sides are parsed as-a typed 'Expr' (never an opaque string) so the validator can scope-check them.--}-module Keiro.Dsl.Parser (- ParseError,- parseSpec,- parseSpecText,-)-where--import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)-import Data.Char (isAlpha, isAlphaNum, isAscii, isDigit, isUpper)-import Data.Maybe (mapMaybe)-import Data.Text (Text)-import Data.Text qualified as T-import Data.Void (Void)-import Keiro.Dsl.Grammar-import Text.Megaparsec hiding (ParseError)-import Text.Megaparsec.Char (char, digitChar, letterChar, space1)-import Text.Megaparsec.Char.Lexer qualified as L---- | A rendered, line-numbered parse error, ready to print to the user.-type ParseError = Text--type P = Parsec Void Text--{- | Parse a @.keiro@ source. The 'FilePath' is used only as the source name in-diagnostics (megaparsec's line/column reporting); it need not exist on disk.-This is the canonical signature shared across all keiro-dsl plans.--}-parseSpec :: FilePath -> Text -> Either ParseError Spec-parseSpec src input =- case runParser (sc *> pSpec <* eof) src input of- Left bundle -> Left (T.pack (errorBundlePretty bundle))- Right spec -> Right spec---- | Convenience wrapper for callers without a source name (tests, stdin).-parseSpecText :: Text -> Either ParseError Spec-parseSpecText = parseSpec "<input>"------------------------------------------------------------------------------------- Lexer------------------------------------------------------------------------------------- | Space consumer: spaces, newlines, and @#@ line comments are all whitespace.-sc :: P ()-sc = L.space space1 (L.skipLineComment "#") empty--lexeme :: P a -> P a-lexeme = L.lexeme sc--symbol :: Text -> P Text-symbol = L.symbol sc--{- | A reserved keyword: the literal word not followed by an identifier-character (so @goto@ matches @goto@ but not @gotoX@).--}-keyword :: Text -> P ()-keyword w = (lexeme . try) (string' w *> notFollowedBy (identChar <|> (char '-' *> identChar)))- where- string' = chunk--identChar :: P Char-identChar = asciiAlphaNum <|> char '_'--asciiLetter :: P Char-asciiLetter = satisfy (\c -> isAscii c && isAlpha c)--asciiUpper :: P Char-asciiUpper = satisfy (\c -> isAscii c && isUpper c)--asciiDigit :: P Char-asciiDigit = satisfy (\c -> isAscii c && isDigit c)--asciiAlphaNum :: P Char-asciiAlphaNum = satisfy (\c -> isAscii c && isAlphaNum c)---- | Fail with the diagnostic caret placed at a previously captured offset.-failAt :: Int -> String -> P a-failAt offset message = region (setErrorOffset offset) (fail message)--{- | Parse a decimal as an unbounded Integer, then reject values that cannot be-represented as Int. Parsing L.decimal directly at Int silently wraps.--}-boundedDecimal :: P Int-boundedDecimal = do- offset <- getOffset- value <- lexeme (L.decimal :: P Integer)- checkedDecimal offset value--checkedDecimal :: Int -> Integer -> P Int-checkedDecimal offset value- | value > fromIntegral (maxBound :: Int) =- failAt- offset- ( "decimal literal "- <> show value- <> " is out of range (maximum "- <> show (maxBound :: Int)- <> ")"- )- | otherwise = pure (fromIntegral value)--{- | Words that may not be used as bare identifiers, because they introduce a-different construct and would otherwise be swallowed (e.g. @aggregate@ ending-one node and beginning the next).--}-reservedWords :: [Text]-reservedWords =- [ "context"- , "module"- , "layout"- , "prefixed"- , "collocated"- , "id"- , "enum"- , "rule"- , "mapped"- , "ex"- , "aggregate"- , "regs"- , "states"- , "command"- , "event"- , "wire"- , "projection"- , "snapshot"- , "category"- , "guard"- , "write"- , "emit"- , "goto"- , "fields"- , "status-map"- , "true"- , "false"- , "retiring"- , "deprecated"- , "upcast"- , "from"- , "HOLE"- , "process"- , "router"- , "dispatch-each"- , "resolve"- , "read-model"- , "dispatch"- , -- EP-4 integration: structural keywords never used as identifiers, so a- -- list like @accept A B C@ stops at the next block keyword.- "intake"- , "contract"- , "topic"- , "accept"- , "bind"- , "dedupe"- , "persist"- , "decode"- , "disposition"- , "publisher"- , "map"- , -- EP-5 pgmq structural keywords.- "workqueue"- , "queue"- , "payload"- , "retry"- , "fanout"- , "dedup"- , "enqueue"- , "seenIn"- , -- EP-6 workflow/operation: reserved so the multi-word result-type parse and- -- node boundaries don't swallow the next block keyword.- "workflow"- , "operation"- , "consistency"- , "body"- , "step"- , "await"- , "sleep"- , "child"- , "patch"- , "continueAsNew"- , -- EP-107 read-model structural words. Clause labels such as table and- -- schema remain usable identifiers because their block parser consumes- -- them with symbol-style matching.- "readmodel"- , "columns"- , "feed"- , "scope"- , "shape"- ]--{- | A CamelCase / snake_case identifier (no dashes): type names, register-names, command\/event\/state names, enum constructors, projection keys.--}-ident :: P Name-ident = (lexeme . try) $ do- c <- asciiLetter <|> char '_'- cs <- many identChar- let w = T.pack (c : cs)- if w `elem` reservedWords- then fail ("unexpected reserved word " <> T.unpack w)- else pure w--{- | A wire-spelling token, which may contain dashes (@partial-divert@,-@hospital-capacity@). Used for the context name, id prefixes, enum wire-spellings, and status-map values.--}-wireWord :: P Text-wireWord = lexeme $ do- c <- asciiLetter <|> asciiDigit- cs <- many (identChar <|> char '-')- pure (T.pack (c : cs))--{- | Patch ids use wire-word spelling, but admit @:@ so the validator can emit-the domain-specific 'WorkflowPatchIdInvalid' diagnostic at the owning item.--}-patchIdWord :: P Text-patchIdWord = lexeme $ do- c <- asciiLetter <|> asciiDigit- cs <- many (identChar <|> char '-' <|> char ':')- pure (T.pack (c : cs))--getLoc :: P Loc-getLoc = (Loc . unPos . sourceLine) <$> getSourcePos------------------------------------------------------------------------------------- Top level-----------------------------------------------------------------------------------data TopItem- = TIId IdDecl- | TIEnum EnumDecl- | TIRule RuleDecl- | TIMapped MappedDecl- | TINode Node--pSpec :: P Spec-pSpec = do- keyword "context"- ctx <- wireWord- mroot <- optional pModuleClause- mlayout <- optional pLayoutClause- items <- many pTopItem- pure- Spec- { specContext = ctx- , specModuleRoot = mroot- , specLayout = mlayout- , specIds = [d | TIId d <- items]- , specEnums = [d | TIEnum d <- items]- , specRules = [d | TIRule d <- items]- , specMapped = [d | TIMapped d <- items]- , specNodes = [n | TINode n <- items]- }---- | @module Acme.Services@ — the optional namespace-prefix clause.-pModuleClause :: P Text-pModuleClause = keyword "module" *> pModulePrefix---- | @layout (prefixed|collocated)@ — the optional placement-style clause.-pLayoutClause :: P Placement-pLayoutClause =- keyword "layout"- *> choice- [ GeneratedPrefix <$ keyword "prefixed"- , CollocatedLeaf <$ keyword "collocated"- ]--{- | A dotted module prefix: one-or-more PascalCase segments joined by dots,-e.g. @Acme@ or @Acme.Services@.--}-pModulePrefix :: P Text-pModulePrefix = lexeme $ do- seg0 <- pSeg- segs <- many (char '.' *> pSeg)- pure (T.intercalate "." (seg0 : segs))- where- pSeg = do- c <- asciiUpper- cs <- many identChar- pure (T.pack (c : cs))--pTopItem :: P TopItem-pTopItem =- choice- [ TIId <$> pIdDecl- , TIEnum <$> pEnumDecl- , TIRule <$> pRuleDecl- , TIMapped <$> pMappedDecl- , TINode . NRouter <$> pRouter- , TINode . NProcess <$> pProcess- , TINode . NContract <$> pContract- , TINode . NIntake <$> pIntake- , TINode . NEmit <$> pEmit- , TINode . NPublisher <$> pPublisher- , TINode . NWorkqueue <$> pWorkqueue- , TINode . NPgmqDispatch <$> pPgmqDispatch- , TINode . NReadModel <$> pReadModel- , TINode . NWorkflow <$> pWorkflow- , TINode . NOperation <$> pOperation- , TINode . NAggregate <$> pAggregate- ]--pIdDecl :: P IdDecl-pIdDecl = do- loc <- getLoc- keyword "id"- name <- ident- _ <- symbol "prefix"- _ <- symbol "="- pfx <- wireWord- pure IdDecl{idName = name, idPrefix = pfx, idLoc = loc}--pEnumDecl :: P EnumDecl-pEnumDecl = do- loc <- getLoc- keyword "enum"- name <- ident- ctors <- braces (many pEnumCtor)- pure EnumDecl{enumName = name, enumCtors = ctors, enumLoc = loc}- where- pEnumCtor = do- c <- ident- _ <- symbol "="- w <- wireWord- pure (c, w)--pRuleDecl :: P RuleDecl-pRuleDecl = do- loc <- getLoc- keyword "rule"- name <- ident- _ <- symbol ":"- dom <- ident- _ <- symbol "->"- cod <- ident- keyword "ex"- cases <- sepBy1 pCase (symbol ";")- pure- RuleDecl- { ruleName = name- , ruleDomain = dom- , ruleCodomain = cod- , ruleCases = cases- , ruleLoc = loc- }- where- pCase = do- c <- ident- _ <- symbol "=>"- e <- pExpr- pure (c, e)------------------------------------------------------------------------------------- Consumer-owned mapped types (EP-149)-----------------------------------------------------------------------------------data MappedKind = MappedRecord | MappedEnum | MappedUnion--data MappedClause- = MCHaskell HaskellSource- | MCBinding Text- | MCBindingVersion Text- | MCCanonical Text- | MCFixtures Text- | MCInitial Text- | MCCodec Text- | MCCodecVersion Text- | MCShape MappedShape--pMappedDecl :: P MappedDecl-pMappedDecl = do- loc <- getLoc- keyword "mapped"- choice [pStructural loc, pOpaque loc]- where- pStructural loc = do- keyword "structural"- kind <-- choice- [ MappedRecord <$ keyword "record"- , MappedEnum <$ keyword "enum"- , MappedUnion <$ keyword "union"- ]- name <- ident- clauses <- braces (many (pStructuralClause kind))- hs <- oneClause "haskell" (\case MCHaskell value -> Just value; _ -> Nothing) clauses- binding <- oneClause "binding" (\case MCBinding value -> Just value; _ -> Nothing) clauses- bindingVersion <- oneClause "binding-version" (\case MCBindingVersion value -> Just value; _ -> Nothing) clauses- canonical <- oneClause "canonical-type" (\case MCCanonical value -> Just value; _ -> Nothing) clauses- fixtures <- oneClause "fixtures" (\case MCFixtures value -> Just value; _ -> Nothing) clauses- initial <- oneClause "initial" (\case MCInitial value -> Just value; _ -> Nothing) clauses- shape <- requiredClause "wire" (\case MCShape value -> Just value; _ -> Nothing) clauses- pure- MappedStructural- { msName = name- , msHaskell = hs- , msBinding = binding- , msBindingVersion = bindingVersion- , msCanonical = canonical- , msFixtures = fixtures- , msInitial = initial- , msShape = shape- , msLoc = loc- }-- pOpaque loc = do- keyword "opaque"- name <- ident- clauses <- braces (many pOpaqueClause)- hs <- oneClause "haskell" (\case MCHaskell value -> Just value; _ -> Nothing) clauses- codec <- oneClause "codec" (\case MCCodec value -> Just value; _ -> Nothing) clauses- version <- oneClause "version" (\case MCCodecVersion value -> Just value; _ -> Nothing) clauses- fixtures <- oneClause "fixtures" (\case MCFixtures value -> Just value; _ -> Nothing) clauses- initial <- oneClause "initial" (\case MCInitial value -> Just value; _ -> Nothing) clauses- pure- MappedOpaque- { moName = name- , moHaskell = hs- , moCodecId = codec- , moCodecVersion = version- , moFixtures = fixtures- , moInitial = initial- , moLoc = loc- }--pStructuralClause :: MappedKind -> P MappedClause-pStructuralClause kind =- choice- [ MCHaskell <$> pHaskellSource- , MCBindingVersion <$> pQuotedFact "binding-version"- , MCBinding <$> pQuotedFact "binding"- , MCCanonical <$> pQuotedFact "canonical-type"- , MCFixtures <$> pQuotedFact "fixtures"- , MCInitial <$> pQuotedFact "initial"- , MCShape <$> pMappedShape kind- ]--pOpaqueClause :: P MappedClause-pOpaqueClause =- choice- [ MCHaskell <$> pHaskellSource- , MCCodec <$> pQuotedFact "codec"- , MCCodecVersion <$> pQuotedFact "version"- , MCFixtures <$> pQuotedFact "fixtures"- , MCInitial <$> pQuotedFact "initial"- ]--pHaskellSource :: P HaskellSource-pHaskellSource = do- keyword "haskell"- keyword "package"- _ <- symbol "="- packageName <- wireWord- keyword "module"- _ <- symbol "="- moduleName <- pModulePrefix- keyword "type"- _ <- symbol "="- typeName <- ident- pure HaskellSource{hsPackage = packageName, hsModule = moduleName, hsType = typeName}--pQuotedFact :: Text -> P Text-pQuotedFact factName = keyword factName *> symbol "=" *> stringLit--pMappedShape :: MappedKind -> P MappedShape-pMappedShape kind = do- keyword "wire"- case kind of- MappedRecord -> do- keyword "object"- keyword "constructor"- _ <- symbol "="- constructor <- ident- unknownFields <- pUnknownFieldsFact- fields <- braces (many pWireField)- pure (ShapeRecord constructor unknownFields fields)- MappedEnum -> do- keyword "string"- ShapeEnum <$> braces (many pWireEnum)- MappedUnion -> do- keyword "tagged-object"- keyword "tag"- _ <- symbol "="- tagField <- stringLit- keyword "contents"- _ <- symbol "="- contentsField <- stringLit- unknownFields <- pUnknownFieldsFact- arms <- braces (many pWireArm)- pure (ShapeUnion (TaggedObject tagField contentsField unknownFields) arms)--pUnknownFieldsFact :: P UnknownFields-pUnknownFieldsFact = do- keyword "unknown-fields"- _ <- symbol "="- choice [RejectUnknown <$ keyword "reject", IgnoreUnknown <$ keyword "ignore"]--pWireField :: P WireField-pWireField = do- loc <- getLoc- haskellName <- ident- keyword "as"- wireKey <- stringLit- _ <- symbol ":"- fieldType <- pMappedTypeExpr- presence <- choice [PRequired <$ keyword "required", POptional <$ keyword "optional"]- onMissing <- optional (keyword "on-missing" *> symbol "=" *> pOnMissing)- pure- WireField- { wfHaskell = haskellName- , wfKey = wireKey- , wfType = fieldType- , wfPresence = presence- , wfOnMissing = onMissing- , wfLoc = loc- }--pWireEnum :: P WireEnum-pWireEnum = do- loc <- getLoc- constructor <- ident- keyword "as"- wireTag <- stringLit- pure WireEnum{weCtor = constructor, weTag = wireTag, weLoc = loc}--pWireArm :: P WireArm-pWireArm = do- loc <- getLoc- constructor <- ident- keyword "as"- wireTag <- stringLit- payload <- optional (symbol ":" *> pMappedTypeExpr)- pure WireArm{waCtor = constructor, waTag = wireTag, waPayload = payload, waLoc = loc}--pMappedTypeExpr :: P TypeExpr-pMappedTypeExpr =- choice- [ TOptional <$> (keyword "Optional" *> pTypeArgument)- , TList <$> (keyword "List" *> pTypeArgument)- , TMap <$> (keyword "Map" *> pTypeArgument)- , TText <$ keyword "Text"- , TInt <$ keyword "Int"- , TBool <$ keyword "Bool"- , TNatural <$ keyword "Natural"- , TTime <$ (keyword "Time" <|> keyword "UTCTime")- , TJson <$ keyword "Json"- , TRef <$> ident- ]- where- pTypeArgument = parens pMappedTypeExpr <|> pTypeAtom- pTypeAtom =- choice- [ TText <$ keyword "Text"- , TInt <$ keyword "Int"- , TBool <$ keyword "Bool"- , TNatural <$ keyword "Natural"- , TTime <$ (keyword "Time" <|> keyword "UTCTime")- , TJson <$ keyword "Json"- , TRef <$> ident- ]--pOnMissing :: P OnMissing-pOnMissing =- choice- [ OmNull <$ keyword "null"- , OmEmptyList <$ (symbol "[" *> symbol "]")- , OmEmptyMap <$ (symbol "{" *> symbol "}")- , OmBool True <$ keyword "true"- , OmBool False <$ keyword "false"- , OmText <$> stringLit- , OmInt <$> integerLiteral- , OmCtor <$> ident- ]--integerLiteral :: P Integer-integerLiteral = lexeme (L.signed (pure ()) L.decimal)--oneClause :: String -> (MappedClause -> Maybe a) -> [MappedClause] -> P (Maybe a)-oneClause clauseName select clauses =- case mapMaybe select clauses of- [] -> pure Nothing- [value] -> pure (Just value)- _ -> fail ("duplicate " <> clauseName <> " clause in mapped declaration")--requiredClause :: String -> (MappedClause -> Maybe a) -> [MappedClause] -> P a-requiredClause clauseName select clauses = do- found <- oneClause clauseName select clauses- maybe (fail ("missing " <> clauseName <> " clause in mapped structural declaration")) pure found------------------------------------------------------------------------------------- Aggregate node-----------------------------------------------------------------------------------data BodyItem- = BICommand Command- | BIEvent Event- | BIWire WireSpec- | BIProjection ProjectionSpec- | BISnapshot SnapshotSpec- | BITransition Transition--pAggregate :: P Aggregate-pAggregate = do- loc <- getLoc- keyword "aggregate"- name <- ident- regs <- pRegsBlock- states <- pStatesLine- positionedItems <- many ((,) <$> getOffset <*> pBodyItem)- let items = map snd positionedItems- wireOffsets = [offset | (offset, BIWire _) <- positionedItems]- projectionOffsets = [offset | (offset, BIProjection _) <- positionedItems]- snapshotOffsets = [offset | (offset, BISnapshot _) <- positionedItems]- case wireOffsets of- _ : duplicateOffset : _ ->- failAt duplicateOffset ("duplicate wire block in aggregate " <> T.unpack name <> " (only one is allowed)")- _ -> pure ()- case projectionOffsets of- _ : duplicateOffset : _ ->- failAt duplicateOffset ("duplicate projection block in aggregate " <> T.unpack name <> " (only one is allowed)")- _ -> pure ()- case snapshotOffsets of- _ : duplicateOffset : _ ->- failAt duplicateOffset ("duplicate snapshot block in aggregate " <> T.unpack name <> " (only one is allowed)")- _ -> pure ()- pure- Aggregate- { aggName = name- , aggRegs = regs- , aggStates = states- , aggCommands = [c | BICommand c <- items]- , aggEvents = [e | BIEvent e <- items]- , aggTransitions = [t | BITransition t <- items]- , aggWire = listToMaybe [w | BIWire w <- items]- , aggProjection = listToMaybe [p | BIProjection p <- items]- , aggSnapshot = listToMaybe [s | BISnapshot s <- items]- , aggLoc = loc- }- where- listToMaybe xs = case xs of (x : _) -> Just x; [] -> Nothing--pRegsBlock :: P [RegDecl]-pRegsBlock = do- keyword "regs"- many pRegDecl--pRegDecl :: P RegDecl-pRegDecl = do- loc <- getLoc- name <- ident- ty <- ident- _ <- symbol "="- initial <- (RegInitText <$> stringLit) <|> (RegInitBare <$> (ident <|> signedDecimalText))- pure RegDecl{regName = name, regType = ty, regInitial = initial, regLoc = loc}--pStatesLine :: P [StateDecl]-pStatesLine = do- keyword "states"- many pStateDecl- where- -- A state decl is an identifier with an optional terminal @!@. The- -- @notFollowedBy@ lookahead stops the list before a transition whose source- -- state would otherwise be swallowed as an extra state, e.g. when a- -- transition directly follows the @states@ line with no command\/event- -- between them. The @try@ backtracks so the identifier is left for- -- 'pTransition'.- pStateDecl = try $ do- loc <- getLoc- -- A @replay-only@ transition marker directly after the states line- -- must not be swallowed: 'ident' would take @replay@ (hyphens are- -- not identifier characters) and strand @-only@.- notFollowedBy (keyword "replay-only")- n <- ident- term <- option False (True <$ symbol "!")- notFollowedBy (symbol "--")- pure StateDecl{stName = n, stTerminal = term, stLoc = loc}--pBodyItem :: P BodyItem-pBodyItem =- choice- [ BICommand <$> pCommand- , BIEvent <$> pEvent- , BIWire <$> pWire- , BIProjection <$> pProjection- , BISnapshot <$> pSnapshot- , BITransition <$> pTransition- ]--pSnapshot :: P SnapshotSpec-pSnapshot = do- loc <- getLoc- keyword "snapshot"- policy <-- choice- [ SnapEvery <$> (keyword "every" *> boundedDecimal)- , SnapOnTerminal <$ symbol "on-terminal"- ]- _ <- symbol "state-codec"- _ <- symbol "version" *> symbol "="- version <- boundedDecimal- _ <- symbol "shape-hash" *> symbol "="- hash <- stringLit- pure SnapshotSpec{snapPolicy = policy, snapCodecVersion = version, snapShapeHash = hash, snapLoc = loc}--pCommand :: P Command-pCommand = do- loc <- getLoc- keyword "command"- name <- ident- fs <- braces (many pField)- pure Command{cmdName = name, cmdFields = fs, cmdLoc = loc}--pField :: P Field-pField = do- n <- ident- mty <- optional (symbol ":" *> ident)- pure Field{fieldName = n, fieldType = mty}--pEvent :: P Event-pEvent = do- loc <- getLoc- (retiring, deprecated) <-- option- (False, False)- ( choice- [ (True, False) <$ keyword "retiring"- , (False, True) <$ keyword "deprecated"- ]- )- keyword "event"- name <- ident- ver <- option 1 pVersion- body <-- choice- [ EventFromCommand <$> (symbol "=" *> keyword "fields" *> parens ident)- , EventFields <$> braces (many pField)- ]- up <- optional pUpcast- pure- Event- { evName = name- , evBody = body- , evVersion = ver- , evUpcastFrom = up- , evRetiring = retiring- , evDeprecated = deprecated- , evLoc = loc- }- where- pUpcast = do- keyword "upcast"- keyword "from"- m <- pVersion- _ <- symbol "="- keyword "HOLE"- pure (m, Hole)--{- | A @vN@ schema-version token (e.g. @v2@). Fails (backtracking) on anything-that is not @v@ immediately followed by digits.--}-pVersion :: P Int-pVersion = do- offset <- getOffset- value <- lexeme (try (char 'v' *> (L.decimal :: P Integer) <* notFollowedBy identChar))- checkedDecimal offset value--pWire :: P WireSpec-pWire = do- keyword "wire"- _ <- symbol "kind"- _ <- symbol "="- k <- wireWord- _ <- symbol "fields"- _ <- symbol "="- f <- wireWord- _ <- symbol "schemaVersion"- _ <- symbol "="- v <- boundedDecimal- pure WireSpec{wireKind = k, wireFields = f, wireSchemaVersion = v}--pProjection :: P ProjectionSpec-pProjection = do- loc <- getLoc- keyword "projection"- table <- ident- cons <- optional (symbol "consistency" *> symbol "=" *> pConsistency)- _ <- symbol "key"- _ <- symbol "="- k <- ident- sm <- optional pStatusMap- pure- ProjectionSpec- { projTable = table- , projConsistency = cons- , projKey = k- , projStatusMap = sm- , projLoc = loc- }- where- pConsistency =- choice [Strong <$ keyword "Strong", Eventual <$ keyword "Eventual"]--pStatusMap :: P Mapping-pStatusMap = do- keyword "status-map"- partial <- option False (True <$ keyword "partial")- pairs <- braces (many pPair)- pure Mapping{mapPairs = pairs, mapPartial = partial}- where- pPair = do- l <- ident- _ <- symbol "=>"- r <- wireWord- pure (l, r)------------------------------------------------------------------------------------- Integration contract (EP-4)-----------------------------------------------------------------------------------pContract :: P ContractNode-pContract = do- loc <- getLoc- keyword "contract"- nm <- ident- _ <- symbol "{"- keyword "schemaVersion"- sv <- boundedDecimal- keyword "discriminator"- disc <- ident- topics <- many pTopic- events <- many pContractEvent- _ <- symbol "}"- pure- ContractNode- { ctrName = nm- , ctrSchemaVersion = sv- , ctrDiscriminator = disc- , ctrTopics = topics- , ctrEvents = events- , ctrLoc = loc- }- where- pTopic = do- keyword "topic"- alias <- ident- t <- stringLit- pure (alias, t)- pContractEvent = do- keyword "event"- nm <- ident- keyword "on"- topicAlias <- ident- fs <- braces (many pContractField)- pure ContractEvent{ceName = nm, ceTopic = topicAlias, ceFields = fs}- pContractField = do- n <- ident- _ <- symbol ":"- ty <- pContractType- _ <- optional (symbol ";")- pure ContractField{cfName = n, cfType = ty}- pContractType =- choice- [ CTypeId <$> (keyword "typeid" *> stringLit)- , CText <$ keyword "text"- , CInt <$ keyword "int"- ]--pIntake :: P IntakeNode-pIntake = do- loc <- getLoc- keyword "intake"- nm <- ident- _ <- symbol "{"- keyword "contract"- ctr <- ident- keyword "topic"- tp <- ident- keyword "accept"- acc <- some ident- binds <- many pBindRow- keyword "dedupe"- keyword "key"- dk <- ident- keyword "policy"- dp <- ident- persistence <-- option InkPersistFull $- keyword "persist"- *> symbol "="- *> choice- [ InkPersistFull <$ keyword "full-envelope"- , InkPersistDedupeOnly <$ keyword "dedupe-only"- ]- dec <- pDecode- disp <- pDisposition- _ <- symbol "}"- pure- IntakeNode- { inkName = nm- , inkContract = ctr- , inkTopic = tp- , inkAccept = acc- , inkBinds = binds- , inkDedupeKey = dk- , inkDedupePolicy = dp- , inkPersist = persistence- , inkDecode = dec- , inkDisposition = disp- , inkLoc = loc- }- where- pBindRow = do- keyword "bind"- f <- ident- keyword "from"- src <- pWireSource- req <- option False (True <$ keyword "required")- xc <- option False (True <$ (keyword "cross-check" *> keyword "body"))- pure BindRow{brField = f, brSource = src, brRequired = req, brCrossCheck = xc}- pWireSource =- choice- [ SrcHeader <$> (keyword "header" *> stringLit)- , SrcKafkaKey <$ keyword "kafka-key"- , SrcKafkaCursor <$ keyword "kafka-cursor"- , SrcBody <$ keyword "body"- ]- pDecode = do- keyword "decode"- _ <- symbol "{"- keyword "envelope"- env <- pEnvelopePolicy- keyword "body"- strict <- (True <$ keyword "strict") <|> (False <$ keyword "lenient")- keyword "schemaVersion"- _ <- symbol "=="- v <- boundedDecimal- _ <- symbol "}"- pure DecodeSpec{decEnvelope = env, decBodyStrict = strict, decBodySchemaVersion = v}- pEnvelopePolicy = do- a <- wireWord- b <- wireWord- pure (a <> " " <> b)- pDisposition = do- keyword "disposition"- rows <- braces (many pDispositionRow)- pure rows- pDispositionRow = do- loc <- getLoc- o <- ident- _ <- symbol "=>"- act <- pInboxAction- pure DispositionRow{drOutcome = o, drAction = act, drLoc = loc}- pInboxAction =- choice- [ IAckOk <$ keyword "ackOk"- , IRetry <$> (keyword "retry" *> pWindow)- , IDeadLetter <$> (keyword "deadLetter" *> optional stringLit)- ]--pEmit :: P EmitNode-pEmit = do- loc <- getLoc- keyword "emit"- nm <- ident- _ <- symbol "{"- keyword "contract"- ctr <- ident- keyword "topic"- tp <- ident- keyword "source"- src <- stringLit- keyword "key"- k <- ident- keyword "map"- disc <- ident- (rows, skip) <- braces pMapRows- keyword "messageId"- mid <- pDerive- keyword "idempotencyKey"- idk <- pDerive- _ <- symbol "}"- pure- EmitNode- { emName = nm- , emContract = ctr- , emTopic = tp- , emSource = src- , emKey = k- , emDiscriminant = disc- , emMap = rows- , emSkip = skip- , emMessageId = mid- , emIdempotencyKey = idk- , emLoc = loc- }- where- pMapRows = do- rows <- many pMapRow- skip <- option False (True <$ try (symbol "_" *> symbol "=>" *> keyword "skip"))- pure (rows, skip)- pMapRow = try $ do- loc <- getLoc- v <- stringLit- _ <- symbol "=>"- ev <- ident- pure EmitMapRow{emrValue = v, emrEvent = ev, emrLoc = loc}- pDerive = do- keyword "derive"- pfx <- optional stringLit- keyword "hole"- pure DeriveSpec{dsPrefix = pfx}--pPublisher :: P PublisherNode-pPublisher = do- loc <- getLoc- keyword "publisher"- nm <- ident- _ <- symbol "{"- keyword "emit"- em <- ident- keyword "ordering"- ord <- ident- keyword "maxAttempts"- ma <- boundedDecimal- keyword "backoff"- bk <- ident- bw <- pWindow- bm <- optional (keyword "max" *> symbol "=" *> pWindow)- multiplier <- optional (keyword "multiplier" *> symbol "=" *> decimalText)- keyword "outboxId"- keyword "stable"- keyword "from"- obf <- ident- _ <- symbol "}"- pure- PublisherNode- { pubName = nm- , pubEmit = em- , pubOrdering = ord- , pubMaxAttempts = ma- , pubBackoff = BackoffSpec{boKind = bk, boWindow = bw, boMax = bm, boMultiplier = multiplier}- , pubOutboxField = obf- , pubLoc = loc- }--pWorkqueue :: P WorkqueueNode-pWorkqueue = do- loc <- getLoc- keyword "workqueue"- nm <- ident- _ <- symbol "{"- keyword "queue"- _ <- symbol "logical" *> symbol "="- logical <- stringLit- keyword "derive"- _ <- symbol "physical" *> symbol "="- phys <- stringLit- _ <- symbol "dlq" *> symbol "="- dlqName <- stringLit- _ <- symbol "table" *> symbol "="- tbl <- stringLit- ordering <- option WqUnordered pOrdering- groupKey <- optional pGroupKey- provision <- option WqStandard pProvision- keyword "payload"- pn <- ident- fields <- braces (many pWqField)- keyword "retry"- _ <- symbol "maxRetries" *> symbol "="- mr <- boundedDecimal- _ <- symbol "delay" *> symbol "="- dl <- pWindow- _ <- symbol "dlq" *> symbol "="- dlqOn <- (True <$ keyword "on") <|> (False <$ keyword "off")- keyword "disposition"- disp <- braces (many pWqDispRow)- _ <- symbol "}"- pure- WorkqueueNode- { wqName = nm- , wqLogical = logical- , wqPhysical = phys- , wqDlq = dlqName- , wqTable = tbl- , wqOrdering = ordering- , wqGroupKey = groupKey- , wqProvision = provision- , wqPayloadName = pn- , wqPayload = fields- , wqMaxRetries = mr- , wqDelay = dl- , wqDlqOn = dlqOn- , wqDisposition = disp- , wqLoc = loc- }- where- pOrdering = do- _ <- symbol "ordering"- choice- [ WqUnordered <$ symbol "unordered"- , WqFifoThroughput <$ symbol "fifo-throughput"- , WqFifoRoundRobin <$ symbol "fifo-roundrobin"- ]- pGroupKey = do- _ <- symbol "group" *> symbol "key" *> symbol "from"- field <- ident- _ <- symbol "via"- via <- ident- fixture <- optional (symbol "fixture" *> stringLit)- pure WqGroupKey{gkField = field, gkVia = via, gkFixture = fixture}- pProvision = do- _ <- symbol "provision"- choice- [ WqStandard <$ symbol "standard"- , WqUnlogged <$ symbol "unlogged"- , do- _ <- symbol "partitioned" *> symbol "("- _ <- symbol "interval" *> symbol "="- interval <- stringLit- _ <- symbol "," *> symbol "retention" *> symbol "="- retention <- stringLit- _ <- symbol ")"- pure (WqPartitioned interval retention)- ]- pWqField = do- n <- ident- _ <- symbol "->"- w <- stringLit- ty <- ident- req <- option False (True <$ keyword "required")- pure WqField{wqfName = n, wqfWire = w, wqfType = ty, wqfRequired = req}- pWqDispRow = do- loc <- getLoc- o <- ident- _ <- symbol "->"- act <- choice [IAckOk <$ keyword "ackOk", IRetry <$> (keyword "retry" *> pWindow), IDeadLetter <$> (keyword "deadLetter" *> optional stringLit)]- pure WqDispRow{wqdOutcome = o, wqdAction = act, wqdLoc = loc}--pReadModel :: P ReadModelNode-pReadModel = do- loc <- getLoc- keyword "readmodel"- name <- ident- _ <- symbol "{"- _ <- symbol "table" *> symbol "="- table <- stringLit- _ <- symbol "schema" *> symbol "="- schema <- stringLit- _ <- symbol "columns"- columns <- braces (many pColumn)- _ <- symbol "version" *> symbol "="- version <- boundedDecimal- _ <- symbol "shape" *> symbol "="- shape <- stringLit- _ <- symbol "consistency" *> symbol "="- consistency <- pConsistency- scope <- optional (symbol "scope" *> symbol "=" *> pScope)- _ <- symbol "feed" *> symbol "="- feed <- pFeed- subscription <- optional (symbol "subscription" *> symbol "=" *> stringLit)- _ <- symbol "}"- pure- ReadModelNode- { rmName = name- , rmTable = table- , rmSchema = schema- , rmColumns = columns- , rmVersion = version- , rmShape = shape- , rmConsistency = consistency- , rmScope = scope- , rmFeed = feed- , rmSubscription = subscription- , rmLoc = loc- }- where- pColumn =- RmColumn- <$> wireWord- <*> ident- <*> option False (True <$ keyword "required")- pConsistency = choice [Strong <$ keyword "Strong", Eventual <$ keyword "Eventual"]- pScope =- choice- [ RmEntireLog <$ keyword "entire-log"- , RmCategory <$> (keyword "category" *> stringLit)- ]- pFeed = choice [RmInline <$ keyword "inline", RmSubscription <$ keyword "subscription"]--pPgmqDispatch :: P PgmqDispatchNode-pPgmqDispatch = do- loc <- getLoc- keyword "dispatch"- nm <- ident- _ <- symbol "{"- keyword "source"- _ <- symbol "readModel" *> symbol "="- srm <- ident- _ <- symbol "key" *> symbol "="- sk <- ident- keyword "fanout"- _ <- symbol "body" *> symbol "="- fb <- ident- keyword "dedup"- _ <- symbol "key" *> symbol "="- dk <- ident- _ <- keyword "seenIn" *> symbol "readModel" *> symbol "="- drm <- ident- _ <- symbol "field" *> symbol "="- drmf <- ident- _ <- keyword "seenIn" *> symbol "queue" *> symbol "="- dq <- ident- _ <- symbol "field" *> symbol "="- dqf <- ident- keyword "enqueue"- _ <- symbol "to" *> symbol "="- enq <- ident- _ <- symbol "}"- pure- PgmqDispatchNode- { pdName = nm- , pdSourceReadModel = srm- , pdSourceKey = sk- , pdFanoutBody = fb- , pdDedupKey = dk- , pdDedupReadModel = drm- , pdDedupReadModelField = drmf- , pdDedupQueue = dq- , pdDedupQueueField = dqf- , pdEnqueueTo = enq- , pdLoc = loc- }--pWorkflow :: P WorkflowNode-pWorkflow = do- loc <- getLoc- keyword "workflow"- wid <- ident- keyword "name"- nm <- stringLit- keyword "in"- inTy <- ident- inFields <- option [] (braces (many pField))- keyword "out"- outTy <- ident- keyword "id"- keyword "from"- keyword "input"- idField <- optional (symbol "." *> ident)- keyword "via"- idVia <- ident- keyword "body"- body <- many pWfBodyItem- pure- WorkflowNode- { wfId = wid- , wfStable = nm- , wfInput = inTy- , wfInputFields = inFields- , wfOutput = outTy- , wfIdField = idField- , wfIdVia = idVia- , wfBody = body- , wfLoc = loc- }- where- pWfBodyItem =- choice- [ do- loc <- getLoc- WfStep <$> (keyword "step" *> wireWord) <*> (symbol "->" *> ident) <*> pure loc- , do- loc <- getLoc- WfAwait <$> (keyword "await" *> wireWord) <*> (symbol "->" *> ident) <*> pure loc- , do- loc <- getLoc- WfSleep <$> (keyword "sleep" *> wireWord) <*> (keyword "after" *> ident) <*> pure loc- , do- loc <- getLoc- WfChild- <$> (keyword "child" *> wireWord)- <*> (keyword "id" *> keyword "input" *> keyword "via" *> ident)- <*> (symbol "->" *> ident)- <*> pure loc- , do- loc <- getLoc- WfPatch- <$> (keyword "patch" *> patchIdWord)- <*> braces (many pWfBodyItem)- <*> pure loc- , do- loc <- getLoc- WfContinueAsNew <$> (keyword "continueAsNew" *> ident) <*> pure loc- ]--pOperation :: P OperationNode-pOperation = do- loc <- getLoc- keyword "operation"- nm <- ident- shape <-- choice- [ pCommandOp- , pQueryOp- , pSignalOp- , pRunOp- ]- pure OperationNode{opName = nm, opShape = shape, opLoc = loc}- where- pCommandOp = do- keyword "command"- keyword "on"- agg <- ident- _ <- keyword "stream" *> keyword "from"- sf <- ident- keyword "via"- sv <- ident- proj <- option [] (keyword "project" *> brackets (many ident))- pure (CommandOp agg sf sv proj)- pQueryOp = do- keyword "query"- rm <- ident- keyword "input"- inp <- ident- keyword "result"- res <- pTypeExpr- cons <- option "Strong" (keyword "consistency" *> ident)- pure (QueryOp rm inp res cons)- pSignalOp = do- keyword "signal"- lbl <- wireWord- keyword "of"- wf <- ident- _ <- keyword "key" *> keyword "from"- kf <- ident- keyword "via"- kv <- ident- keyword "value"- val <- ident- pure (SignalOp lbl wf kf kv val)- pRunOp = do- keyword "run"- wf <- ident- keyword "input"- inp <- ident- _ <- keyword "outcome" *> symbol "->"- oc <- ident- pure (RunOp wf inp oc)- -- A result type expression, possibly multi-word like @Maybe TransferDecision@.- pTypeExpr = do- ws <- some ident- pure (T.unwords ws)------------------------------------------------------------------------------------- Process manager + durable timer (EP-3)-----------------------------------------------------------------------------------pProcess :: P ProcessNode-pProcess = do- loc <- getLoc- keyword "process"- pid <- ident- keyword "name"- nm <- stringLit- inp <- pInputDecl- corr <- pCorrelate- saga <- pSaga- keyword "target"- tgt <- ident- projs <- keyword "projections" *> brackets (many ident)- handle <- pHandle- _ <- optional pDispatchIdLine- rejected <- pPolicyLine "rejected"- poison <- pPolicyLine "poison"- timer <- pTimerNode- pure- ProcessNode- { procId = pid- , procName = nm- , procInput = inp- , procCorrelate = corr- , procSaga = saga- , procTarget = tgt- , procProjections = projs- , procHandle = handle- , procRejected = rejected- , procPoison = poison- , procTimer = timer- , procLoc = loc- }--pRouter :: P RouterNode-pRouter = do- loc <- getLoc- keyword "router"- rid <- ident- keyword "name"- nm <- stringLit- inp <- pInputDecl- key <- pRouterKey- resolved <- pResolveDecl- keyword "target"- target <- ident- projections <- keyword "projections" *> brackets (many ident)- dispatch <- pRouterDispatch- pRouterDispatchIdLine- rejected <- pPolicyLine "rejected"- poison <- pPolicyLine "poison"- pure- RouterNode- { rtId = rid- , rtName = nm- , rtInput = inp- , rtKey = key- , rtResolve = resolved- , rtTarget = target- , rtProjections = projections- , rtDispatch = dispatch- , rtRejected = rejected- , rtPoison = poison- , rtLoc = loc- }--pRouterKey :: P CorrelateDecl-pRouterKey = do- keyword "key"- _ <- keyword "input" *> symbol "."- field <- ident- keyword "via"- via <- ident- pure CorrelateDecl{corrField = field, corrVia = via}--pResolveDecl :: P ResolveDecl-pResolveDecl = do- loc <- getLoc- keyword "resolve"- keyword "stable"- keyword "via"- source <- choice [ResolveReadModel <$> (keyword "read-model" *> ident), ResolveHole <$ keyword "hole"]- keyword "row"- row <- braces (many ident)- pure ResolveDecl{rvSource = source, rvRow = row, rvLoc = loc}--pRouterDispatch :: P RouterDispatchNode-pRouterDispatch = do- loc <- getLoc- keyword "dispatch-each"- command <- ident- fields <- braces (many pFieldBinding)- disposition <-- DispatchDisposition- <$> (keyword "on-appended" *> pDisp)- <*> (symbol ";" *> keyword "on-duplicate" *> pDisp)- <*> (symbol ";" *> keyword "on-failed" *> pDisp)- pure RouterDispatchNode{rdCommand = command, rdFields = fields, rdDisposition = disposition, rdLoc = loc}--pRouterDispatchIdLine :: P ()-pRouterDispatchIdLine = do- keyword "dispatch-id"- _ <- symbol "strategy" *> symbol "=" *> keyword "uuidv5"- _ <- symbol "from" *> symbol "=" *> parens fixedInputs- pure ()- where- fixedInputs = do- keyword "name"- _ <- symbol ","- keyword "key"- _ <- symbol ","- keyword "sourceEventId"- _ <- symbol ","- keyword "targetStreamName"- _ <- symbol ","- keyword "occurrence"--pPolicyLine :: Text -> P PolicyChoice-pPolicyLine clause = keyword clause *> symbol "=>" *> pPolicyChoice--pPolicyChoice :: P PolicyChoice-pPolicyChoice =- choice- [ PolHalt <$ keyword "halt"- , PolDeadLetter <$ keyword "deadLetter"- , PolSkip <$ keyword "skip"- ]--pInputDecl :: P InputDecl-pInputDecl = do- keyword "input"- nm <- ident- fs <- braces (many pField)- pure InputDecl{inName = nm, inFields = fs}--pCorrelate :: P CorrelateDecl-pCorrelate = do- keyword "correlate"- _ <- keyword "input" *> symbol "."- f <- ident- keyword "via"- v <- ident- pure CorrelateDecl{corrField = f, corrVia = v}--pSaga :: P SagaRef-pSaga = do- keyword "saga"- agg <- ident- keyword "category"- categoryName <- stringLit- pure SagaRef{sagaAgg = agg, sagaCategory = categoryName}--pHandle :: P HandleNode-pHandle = do- keyword "on"- onName <- ident- adv <- pAdvance- disps <- many pDispatch- keyword "schedule"- sched <- ident- pure HandleNode{hOn = onName, hAdvance = adv, hDispatch = disps, hSchedule = sched}--pAdvance :: P AdvanceNode-pAdvance = do- keyword "advance"- cmd <- ident- fs <- braces (many pFieldBinding)- pure AdvanceNode{advCommand = cmd, advFields = fs}--pDispatch :: P DispatchNode-pDispatch = do- loc <- getLoc- keyword "dispatch"- tgt <- ident- _ <- symbol "@"- key <- dottedRef- cmd <- ident- fs <- braces (many pFieldBinding)- disp <-- DispatchDisposition- <$> (keyword "on-appended" *> pDisp)- <*> (symbol ";" *> keyword "on-duplicate" *> pDisp)- <*> (symbol ";" *> keyword "on-failed" *> pDisp)- pure DispatchNode{dispTarget = tgt, dispKey = key, dispCommand = cmd, dispFields = fs, dispDisposition = disp, dispLoc = loc}--pDisp :: P Disp-pDisp =- choice- [ DAckOk <$ keyword "AckOk"- , DRetry <$ keyword "Retry"- , DDeadLetter <$> (keyword "DeadLetter" *> stringLit)- ]---- The dispatch-id line is a fixed, runtime-owned strategy; parse and discard.-pDispatchIdLine :: P ()-pDispatchIdLine = do- keyword "dispatch-id"- _ <- symbol "strategy" *> symbol "=" *> ident- _ <- symbol "from" *> symbol "=" *> parens (sepBy dottedRef (symbol ","))- pure ()--pTimerNode :: P TimerNode-pTimerNode = do- loc <- getLoc- keyword "timer"- nm <- ident- tid <- keyword "id" *> pIdExpr- fat <- keyword "fireAt" *> pFireAt- pay <- keyword "payload" *> braces (many pFieldBinding)- fire <- pFire- _ <- keyword "decode" *> keyword "unknown-status" *> symbol "=>"- unk <- ident- keyword "max-attempts"- ma <- boundedDecimal- keyword "dead-letter"- dl <- stringLit- pure- TimerNode- { tmName = nm- , tmId = tid- , tmFireAt = fat- , tmPayload = pay- , tmFire = fire- , tmDecodeUnknown = unk- , tmMaxAttempts = ma- , tmDeadLetter = dl- , tmLoc = loc- }--pIdExpr :: P IdExpr-pIdExpr = do- keyword "uuidv5"- pfx <- stringLit- _ <- symbol "<>"- _ <- ident -- correlationId (fixed)- pure IdExpr{ideStrategy = UuidV5Id, idePrefix = pfx}--pFireAt :: P FireAtExpr-pFireAt = do- _ <- keyword "input" *> symbol "."- f <- ident- _ <- symbol "+"- w <- pWindow- pure FireAtExpr{faField = f, faWindow = w}--pWindow :: P Text-pWindow = lexeme $ do- ds <- some digitChar- u <- choice [char 's', char 'm', char 'h'] <?> "time unit: s, m, or h"- notFollowedBy letterChar <?> "time unit: s, m, or h"- pure (T.pack (ds <> [u]))--decimalText :: P Text-decimalText = lexeme $ do- whole <- some digitChar- fractional <- optional (char '.' *> some digitChar)- pure (T.pack (whole <> maybe "" ('.' :) fractional))--signedDecimalText :: P Text-signedDecimalText = lexeme $ do- sign <- optional (char '-')- digits <- some digitChar- pure (T.pack (maybe "" pure sign <> digits))--pFire :: P FireNode-pFire = do- keyword "fire"- keyword "dispatch"- tgt <- ident- _ <- symbol "@"- key <- dottedRef- cmd <- ident- fs <- braces (many pFieldBinding)- fid <- keyword "fired-event-id" *> pIdExpr- disp <-- FireDisposition- <$> (keyword "on-ok" *> pFireOutcome)- <*> (symbol ";" *> keyword "on-reject" *> pFireOutcome)- <*> (symbol ";" *> keyword "on-ambiguous" *> pFireOutcome)- <*> (symbol ";" *> keyword "on-error" *> pFireOutcome)- <*> (symbol ";" *> keyword "not-mine" *> pFireOutcome)- pure FireNode{fireTarget = tgt, fireKey = key, fireCommand = cmd, fireFields = fs, fireFiredEventId = fid, fireDisposition = disp}--pFireOutcome :: P FireOutcome-pFireOutcome = choice [OFired <$ keyword "Fired", ORetry <$ keyword "Retry"]--pFieldBinding :: P FieldBinding-pFieldBinding = do- n <- ident- v <- optional (symbol "=" *> pBindingValue)- pure FieldBinding{fbName = n, fbValue = v}---- | A binding value: a quoted string (kept quoted) or a dotted reference.-pBindingValue :: P Text-pBindingValue = choice [quoted, dottedRef]- where- quoted = do- s <- stringLit- pure ("\"" <> s <> "\"")--{- | A dotted/plain reference token like @input.hospitalId@, @timer.id@,-@correlationId@.--}-dottedRef :: P Text-dottedRef = lexeme $ do- c <- asciiLetter- cs <- many (asciiAlphaNum <|> char '_' <|> char '.')- pure (T.pack (c : cs))--{- | A double-quoted string literal, returning raw (unescaped) inner text.-The surface syntax supports a closed escape set so unknown escapes remain-available for backward-compatible extensions.--}-stringLit :: P Text-stringLit = lexeme $ do- _ <- char '"'- s <- many strChar- _ <- char '"'- pure (T.pack s)- where- strChar =- choice- [ char '\\' *> escapeCode- , char '\n' *> fail "unescaped newline in string literal (write \\n)"- , anySingleBut '"'- ]- escapeCode =- choice- [ '"' <$ char '"'- , '\\' <$ char '\\'- , '\n' <$ char 'n'- , '\t' <$ char 't'- , '\r' <$ char 'r'- , anySingle >>= \c -> fail ("unknown escape sequence \\" <> [c] <> " in string literal")- ]--brackets :: P a -> P a-brackets = between (symbol "[") (symbol "]")------------------------------------------------------------------------------------- Transitions-----------------------------------------------------------------------------------data Clause- = CGuard Expr- | CWrite Name Expr- | CEmit Name- | CGoto Name--pTransition :: P Transition-pTransition = do- startOffset <- getOffset- loc <- getLoc- -- Plan 143: a @replay-only@ prefix marks the transition as serving- -- inversion only; it lowers to a keiki 'ReplayOnly' edge.- mode <- option TmLive (TmReplayOnly <$ keyword "replay-only")- src <- ident- _ <- symbol "--"- cmd <- ident- _ <- symbol "-->"- positionedClauses <- many ((,) <$> getOffset <*> (pClause <* optional (symbol ";")))- let clauses = map snd positionedClauses- gotos = [(offset, target) | (offset, CGoto target) <- positionedClauses]- transitionName = T.unpack src <> " -- " <> T.unpack cmd- gt <- case gotos of- [] -> failAt startOffset ("transition " <> transitionName <> " is missing a goto clause")- [(_, target)] -> pure target- (_, firstTarget) : (duplicateOffset, _) : _ ->- failAt- duplicateOffset- ("duplicate goto clause (transition " <> transitionName <> " already declared goto " <> T.unpack firstTarget <> ")")- let guards = [e | CGuard e <- clauses]- pure- Transition- { tSource = src- , tCommand = cmd- , tGuard = case guards of [] -> Nothing; es -> Just (foldr1 EAnd es)- , tWrites = [(r, e) | CWrite r e <- clauses]- , tEmits = [n | CEmit n <- clauses]- , tGoto = gt- , tMode = mode- , tLoc = loc- }--pClause :: P Clause-pClause =- choice- [ CGuard <$> (keyword "guard" *> pExpr)- , (\r e -> CWrite r e) <$> (keyword "write" *> ident) <*> (symbol ":=" *> pExpr)- , try $ do- keyword "emit"- eventName <- ident- notFollowedBy (symbol "{")- pure (CEmit eventName)- , CGoto <$> (keyword "goto" *> ident)- ]------------------------------------------------------------------------------------- Expr sublanguage-----------------------------------------------------------------------------------pExpr :: P Expr-pExpr = makeExprParser pTerm operatorTable--pTerm :: P Expr-pTerm =- choice- [ parens pExpr- , EAtom . ABool <$> (True <$ keyword "true" <|> False <$ keyword "false")- , EAtom . AName <$> ident- ]--{- | Highest precedence first: relational comparisons bind tighter than @&&@,-which binds tighter than @||@.--}-operatorTable :: [[Operator P Expr]]-operatorTable =- [- [ InfixN (ECmp OpLe <$ op "<=")- , InfixN (ECmp OpGe <$ op ">=")- , InfixN (ECmp OpEq <$ op "==")- , InfixN (ECmp OpNeq <$ op "!=")- , InfixN (ECmp OpLt <$ op "<")- , InfixN (ECmp OpGt <$ op ">")- ]- , [InfixL (EAnd <$ op "&&")]- , [InfixL (EOr <$ op "||")]- ]- where- op s = symbol s+-- | The megaparsec parser for the keiro DSL. Turns @.keiro@ text into the typed+-- 'Spec' AST. The notation is keyword-driven: newlines and @#@-comments are+-- whitespace, structure comes from keywords (@aggregate@, @regs@, @states@,+-- @command@, @event@, @wire@, @projection@) and the transition arrow+-- @Src -- Command --> clauses@. Guards and write right-hand sides are parsed as+-- a typed 'Expr' (never an opaque string) so the validator can scope-check them.+module Keiro.Dsl.Parser+ ( ParseError,+ ParseFailure (..),+ ParsedSource (..),+ parseSource,+ parseSpec,+ parseSpecText,+ renderParseFailure,+ )+where++import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)+import Data.Bifunctor (first)+import Data.Char (isAlpha, isAlphaNum, isAscii, isDigit, isUpper)+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Read qualified as TR+import Data.Void (Void)+import Keiro.Dsl.Grammar+import Keiro.Dsl.LanguageVersion+import Numeric.Natural (Natural)+import Text.Megaparsec hiding (ParseError)+import Text.Megaparsec.Char (char, digitChar, letterChar, space1)+import Text.Megaparsec.Char.Lexer qualified as L++-- | A rendered, line-numbered parse error, ready to print to the user.+type ParseError = Text++type P = Parsec Void Text++-- | Parse a @.keiro@ source. The 'FilePath' is used only as the source name in+-- diagnostics (megaparsec's line/column reporting); it need not exist on disk.+-- This is the canonical signature shared across all keiro-dsl plans.+parseSpec :: FilePath -> Text -> Either ParseError Spec+parseSpec src input = parsedSpec <$> first renderParseFailure (parseSource src input)++-- | Convenience wrapper for callers without a source name (tests, stdin).+parseSpecText :: Text -> Either ParseError Spec+parseSpecText = parseSpec "<input>"++-- | Parse a source without discarding the language contract selected for it.+-- Source selection completes before the selected body grammar is run.+parseSource :: FilePath -> Text -> Either ParseFailure ParsedSource+parseSource src input = do+ sourceLanguage <- selectSourceLanguage src input+ ensureBodyFeatures src sourceLanguage input+ definition <- case lookupLanguageDefinition (effectiveLanguageVersion sourceLanguage) of+ Just value -> Right value+ Nothing -> Left (unsupportedDiagnostic src sourceLanguage)+ spec <- parseSelectedBody definition sourceLanguage+ pure ParsedSource {parsedSourceLanguage = sourceLanguage, parsedSpec = spec}+ where+ parseSelectedBody definition sourceLanguage =+ let parser = case definitionBodyParser definition of+ LanguageBodyParserV1 ->+ sc+ *> case sourceLanguage of+ LegacyUnversioned -> pSpec False <* eof+ DeclaredLanguage {} -> pDeclaredPreamble *> pSpec False <* eof+ LanguageBodyParserV2 ->+ sc *> pDeclaredPreamble *> pSpec True <* eof+ in case runParser parser src input of+ Left bundle -> Left (BodyGrammarFailure (T.pack (errorBundlePretty bundle)))+ Right spec -> Right spec++-- | Consume a preamble already validated by 'selectSourceLanguage'.+pDeclaredPreamble :: P ()+pDeclaredPreamble = do+ keyword "language"+ keyword "keiro-dsl"+ _ <- lexeme (some digitChar)+ pure ()++data SignificantLine = SignificantLine+ { significantLineNumber :: !Int,+ significantLineText :: !Text+ }++selectSourceLanguage :: FilePath -> Text -> Either ParseFailure SourceLanguage+selectSourceLanguage src input =+ case significantLines input of+ [] -> Right LegacyUnversioned+ firstLine : rest ->+ case filter isLanguageLine (firstLine : rest) of+ [] -> Right LegacyUnversioned+ languageLine : laterLanguageLines+ | significantLineNumber languageLine /= significantLineNumber firstLine ->+ Left (sourceFailure MisplacedLanguagePreamble languageLine Nothing Nothing)+ | otherwise -> do+ version <- parsePreamble languageLine+ case laterLanguageLines of+ duplicateLine : _ ->+ Left (sourceFailure DuplicateLanguagePreamble duplicateLine Nothing Nothing)+ [] -> case lookupLanguageDefinition version of+ Nothing -> Left (sourceFailure UnsupportedLanguageVersion languageLine (Just (languageVersionText version)) (Just version))+ Just _ -> Right (DeclaredLanguage version (Loc (significantLineNumber languageLine)))+ where+ sourceFailure code line tokenText declared =+ SourceLanguageFailure+ SourceLanguageDiagnostic+ { sourceLanguageErrorCode = code,+ sourceLanguageSource = src,+ sourceLanguageLoc = Loc (significantLineNumber line),+ sourceLanguageToken = tokenText,+ sourceLanguageDeclaredVersion = declared,+ sourceLanguageSupportedVersions = supportedLanguageVersions+ }++ parsePreamble line = case T.words (significantLineText line) of+ ["language", "keiro-dsl", tokenText]+ | T.all (\c -> isAscii c && isDigit c) tokenText && not (T.null tokenText) ->+ case TR.decimal tokenText :: Either String (Natural, Text) of+ Right (value, "") -> case languageVersion value of+ Just version -> Right version+ Nothing -> invalid line tokenText+ _ -> invalid line tokenText+ wordsFound -> invalid line (T.unwords wordsFound)++ invalid line tokenText =+ Left (sourceFailure InvalidLanguageVersion line (Just tokenText) Nothing)++unsupportedDiagnostic :: FilePath -> SourceLanguage -> ParseFailure+unsupportedDiagnostic src sourceLanguage =+ SourceLanguageFailure+ SourceLanguageDiagnostic+ { sourceLanguageErrorCode = UnsupportedLanguageVersion,+ sourceLanguageSource = src,+ sourceLanguageLoc = case sourceLanguage of+ LegacyUnversioned -> Loc 1+ DeclaredLanguage {languageVersionLoc = loc} -> loc,+ sourceLanguageToken = Just (languageVersionText (effectiveLanguageVersion sourceLanguage)),+ sourceLanguageDeclaredVersion = Just (effectiveLanguageVersion sourceLanguage),+ sourceLanguageSupportedVersions = supportedLanguageVersions+ }++significantLines :: Text -> [SignificantLine]+significantLines =+ mapMaybe significant . zip [1 ..] . T.lines+ where+ significant (lineNumber, line) =+ let content = T.strip (T.takeWhile (/= '#') line)+ in if T.null content+ then Nothing+ else Just SignificantLine {significantLineNumber = lineNumber, significantLineText = content}++isLanguageLine :: SignificantLine -> Bool+isLanguageLine line = case T.words (significantLineText line) of+ "language" : _ -> True+ _ -> False++-- | Reject syntax owned by a successor before the frozen predecessor grammar+-- can turn it into generic parser noise.+ensureBodyFeatures :: FilePath -> SourceLanguage -> Text -> Either ParseFailure ()+ensureBodyFeatures src sourceLanguage input =+ case filter requiresSuccessorSyntax (significantLines input) of+ marker : _+ | languageVersionNumber (effectiveLanguageVersion sourceLanguage) < 2 ->+ Left+ ( SourceLanguageFailure+ SourceLanguageDiagnostic+ { sourceLanguageErrorCode = LanguageFeatureRequiresVersion,+ sourceLanguageSource = src,+ sourceLanguageLoc = Loc (significantLineNumber marker),+ sourceLanguageToken = Just (languageVersionText (effectiveLanguageVersion sourceLanguage)),+ sourceLanguageDeclaredVersion = Just (effectiveLanguageVersion sourceLanguage),+ sourceLanguageSupportedVersions = supportedLanguageVersions+ }+ )+ _ -> Right ()+ where+ requiresSuccessorSyntax line =+ case wordsFound of+ "mapped" : "nominal" : _ -> True+ _ ->+ "using" `elem` wordsFound+ || "Integer" `elem` wordsFound+ || "implementation hole" `T.isInfixOf` content+ || "reg." `T.isInfixOf` content+ || "cmd." `T.isInfixOf` content+ where+ content = significantLineText line+ wordsFound = T.words content++--------------------------------------------------------------------------------+-- Lexer+--------------------------------------------------------------------------------++-- | Space consumer: spaces, newlines, and @#@ line comments are all whitespace.+sc :: P ()+sc = L.space space1 (L.skipLineComment "#") empty++lexeme :: P a -> P a+lexeme = L.lexeme sc++symbol :: Text -> P Text+symbol = L.symbol sc++-- | A reserved keyword: the literal word not followed by an identifier+-- character (so @goto@ matches @goto@ but not @gotoX@).+keyword :: Text -> P ()+keyword w = (lexeme . try) (string' w *> notFollowedBy (identChar <|> (char '-' *> identChar)))+ where+ string' = chunk++identChar :: P Char+identChar = asciiAlphaNum <|> char '_'++asciiLetter :: P Char+asciiLetter = satisfy (\c -> isAscii c && isAlpha c)++asciiUpper :: P Char+asciiUpper = satisfy (\c -> isAscii c && isUpper c)++asciiDigit :: P Char+asciiDigit = satisfy (\c -> isAscii c && isDigit c)++asciiAlphaNum :: P Char+asciiAlphaNum = satisfy (\c -> isAscii c && isAlphaNum c)++-- | Fail with the diagnostic caret placed at a previously captured offset.+failAt :: Int -> String -> P a+failAt offset message = region (setErrorOffset offset) (fail message)++-- | Parse a decimal as an unbounded Integer, then reject values that cannot be+-- represented as Int. Parsing L.decimal directly at Int silently wraps.+boundedDecimal :: P Int+boundedDecimal = do+ offset <- getOffset+ value <- lexeme (L.decimal :: P Integer)+ checkedDecimal offset value++checkedDecimal :: Int -> Integer -> P Int+checkedDecimal offset value+ | value > fromIntegral (maxBound :: Int) =+ failAt+ offset+ ( "decimal literal "+ <> show value+ <> " is out of range (maximum "+ <> show (maxBound :: Int)+ <> ")"+ )+ | otherwise = pure (fromIntegral value)++-- | Words that may not be used as bare identifiers, because they introduce a+-- different construct and would otherwise be swallowed (e.g. @aggregate@ ending+-- one node and beginning the next).+reservedWords :: [Text]+reservedWords =+ [ "context",+ "module",+ "layout",+ "prefixed",+ "collocated",+ "id",+ "enum",+ "rule",+ "mapped",+ "ex",+ "aggregate",+ "regs",+ "states",+ "command",+ "event",+ "wire",+ "projection",+ "snapshot",+ "category",+ "guard",+ "write",+ "emit",+ "goto",+ "fields",+ "status-map",+ "true",+ "false",+ "retiring",+ "deprecated",+ "upcast",+ "from",+ "HOLE",+ "process",+ "router",+ "dispatch-each",+ "resolve",+ "read-model",+ "dispatch",+ -- EP-4 integration: structural keywords never used as identifiers, so a+ -- list like @accept A B C@ stops at the next block keyword.+ "intake",+ "contract",+ "topic",+ "accept",+ "bind",+ "dedupe",+ "persist",+ "decode",+ "disposition",+ "publisher",+ "map",+ -- EP-5 pgmq structural keywords.+ "workqueue",+ "queue",+ "payload",+ "retry",+ "fanout",+ "dedup",+ "enqueue",+ "seenIn",+ -- EP-6 workflow/operation: reserved so the multi-word result-type parse and+ -- node boundaries don't swallow the next block keyword.+ "workflow",+ "operation",+ "consistency",+ "body",+ "step",+ "await",+ "sleep",+ "child",+ "patch",+ "continueAsNew",+ -- EP-107 read-model structural words. Clause labels such as table and+ -- schema remain usable identifiers because their block parser consumes+ -- them with symbol-style matching.+ "readmodel",+ "columns",+ "feed",+ "scope",+ "shape"+ ]++-- | A CamelCase / snake_case identifier (no dashes): type names, register+-- names, command\/event\/state names, enum constructors, projection keys.+ident :: P Name+ident = (lexeme . try) $ do+ c <- asciiLetter <|> char '_'+ cs <- many identChar+ let w = T.pack (c : cs)+ if w `elem` reservedWords+ then fail ("unexpected reserved word " <> T.unpack w)+ else pure w++-- | A wire-spelling token, which may contain dashes (@partial-divert@,+-- @hospital-capacity@). Used for the context name, id prefixes, enum wire+-- spellings, and status-map values.+wireWord :: P Text+wireWord = lexeme $ do+ c <- asciiLetter <|> asciiDigit+ cs <- many (identChar <|> char '-')+ pure (T.pack (c : cs))++-- | Patch ids use wire-word spelling, but admit @:@ so the validator can emit+-- the domain-specific 'WorkflowPatchIdInvalid' diagnostic at the owning item.+patchIdWord :: P Text+patchIdWord = lexeme $ do+ c <- asciiLetter <|> asciiDigit+ cs <- many (identChar <|> char '-' <|> char ':')+ pure (T.pack (c : cs))++getLoc :: P Loc+getLoc = (Loc . unPos . sourceLine) <$> getSourcePos++--------------------------------------------------------------------------------+-- Top level+--------------------------------------------------------------------------------++data TopItem+ = TIId IdDecl+ | TIEnum EnumDecl+ | TIRule RuleDecl+ | TINominalScalar NominalScalarDecl+ | TIMapped MappedDecl+ | TINode Node++pSpec :: Bool -> P Spec+pSpec nominalSyntax = do+ keyword "context"+ ctx <- wireWord+ mroot <- optional pModuleClause+ mlayout <- optional pLayoutClause+ items <- many (pTopItem nominalSyntax)+ pure+ Spec+ { specContext = ctx,+ specModuleRoot = mroot,+ specLayout = mlayout,+ specIds = [d | TIId d <- items],+ specEnums = [d | TIEnum d <- items],+ specRules = [d | TIRule d <- items],+ specNominalScalars = [d | TINominalScalar d <- items],+ specMapped = [d | TIMapped d <- items],+ specNodes = [n | TINode n <- items]+ }++-- | @module Acme.Services@ — the optional namespace-prefix clause.+pModuleClause :: P Text+pModuleClause = keyword "module" *> pModulePrefix++-- | @layout (prefixed|collocated)@ — the optional placement-style clause.+pLayoutClause :: P Placement+pLayoutClause =+ keyword "layout"+ *> choice+ [ GeneratedPrefix <$ keyword "prefixed",+ CollocatedLeaf <$ keyword "collocated"+ ]++-- | A dotted module prefix: one-or-more PascalCase segments joined by dots,+-- e.g. @Acme@ or @Acme.Services@.+pModulePrefix :: P Text+pModulePrefix = lexeme $ do+ seg0 <- pSeg+ segs <- many (char '.' *> pSeg)+ pure (T.intercalate "." (seg0 : segs))+ where+ pSeg = do+ c <- asciiUpper+ cs <- many identChar+ pure (T.pack (c : cs))++pTopItem :: Bool -> P TopItem+pTopItem nominalSyntax =+ choice+ ( [ TIId <$> pIdDecl nominalSyntax,+ TIEnum <$> pEnumDecl nominalSyntax,+ TIRule <$> pRuleDecl nominalSyntax,+ pMappedTopItem nominalSyntax+ ]+ ++ [ TINode . NRouter <$> pRouter,+ TINode . NProcess <$> pProcess,+ TINode . NContract <$> pContract,+ TINode . NIntake <$> pIntake,+ TINode . NEmit <$> pEmit,+ TINode . NPublisher <$> pPublisher,+ TINode . NWorkqueue <$> pWorkqueue,+ TINode . NPgmqDispatch <$> pPgmqDispatch,+ TINode . NReadModel <$> pReadModel,+ TINode . NWorkflow <$> pWorkflow,+ TINode . NOperation <$> pOperation,+ TINode . NAggregate <$> pAggregate nominalSyntax+ ]+ )++pIdDecl :: Bool -> P IdDecl+pIdDecl nominalSyntax = do+ loc <- getLoc+ keyword "id"+ name <- ident+ _ <- symbol "prefix"+ _ <- symbol "="+ pfx <- wireWord+ binding <- if nominalSyntax then optional pUsingNominalBinding else pure Nothing+ pure IdDecl {idName = name, idPrefix = pfx, idBinding = binding, idLoc = loc}++pEnumDecl :: Bool -> P EnumDecl+pEnumDecl nominalSyntax = do+ loc <- getLoc+ keyword "enum"+ name <- ident+ ctors <- braces (many pEnumCtor)+ binding <- if nominalSyntax then optional pUsingNominalBinding else pure Nothing+ pure EnumDecl {enumName = name, enumCtors = ctors, enumBinding = binding, enumLoc = loc}+ where+ pEnumCtor = do+ c <- ident+ _ <- symbol "="+ w <- wireWord+ pure (c, w)++pRuleDecl :: Bool -> P RuleDecl+pRuleDecl scalarSyntax = do+ loc <- getLoc+ keyword "rule"+ name <- ident+ _ <- symbol ":"+ dom <- ident+ _ <- symbol "->"+ cod <- ident+ keyword "ex"+ cases <- sepBy1 pCase (symbol ";")+ pure+ RuleDecl+ { ruleName = name,+ ruleDomain = dom,+ ruleCodomain = cod,+ ruleCases = cases,+ ruleLoc = loc+ }+ where+ pCase = do+ c <- ident+ _ <- symbol "=>"+ e <- pExpr scalarSyntax+ pure (c, e)++--------------------------------------------------------------------------------+-- Consumer-owned mapped and nominal types+--------------------------------------------------------------------------------++data MappedKind = MappedRecord | MappedEnum | MappedUnion++data MappedClause+ = MCHaskell HaskellSource+ | MCBinding Text+ | MCBindingVersion Text+ | MCCanonical Text+ | MCFixtures Text+ | MCInitial Text+ | MCCodec Text+ | MCCodecVersion Text+ | MCShape MappedShape++pMappedTopItem :: Bool -> P TopItem+pMappedTopItem nominalSyntax = do+ loc <- getLoc+ keyword "mapped"+ choice+ ( [TINominalScalar <$> pNominalScalarAfterMapped loc | nominalSyntax]+ ++ [ TIMapped <$> pMappedStructural loc,+ TIMapped <$> pMappedOpaque loc+ ]+ )++pMappedStructural :: Loc -> P MappedDecl+pMappedStructural loc = do+ keyword "structural"+ kind <-+ choice+ [ MappedRecord <$ keyword "record",+ MappedEnum <$ keyword "enum",+ MappedUnion <$ keyword "union"+ ]+ name <- ident+ clauses <- braces (many (pStructuralClause kind))+ hs <- oneClause "haskell" (\case MCHaskell value -> Just value; _ -> Nothing) clauses+ binding <- oneClause "binding" (\case MCBinding value -> Just value; _ -> Nothing) clauses+ bindingVersion <- oneClause "binding-version" (\case MCBindingVersion value -> Just value; _ -> Nothing) clauses+ canonical <- oneClause "canonical-type" (\case MCCanonical value -> Just value; _ -> Nothing) clauses+ fixtures <- oneClause "fixtures" (\case MCFixtures value -> Just value; _ -> Nothing) clauses+ initial <- oneClause "initial" (\case MCInitial value -> Just value; _ -> Nothing) clauses+ shape <- requiredClause "wire" (\case MCShape value -> Just value; _ -> Nothing) clauses+ pure+ MappedStructural+ { msName = name,+ msHaskell = hs,+ msBinding = binding,+ msBindingVersion = bindingVersion,+ msCanonical = canonical,+ msFixtures = fixtures,+ msInitial = initial,+ msShape = shape,+ msLoc = loc+ }++pMappedOpaque :: Loc -> P MappedDecl+pMappedOpaque loc = do+ keyword "opaque"+ name <- ident+ clauses <- braces (many pOpaqueClause)+ hs <- oneClause "haskell" (\case MCHaskell value -> Just value; _ -> Nothing) clauses+ codec <- oneClause "codec" (\case MCCodec value -> Just value; _ -> Nothing) clauses+ version <- oneClause "version" (\case MCCodecVersion value -> Just value; _ -> Nothing) clauses+ fixtures <- oneClause "fixtures" (\case MCFixtures value -> Just value; _ -> Nothing) clauses+ initial <- oneClause "initial" (\case MCInitial value -> Just value; _ -> Nothing) clauses+ pure+ MappedOpaque+ { moName = name,+ moHaskell = hs,+ moCodecId = codec,+ moCodecVersion = version,+ moFixtures = fixtures,+ moInitial = initial,+ moLoc = loc+ }++pNominalScalarAfterMapped :: Loc -> P NominalScalarDecl+pNominalScalarAfterMapped loc = do+ keyword "nominal"+ name <- ident+ _ <- symbol ":"+ representation <- ident+ binding <- pNominalBindingBlock loc+ pure+ NominalScalarDecl+ { nominalScalarName = name,+ nominalScalarRepresentation = representation,+ nominalScalarBinding = binding,+ nominalScalarLoc = loc+ }++pUsingNominalBinding :: P NominalBindingDecl+pUsingNominalBinding = do+ keyword "using"+ loc <- getLoc+ pNominalBindingBlock loc++pNominalBindingBlock :: Loc -> P NominalBindingDecl+pNominalBindingBlock loc = do+ clauses <- braces (many pNominalClause)+ hs <- oneClause "haskell" (\case MCHaskell value -> Just value; _ -> Nothing) clauses+ binding <- oneClause "binding" (\case MCBinding value -> Just value; _ -> Nothing) clauses+ bindingVersion <- oneClause "binding-version" (\case MCBindingVersion value -> Just value; _ -> Nothing) clauses+ canonical <- oneClause "canonical-type" (\case MCCanonical value -> Just value; _ -> Nothing) clauses+ fixtures <- oneClause "fixtures" (\case MCFixtures value -> Just value; _ -> Nothing) clauses+ initial <- oneClause "initial" (\case MCInitial value -> Just value; _ -> Nothing) clauses+ pure+ NominalBindingDecl+ { nominalHaskell = hs,+ nominalBinding = binding,+ nominalBindingVersion = bindingVersion,+ nominalCanonicalType = canonical,+ nominalFixtures = fixtures,+ nominalInitial = initial,+ nominalLoc = loc+ }++pNominalClause :: P MappedClause+pNominalClause =+ choice+ [ MCHaskell <$> pHaskellSource,+ MCBindingVersion <$> pQuotedFact "binding-version",+ MCBinding <$> pQuotedFact "binding",+ MCCanonical <$> pQuotedFact "canonical-type",+ MCFixtures <$> pQuotedFact "fixtures",+ MCInitial <$> pQuotedFact "initial"+ ]++pStructuralClause :: MappedKind -> P MappedClause+pStructuralClause kind =+ choice+ [ MCHaskell <$> pHaskellSource,+ MCBindingVersion <$> pQuotedFact "binding-version",+ MCBinding <$> pQuotedFact "binding",+ MCCanonical <$> pQuotedFact "canonical-type",+ MCFixtures <$> pQuotedFact "fixtures",+ MCInitial <$> pQuotedFact "initial",+ MCShape <$> pMappedShape kind+ ]++pOpaqueClause :: P MappedClause+pOpaqueClause =+ choice+ [ MCHaskell <$> pHaskellSource,+ MCCodec <$> pQuotedFact "codec",+ MCCodecVersion <$> pQuotedFact "version",+ MCFixtures <$> pQuotedFact "fixtures",+ MCInitial <$> pQuotedFact "initial"+ ]++pHaskellSource :: P HaskellSource+pHaskellSource = do+ keyword "haskell"+ keyword "package"+ _ <- symbol "="+ packageName <- wireWord+ keyword "module"+ _ <- symbol "="+ moduleName <- pModulePrefix+ keyword "type"+ _ <- symbol "="+ typeName <- ident+ pure HaskellSource {hsPackage = packageName, hsModule = moduleName, hsType = typeName}++pQuotedFact :: Text -> P Text+pQuotedFact factName = keyword factName *> symbol "=" *> stringLit++pMappedShape :: MappedKind -> P MappedShape+pMappedShape kind = do+ keyword "wire"+ case kind of+ MappedRecord -> do+ keyword "object"+ keyword "constructor"+ _ <- symbol "="+ constructor <- ident+ unknownFields <- pUnknownFieldsFact+ fields <- braces (many pWireField)+ pure (ShapeRecord constructor unknownFields fields)+ MappedEnum -> do+ keyword "string"+ ShapeEnum <$> braces (many pWireEnum)+ MappedUnion -> do+ keyword "tagged-object"+ keyword "tag"+ _ <- symbol "="+ tagField <- stringLit+ keyword "contents"+ _ <- symbol "="+ contentsField <- stringLit+ unknownFields <- pUnknownFieldsFact+ arms <- braces (many pWireArm)+ pure (ShapeUnion (TaggedObject tagField contentsField unknownFields) arms)++pUnknownFieldsFact :: P UnknownFields+pUnknownFieldsFact = do+ keyword "unknown-fields"+ _ <- symbol "="+ choice [RejectUnknown <$ keyword "reject", IgnoreUnknown <$ keyword "ignore"]++pWireField :: P WireField+pWireField = do+ loc <- getLoc+ haskellName <- ident+ keyword "as"+ wireKey <- stringLit+ _ <- symbol ":"+ fieldType <- pMappedTypeExpr+ presence <- choice [PRequired <$ keyword "required", POptional <$ keyword "optional"]+ onMissing <- optional (keyword "on-missing" *> symbol "=" *> pOnMissing)+ pure+ WireField+ { wfHaskell = haskellName,+ wfKey = wireKey,+ wfType = fieldType,+ wfPresence = presence,+ wfOnMissing = onMissing,+ wfLoc = loc+ }++pWireEnum :: P WireEnum+pWireEnum = do+ loc <- getLoc+ constructor <- ident+ keyword "as"+ wireTag <- stringLit+ pure WireEnum {weCtor = constructor, weTag = wireTag, weLoc = loc}++pWireArm :: P WireArm+pWireArm = do+ loc <- getLoc+ constructor <- ident+ keyword "as"+ wireTag <- stringLit+ payload <- optional (symbol ":" *> pMappedTypeExpr)+ pure WireArm {waCtor = constructor, waTag = wireTag, waPayload = payload, waLoc = loc}++pMappedTypeExpr :: P TypeExpr+pMappedTypeExpr =+ choice+ [ TOptional <$> (keyword "Optional" *> pTypeArgument),+ TList <$> (keyword "List" *> pTypeArgument),+ TMap <$> (keyword "Map" *> pTypeArgument),+ TText <$ keyword "Text",+ TInt <$ keyword "Int",+ TInteger <$ keyword "Integer",+ TBool <$ keyword "Bool",+ TNatural <$ keyword "Natural",+ TTime <$ (keyword "Time" <|> keyword "UTCTime"),+ TJson <$ keyword "Json",+ TRef <$> ident+ ]+ where+ pTypeArgument = parens pMappedTypeExpr <|> pTypeAtom+ pTypeAtom =+ choice+ [ TText <$ keyword "Text",+ TInt <$ keyword "Int",+ TInteger <$ keyword "Integer",+ TBool <$ keyword "Bool",+ TNatural <$ keyword "Natural",+ TTime <$ (keyword "Time" <|> keyword "UTCTime"),+ TJson <$ keyword "Json",+ TRef <$> ident+ ]++pOnMissing :: P OnMissing+pOnMissing =+ choice+ [ OmNull <$ keyword "null",+ OmEmptyList <$ (symbol "[" *> symbol "]"),+ OmEmptyMap <$ (symbol "{" *> symbol "}"),+ OmBool True <$ keyword "true",+ OmBool False <$ keyword "false",+ OmText <$> stringLit,+ OmInt <$> integerLiteral,+ OmCtor <$> ident+ ]++integerLiteral :: P Integer+integerLiteral = lexeme (L.signed (pure ()) L.decimal)++oneClause :: String -> (MappedClause -> Maybe a) -> [MappedClause] -> P (Maybe a)+oneClause clauseName select clauses =+ case mapMaybe select clauses of+ [] -> pure Nothing+ [value] -> pure (Just value)+ _ -> fail ("duplicate " <> clauseName <> " clause in mapped declaration")++requiredClause :: String -> (MappedClause -> Maybe a) -> [MappedClause] -> P a+requiredClause clauseName select clauses = do+ found <- oneClause clauseName select clauses+ maybe (fail ("missing " <> clauseName <> " clause in mapped structural declaration")) pure found++--------------------------------------------------------------------------------+-- Aggregate node+--------------------------------------------------------------------------------++data BodyItem+ = BICommand Command+ | BIEvent Event+ | BIWire WireSpec+ | BIProjection ProjectionSpec+ | BISnapshot SnapshotSpec+ | BITransition Transition++pAggregate :: Bool -> P Aggregate+pAggregate scalarSyntax = do+ loc <- getLoc+ keyword "aggregate"+ name <- ident+ regs <- pRegsBlock+ states <- pStatesLine+ positionedItems <- many ((,) <$> getOffset <*> pBodyItem scalarSyntax)+ let items = map snd positionedItems+ wireOffsets = [offset | (offset, BIWire _) <- positionedItems]+ projectionOffsets = [offset | (offset, BIProjection _) <- positionedItems]+ snapshotOffsets = [offset | (offset, BISnapshot _) <- positionedItems]+ case wireOffsets of+ _ : duplicateOffset : _ ->+ failAt duplicateOffset ("duplicate wire block in aggregate " <> T.unpack name <> " (only one is allowed)")+ _ -> pure ()+ case projectionOffsets of+ _ : duplicateOffset : _ ->+ failAt duplicateOffset ("duplicate projection block in aggregate " <> T.unpack name <> " (only one is allowed)")+ _ -> pure ()+ case snapshotOffsets of+ _ : duplicateOffset : _ ->+ failAt duplicateOffset ("duplicate snapshot block in aggregate " <> T.unpack name <> " (only one is allowed)")+ _ -> pure ()+ pure+ Aggregate+ { aggName = name,+ aggRegs = regs,+ aggStates = states,+ aggCommands = [c | BICommand c <- items],+ aggEvents = [e | BIEvent e <- items],+ aggTransitions = [t | BITransition t <- items],+ aggWire = listToMaybe [w | BIWire w <- items],+ aggProjection = listToMaybe [p | BIProjection p <- items],+ aggSnapshot = listToMaybe [s | BISnapshot s <- items],+ aggLoc = loc+ }+ where+ listToMaybe xs = case xs of (x : _) -> Just x; [] -> Nothing++pRegsBlock :: P [RegDecl]+pRegsBlock = do+ keyword "regs"+ many pRegDecl++pRegDecl :: P RegDecl+pRegDecl = do+ loc <- getLoc+ name <- ident+ ty <- pMappedTypeExpr+ _ <- symbol "="+ initial <- (RegInitText <$> stringLit) <|> (RegInitBare <$> (ident <|> signedDecimalText))+ pure RegDecl {regName = name, regType = ty, regInitial = initial, regLoc = loc}++pStatesLine :: P [StateDecl]+pStatesLine = do+ keyword "states"+ many pStateDecl+ where+ -- A state decl is an identifier with an optional terminal @!@. The+ -- @notFollowedBy@ lookahead stops the list before a transition whose source+ -- state would otherwise be swallowed as an extra state, e.g. when a+ -- transition directly follows the @states@ line with no command\/event+ -- between them. The @try@ backtracks so the identifier is left for+ -- 'pTransition'.+ pStateDecl = try $ do+ loc <- getLoc+ -- A @replay-only@ transition marker directly after the states line+ -- must not be swallowed: 'ident' would take @replay@ (hyphens are+ -- not identifier characters) and strand @-only@.+ notFollowedBy (keyword "replay-only")+ n <- ident+ term <- option False (True <$ symbol "!")+ notFollowedBy (symbol "--")+ pure StateDecl {stName = n, stTerminal = term, stLoc = loc}++pBodyItem :: Bool -> P BodyItem+pBodyItem scalarSyntax =+ choice+ [ BICommand <$> pCommand,+ BIEvent <$> pEvent,+ BIWire <$> pWire,+ BIProjection <$> pProjection,+ BISnapshot <$> pSnapshot,+ BITransition <$> pTransition scalarSyntax+ ]++pSnapshot :: P SnapshotSpec+pSnapshot = do+ loc <- getLoc+ keyword "snapshot"+ policy <-+ choice+ [ SnapEvery <$> (keyword "every" *> boundedDecimal),+ SnapOnTerminal <$ symbol "on-terminal"+ ]+ _ <- symbol "state-codec"+ _ <- symbol "version" *> symbol "="+ version <- boundedDecimal+ _ <- symbol "shape-hash" *> symbol "="+ hash <- stringLit+ pure SnapshotSpec {snapPolicy = policy, snapCodecVersion = version, snapShapeHash = hash, snapLoc = loc}++pCommand :: P Command+pCommand = do+ loc <- getLoc+ keyword "command"+ name <- ident+ fs <- braces (many pAggregateField)+ pure Command {cmdName = name, cmdFields = fs, cmdLoc = loc}++pAggregateField :: P AggregateField+pAggregateField = do+ loc <- getLoc+ n <- ident+ mty <- optional (symbol ":" *> pMappedTypeExpr)+ pure AggregateField {aggregateFieldName = n, aggregateFieldType = mty, aggregateFieldLoc = loc}++pField :: P Field+pField = do+ n <- ident+ mty <- optional (symbol ":" *> ident)+ pure Field {fieldName = n, fieldType = mty}++pEvent :: P Event+pEvent = do+ loc <- getLoc+ (retiring, deprecated) <-+ option+ (False, False)+ ( choice+ [ (True, False) <$ keyword "retiring",+ (False, True) <$ keyword "deprecated"+ ]+ )+ keyword "event"+ name <- ident+ ver <- option 1 pVersion+ body <-+ choice+ [ EventFromCommand <$> (symbol "=" *> keyword "fields" *> parens ident),+ EventFields <$> braces (many pAggregateField)+ ]+ up <- optional pUpcast+ pure+ Event+ { evName = name,+ evBody = body,+ evVersion = ver,+ evUpcastFrom = up,+ evRetiring = retiring,+ evDeprecated = deprecated,+ evLoc = loc+ }+ where+ pUpcast = do+ keyword "upcast"+ keyword "from"+ m <- pVersion+ _ <- symbol "="+ keyword "HOLE"+ pure (m, Hole)++-- | A @vN@ schema-version token (e.g. @v2@). Fails (backtracking) on anything+-- that is not @v@ immediately followed by digits.+pVersion :: P Int+pVersion = do+ offset <- getOffset+ value <- lexeme (try (char 'v' *> (L.decimal :: P Integer) <* notFollowedBy identChar))+ checkedDecimal offset value++pWire :: P WireSpec+pWire = do+ keyword "wire"+ _ <- symbol "kind"+ _ <- symbol "="+ k <- wireWord+ _ <- symbol "fields"+ _ <- symbol "="+ f <- wireWord+ _ <- symbol "schemaVersion"+ _ <- symbol "="+ v <- boundedDecimal+ pure WireSpec {wireKind = k, wireFields = f, wireSchemaVersion = v}++pProjection :: P ProjectionSpec+pProjection = do+ loc <- getLoc+ keyword "projection"+ table <- ident+ cons <- optional (symbol "consistency" *> symbol "=" *> pConsistency)+ _ <- symbol "key"+ _ <- symbol "="+ k <- ident+ sm <- optional pStatusMap+ pure+ ProjectionSpec+ { projTable = table,+ projConsistency = cons,+ projKey = k,+ projStatusMap = sm,+ projLoc = loc+ }+ where+ pConsistency =+ choice [Strong <$ keyword "Strong", Eventual <$ keyword "Eventual"]++pStatusMap :: P Mapping+pStatusMap = do+ keyword "status-map"+ partial <- option False (True <$ keyword "partial")+ pairs <- braces (many pPair)+ pure Mapping {mapPairs = pairs, mapPartial = partial}+ where+ pPair = do+ l <- ident+ _ <- symbol "=>"+ r <- wireWord+ pure (l, r)++--------------------------------------------------------------------------------+-- Integration contract (EP-4)+--------------------------------------------------------------------------------++pContract :: P ContractNode+pContract = do+ loc <- getLoc+ keyword "contract"+ nm <- ident+ _ <- symbol "{"+ keyword "schemaVersion"+ sv <- boundedDecimal+ keyword "discriminator"+ disc <- ident+ topics <- many pTopic+ events <- many pContractEvent+ _ <- symbol "}"+ pure+ ContractNode+ { ctrName = nm,+ ctrSchemaVersion = sv,+ ctrDiscriminator = disc,+ ctrTopics = topics,+ ctrEvents = events,+ ctrLoc = loc+ }+ where+ pTopic = do+ keyword "topic"+ alias <- ident+ t <- stringLit+ pure (alias, t)+ pContractEvent = do+ keyword "event"+ nm <- ident+ keyword "on"+ topicAlias <- ident+ fs <- braces (many pContractField)+ pure ContractEvent {ceName = nm, ceTopic = topicAlias, ceFields = fs}+ pContractField = do+ n <- ident+ _ <- symbol ":"+ ty <- pContractType+ _ <- optional (symbol ";")+ pure ContractField {cfName = n, cfType = ty}+ pContractType =+ choice+ [ CTypeId <$> (keyword "typeid" *> stringLit),+ CText <$ keyword "text",+ CInt <$ keyword "int"+ ]++pIntake :: P IntakeNode+pIntake = do+ loc <- getLoc+ keyword "intake"+ nm <- ident+ _ <- symbol "{"+ keyword "contract"+ ctr <- ident+ keyword "topic"+ tp <- ident+ keyword "accept"+ acc <- some ident+ binds <- many pBindRow+ keyword "dedupe"+ keyword "key"+ dk <- ident+ keyword "policy"+ dp <- ident+ persistence <-+ option InkPersistFull $+ keyword "persist"+ *> symbol "="+ *> choice+ [ InkPersistFull <$ keyword "full-envelope",+ InkPersistDedupeOnly <$ keyword "dedupe-only"+ ]+ dec <- pDecode+ disp <- pDisposition+ _ <- symbol "}"+ pure+ IntakeNode+ { inkName = nm,+ inkContract = ctr,+ inkTopic = tp,+ inkAccept = acc,+ inkBinds = binds,+ inkDedupeKey = dk,+ inkDedupePolicy = dp,+ inkPersist = persistence,+ inkDecode = dec,+ inkDisposition = disp,+ inkLoc = loc+ }+ where+ pBindRow = do+ keyword "bind"+ f <- ident+ keyword "from"+ src <- pWireSource+ req <- option False (True <$ keyword "required")+ xc <- option False (True <$ (keyword "cross-check" *> keyword "body"))+ pure BindRow {brField = f, brSource = src, brRequired = req, brCrossCheck = xc}+ pWireSource =+ choice+ [ SrcHeader <$> (keyword "header" *> stringLit),+ SrcKafkaKey <$ keyword "kafka-key",+ SrcKafkaCursor <$ keyword "kafka-cursor",+ SrcBody <$ keyword "body"+ ]+ pDecode = do+ keyword "decode"+ _ <- symbol "{"+ keyword "envelope"+ env <- pEnvelopePolicy+ keyword "body"+ strict <- (True <$ keyword "strict") <|> (False <$ keyword "lenient")+ keyword "schemaVersion"+ _ <- symbol "=="+ v <- boundedDecimal+ _ <- symbol "}"+ pure DecodeSpec {decEnvelope = env, decBodyStrict = strict, decBodySchemaVersion = v}+ pEnvelopePolicy = do+ a <- wireWord+ b <- wireWord+ pure (a <> " " <> b)+ pDisposition = do+ keyword "disposition"+ rows <- braces (many pDispositionRow)+ pure rows+ pDispositionRow = do+ loc <- getLoc+ o <- ident+ _ <- symbol "=>"+ act <- pInboxAction+ pure DispositionRow {drOutcome = o, drAction = act, drLoc = loc}+ pInboxAction =+ choice+ [ IAckOk <$ keyword "ackOk",+ IRetry <$> (keyword "retry" *> pWindow),+ IDeadLetter <$> (keyword "deadLetter" *> optional stringLit)+ ]++pEmit :: P EmitNode+pEmit = do+ loc <- getLoc+ keyword "emit"+ nm <- ident+ _ <- symbol "{"+ keyword "contract"+ ctr <- ident+ keyword "topic"+ tp <- ident+ keyword "source"+ src <- stringLit+ keyword "key"+ k <- ident+ keyword "map"+ disc <- ident+ (rows, skip) <- braces pMapRows+ keyword "messageId"+ mid <- pDerive+ keyword "idempotencyKey"+ idk <- pDerive+ _ <- symbol "}"+ pure+ EmitNode+ { emName = nm,+ emContract = ctr,+ emTopic = tp,+ emSource = src,+ emKey = k,+ emDiscriminant = disc,+ emMap = rows,+ emSkip = skip,+ emMessageId = mid,+ emIdempotencyKey = idk,+ emLoc = loc+ }+ where+ pMapRows = do+ rows <- many pMapRow+ skip <- option False (True <$ try (symbol "_" *> symbol "=>" *> keyword "skip"))+ pure (rows, skip)+ pMapRow = try $ do+ loc <- getLoc+ v <- stringLit+ _ <- symbol "=>"+ ev <- ident+ pure EmitMapRow {emrValue = v, emrEvent = ev, emrLoc = loc}+ pDerive = do+ keyword "derive"+ pfx <- optional stringLit+ keyword "hole"+ pure DeriveSpec {dsPrefix = pfx}++pPublisher :: P PublisherNode+pPublisher = do+ loc <- getLoc+ keyword "publisher"+ nm <- ident+ _ <- symbol "{"+ keyword "emit"+ em <- ident+ keyword "ordering"+ ord <- ident+ keyword "maxAttempts"+ ma <- boundedDecimal+ keyword "backoff"+ bk <- ident+ bw <- pWindow+ bm <- optional (keyword "max" *> symbol "=" *> pWindow)+ multiplier <- optional (keyword "multiplier" *> symbol "=" *> decimalText)+ keyword "outboxId"+ keyword "stable"+ keyword "from"+ obf <- ident+ _ <- symbol "}"+ pure+ PublisherNode+ { pubName = nm,+ pubEmit = em,+ pubOrdering = ord,+ pubMaxAttempts = ma,+ pubBackoff = BackoffSpec {boKind = bk, boWindow = bw, boMax = bm, boMultiplier = multiplier},+ pubOutboxField = obf,+ pubLoc = loc+ }++pWorkqueue :: P WorkqueueNode+pWorkqueue = do+ loc <- getLoc+ keyword "workqueue"+ nm <- ident+ _ <- symbol "{"+ keyword "queue"+ _ <- symbol "logical" *> symbol "="+ logical <- stringLit+ keyword "derive"+ _ <- symbol "physical" *> symbol "="+ phys <- stringLit+ _ <- symbol "dlq" *> symbol "="+ dlqName <- stringLit+ _ <- symbol "table" *> symbol "="+ tbl <- stringLit+ ordering <- option WqUnordered pOrdering+ groupKey <- optional pGroupKey+ provision <- option WqStandard pProvision+ keyword "payload"+ pn <- ident+ fields <- braces (many pWqField)+ keyword "retry"+ _ <- symbol "maxRetries" *> symbol "="+ mr <- boundedDecimal+ _ <- symbol "delay" *> symbol "="+ dl <- pWindow+ _ <- symbol "dlq" *> symbol "="+ dlqOn <- (True <$ keyword "on") <|> (False <$ keyword "off")+ keyword "disposition"+ disp <- braces (many pWqDispRow)+ _ <- symbol "}"+ pure+ WorkqueueNode+ { wqName = nm,+ wqLogical = logical,+ wqPhysical = phys,+ wqDlq = dlqName,+ wqTable = tbl,+ wqOrdering = ordering,+ wqGroupKey = groupKey,+ wqProvision = provision,+ wqPayloadName = pn,+ wqPayload = fields,+ wqMaxRetries = mr,+ wqDelay = dl,+ wqDlqOn = dlqOn,+ wqDisposition = disp,+ wqLoc = loc+ }+ where+ pOrdering = do+ _ <- symbol "ordering"+ choice+ [ WqUnordered <$ symbol "unordered",+ WqFifoThroughput <$ symbol "fifo-throughput",+ WqFifoRoundRobin <$ symbol "fifo-roundrobin"+ ]+ pGroupKey = do+ _ <- symbol "group" *> symbol "key" *> symbol "from"+ field <- ident+ _ <- symbol "via"+ via <- ident+ fixture <- optional (symbol "fixture" *> stringLit)+ pure WqGroupKey {gkField = field, gkVia = via, gkFixture = fixture}+ pProvision = do+ _ <- symbol "provision"+ choice+ [ WqStandard <$ symbol "standard",+ WqUnlogged <$ symbol "unlogged",+ do+ _ <- symbol "partitioned" *> symbol "("+ _ <- symbol "interval" *> symbol "="+ interval <- stringLit+ _ <- symbol "," *> symbol "retention" *> symbol "="+ retention <- stringLit+ _ <- symbol ")"+ pure (WqPartitioned interval retention)+ ]+ pWqField = do+ n <- ident+ _ <- symbol "->"+ w <- stringLit+ ty <- ident+ req <- option False (True <$ keyword "required")+ pure WqField {wqfName = n, wqfWire = w, wqfType = ty, wqfRequired = req}+ pWqDispRow = do+ loc <- getLoc+ o <- ident+ _ <- symbol "->"+ act <- choice [IAckOk <$ keyword "ackOk", IRetry <$> (keyword "retry" *> pWindow), IDeadLetter <$> (keyword "deadLetter" *> optional stringLit)]+ pure WqDispRow {wqdOutcome = o, wqdAction = act, wqdLoc = loc}++pReadModel :: P ReadModelNode+pReadModel = do+ loc <- getLoc+ keyword "readmodel"+ name <- ident+ _ <- symbol "{"+ _ <- symbol "table" *> symbol "="+ table <- stringLit+ _ <- symbol "schema" *> symbol "="+ schema <- stringLit+ _ <- symbol "columns"+ columns <- braces (many pColumn)+ _ <- symbol "version" *> symbol "="+ version <- boundedDecimal+ _ <- symbol "shape" *> symbol "="+ shape <- stringLit+ _ <- symbol "consistency" *> symbol "="+ consistency <- pConsistency+ scope <- optional (symbol "scope" *> symbol "=" *> pScope)+ _ <- symbol "feed" *> symbol "="+ feed <- pFeed+ subscription <- optional (symbol "subscription" *> symbol "=" *> stringLit)+ _ <- symbol "}"+ pure+ ReadModelNode+ { rmName = name,+ rmTable = table,+ rmSchema = schema,+ rmColumns = columns,+ rmVersion = version,+ rmShape = shape,+ rmConsistency = consistency,+ rmScope = scope,+ rmFeed = feed,+ rmSubscription = subscription,+ rmLoc = loc+ }+ where+ pColumn =+ RmColumn+ <$> wireWord+ <*> ident+ <*> option False (True <$ keyword "required")+ pConsistency = choice [Strong <$ keyword "Strong", Eventual <$ keyword "Eventual"]+ pScope =+ choice+ [ RmEntireLog <$ keyword "entire-log",+ RmCategory <$> (keyword "category" *> stringLit)+ ]+ pFeed = choice [RmInline <$ keyword "inline", RmSubscription <$ keyword "subscription"]++pPgmqDispatch :: P PgmqDispatchNode+pPgmqDispatch = do+ loc <- getLoc+ keyword "dispatch"+ nm <- ident+ _ <- symbol "{"+ keyword "source"+ _ <- symbol "readModel" *> symbol "="+ srm <- ident+ _ <- symbol "key" *> symbol "="+ sk <- ident+ keyword "fanout"+ _ <- symbol "body" *> symbol "="+ fb <- ident+ keyword "dedup"+ _ <- symbol "key" *> symbol "="+ dk <- ident+ _ <- keyword "seenIn" *> symbol "readModel" *> symbol "="+ drm <- ident+ _ <- symbol "field" *> symbol "="+ drmf <- ident+ _ <- keyword "seenIn" *> symbol "queue" *> symbol "="+ dq <- ident+ _ <- symbol "field" *> symbol "="+ dqf <- ident+ keyword "enqueue"+ _ <- symbol "to" *> symbol "="+ enq <- ident+ _ <- symbol "}"+ pure+ PgmqDispatchNode+ { pdName = nm,+ pdSourceReadModel = srm,+ pdSourceKey = sk,+ pdFanoutBody = fb,+ pdDedupKey = dk,+ pdDedupReadModel = drm,+ pdDedupReadModelField = drmf,+ pdDedupQueue = dq,+ pdDedupQueueField = dqf,+ pdEnqueueTo = enq,+ pdLoc = loc+ }++pWorkflow :: P WorkflowNode+pWorkflow = do+ loc <- getLoc+ keyword "workflow"+ wid <- ident+ keyword "name"+ nm <- stringLit+ keyword "in"+ inTy <- ident+ inFields <- option [] (braces (many pField))+ keyword "out"+ outTy <- ident+ keyword "id"+ keyword "from"+ keyword "input"+ idField <- optional (symbol "." *> ident)+ keyword "via"+ idVia <- ident+ keyword "body"+ body <- many pWfBodyItem+ pure+ WorkflowNode+ { wfId = wid,+ wfStable = nm,+ wfInput = inTy,+ wfInputFields = inFields,+ wfOutput = outTy,+ wfIdField = idField,+ wfIdVia = idVia,+ wfBody = body,+ wfLoc = loc+ }+ where+ pWfBodyItem =+ choice+ [ do+ loc <- getLoc+ WfStep <$> (keyword "step" *> wireWord) <*> (symbol "->" *> ident) <*> pure loc,+ do+ loc <- getLoc+ WfAwait <$> (keyword "await" *> wireWord) <*> (symbol "->" *> ident) <*> pure loc,+ do+ loc <- getLoc+ WfSleep <$> (keyword "sleep" *> wireWord) <*> (keyword "after" *> ident) <*> pure loc,+ do+ loc <- getLoc+ WfChild+ <$> (keyword "child" *> wireWord)+ <*> (keyword "id" *> keyword "input" *> keyword "via" *> ident)+ <*> (symbol "->" *> ident)+ <*> pure loc,+ do+ loc <- getLoc+ WfPatch+ <$> (keyword "patch" *> patchIdWord)+ <*> braces (many pWfBodyItem)+ <*> pure loc,+ do+ loc <- getLoc+ WfContinueAsNew <$> (keyword "continueAsNew" *> ident) <*> pure loc+ ]++pOperation :: P OperationNode+pOperation = do+ loc <- getLoc+ keyword "operation"+ nm <- ident+ shape <-+ choice+ [ pCommandOp,+ pQueryOp,+ pSignalOp,+ pRunOp+ ]+ pure OperationNode {opName = nm, opShape = shape, opLoc = loc}+ where+ pCommandOp = do+ keyword "command"+ keyword "on"+ agg <- ident+ _ <- keyword "stream" *> keyword "from"+ sf <- ident+ keyword "via"+ sv <- ident+ proj <- option [] (keyword "project" *> brackets (many ident))+ pure (CommandOp agg sf sv proj)+ pQueryOp = do+ keyword "query"+ rm <- ident+ keyword "input"+ inp <- ident+ keyword "result"+ res <- pTypeExpr+ cons <- option "Strong" (keyword "consistency" *> ident)+ pure (QueryOp rm inp res cons)+ pSignalOp = do+ keyword "signal"+ lbl <- wireWord+ keyword "of"+ wf <- ident+ _ <- keyword "key" *> keyword "from"+ kf <- ident+ keyword "via"+ kv <- ident+ keyword "value"+ val <- ident+ pure (SignalOp lbl wf kf kv val)+ pRunOp = do+ keyword "run"+ wf <- ident+ keyword "input"+ inp <- ident+ _ <- keyword "outcome" *> symbol "->"+ oc <- ident+ pure (RunOp wf inp oc)+ -- A result type expression, possibly multi-word like @Maybe TransferDecision@.+ pTypeExpr = do+ ws <- some ident+ pure (T.unwords ws)++--------------------------------------------------------------------------------+-- Process manager + durable timer (EP-3)+--------------------------------------------------------------------------------++pProcess :: P ProcessNode+pProcess = do+ loc <- getLoc+ keyword "process"+ pid <- ident+ keyword "name"+ nm <- stringLit+ inp <- pInputDecl+ corr <- pCorrelate+ saga <- pSaga+ keyword "target"+ tgt <- ident+ projs <- keyword "projections" *> brackets (many ident)+ handle <- pHandle+ _ <- optional pDispatchIdLine+ rejected <- pPolicyLine "rejected"+ poison <- pPolicyLine "poison"+ timer <- pTimerNode+ pure+ ProcessNode+ { procId = pid,+ procName = nm,+ procInput = inp,+ procCorrelate = corr,+ procSaga = saga,+ procTarget = tgt,+ procProjections = projs,+ procHandle = handle,+ procRejected = rejected,+ procPoison = poison,+ procTimer = timer,+ procLoc = loc+ }++pRouter :: P RouterNode+pRouter = do+ loc <- getLoc+ keyword "router"+ rid <- ident+ keyword "name"+ nm <- stringLit+ inp <- pInputDecl+ key <- pRouterKey+ resolved <- pResolveDecl+ keyword "target"+ target <- ident+ projections <- keyword "projections" *> brackets (many ident)+ dispatch <- pRouterDispatch+ pRouterDispatchIdLine+ rejected <- pPolicyLine "rejected"+ poison <- pPolicyLine "poison"+ pure+ RouterNode+ { rtId = rid,+ rtName = nm,+ rtInput = inp,+ rtKey = key,+ rtResolve = resolved,+ rtTarget = target,+ rtProjections = projections,+ rtDispatch = dispatch,+ rtRejected = rejected,+ rtPoison = poison,+ rtLoc = loc+ }++pRouterKey :: P CorrelateDecl+pRouterKey = do+ keyword "key"+ _ <- keyword "input" *> symbol "."+ field <- ident+ keyword "via"+ via <- ident+ pure CorrelateDecl {corrField = field, corrVia = via}++pResolveDecl :: P ResolveDecl+pResolveDecl = do+ loc <- getLoc+ keyword "resolve"+ keyword "stable"+ keyword "via"+ source <- choice [ResolveReadModel <$> (keyword "read-model" *> ident), ResolveHole <$ keyword "hole"]+ keyword "row"+ row <- braces (many ident)+ pure ResolveDecl {rvSource = source, rvRow = row, rvLoc = loc}++pRouterDispatch :: P RouterDispatchNode+pRouterDispatch = do+ loc <- getLoc+ keyword "dispatch-each"+ command <- ident+ fields <- braces (many pFieldBinding)+ disposition <-+ DispatchDisposition+ <$> (keyword "on-appended" *> pDisp)+ <*> (symbol ";" *> keyword "on-duplicate" *> pDisp)+ <*> (symbol ";" *> keyword "on-failed" *> pDisp)+ pure RouterDispatchNode {rdCommand = command, rdFields = fields, rdDisposition = disposition, rdLoc = loc}++pRouterDispatchIdLine :: P ()+pRouterDispatchIdLine = do+ keyword "dispatch-id"+ _ <- symbol "strategy" *> symbol "=" *> keyword "uuidv5"+ _ <- symbol "from" *> symbol "=" *> parens fixedInputs+ pure ()+ where+ fixedInputs = do+ keyword "name"+ _ <- symbol ","+ keyword "key"+ _ <- symbol ","+ keyword "sourceEventId"+ _ <- symbol ","+ keyword "targetStreamName"+ _ <- symbol ","+ keyword "occurrence"++pPolicyLine :: Text -> P PolicyChoice+pPolicyLine clause = keyword clause *> symbol "=>" *> pPolicyChoice++pPolicyChoice :: P PolicyChoice+pPolicyChoice =+ choice+ [ PolHalt <$ keyword "halt",+ PolDeadLetter <$ keyword "deadLetter",+ PolSkip <$ keyword "skip"+ ]++pInputDecl :: P InputDecl+pInputDecl = do+ keyword "input"+ nm <- ident+ fs <- braces (many pField)+ pure InputDecl {inName = nm, inFields = fs}++pCorrelate :: P CorrelateDecl+pCorrelate = do+ keyword "correlate"+ _ <- keyword "input" *> symbol "."+ f <- ident+ keyword "via"+ v <- ident+ pure CorrelateDecl {corrField = f, corrVia = v}++pSaga :: P SagaRef+pSaga = do+ keyword "saga"+ agg <- ident+ keyword "category"+ categoryName <- stringLit+ pure SagaRef {sagaAgg = agg, sagaCategory = categoryName}++pHandle :: P HandleNode+pHandle = do+ keyword "on"+ onName <- ident+ adv <- pAdvance+ disps <- many pDispatch+ keyword "schedule"+ sched <- ident+ pure HandleNode {hOn = onName, hAdvance = adv, hDispatch = disps, hSchedule = sched}++pAdvance :: P AdvanceNode+pAdvance = do+ keyword "advance"+ cmd <- ident+ fs <- braces (many pFieldBinding)+ pure AdvanceNode {advCommand = cmd, advFields = fs}++pDispatch :: P DispatchNode+pDispatch = do+ loc <- getLoc+ keyword "dispatch"+ tgt <- ident+ _ <- symbol "@"+ key <- dottedRef+ cmd <- ident+ fs <- braces (many pFieldBinding)+ disp <-+ DispatchDisposition+ <$> (keyword "on-appended" *> pDisp)+ <*> (symbol ";" *> keyword "on-duplicate" *> pDisp)+ <*> (symbol ";" *> keyword "on-failed" *> pDisp)+ pure DispatchNode {dispTarget = tgt, dispKey = key, dispCommand = cmd, dispFields = fs, dispDisposition = disp, dispLoc = loc}++pDisp :: P Disp+pDisp =+ choice+ [ DAckOk <$ keyword "AckOk",+ DRetry <$ keyword "Retry",+ DDeadLetter <$> (keyword "DeadLetter" *> stringLit)+ ]++-- The dispatch-id line is a fixed, runtime-owned strategy; parse and discard.+pDispatchIdLine :: P ()+pDispatchIdLine = do+ keyword "dispatch-id"+ _ <- symbol "strategy" *> symbol "=" *> ident+ _ <- symbol "from" *> symbol "=" *> parens (sepBy dottedRef (symbol ","))+ pure ()++pTimerNode :: P TimerNode+pTimerNode = do+ loc <- getLoc+ keyword "timer"+ nm <- ident+ tid <- keyword "id" *> pIdExpr+ fat <- keyword "fireAt" *> pFireAt+ pay <- keyword "payload" *> braces (many pFieldBinding)+ fire <- pFire+ _ <- keyword "decode" *> keyword "unknown-status" *> symbol "=>"+ unk <- ident+ keyword "max-attempts"+ ma <- boundedDecimal+ keyword "dead-letter"+ dl <- stringLit+ pure+ TimerNode+ { tmName = nm,+ tmId = tid,+ tmFireAt = fat,+ tmPayload = pay,+ tmFire = fire,+ tmDecodeUnknown = unk,+ tmMaxAttempts = ma,+ tmDeadLetter = dl,+ tmLoc = loc+ }++pIdExpr :: P IdExpr+pIdExpr = do+ keyword "uuidv5"+ pfx <- stringLit+ _ <- symbol "<>"+ _ <- ident -- correlationId (fixed)+ pure IdExpr {ideStrategy = UuidV5Id, idePrefix = pfx}++pFireAt :: P FireAtExpr+pFireAt = do+ _ <- keyword "input" *> symbol "."+ f <- ident+ _ <- symbol "+"+ w <- pWindow+ pure FireAtExpr {faField = f, faWindow = w}++pWindow :: P Text+pWindow = lexeme $ do+ ds <- some digitChar+ u <- choice [char 's', char 'm', char 'h'] <?> "time unit: s, m, or h"+ notFollowedBy letterChar <?> "time unit: s, m, or h"+ pure (T.pack (ds <> [u]))++decimalText :: P Text+decimalText = lexeme $ do+ whole <- some digitChar+ fractional <- optional (char '.' *> some digitChar)+ pure (T.pack (whole <> maybe "" ('.' :) fractional))++signedDecimalText :: P Text+signedDecimalText = lexeme $ do+ sign <- optional (char '-')+ digits <- some digitChar+ fractional <- optional (char '.' *> some digitChar)+ pure (T.pack (maybe "" pure sign <> digits <> maybe "" ('.' :) fractional))++pFire :: P FireNode+pFire = do+ keyword "fire"+ keyword "dispatch"+ tgt <- ident+ _ <- symbol "@"+ key <- dottedRef+ cmd <- ident+ fs <- braces (many pFieldBinding)+ fid <- keyword "fired-event-id" *> pIdExpr+ disp <-+ FireDisposition+ <$> (keyword "on-ok" *> pFireOutcome)+ <*> (symbol ";" *> keyword "on-reject" *> pFireOutcome)+ <*> (symbol ";" *> keyword "on-ambiguous" *> pFireOutcome)+ <*> (symbol ";" *> keyword "on-error" *> pFireOutcome)+ <*> (symbol ";" *> keyword "not-mine" *> pFireOutcome)+ pure FireNode {fireTarget = tgt, fireKey = key, fireCommand = cmd, fireFields = fs, fireFiredEventId = fid, fireDisposition = disp}++pFireOutcome :: P FireOutcome+pFireOutcome = choice [OFired <$ keyword "Fired", ORetry <$ keyword "Retry"]++pFieldBinding :: P FieldBinding+pFieldBinding = do+ n <- ident+ v <- optional (symbol "=" *> pBindingValue)+ pure FieldBinding {fbName = n, fbValue = v}++-- | A binding value: a quoted string (kept quoted) or a dotted reference.+pBindingValue :: P Text+pBindingValue = choice [quoted, dottedRef]+ where+ quoted = do+ s <- stringLit+ pure ("\"" <> s <> "\"")++-- | A dotted/plain reference token like @input.hospitalId@, @timer.id@,+-- @correlationId@.+dottedRef :: P Text+dottedRef = lexeme $ do+ c <- asciiLetter+ cs <- many (asciiAlphaNum <|> char '_' <|> char '.')+ pure (T.pack (c : cs))++-- | A double-quoted string literal, returning raw (unescaped) inner text.+-- The surface syntax supports a closed escape set so unknown escapes remain+-- available for backward-compatible extensions.+stringLit :: P Text+stringLit = lexeme $ do+ _ <- char '"'+ s <- many strChar+ _ <- char '"'+ pure (T.pack s)+ where+ strChar =+ choice+ [ char '\\' *> escapeCode,+ char '\n' *> fail "unescaped newline in string literal (write \\n)",+ anySingleBut '"'+ ]+ escapeCode =+ choice+ [ '"' <$ char '"',+ '\\' <$ char '\\',+ '\n' <$ char 'n',+ '\t' <$ char 't',+ '\r' <$ char 'r',+ anySingle >>= \c -> fail ("unknown escape sequence \\" <> [c] <> " in string literal")+ ]++brackets :: P a -> P a+brackets = between (symbol "[") (symbol "]")++--------------------------------------------------------------------------------+-- Transitions+--------------------------------------------------------------------------------++data Clause+ = CGuard Expr+ | CWrite Name Expr+ | CEmit Name+ | CGoto Name+ | CImplementationHole++pTransition :: Bool -> P Transition+pTransition scalarSyntax = do+ startOffset <- getOffset+ loc <- getLoc+ -- Plan 143: a @replay-only@ prefix marks the transition as serving+ -- inversion only; it lowers to a keiki 'ReplayOnly' edge.+ mode <- option TmLive (TmReplayOnly <$ keyword "replay-only")+ src <- ident+ _ <- symbol "--"+ cmd <- ident+ _ <- symbol "-->"+ positionedClauses <- many ((,) <$> getOffset <*> (pClause scalarSyntax <* optional (symbol ";")))+ let clauses = map snd positionedClauses+ gotos = [(offset, target) | (offset, CGoto target) <- positionedClauses]+ holeOffsets = [offset | (offset, CImplementationHole) <- positionedClauses]+ transitionName = T.unpack src <> " -- " <> T.unpack cmd+ gt <- case gotos of+ [] -> failAt startOffset ("transition " <> transitionName <> " is missing a goto clause")+ [(_, target)] -> pure target+ (_, firstTarget) : (duplicateOffset, _) : _ ->+ failAt+ duplicateOffset+ ("duplicate goto clause (transition " <> transitionName <> " already declared goto " <> T.unpack firstTarget <> ")")+ case holeOffsets of+ _ : duplicateOffset : _ -> failAt duplicateOffset ("duplicate implementation hole clause in transition " <> transitionName)+ _ -> pure ()+ let guards = [e | CGuard e <- clauses]+ pure+ Transition+ { tSource = src,+ tCommand = cmd,+ tImplementation = case holeOffsets of+ _ : _ -> HoleImplementation+ [] | scalarSyntax -> GeneratedImplementation+ [] -> LegacyHoleImplementation,+ tGuard = case guards of [] -> Nothing; es -> Just (foldr1 EAnd es),+ tWrites = [(r, e) | CWrite r e <- clauses],+ tEmits = [n | CEmit n <- clauses],+ tGoto = gt,+ tMode = mode,+ tLoc = loc+ }++pClause :: Bool -> P Clause+pClause scalarSyntax =+ choice+ ( [CImplementationHole <$ (keyword "implementation" *> keyword "hole") | scalarSyntax]+ ++ [ CGuard <$> (keyword "guard" *> pExpr scalarSyntax),+ (\r e -> CWrite r e) <$> (keyword "write" *> ident) <*> (symbol ":=" *> pExpr scalarSyntax),+ try $ do+ keyword "emit"+ eventName <- ident+ notFollowedBy (symbol "{")+ pure (CEmit eventName),+ CGoto <$> (keyword "goto" *> ident)+ ]+ )++--------------------------------------------------------------------------------+-- Expr sublanguage+--------------------------------------------------------------------------------++pExpr :: Bool -> P Expr+pExpr scalarSyntax+ | scalarSyntax = makeExprParser pScalarTerm scalarOperatorTable+ | otherwise = makeExprParser pLegacyTerm legacyOperatorTable++pLegacyTerm :: P Expr+pLegacyTerm =+ choice+ [ parens (pExpr False),+ EAtom . ABool <$> (True <$ keyword "true" <|> False <$ keyword "false"),+ EAtom . AName <$> ident+ ]++-- | Highest precedence first: relational comparisons bind tighter than @&&@,+-- which binds tighter than @||@.+legacyOperatorTable :: [[Operator P Expr]]+legacyOperatorTable =+ [ [InfixL arithmeticUnsupported],+ [ InfixN (ECmp OpLe <$ op "<="),+ InfixN (ECmp OpGe <$ op ">="),+ InfixN (ECmp OpEq <$ op "=="),+ InfixN (ECmp OpNeq <$ op "!="),+ InfixN (ECmp OpLt <$ op "<"),+ InfixN (ECmp OpGt <$ op ">")+ ],+ [InfixL (EAnd <$ op "&&")],+ [InfixL (EOr <$ op "||")]+ ]+ where+ op s = symbol s+ arithmeticUnsupported = do+ offset <- getOffset+ operator <- lexeme (oneOf ['+', '-', '*', '/'])+ failAt offset ("aggregate arithmetic operator '" <> [operator] <> "' is unsupported; compare or copy whole values instead")++pScalarTerm :: P Expr+pScalarTerm =+ choice+ [ parens (pExpr True),+ collectionTermUnsupported,+ try pIdLiteral,+ do+ loc <- getLoc+ ELiteral loc . LiteralBool <$> (True <$ keyword "true" <|> False <$ keyword "false"),+ do+ loc <- getLoc+ ELiteral loc . LiteralText <$> stringLit,+ try $ do+ loc <- getLoc+ ELiteral loc . LiteralIntegral <$> integerLiteral,+ pScalarPath+ ]++pIdLiteral :: P Expr+pIdLiteral = do+ loc <- getLoc+ constructor <- ident+ value <- parens stringLit+ pure (ELiteral loc (LiteralId constructor value))++pScalarPath :: P Expr+pScalarPath = do+ loc <- getLoc+ firstName <- ident+ rest <- many (symbol "." *> ident)+ pure $ case (firstName, rest) of+ ("reg", name : path) -> EPath loc RegisterRoot (name : path)+ ("cmd", name : path) -> EPath loc CommandRoot (name : path)+ (_, [constructor]) | startsUpper firstName -> ELiteral loc (LiteralQualified firstName constructor)+ _ -> EPath loc UnqualifiedRoot (firstName : rest)+ where+ startsUpper value = maybe False (isUpper . fst) (T.uncons value)++collectionTermUnsupported :: P Expr+collectionTermUnsupported = do+ offset <- getOffset+ choice+ [ () <$ symbol "[",+ () <$ symbol "{",+ () <$ keyword "keys",+ () <$ keyword "values",+ () <$ keyword "any",+ () <$ keyword "all"+ ]+ failAt offset collectionExpressionMessage++collectionExpressionMessage :: String+collectionExpressionMessage = "CollectionExpressionUnsupported: collection expressions are reserved for plan 166"++scalarOperatorTable :: [[Operator P Expr]]+scalarOperatorTable =+ [ [InfixL (op "*" *> located EMultiply)],+ [ InfixL (op "+" *> located EAdd),+ InfixL (op "-" *> located ESubtract),+ InfixL scalarArithmeticUnsupported+ ],+ [ InfixN (ECmp OpLe <$ op "<="),+ InfixN (ECmp OpGe <$ op ">="),+ InfixN (ECmp OpEq <$ op "=="),+ InfixN (ECmp OpNeq <$ op "!="),+ InfixN (ECmp OpLt <$ op "<"),+ InfixN (ECmp OpGt <$ op ">"),+ InfixN collectionOperatorUnsupported+ ],+ [InfixL (EAnd <$ op "&&")],+ [InfixL (EOr <$ op "||")]+ ]+ where+ op value = symbol value+ located constructor = do+ loc <- getLoc+ pure (constructor loc)+ scalarArithmeticUnsupported = do+ offset <- getOffset+ operator <- lexeme (oneOf ['/', '%'])+ failAt offset ("aggregate arithmetic operator '" <> [operator] <> "' is unsupported")+ collectionOperatorUnsupported = do+ offset <- getOffset+ _ <- try (keyword "not" *> keyword "in") <|> keyword "in"+ failAt offset collectionExpressionMessage -------------------------------------------------------------------------------- -- Helpers
src/Keiro/Dsl/PrettyPrint.hs view
@@ -1,12 +1,12 @@-{- | Pretty-printer for the keiro DSL: renders a 'Spec' back to @.keiro@ text.-The layout need not be byte-identical to the original source (the parser-treats whitespace as insignificant), but it must round-trip:-@parseSpec (renderSpec s) == Right s@ modulo source locations. The only-subtle part is expression printing, which uses a @showsPrec@-style precedence-scheme so left-associative @&&@/@||@ and non-associative comparisons re-parse-to the identical AST.--}-module Keiro.Dsl.PrettyPrint (+-- | Pretty-printer for the keiro DSL: renders a 'Spec' back to @.keiro@ text.+-- The layout need not be byte-identical to the original source (the parser+-- treats whitespace as insignificant), but it must round-trip:+-- @parseSpec (renderSpec s) == Right s@ modulo source locations. The only+-- subtle part is expression printing, which uses a @showsPrec@-style precedence+-- scheme so left-associative @&&@/@||@ and non-associative comparisons re-parse+-- to the identical AST.+module Keiro.Dsl.PrettyPrint+ ( renderSource, renderSpec, renderTransition, renderExpr,@@ -14,12 +14,13 @@ renderResolveSurface, renderRouterDispatchSurface, renderTimerPayloadSurface,-)+ ) where import Data.Text (Text) import Data.Text qualified as T import Keiro.Dsl.Grammar+import Keiro.Dsl.LanguageVersion import Prettyprinter import Prettyprinter.Render.Text (renderStrict) @@ -27,6 +28,14 @@ renderSpec :: Spec -> Text renderSpec = renderDoc . docSpec +-- | Render a source while preserving whether it explicitly declared a version.+renderSource :: ParsedSource -> Text+renderSource ParsedSource {parsedSourceLanguage = sourceLanguage, parsedSpec = spec} =+ case sourceLanguage of+ LegacyUnversioned -> renderSpec spec+ DeclaredLanguage {declaredLanguageVersion = version} ->+ "language keiro-dsl " <> languageVersionText version <> "\n" <> renderSpec spec+ renderHandleSurface :: HandleNode -> Text renderHandleSurface = renderDoc . docHandle @@ -38,27 +47,29 @@ renderTimerPayloadSurface :: TimerNode -> Text renderTimerPayloadSurface timer =- renderDoc ("payload" <+> braced (map docFieldBinding (tmPayload timer)))+ renderDoc ("payload" <+> braced (map docFieldBinding (tmPayload timer))) renderDoc :: Doc ann -> Text-renderDoc = renderStrict . layoutPretty LayoutOptions{layoutPageWidth = Unbounded}+renderDoc = renderStrict . layoutPretty LayoutOptions {layoutPageWidth = Unbounded} docSpec :: Spec -> Doc ann docSpec s =- vsep $- ["context" <+> pretty (specContext s)]- ++ maybe [] (\r -> ["module" <+> pretty r]) (specModuleRoot s)- ++ maybe [] (\l -> ["layout" <+> docLayout l]) (specLayout s)- ++ [mempty]- ++ map docId (specIds s)- ++ blankAfter (specIds s)- ++ map docEnum (specEnums s)- ++ blankAfter (specEnums s)- ++ map docRule (specRules s)- ++ blankAfter (specRules s)- ++ map docMapped (specMapped s)- ++ blankAfter (specMapped s)- ++ map docNode (specNodes s)+ vsep $+ ["context" <+> pretty (specContext s)]+ ++ maybe [] (\r -> ["module" <+> pretty r]) (specModuleRoot s)+ ++ maybe [] (\l -> ["layout" <+> docLayout l]) (specLayout s)+ ++ [mempty]+ ++ map docId (specIds s)+ ++ blankAfter (specIds s)+ ++ map docEnum (specEnums s)+ ++ blankAfter (specEnums s)+ ++ map docRule (specRules s)+ ++ blankAfter (specRules s)+ ++ map docNominalScalar (specNominalScalars s)+ ++ blankAfter (specNominalScalars s)+ ++ map docMapped (specMapped s)+ ++ blankAfter (specMapped s)+ ++ map docNode (specNodes s) where blankAfter xs = if null xs then [] else [mempty] @@ -67,44 +78,79 @@ docLayout CollocatedLeaf = "collocated" docId :: IdDecl -> Doc ann-docId d = "id" <+> pretty (idName d) <+> ("prefix=" <> pretty (idPrefix d))+docId d =+ case idBinding d of+ Nothing -> "id" <+> pretty (idName d) <+> ("prefix=" <> pretty (idPrefix d))+ Just binding ->+ vsep $+ ["id" <+> pretty (idName d) <+> ("prefix=" <> pretty (idPrefix d)) <+> "using" <+> "{"]+ ++ map (indent 2) (docNominalBindingFacts binding)+ ++ ["}"] docEnum :: EnumDecl -> Doc ann docEnum d =- "enum" <+> pretty (enumName d) <+> braced (map ctor (enumCtors d))+ case enumBinding d of+ Nothing -> enumHeader+ Just binding ->+ vsep $+ [enumHeader <+> "using" <+> "{"]+ ++ map (indent 2) (docNominalBindingFacts binding)+ ++ ["}"] where+ enumHeader = "enum" <+> pretty (enumName d) <+> braced (map ctor (enumCtors d)) ctor (c, w) = pretty c <> "=" <> pretty w docRule :: RuleDecl -> Doc ann docRule d =- vsep- [ "rule" <+> pretty (ruleName d) <+> ":" <+> pretty (ruleDomain d) <+> "->" <+> pretty (ruleCodomain d)- , indent 2 ("ex" <+> hsep (punctuate " ;" (map cas (ruleCases d))))- ]+ vsep+ [ "rule" <+> pretty (ruleName d) <+> ":" <+> pretty (ruleDomain d) <+> "->" <+> pretty (ruleCodomain d),+ indent 2 ("ex" <+> hsep (punctuate " ;" (map cas (ruleCases d))))+ ] where cas (c, e) = pretty c <+> "=>" <+> docExpr 0 e docMapped :: MappedDecl -> Doc ann-docMapped MappedStructural{msName = name, msHaskell = haskell, msBinding = binding, msBindingVersion = bindingVersion, msCanonical = canonical, msFixtures = fixtures, msInitial = initial, msShape = shape} =- vsep $- ["mapped structural" <+> docShapeKind shape <+> pretty name <+> "{"]- ++ maybe [] (pure . indent 2 . docHaskellSource) haskell- ++ maybe [] (pure . indent 2 . docQuotedFact "binding") binding- ++ maybe [] (pure . indent 2 . docQuotedFact "binding-version") bindingVersion- ++ maybe [] (pure . indent 2 . docQuotedFact "canonical-type") canonical- ++ maybe [] (pure . indent 2 . docQuotedFact "fixtures") fixtures- ++ maybe [] (pure . indent 2 . docQuotedFact "initial") initial- ++ [indent 2 (docMappedShape shape), "}"]-docMapped MappedOpaque{moName = name, moHaskell = haskell, moCodecId = codec, moCodecVersion = version, moFixtures = fixtures, moInitial = initial} =- vsep $- ["mapped opaque" <+> pretty name <+> "{"]- ++ maybe [] (pure . indent 2 . docHaskellSource) haskell- ++ maybe [] (pure . indent 2 . docQuotedFact "codec") codec- ++ maybe [] (pure . indent 2 . docQuotedFact "version") version- ++ maybe [] (pure . indent 2 . docQuotedFact "fixtures") fixtures- ++ maybe [] (pure . indent 2 . docQuotedFact "initial") initial- ++ ["}"]+docMapped MappedStructural {msName = name, msHaskell = haskell, msBinding = binding, msBindingVersion = bindingVersion, msCanonical = canonical, msFixtures = fixtures, msInitial = initial, msShape = shape} =+ vsep $+ ["mapped structural" <+> docShapeKind shape <+> pretty name <+> "{"]+ ++ maybe [] (pure . indent 2 . docHaskellSource) haskell+ ++ maybe [] (pure . indent 2 . docQuotedFact "binding") binding+ ++ maybe [] (pure . indent 2 . docQuotedFact "binding-version") bindingVersion+ ++ maybe [] (pure . indent 2 . docQuotedFact "canonical-type") canonical+ ++ maybe [] (pure . indent 2 . docQuotedFact "fixtures") fixtures+ ++ maybe [] (pure . indent 2 . docQuotedFact "initial") initial+ ++ [indent 2 (docMappedShape shape), "}"]+docMapped MappedOpaque {moName = name, moHaskell = haskell, moCodecId = codec, moCodecVersion = version, moFixtures = fixtures, moInitial = initial} =+ vsep $+ ["mapped opaque" <+> pretty name <+> "{"]+ ++ maybe [] (pure . indent 2 . docHaskellSource) haskell+ ++ maybe [] (pure . indent 2 . docQuotedFact "codec") codec+ ++ maybe [] (pure . indent 2 . docQuotedFact "version") version+ ++ maybe [] (pure . indent 2 . docQuotedFact "fixtures") fixtures+ ++ maybe [] (pure . indent 2 . docQuotedFact "initial") initial+ ++ ["}"] +docNominalScalar :: NominalScalarDecl -> Doc ann+docNominalScalar declaration =+ vsep $+ [ "mapped nominal"+ <+> pretty (nominalScalarName declaration)+ <+> ":"+ <+> pretty (nominalScalarRepresentation declaration)+ <+> "{"+ ]+ ++ map (indent 2) (docNominalBindingFacts (nominalScalarBinding declaration))+ ++ ["}"]++docNominalBindingFacts :: NominalBindingDecl -> [Doc ann]+docNominalBindingFacts binding =+ maybe [] (pure . docHaskellSource) (nominalHaskell binding)+ ++ maybe [] (pure . docQuotedFact "binding") (nominalBinding binding)+ ++ maybe [] (pure . docQuotedFact "binding-version") (nominalBindingVersion binding)+ ++ maybe [] (pure . docQuotedFact "canonical-type") (nominalCanonicalType binding)+ ++ maybe [] (pure . docQuotedFact "fixtures") (nominalFixtures binding)+ ++ maybe [] (pure . docQuotedFact "initial") (nominalInitial binding)+ docShapeKind :: MappedShape -> Doc ann docShapeKind (ShapeRecord _ _ _) = "record" docShapeKind (ShapeEnum _) = "enum"@@ -112,36 +158,36 @@ docHaskellSource :: HaskellSource -> Doc ann docHaskellSource source =- "haskell"- <+> ("package=" <> pretty (hsPackage source))- <+> ("module=" <> pretty (hsModule source))- <+> ("type=" <> pretty (hsType source))+ "haskell"+ <+> ("package=" <> pretty (hsPackage source))+ <+> ("module=" <> pretty (hsModule source))+ <+> ("type=" <> pretty (hsType source)) docQuotedFact :: Doc ann -> Text -> Doc ann docQuotedFact label value = label <+> "=" <+> dquoted value docMappedShape :: MappedShape -> Doc ann docMappedShape (ShapeRecord constructor unknownFields fields) =- vsep $- [ "wire object"- <+> ("constructor=" <> pretty constructor)- <+> ("unknown-fields=" <> docUnknownFields unknownFields)- <+> "{"- ]- ++ map (indent 2 . docWireField) fields- ++ ["}"]+ vsep $+ [ "wire object"+ <+> ("constructor=" <> pretty constructor)+ <+> ("unknown-fields=" <> docUnknownFields unknownFields)+ <+> "{"+ ]+ ++ map (indent 2 . docWireField) fields+ ++ ["}"] docMappedShape (ShapeEnum entries) =- vsep $ ["wire string {"] ++ map (indent 2 . docWireEnum) entries ++ ["}"]+ vsep $ ["wire string {"] ++ map (indent 2 . docWireEnum) entries ++ ["}"] docMappedShape (ShapeUnion encoding arms) =- vsep $- [ "wire tagged-object"- <+> ("tag=" <> dquoted (ueTagField encoding))- <+> ("contents=" <> dquoted (ueContentsField encoding))- <+> ("unknown-fields=" <> docUnknownFields (ueUnknownFields encoding))- <+> "{"- ]- ++ map (indent 2 . docWireArm) arms- ++ ["}"]+ vsep $+ [ "wire tagged-object"+ <+> ("tag=" <> dquoted (ueTagField encoding))+ <+> ("contents=" <> dquoted (ueContentsField encoding))+ <+> ("unknown-fields=" <> docUnknownFields (ueUnknownFields encoding))+ <+> "{"+ ]+ ++ map (indent 2 . docWireArm) arms+ ++ ["}"] docUnknownFields :: UnknownFields -> Doc ann docUnknownFields RejectUnknown = "reject"@@ -149,13 +195,13 @@ docWireField :: WireField -> Doc ann docWireField field =- pretty (wfHaskell field)- <+> "as"- <+> dquoted (wfKey field)- <+> ":"- <+> docTypeExpr (wfType field)- <+> docPresence (wfPresence field)- <> maybe mempty (\value -> " on-missing=" <> docOnMissing value) (wfOnMissing field)+ pretty (wfHaskell field)+ <+> "as"+ <+> dquoted (wfKey field)+ <+> ":"+ <+> docTypeExpr (wfType field)+ <+> docPresence (wfPresence field)+ <> maybe mempty (\value -> " on-missing=" <> docOnMissing value) (wfOnMissing field) docPresence :: Presence -> Doc ann docPresence PRequired = "required"@@ -176,14 +222,15 @@ docWireArm :: WireArm -> Doc ann docWireArm arm =- pretty (waCtor arm)- <+> "as"- <+> dquoted (waTag arm)- <> maybe mempty (\payload -> " : " <> docTypeExpr payload) (waPayload arm)+ pretty (waCtor arm)+ <+> "as"+ <+> dquoted (waTag arm)+ <> maybe mempty (\payload -> " : " <> docTypeExpr payload) (waPayload arm) docTypeExpr :: TypeExpr -> Doc ann docTypeExpr TText = "Text" docTypeExpr TInt = "Int"+docTypeExpr TInteger = "Integer" docTypeExpr TBool = "Bool" docTypeExpr TNatural = "Natural" docTypeExpr TTime = "Time"@@ -194,9 +241,9 @@ docTypeExpr (TRef name) = pretty name docTypeArgument :: TypeExpr -> Doc ann-docTypeArgument value@TOptional{} = parens (docTypeExpr value)-docTypeArgument value@TList{} = parens (docTypeExpr value)-docTypeArgument value@TMap{} = parens (docTypeExpr value)+docTypeArgument value@TOptional {} = parens (docTypeExpr value)+docTypeArgument value@TList {} = parens (docTypeExpr value)+docTypeArgument value@TMap {} = parens (docTypeExpr value) docTypeArgument value = docTypeExpr value docNode :: Node -> Doc ann@@ -215,93 +262,93 @@ docWorkflow :: WorkflowNode -> Doc ann docWorkflow w =- vsep $- [ "workflow" <+> pretty (wfId w)- , indent 2 ("name" <+> dquoted (wfStable w))- , indent 2 ("in" <+> pretty (wfInput w) <> inFieldsDoc)- , indent 2 ("out" <+> pretty (wfOutput w))- , indent 2 ("id from input" <> maybe mempty (\f -> "." <> pretty f) (wfIdField w) <+> "via" <+> pretty (wfIdVia w))- , indent 2 "body"- ]- ++ map (indent 4 . bodyItem) (wfBody w)+ vsep $+ [ "workflow" <+> pretty (wfId w),+ indent 2 ("name" <+> dquoted (wfStable w)),+ indent 2 ("in" <+> pretty (wfInput w) <> inFieldsDoc),+ indent 2 ("out" <+> pretty (wfOutput w)),+ indent 2 ("id from input" <> maybe mempty (\f -> "." <> pretty f) (wfIdField w) <+> "via" <+> pretty (wfIdVia w)),+ indent 2 "body"+ ]+ ++ map (indent 4 . bodyItem) (wfBody w) where inFieldsDoc = case wfInputFields w of- [] -> mempty- fs -> " " <> braced (map docField fs)+ [] -> mempty+ fs -> " " <> braced (map docField fs) bodyItem (WfStep l r _) = "step" <+> pretty l <+> "->" <+> pretty r bodyItem (WfAwait l r _) = "await" <+> pretty l <+> "->" <+> pretty r bodyItem (WfSleep l a _) = "sleep" <+> pretty l <+> "after" <+> pretty a bodyItem (WfChild l v r _) = "child" <+> pretty l <+> "id input via" <+> pretty v <+> "->" <+> pretty r bodyItem (WfPatch patchId items _) =- vsep $ ["patch" <+> pretty patchId <+> "{"] ++ map (indent 2 . bodyItem) items ++ ["}"]+ vsep $ ["patch" <+> pretty patchId <+> "{"] ++ map (indent 2 . bodyItem) items ++ ["}"] bodyItem (WfContinueAsNew seedType _) = "continueAsNew" <+> pretty seedType docOperation :: OperationNode -> Doc ann docOperation o =- vsep $ ["operation" <+> pretty (opName o)] ++ map (indent 2) (shapeLines (opShape o))+ vsep $ ["operation" <+> pretty (opName o)] ++ map (indent 2) (shapeLines (opShape o)) where shapeLines (CommandOp agg sf sv proj) =- [ "command on" <+> pretty agg- , indent 2 ("stream from" <+> pretty sf <+> "via" <+> pretty sv)- ]- ++ [indent 2 ("project" <+> bracketed (map pretty proj)) | not (null proj)]+ [ "command on" <+> pretty agg,+ indent 2 ("stream from" <+> pretty sf <+> "via" <+> pretty sv)+ ]+ ++ [indent 2 ("project" <+> bracketed (map pretty proj)) | not (null proj)] shapeLines (QueryOp rm inp res cons) =- [ "query" <+> pretty rm- , indent 2 ("input" <+> pretty inp)- , indent 2 ("result" <+> pretty res)- , indent 2 ("consistency" <+> pretty cons)- ]+ [ "query" <+> pretty rm,+ indent 2 ("input" <+> pretty inp),+ indent 2 ("result" <+> pretty res),+ indent 2 ("consistency" <+> pretty cons)+ ] shapeLines (SignalOp lbl wf kf kv val) =- [ "signal" <+> pretty lbl <+> "of" <+> pretty wf- , indent 2 ("key from" <+> pretty kf <+> "via" <+> pretty kv)- , indent 2 ("value" <+> pretty val)- ]+ [ "signal" <+> pretty lbl <+> "of" <+> pretty wf,+ indent 2 ("key from" <+> pretty kf <+> "via" <+> pretty kv),+ indent 2 ("value" <+> pretty val)+ ] shapeLines (RunOp wf inp oc) =- [ "run" <+> pretty wf- , indent 2 ("input" <+> pretty inp)- , indent 2 ("outcome ->" <+> pretty oc)- ]+ [ "run" <+> pretty wf,+ indent 2 ("input" <+> pretty inp),+ indent 2 ("outcome ->" <+> pretty oc)+ ] docWorkqueue :: WorkqueueNode -> Doc ann docWorkqueue w =- vsep $- [ "workqueue" <+> pretty (wqName w) <+> "{"- , indent 2 ("queue logical =" <+> dquoted (wqLogical w))- , indent 2 ("derive physical =" <+> dquoted (wqPhysical w))- , indent 4 ("dlq =" <+> dquoted (wqDlq w))- , indent 4 ("table =" <+> dquoted (wqTable w))- ]- ++ orderingLines- ++ groupKeyLines- ++ provisionLines- ++ [indent 2 ("payload" <+> pretty (wqPayloadName w) <+> "{")]- ++ map (indent 4 . field) (wqPayload w)- ++ [ indent 2 "}"- , indent 2 ("retry maxRetries =" <+> pretty (wqMaxRetries w) <+> "delay =" <+> pretty (wqDelay w) <+> "dlq =" <+> (if wqDlqOn w then "on" else "off"))- , indent 2 "disposition {"- ]- ++ map (indent 4 . dispRow) (wqDisposition w)- ++ [indent 2 "}", "}"]+ vsep $+ [ "workqueue" <+> pretty (wqName w) <+> "{",+ indent 2 ("queue logical =" <+> dquoted (wqLogical w)),+ indent 2 ("derive physical =" <+> dquoted (wqPhysical w)),+ indent 4 ("dlq =" <+> dquoted (wqDlq w)),+ indent 4 ("table =" <+> dquoted (wqTable w))+ ]+ ++ orderingLines+ ++ groupKeyLines+ ++ provisionLines+ ++ [indent 2 ("payload" <+> pretty (wqPayloadName w) <+> "{")]+ ++ map (indent 4 . field) (wqPayload w)+ ++ [ indent 2 "}",+ indent 2 ("retry maxRetries =" <+> pretty (wqMaxRetries w) <+> "delay =" <+> pretty (wqDelay w) <+> "dlq =" <+> (if wqDlqOn w then "on" else "off")),+ indent 2 "disposition {"+ ]+ ++ map (indent 4 . dispRow) (wqDisposition w)+ ++ [indent 2 "}", "}"] where orderingLines = case wqOrdering w of- WqUnordered -> []- WqFifoThroughput -> [indent 2 "ordering fifo-throughput"]- WqFifoRoundRobin -> [indent 2 "ordering fifo-roundrobin"]+ WqUnordered -> []+ WqFifoThroughput -> [indent 2 "ordering fifo-throughput"]+ WqFifoRoundRobin -> [indent 2 "ordering fifo-roundrobin"] groupKeyLines = case wqGroupKey w of- Nothing -> []- Just groupKey ->- [ indent 2 $- "group key from"- <+> pretty (gkField groupKey)- <+> "via"- <+> pretty (gkVia groupKey)- <> maybe mempty (\fixture -> " fixture " <> dquoted fixture) (gkFixture groupKey)- ]+ Nothing -> []+ Just groupKey ->+ [ indent 2 $+ "group key from"+ <+> pretty (gkField groupKey)+ <+> "via"+ <+> pretty (gkVia groupKey)+ <> maybe mempty (\fixture -> " fixture " <> dquoted fixture) (gkFixture groupKey)+ ] provisionLines = case wqProvision w of- WqStandard -> []- WqUnlogged -> [indent 2 "provision unlogged"]- WqPartitioned interval retention ->- [indent 2 ("provision partitioned(interval=" <> dquoted interval <> ", retention=" <> dquoted retention <> ")")]+ WqStandard -> []+ WqUnlogged -> [indent 2 "provision unlogged"]+ WqPartitioned interval retention ->+ [indent 2 ("provision partitioned(interval=" <> dquoted interval <> ", retention=" <> dquoted retention <> ")")] field f = pretty (wqfName f) <+> "->" <+> dquoted (wqfWire f) <+> pretty (wqfType f) <> (if wqfRequired f then " required" else mempty) dispRow r = pretty (wqdOutcome r) <+> "->" <+> act (wqdAction r) act IAckOk = "ackOk"@@ -311,40 +358,40 @@ docPgmqDispatch :: PgmqDispatchNode -> Doc ann docPgmqDispatch d =- vsep- [ "dispatch" <+> pretty (pdName d) <+> "{"- , indent 2 ("source readModel =" <+> pretty (pdSourceReadModel d) <+> "key =" <+> pretty (pdSourceKey d))- , indent 2 ("fanout body =" <+> pretty (pdFanoutBody d))- , indent 2 ("dedup key =" <+> pretty (pdDedupKey d))- , indent 4 ("seenIn readModel =" <+> pretty (pdDedupReadModel d) <+> "field =" <+> pretty (pdDedupReadModelField d))- , indent 4 ("seenIn queue =" <+> pretty (pdDedupQueue d) <+> "field =" <+> pretty (pdDedupQueueField d))- , indent 2 ("enqueue to =" <+> pretty (pdEnqueueTo d))- , "}"- ]+ vsep+ [ "dispatch" <+> pretty (pdName d) <+> "{",+ indent 2 ("source readModel =" <+> pretty (pdSourceReadModel d) <+> "key =" <+> pretty (pdSourceKey d)),+ indent 2 ("fanout body =" <+> pretty (pdFanoutBody d)),+ indent 2 ("dedup key =" <+> pretty (pdDedupKey d)),+ indent 4 ("seenIn readModel =" <+> pretty (pdDedupReadModel d) <+> "field =" <+> pretty (pdDedupReadModelField d)),+ indent 4 ("seenIn queue =" <+> pretty (pdDedupQueue d) <+> "field =" <+> pretty (pdDedupQueueField d)),+ indent 2 ("enqueue to =" <+> pretty (pdEnqueueTo d)),+ "}"+ ] docReadModel :: ReadModelNode -> Doc ann docReadModel readModel =- vsep $- [ "readmodel" <+> pretty (rmName readModel) <+> "{"- , indent 2 ("table =" <+> dquoted (rmTable readModel))- , indent 2 ("schema =" <+> dquoted (rmSchema readModel))- , indent 2 "columns {"- ]- ++ map (indent 4 . docColumn) (rmColumns readModel)- ++ [ indent 2 "}"- , indent 2 ("version =" <+> pretty (rmVersion readModel))- , indent 2 ("shape =" <+> dquoted (rmShape readModel))- , indent 2 ("consistency =" <+> docConsistency (rmConsistency readModel))- ]- ++ maybe [] (pure . indent 2 . ("scope =" <+>) . docScope) (rmScope readModel)- ++ [indent 2 ("feed =" <+> docFeed (rmFeed readModel))]- ++ maybe [] (pure . indent 2 . ("subscription =" <+>) . dquoted) (rmSubscription readModel)- ++ ["}"]+ vsep $+ [ "readmodel" <+> pretty (rmName readModel) <+> "{",+ indent 2 ("table =" <+> dquoted (rmTable readModel)),+ indent 2 ("schema =" <+> dquoted (rmSchema readModel)),+ indent 2 "columns {"+ ]+ ++ map (indent 4 . docColumn) (rmColumns readModel)+ ++ [ indent 2 "}",+ indent 2 ("version =" <+> pretty (rmVersion readModel)),+ indent 2 ("shape =" <+> dquoted (rmShape readModel)),+ indent 2 ("consistency =" <+> docConsistency (rmConsistency readModel))+ ]+ ++ maybe [] (pure . indent 2 . ("scope =" <+>) . docScope) (rmScope readModel)+ ++ [indent 2 ("feed =" <+> docFeed (rmFeed readModel))]+ ++ maybe [] (pure . indent 2 . ("subscription =" <+>) . dquoted) (rmSubscription readModel)+ ++ ["}"] where docColumn columnDecl =- pretty (rmcName columnDecl)- <+> pretty (rmcType columnDecl)- <> if rmcRequired columnDecl then " required" else mempty+ pretty (rmcName columnDecl)+ <+> pretty (rmcType columnDecl)+ <> if rmcRequired columnDecl then " required" else mempty docScope RmEntireLog = "entire-log" docScope (RmCategory categoryName) = "category" <+> dquoted categoryName docFeed RmInline = "inline"@@ -352,73 +399,73 @@ docEmit :: EmitNode -> Doc ann docEmit e =- vsep $- [ "emit" <+> pretty (emName e) <+> "{"- , indent 2 ("contract" <+> pretty (emContract e))- , indent 2 ("topic" <+> pretty (emTopic e))- , indent 2 ("source" <+> dquoted (emSource e))- , indent 2 ("key" <+> pretty (emKey e))- , indent 2 ("map" <+> pretty (emDiscriminant e) <+> "{")- ]- ++ map (indent 4 . row) (emMap e)- ++ [indent 4 "_ => skip" | emSkip e]- ++ [ indent 2 "}"- , indent 2 ("messageId" <+> docDerive (emMessageId e))- , indent 2 ("idempotencyKey" <+> docDerive (emIdempotencyKey e))- , "}"- ]+ vsep $+ [ "emit" <+> pretty (emName e) <+> "{",+ indent 2 ("contract" <+> pretty (emContract e)),+ indent 2 ("topic" <+> pretty (emTopic e)),+ indent 2 ("source" <+> dquoted (emSource e)),+ indent 2 ("key" <+> pretty (emKey e)),+ indent 2 ("map" <+> pretty (emDiscriminant e) <+> "{")+ ]+ ++ map (indent 4 . row) (emMap e)+ ++ [indent 4 "_ => skip" | emSkip e]+ ++ [ indent 2 "}",+ indent 2 ("messageId" <+> docDerive (emMessageId e)),+ indent 2 ("idempotencyKey" <+> docDerive (emIdempotencyKey e)),+ "}"+ ] where row r = dquoted (emrValue r) <+> "=>" <+> pretty (emrEvent r) docDerive d = "derive" <> maybe mempty (\p -> " " <> dquoted p) (dsPrefix d) <+> "hole" docPublisher :: PublisherNode -> Doc ann docPublisher p =- vsep- [ "publisher" <+> pretty (pubName p) <+> "{"- , indent 2 ("emit" <+> pretty (pubEmit p))- , indent 2 ("ordering" <+> pretty (pubOrdering p))- , indent 2 ("maxAttempts" <+> pretty (pubMaxAttempts p))- , indent 2 (docBackoff (pubBackoff p))- , indent 2 ("outboxId stable from" <+> pretty (pubOutboxField p))- , "}"- ]+ vsep+ [ "publisher" <+> pretty (pubName p) <+> "{",+ indent 2 ("emit" <+> pretty (pubEmit p)),+ indent 2 ("ordering" <+> pretty (pubOrdering p)),+ indent 2 ("maxAttempts" <+> pretty (pubMaxAttempts p)),+ indent 2 (docBackoff (pubBackoff p)),+ indent 2 ("outboxId stable from" <+> pretty (pubOutboxField p)),+ "}"+ ] docIntake :: IntakeNode -> Doc ann docIntake i =- vsep $- [ "intake" <+> pretty (inkName i) <+> "{"- , indent 2 ("contract" <+> pretty (inkContract i))- , indent 2 ("topic" <+> pretty (inkTopic i))- , indent 2 ("accept" <+> hsep (map pretty (inkAccept i)))- ]- ++ map (indent 2 . docBind) (inkBinds i)- ++ [ indent 2 ("dedupe key" <+> pretty (inkDedupeKey i) <+> "policy" <+> pretty (inkDedupePolicy i))- ]- ++ [indent 2 "persist = dedupe-only" | inkPersist i == InkPersistDedupeOnly]- ++ [ indent 2 (docDecode (inkDecode i))- , indent 2 "disposition {"- ]- ++ map (indent 4 . docDispRow) (inkDisposition i)- ++ [indent 2 "}", "}"]+ vsep $+ [ "intake" <+> pretty (inkName i) <+> "{",+ indent 2 ("contract" <+> pretty (inkContract i)),+ indent 2 ("topic" <+> pretty (inkTopic i)),+ indent 2 ("accept" <+> hsep (map pretty (inkAccept i)))+ ]+ ++ map (indent 2 . docBind) (inkBinds i)+ ++ [ indent 2 ("dedupe key" <+> pretty (inkDedupeKey i) <+> "policy" <+> pretty (inkDedupePolicy i))+ ]+ ++ [indent 2 "persist = dedupe-only" | inkPersist i == InkPersistDedupeOnly]+ ++ [ indent 2 (docDecode (inkDecode i)),+ indent 2 "disposition {"+ ]+ ++ map (indent 4 . docDispRow) (inkDisposition i)+ ++ [indent 2 "}", "}"] where docBind b =- "bind"- <+> pretty (brField b)- <+> "from"- <+> docSource (brSource b)- <> (if brRequired b then " required" else mempty)- <> (if brCrossCheck b then " cross-check body" else mempty)+ "bind"+ <+> pretty (brField b)+ <+> "from"+ <+> docSource (brSource b)+ <> (if brRequired b then " required" else mempty)+ <> (if brCrossCheck b then " cross-check body" else mempty) docSource (SrcHeader h) = "header" <+> dquoted h docSource SrcBody = "body" docSource SrcKafkaKey = "kafka-key" docSource SrcKafkaCursor = "kafka-cursor" docDecode d =- vsep- [ "decode {"- , indent 2 ("envelope" <+> pretty (decEnvelope d))- , indent 2 ("body" <+> (if decBodyStrict d then "strict" else "lenient") <+> "schemaVersion ==" <+> pretty (decBodySchemaVersion d))- , "}"- ]+ vsep+ [ "decode {",+ indent 2 ("envelope" <+> pretty (decEnvelope d)),+ indent 2 ("body" <+> (if decBodyStrict d then "strict" else "lenient") <+> "schemaVersion ==" <+> pretty (decBodySchemaVersion d)),+ "}"+ ] docDispRow r = pretty (drOutcome r) <+> "=>" <+> docAction (drAction r) docAction IAckOk = "ackOk" docAction (IRetry w) = "retry" <+> pretty w@@ -431,21 +478,21 @@ docContract :: ContractNode -> Doc ann docContract c =- vsep $- [ "contract" <+> pretty (ctrName c) <+> "{"- , indent 2 ("schemaVersion" <+> pretty (ctrSchemaVersion c))- , indent 2 ("discriminator" <+> pretty (ctrDiscriminator c))- ]- ++ map (indent 2 . docTopic) (ctrTopics c)- ++ map (indent 2 . docContractEvent) (ctrEvents c)- ++ ["}"]+ vsep $+ [ "contract" <+> pretty (ctrName c) <+> "{",+ indent 2 ("schemaVersion" <+> pretty (ctrSchemaVersion c)),+ indent 2 ("discriminator" <+> pretty (ctrDiscriminator c))+ ]+ ++ map (indent 2 . docTopic) (ctrTopics c)+ ++ map (indent 2 . docContractEvent) (ctrEvents c)+ ++ ["}"] where docTopic (alias, t) = "topic" <+> pretty alias <+> dquoted t docContractEvent e =- vsep $- ["event" <+> pretty (ceName e) <+> "on" <+> pretty (ceTopic e) <+> "{"]- ++ map (indent 2 . docContractField) (ceFields e)- ++ ["}"]+ vsep $+ ["event" <+> pretty (ceName e) <+> "on" <+> pretty (ceTopic e) <+> "{"]+ ++ map (indent 2 . docContractField) (ceFields e)+ ++ ["}"] docContractField f = pretty (cfName f) <> ":" <+> docContractType (cfType f) docContractType (CTypeId p) = "typeid" <+> dquoted p docContractType CText = "text"@@ -457,60 +504,60 @@ docProcess :: ProcessNode -> Doc ann docProcess p =- vsep- [ "process" <+> pretty (procId p)- , indent 2 ("name" <+> dquoted (procName p))- , indent 2 (docInput (procInput p))- , indent 2 (docCorrelate (procCorrelate p))- , indent 2 (docSaga (procSaga p))- , indent 2 ("target" <+> pretty (procTarget p))- , indent 2 ("projections" <+> bracketed (map pretty (procProjections p)))- , mempty- , indent 2 (docHandle (procHandle p))- , mempty- , indent 2 "dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)"- , indent 2 ("rejected =>" <+> docPolicyChoice (procRejected p))- , indent 2 ("poison =>" <+> docPolicyChoice (procPoison p))- , mempty- , indent 2 (docTimer (procTimer p))- ]+ vsep+ [ "process" <+> pretty (procId p),+ indent 2 ("name" <+> dquoted (procName p)),+ indent 2 (docInput (procInput p)),+ indent 2 (docCorrelate (procCorrelate p)),+ indent 2 (docSaga (procSaga p)),+ indent 2 ("target" <+> pretty (procTarget p)),+ indent 2 ("projections" <+> bracketed (map pretty (procProjections p))),+ mempty,+ indent 2 (docHandle (procHandle p)),+ mempty,+ indent 2 "dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)",+ indent 2 ("rejected =>" <+> docPolicyChoice (procRejected p)),+ indent 2 ("poison =>" <+> docPolicyChoice (procPoison p)),+ mempty,+ indent 2 (docTimer (procTimer p))+ ] docRouter :: RouterNode -> Doc ann docRouter r =- vsep- [ "router" <+> pretty (rtId r)- , indent 2 ("name" <+> dquoted (rtName r))- , indent 2 (docInput (rtInput r))- , indent 2 (docRouterKey (rtKey r))- , indent 2 (docResolve (rtResolve r))- , indent 2 ("target" <+> pretty (rtTarget r))- , indent 2 ("projections" <+> bracketed (map pretty (rtProjections r)))- , indent 2 (docRouterDispatch (rtDispatch r))- , indent 2 "dispatch-id strategy=uuidv5 from=(name, key, sourceEventId, targetStreamName, occurrence)"- , indent 2 ("rejected =>" <+> docPolicyChoice (rtRejected r))- , indent 2 ("poison =>" <+> docPolicyChoice (rtPoison r))- ]+ vsep+ [ "router" <+> pretty (rtId r),+ indent 2 ("name" <+> dquoted (rtName r)),+ indent 2 (docInput (rtInput r)),+ indent 2 (docRouterKey (rtKey r)),+ indent 2 (docResolve (rtResolve r)),+ indent 2 ("target" <+> pretty (rtTarget r)),+ indent 2 ("projections" <+> bracketed (map pretty (rtProjections r))),+ indent 2 (docRouterDispatch (rtDispatch r)),+ indent 2 "dispatch-id strategy=uuidv5 from=(name, key, sourceEventId, targetStreamName, occurrence)",+ indent 2 ("rejected =>" <+> docPolicyChoice (rtRejected r)),+ indent 2 ("poison =>" <+> docPolicyChoice (rtPoison r))+ ] docRouterKey :: CorrelateDecl -> Doc ann docRouterKey key = "key" <+> ("input." <> pretty (corrField key)) <+> "via" <+> pretty (corrVia key) docResolve :: ResolveDecl -> Doc ann docResolve resolve =- "resolve stable via"- <+> source- <+> "row"- <+> braced (map pretty (rvRow resolve))+ "resolve stable via"+ <+> source+ <+> "row"+ <+> braced (map pretty (rvRow resolve)) where source = case rvSource resolve of- ResolveReadModel name -> "read-model" <+> pretty name- ResolveHole -> "hole"+ ResolveReadModel name -> "read-model" <+> pretty name+ ResolveHole -> "hole" docRouterDispatch :: RouterDispatchNode -> Doc ann docRouterDispatch dispatch =- vsep- [ "dispatch-each" <+> pretty (rdCommand dispatch) <+> braced (map docFieldBinding (rdFields dispatch))- , indent 2 (docDispDisposition (rdDisposition dispatch))- ]+ vsep+ [ "dispatch-each" <+> pretty (rdCommand dispatch) <+> braced (map docFieldBinding (rdFields dispatch)),+ indent 2 (docDispDisposition (rdDisposition dispatch))+ ] docPolicyChoice :: PolicyChoice -> Doc ann docPolicyChoice PolHalt = "halt"@@ -528,25 +575,25 @@ docHandle :: HandleNode -> Doc ann docHandle h =- vsep $- ["on" <+> pretty (hOn h)]- ++ [indent 2 (docAdvance (hAdvance h))]- ++ map (indent 2 . docDispatch) (hDispatch h)- ++ [indent 2 ("schedule" <+> pretty (hSchedule h))]+ vsep $+ ["on" <+> pretty (hOn h)]+ ++ [indent 2 (docAdvance (hAdvance h))]+ ++ map (indent 2 . docDispatch) (hDispatch h)+ ++ [indent 2 ("schedule" <+> pretty (hSchedule h))] docAdvance :: AdvanceNode -> Doc ann docAdvance a = "advance" <+> pretty (advCommand a) <+> braced (map docFieldBinding (advFields a)) docDispatch :: DispatchNode -> Doc ann docDispatch d =- vsep- [ "dispatch" <+> (pretty (dispTarget d) <> "@" <> pretty (dispKey d)) <+> pretty (dispCommand d) <+> braced (map docFieldBinding (dispFields d))- , indent 2 (docDispDisposition (dispDisposition d))- ]+ vsep+ [ "dispatch" <+> (pretty (dispTarget d) <> "@" <> pretty (dispKey d)) <+> pretty (dispCommand d) <+> braced (map docFieldBinding (dispFields d)),+ indent 2 (docDispDisposition (dispDisposition d))+ ] docDispDisposition :: DispatchDisposition -> Doc ann docDispDisposition x =- "on-appended" <+> docDisp (onAppended x) <+> ";" <+> "on-duplicate" <+> docDisp (onDuplicate x) <+> ";" <+> "on-failed" <+> docDisp (onFailed x)+ "on-appended" <+> docDisp (onAppended x) <+> ";" <+> "on-duplicate" <+> docDisp (onDuplicate x) <+> ";" <+> "on-failed" <+> docDisp (onFailed x) docDisp :: Disp -> Doc ann docDisp DAckOk = "AckOk"@@ -555,15 +602,15 @@ docTimer :: TimerNode -> Doc ann docTimer t =- vsep- [ "timer" <+> pretty (tmName t)- , indent 2 ("id" <+> docIdExpr (tmId t))- , indent 2 ("fireAt" <+> docFireAt (tmFireAt t))- , indent 2 ("payload" <+> braced (map docFieldBinding (tmPayload t)))- , indent 2 (docFire (tmFire t))- , indent 2 ("decode unknown-status =>" <+> pretty (tmDecodeUnknown t))- , indent 2 ("max-attempts" <+> pretty (tmMaxAttempts t) <+> "dead-letter" <+> dquoted (tmDeadLetter t))- ]+ vsep+ [ "timer" <+> pretty (tmName t),+ indent 2 ("id" <+> docIdExpr (tmId t)),+ indent 2 ("fireAt" <+> docFireAt (tmFireAt t)),+ indent 2 ("payload" <+> braced (map docFieldBinding (tmPayload t))),+ indent 2 (docFire (tmFire t)),+ indent 2 ("decode unknown-status =>" <+> pretty (tmDecodeUnknown t)),+ indent 2 ("max-attempts" <+> pretty (tmMaxAttempts t) <+> "dead-letter" <+> dquoted (tmDeadLetter t))+ ] docIdExpr :: IdExpr -> Doc ann docIdExpr e = "uuidv5" <+> dquoted (idePrefix e) <+> "<>" <+> "correlationId"@@ -573,28 +620,28 @@ docFire :: FireNode -> Doc ann docFire f =- vsep- [ "fire dispatch" <+> (pretty (fireTarget f) <> "@" <> pretty (fireKey f)) <+> pretty (fireCommand f) <+> braced (map docFieldBinding (fireFields f))- , indent 2 ("fired-event-id" <+> docIdExpr (fireFiredEventId f))- , indent 2 (docFireDisposition (fireDisposition f))- ]+ vsep+ [ "fire dispatch" <+> (pretty (fireTarget f) <> "@" <> pretty (fireKey f)) <+> pretty (fireCommand f) <+> braced (map docFieldBinding (fireFields f)),+ indent 2 ("fired-event-id" <+> docIdExpr (fireFiredEventId f)),+ indent 2 (docFireDisposition (fireDisposition f))+ ] docFireDisposition :: FireDisposition -> Doc ann docFireDisposition x =- "on-ok"- <+> docFireOutcome (onOk x)- <+> ";"- <+> "on-reject"- <+> docFireOutcome (onReject x)- <+> ";"- <+> "on-ambiguous"- <+> docFireOutcome (onAmbiguous x)- <+> ";"- <+> "on-error"- <+> docFireOutcome (onError x)- <+> ";"- <+> "not-mine"- <+> docFireOutcome (notMine x)+ "on-ok"+ <+> docFireOutcome (onOk x)+ <+> ";"+ <+> "on-reject"+ <+> docFireOutcome (onReject x)+ <+> ";"+ <+> "on-ambiguous"+ <+> docFireOutcome (onAmbiguous x)+ <+> ";"+ <+> "on-error"+ <+> docFireOutcome (onError x)+ <+> ";"+ <+> "not-mine"+ <+> docFireOutcome (notMine x) docFireOutcome :: FireOutcome -> Doc ann docFireOutcome OFired = "Fired"@@ -602,12 +649,12 @@ docFieldBinding :: FieldBinding -> Doc ann docFieldBinding b = case fbValue b of- Nothing -> pretty (fbName b)- Just v -> pretty (fbName b) <> "=" <> docValue v+ Nothing -> pretty (fbName b)+ Just v -> pretty (fbName b) <> "=" <> docValue v where docValue v = case T.stripPrefix "\"" v >>= T.stripSuffix "\"" of- Just rawInner -> dquoted rawInner- Nothing -> pretty v+ Just rawInner -> dquoted rawInner+ Nothing -> pretty v dquoted :: Text -> Doc ann dquoted t = "\"" <> pretty (T.concatMap escapeChar t) <> "\""@@ -625,37 +672,37 @@ docAggregate :: Aggregate -> Doc ann docAggregate a =- vsep $- [ "aggregate" <+> pretty (aggName a)- , indent 2 "regs"- , indent 4 (vsep (map docReg (aggRegs a)))- , indent 2 ("states" <+> hsep (map docState (aggStates a)))- , mempty- ]- ++ map (indent 2 . docCommand) (aggCommands a)- ++ blank (aggCommands a)- ++ map (indent 2 . docEvent) (aggEvents a)- ++ blank (aggEvents a)- ++ map (indent 2 . docTransition) (aggTransitions a)- ++ blank (aggTransitions a)- ++ maybe [] (\w -> [indent 2 (docWire w)]) (aggWire a)- ++ maybe [] (\p -> [indent 2 (docProjection p)]) (aggProjection a)- ++ maybe [] (\snapshot -> [indent 2 (docSnapshot snapshot)]) (aggSnapshot a)+ vsep $+ [ "aggregate" <+> pretty (aggName a),+ indent 2 "regs",+ indent 4 (vsep (map docReg (aggRegs a))),+ indent 2 ("states" <+> hsep (map docState (aggStates a))),+ mempty+ ]+ ++ map (indent 2 . docCommand) (aggCommands a)+ ++ blank (aggCommands a)+ ++ map (indent 2 . docEvent) (aggEvents a)+ ++ blank (aggEvents a)+ ++ map (indent 2 . docTransition) (aggTransitions a)+ ++ blank (aggTransitions a)+ ++ maybe [] (\w -> [indent 2 (docWire w)]) (aggWire a)+ ++ maybe [] (\p -> [indent 2 (docProjection p)]) (aggProjection a)+ ++ maybe [] (\snapshot -> [indent 2 (docSnapshot snapshot)]) (aggSnapshot a) where blank xs = if null xs then [] else [mempty] docSnapshot :: SnapshotSpec -> Doc ann docSnapshot snapshot =- vsep- [ "snapshot" <+> policyDoc (snapPolicy snapshot)- , indent 2 ("state-codec version=" <> pretty (snapCodecVersion snapshot) <+> "shape-hash=" <> dquoted (snapShapeHash snapshot))- ]+ vsep+ [ "snapshot" <+> policyDoc (snapPolicy snapshot),+ indent 2 ("state-codec version=" <> pretty (snapCodecVersion snapshot) <+> "shape-hash=" <> dquoted (snapShapeHash snapshot))+ ] where policyDoc (SnapEvery interval) = "every" <+> pretty interval policyDoc SnapOnTerminal = "on-terminal" docReg :: RegDecl -> Doc ann-docReg r = pretty (regName r) <+> pretty (regType r) <+> "=" <+> docRegInitial (regInitial r)+docReg r = pretty (regName r) <+> docTypeExpr (regType r) <+> "=" <+> docRegInitial (regInitial r) docRegInitial :: RegInitial -> Doc ann docRegInitial (RegInitBare value) = pretty value@@ -663,90 +710,95 @@ docBackoff :: BackoffSpec -> Doc ann docBackoff backoff =- "backoff"- <+> pretty (boKind backoff)- <+> pretty (boWindow backoff)- <+> maybe mempty (\window -> "max=" <> pretty window) (boMax backoff)- <+> maybe mempty (\multiplier -> "multiplier=" <> pretty multiplier) (boMultiplier backoff)+ "backoff"+ <+> pretty (boKind backoff)+ <+> pretty (boWindow backoff)+ <+> maybe mempty (\window -> "max=" <> pretty window) (boMax backoff)+ <+> maybe mempty (\multiplier -> "multiplier=" <> pretty multiplier) (boMultiplier backoff) docState :: StateDecl -> Doc ann docState s = pretty (stName s) <> (if stTerminal s then "!" else mempty) docCommand :: Command -> Doc ann-docCommand c = "command" <+> pretty (cmdName c) <+> braced (map docField (cmdFields c))+docCommand c = "command" <+> pretty (cmdName c) <+> braced (map docAggregateField (cmdFields c)) +docAggregateField :: AggregateField -> Doc ann+docAggregateField f = case aggregateFieldType f of+ Nothing -> pretty (aggregateFieldName f)+ Just ty -> pretty (aggregateFieldName f) <> ":" <> docTypeExpr ty+ docField :: Field -> Doc ann docField f = case fieldType f of- Nothing -> pretty (fieldName f)- Just ty -> pretty (fieldName f) <> ":" <> pretty ty+ Nothing -> pretty (fieldName f)+ Just ty -> pretty (fieldName f) <> ":" <> pretty ty docEvent :: Event -> Doc ann docEvent e =- case evUpcastFrom e of- Nothing -> line1- Just (m, _) -> vsep [line1, indent 2 ("upcast from v" <> pretty m <+> "=" <+> "HOLE")]+ case evUpcastFrom e of+ Nothing -> line1+ Just (m, _) -> vsep [line1, indent 2 ("upcast from v" <> pretty m <+> "=" <+> "HOLE")] where kw = case (evRetiring e, evDeprecated e) of- (False, False) -> "event"- (True, False) -> "retiring event"- (False, True) -> "deprecated event"- (True, True) -> "retiring deprecated event"+ (False, False) -> "event"+ (True, False) -> "retiring event"+ (False, True) -> "deprecated event"+ (True, True) -> "retiring deprecated event" nameVer =- pretty (evName e)- <> (if evVersion e > 1 then " v" <> pretty (evVersion e) else mempty)+ pretty (evName e)+ <> (if evVersion e > 1 then " v" <> pretty (evVersion e) else mempty) bodyDoc = case evBody e of- EventFromCommand cmd -> "=" <+> ("fields(" <> pretty cmd <> ")")- EventFields fs -> braced (map docField fs)+ EventFromCommand cmd -> "=" <+> ("fields(" <> pretty cmd <> ")")+ EventFields fs -> braced (map docAggregateField fs) line1 = kw <+> nameVer <+> bodyDoc -{- | Render one transition in concrete @.keiro@ syntax. Exported for @diff@'s-guard-tightening advisory, which prints a paste-ready replay-only twin-(plan 143).--}+-- | Render one transition in concrete @.keiro@ syntax. Exported for @diff@'s+-- guard-tightening advisory, which prints a paste-ready replay-only twin+-- (plan 143). renderTransition :: Transition -> Text renderTransition =- renderStrict- . layoutPretty LayoutOptions{layoutPageWidth = Unbounded}- . docTransition+ renderStrict+ . layoutPretty LayoutOptions {layoutPageWidth = Unbounded}+ . docTransition -- | Render one expression in canonical concrete syntax. renderExpr :: Expr -> Text renderExpr =- renderStrict- . layoutPretty LayoutOptions{layoutPageWidth = Unbounded}- . docExpr 0+ renderStrict+ . layoutPretty LayoutOptions {layoutPageWidth = Unbounded}+ . docExpr 0 docTransition :: Transition -> Doc ann docTransition t =- vsep $- [modePrefix <> pretty (tSource t) <+> "--" <+> pretty (tCommand t) <+> "-->"]- ++ map (indent 2) clauses+ vsep $+ [modePrefix <> pretty (tSource t) <+> "--" <+> pretty (tCommand t) <+> "-->"]+ ++ map (indent 2) clauses where modePrefix = case tMode t of- TmLive -> mempty- TmReplayOnly -> "replay-only "+ TmLive -> mempty+ TmReplayOnly -> "replay-only " clauses =- maybe [] (\g -> ["guard" <+> docExpr 0 g]) (tGuard t)- ++ map (\(r, e) -> "write" <+> pretty r <+> ":=" <+> docExpr 0 e) (tWrites t)- ++ map (\ev -> "emit" <+> pretty ev) (tEmits t)- ++ ["goto" <+> pretty (tGoto t)]+ ["implementation hole" | tImplementation t == HoleImplementation]+ ++ maybe [] (\g -> ["guard" <+> docExpr 0 g]) (tGuard t)+ ++ map (\(r, e) -> "write" <+> pretty r <+> ":=" <+> docExpr 0 e) (tWrites t)+ ++ map (\ev -> "emit" <+> pretty ev) (tEmits t)+ ++ ["goto" <+> pretty (tGoto t)] docWire :: WireSpec -> Doc ann docWire w =- "wire"- <+> ("kind=" <> pretty (wireKind w))- <+> ("fields=" <> pretty (wireFields w))- <+> ("schemaVersion=" <> pretty (wireSchemaVersion w))+ "wire"+ <+> ("kind=" <> pretty (wireKind w))+ <+> ("fields=" <> pretty (wireFields w))+ <+> ("schemaVersion=" <> pretty (wireSchemaVersion w)) docProjection :: ProjectionSpec -> Doc ann docProjection p =- vsep $- [ hsep $- ["projection", pretty (projTable p)]- ++ maybe [] (pure . ("consistency=" <>) . docConsistency) (projConsistency p)- ++ ["key=" <> pretty (projKey p)]- ]- ++ maybe [] (\m -> [indent 2 (statusMapHead m <+> braced (map pair (mapPairs m)))]) (projStatusMap p)+ vsep $+ [ hsep $+ ["projection", pretty (projTable p)]+ ++ maybe [] (pure . ("consistency=" <>) . docConsistency) (projConsistency p)+ ++ ["key=" <> pretty (projKey p)]+ ]+ ++ maybe [] (\m -> [indent 2 (statusMapHead m <+> braced (map pair (mapPairs m)))]) (projStatusMap p) where statusMapHead m = if mapPartial m then "status-map partial" else "status-map" pair (l, r) = pretty l <> "=>" <> pretty r@@ -755,23 +807,51 @@ docConsistency Strong = "Strong" docConsistency Eventual = "Eventual" -{- | @showsPrec@-style expression renderer. The 'Int' is the minimum precedence-allowed without parentheses in the current context. Precedence levels:-@||@ = 1, @&&@ = 2, comparisons = 3, atoms = 4.--}+-- | @showsPrec@-style expression renderer. The 'Int' is the minimum precedence+-- allowed without parentheses in the current context. Precedence levels:+-- @||@ = 1, @&&@ = 2, comparisons = 3, addition/subtraction = 4,+-- multiplication = 5, atoms = 6. docExpr :: Int -> Expr -> Doc ann docExpr ctx e = parensIf (precOf e < ctx) (body e) where body (EOr l r) = docExpr 1 l <+> "||" <+> docExpr 2 r body (EAnd l r) = docExpr 2 l <+> "&&" <+> docExpr 3 r body (ECmp op l r) = docExpr 4 l <+> docCmp op <+> docExpr 4 r+ body (EAdd _ l r) = docExpr 4 l <+> "+" <+> docExpr 5 r+ body (ESubtract _ l r) = docExpr 4 l <+> "-" <+> docExpr 5 r+ body (EMultiply _ l r) = docExpr 5 l <+> "*" <+> docExpr 6 r+ body (EPath _ root path) = docPath root path+ body (ELiteral _ literal) = docLiteral literal body (EAtom a) = docAtom a precOf :: Expr -> Int-precOf EOr{} = 1-precOf EAnd{} = 2-precOf ECmp{} = 3-precOf EAtom{} = 4+precOf EOr {} = 1+precOf EAnd {} = 2+precOf ECmp {} = 3+precOf EAdd {} = 4+precOf ESubtract {} = 4+precOf EMultiply {} = 5+precOf EPath {} = 6+precOf ELiteral {} = 6+precOf EAtom {} = 6++docRoot :: ExprRoot -> Doc ann+docRoot UnqualifiedRoot = mempty+docRoot RegisterRoot = "reg"+docRoot CommandRoot = "cmd"++docPath :: ExprRoot -> [Name] -> Doc ann+docPath UnqualifiedRoot [] = mempty+docPath UnqualifiedRoot (first : rest) = pretty first <> hcat (map (("." <>) . pretty) rest)+docPath root path = docRoot root <> hcat (map (("." <>) . pretty) path)++docLiteral :: ScalarLiteral -> Doc ann+docLiteral (LiteralText value) = dquoted value+docLiteral (LiteralIntegral value) = pretty value+docLiteral (LiteralBool True) = "true"+docLiteral (LiteralBool False) = "false"+docLiteral (LiteralQualified typeName constructor) = pretty typeName <> "." <> pretty constructor+docLiteral (LiteralId typeName value) = pretty typeName <> "(" <> dquoted value <> ")" docCmp :: CmpOp -> Doc ann docCmp OpEq = "=="
src/Keiro/Dsl/ReadModelShape.hs view
@@ -1,14 +1,14 @@-{- | Deterministic identities derived from a first-class read-model declaration.-The validator, scaffolder, harness, and differ share these functions so captured-fixtures and generated runtime values cannot silently disagree.--}-module Keiro.Dsl.ReadModelShape (- canonicalShape,+-- | Deterministic identities derived from a first-class read-model declaration.+-- The validator, scaffolder, harness, and differ share these functions so captured+-- fixtures and generated runtime values cannot silently disagree.+module Keiro.Dsl.ReadModelShape+ ( canonicalShape, deriveShapeHash, fnv1a64, registryNameFor, subscriptionNameFor,-) where+ )+where import Data.Bits (shiftR, xor, (.&.), (.|.)) import Data.Maybe (fromMaybe)@@ -21,25 +21,25 @@ -- | The ordered table-and-column identity hashed into 'deriveShapeHash'. canonicalShape :: ReadModelNode -> Text canonicalShape readModel =- T.intercalate "|" (rmTable readModel : map columnSegment (rmColumns readModel))+ T.intercalate "|" (rmTable readModel : map columnSegment (rmColumns readModel)) where columnSegment columnDecl =- T.intercalate- ":"- [ rmcName columnDecl- , rmcType columnDecl- , if rmcRequired columnDecl then "req" else "null"- ]+ T.intercalate+ ":"+ [ rmcName columnDecl,+ rmcType columnDecl,+ if rmcRequired columnDecl then "req" else "null"+ ] -- | A fixed-width FNV-1a-64 digest over the canonical shape's UTF-8 bytes. deriveShapeHash :: ReadModelNode -> Text deriveShapeHash readModel =- "fnv1a:" <> fnv1a64 (canonicalShape readModel)+ "fnv1a:" <> fnv1a64 (canonicalShape readModel) -- | A fixed-width FNV-1a-64 digest over a 'Text' value's UTF-8 bytes. fnv1a64 :: Text -> Text fnv1a64 input =- T.justifyRight 16 '0' (T.pack (showHex digest ""))+ T.justifyRight 16 '0' (T.pack (showHex digest "")) where digest = foldl' step offsetBasis (concatMap utf8Bytes (T.unpack input)) step hash byte = (hash `xor` byte) * fnvPrime@@ -47,12 +47,12 @@ -- | The runtime registry identity derived from context and notation name. registryNameFor :: Name -> ReadModelNode -> Text registryNameFor contextName readModel =- contextName <> "-" <> T.replace "_" "-" (rmName readModel)+ contextName <> "-" <> T.replace "_" "-" (rmName readModel) -- | The explicit subscription override or its deterministic default. subscriptionNameFor :: Name -> ReadModelNode -> Text subscriptionNameFor contextName readModel =- fromMaybe (registryNameFor contextName readModel <> "-sub") (rmSubscription readModel)+ fromMaybe (registryNameFor contextName readModel <> "-sub") (rmSubscription readModel) offsetBasis :: Word64 offsetBasis = 0xcbf29ce484222325@@ -60,27 +60,26 @@ fnvPrime :: Word64 fnvPrime = 0x100000001b3 -{- | Encode one Unicode scalar value as UTF-8 bytes, represented as 'Word64'-values so the hash fold needs no bytestring package dependency.--}+-- | Encode one Unicode scalar value as UTF-8 bytes, represented as 'Word64'+-- values so the hash fold needs no bytestring package dependency. utf8Bytes :: Char -> [Word64] utf8Bytes character- | codePoint <= 0x7f = [byte codePoint]- | codePoint <= 0x7ff =- [ byte (0xc0 .|. (codePoint `shiftR` 6))- , continuation codePoint- ]- | codePoint <= 0xffff =- [ byte (0xe0 .|. (codePoint `shiftR` 12))- , continuation (codePoint `shiftR` 6)- , continuation codePoint- ]- | otherwise =- [ byte (0xf0 .|. (codePoint `shiftR` 18))- , continuation (codePoint `shiftR` 12)- , continuation (codePoint `shiftR` 6)- , continuation codePoint- ]+ | codePoint <= 0x7f = [byte codePoint]+ | codePoint <= 0x7ff =+ [ byte (0xc0 .|. (codePoint `shiftR` 6)),+ continuation codePoint+ ]+ | codePoint <= 0xffff =+ [ byte (0xe0 .|. (codePoint `shiftR` 12)),+ continuation (codePoint `shiftR` 6),+ continuation codePoint+ ]+ | otherwise =+ [ byte (0xf0 .|. (codePoint `shiftR` 18)),+ continuation (codePoint `shiftR` 12),+ continuation (codePoint `shiftR` 6),+ continuation codePoint+ ] where codePoint = fromEnum character byte = fromIntegral
src/Keiro/Dsl/ReplayImpact.hs view
@@ -1,211 +1,255 @@-{- | Stored-data replay impact for a specification diff.--The ordinary differ classifies compatibility across every persisted surface.-This module answers a narrower deployment question: can the candidate binary-interpret an already-stored aggregate log differently?--The result is deliberately conservative. New aggregates, events, and-transitions are replay-neutral because no old log depends on them. A removed-or changed old transition affects the event types emitted by either side, and-a decode-surface change affects that event type directly. Snapshot-bearing-streams are included whenever the fold itself can change.--}-module Keiro.Dsl.ReplayImpact (- AggregateImpact (..),+-- | Stored-data replay impact for a specification diff.+--+-- The ordinary differ classifies compatibility across every persisted surface.+-- This module answers a narrower deployment question: can the candidate binary+-- interpret an already-stored aggregate log differently?+--+-- The result is deliberately conservative. New aggregates, events, and+-- transitions are replay-neutral because no old log depends on them. A removed+-- or changed old transition affects the event types emitted by either side, and+-- a decode-surface change affects that event type directly. Snapshot-bearing+-- streams are included whenever the fold itself can change.+module Keiro.Dsl.ReplayImpact+ ( AggregateImpact (..), ReplayImpact (..), replayImpact, renderReplayImpact,-) where+ )+where import Data.Aeson (ToJSON (..), object, (.=)) import Data.List (delete, find)+import Data.List.NonEmpty qualified as NE import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text+import Keiro.Dsl.AggregateType import Keiro.Dsl.FoldFingerprint (aggregateFoldSurface) import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType import Keiro.Dsl.PrettyPrint (renderTransition)-import Keiro.Dsl.TypeGraph (MappedKey (..), TypeGraph (..), resolveTypeGraph, wireFingerprint)+import Keiro.Dsl.TypeGraph (BindingVersion (..), CanonicalTypeId (..), MappedKey (..), QualifiedValueName (..), TypeGraph (..), resolveTypeGraph, wireFingerprint) -- | The smallest conservative audit input for one aggregate. data AggregateImpact = AggregateImpact- { eventTypes :: !(Set Name)- , includeSnapshotStreams :: !Bool- }- deriving stock (Eq, Show)+ { eventTypes :: !(Set Name),+ includeSnapshotStreams :: !Bool+ }+ deriving stock (Eq, Show) -- | A deploy either preserves replay or carries per-aggregate audit inputs. data ReplayImpact- = ReplayNeutral- | ReplayAffected !(Map Name AggregateImpact)- deriving stock (Eq, Show)+ = ReplayNeutral+ | ReplayAffected !(Map Name AggregateImpact)+ deriving stock (Eq, Show) instance ToJSON AggregateImpact where- toJSON impact =- object- [ "eventTypes" .= Set.toAscList (eventTypes impact)- , "includeSnapshotStreams" .= includeSnapshotStreams impact- ]+ toJSON impact =+ object+ [ "eventTypes" .= Set.toAscList (eventTypes impact),+ "includeSnapshotStreams" .= includeSnapshotStreams impact+ ] instance ToJSON ReplayImpact where- toJSON ReplayNeutral = object ["verdict" .= ("replay-neutral" :: Text)]- toJSON (ReplayAffected aggregates) =- object- [ "verdict" .= ("affected" :: Text)- , "aggregates" .= aggregates- ]+ toJSON ReplayNeutral = object ["verdict" .= ("replay-neutral" :: Text)]+ toJSON (ReplayAffected aggregates) =+ object+ [ "verdict" .= ("affected" :: Text),+ "aggregates" .= aggregates+ ] -- | Compute replay impact for every aggregate that existed in the old spec. replayImpact :: Spec -> Spec -> ReplayImpact replayImpact oldSpec newSpec =- case Map.filter hasImpact impacts of- filtered- | Map.null filtered -> ReplayNeutral- | otherwise -> ReplayAffected filtered+ case Map.filter hasImpact impacts of+ filtered+ | Map.null filtered -> ReplayNeutral+ | otherwise -> ReplayAffected filtered where oldAggregates = [(aggName aggregate, aggregate) | NAggregate aggregate <- specNodes oldSpec] newAggregates = Map.fromList [(aggName aggregate, aggregate) | NAggregate aggregate <- specNodes newSpec] impacts =- Map.fromList- [ (name, maybe (removedAggregateImpact oldAggregate) (matchedAggregateImpact oldSpec newSpec oldAggregate) (Map.lookup name newAggregates))- | (name, oldAggregate) <- oldAggregates- ]+ Map.fromList+ [ (name, maybe (removedAggregateImpact oldAggregate) (matchedAggregateImpact oldSpec newSpec oldAggregate) (Map.lookup name newAggregates))+ | (name, oldAggregate) <- oldAggregates+ ] hasImpact :: AggregateImpact -> Bool hasImpact impact =- not (Set.null (eventTypes impact))- || includeSnapshotStreams impact+ not (Set.null (eventTypes impact))+ || includeSnapshotStreams impact removedAggregateImpact :: Aggregate -> AggregateImpact removedAggregateImpact aggregate =- AggregateImpact- { eventTypes = Set.fromList (evName <$> aggEvents aggregate)- , includeSnapshotStreams = True- }+ AggregateImpact+ { eventTypes = Set.fromList (evName <$> aggEvents aggregate),+ includeSnapshotStreams = True+ } matchedAggregateImpact :: Spec -> Spec -> Aggregate -> Aggregate -> AggregateImpact matchedAggregateImpact oldSpec newSpec oldAggregate newAggregate =- AggregateImpact- { eventTypes =- decodeAffected- <> transitionAffected- <> if nonTransitionFoldChanged then oldEventTypes else Set.empty- , includeSnapshotStreams = transitionFoldChanged || nonTransitionFoldChanged || mappedRegisterChanged- }+ AggregateImpact+ { eventTypes =+ decodeAffected+ <> transitionAffected+ <> if nonTransitionFoldChanged then oldEventTypes else Set.empty,+ includeSnapshotStreams = transitionFoldChanged || nonTransitionFoldChanged || mappedRegisterChanged+ } where oldEventTypes = Set.fromList (evName <$> aggEvents oldAggregate) decodeAffected = decodeSurfaceAffected oldSpec newSpec oldAggregate newAggregate mappedRegisterChanged =- mappedRegisterSurface oldSpec oldAggregate- /= mappedRegisterSurface newSpec newAggregate+ mappedRegisterSurface oldSpec oldAggregate+ /= mappedRegisterSurface newSpec newAggregate (transitionAffected, transitionFoldChanged) =- changedTransitionEvents (aggTransitions oldAggregate) (aggTransitions newAggregate)+ changedTransitionEvents (aggTransitions oldAggregate) (aggTransitions newAggregate) nonTransitionFoldChanged =- aggregateFoldSurface oldSpec oldAggregate- /= aggregateFoldSurface- newSpec- newAggregate- { aggTransitions = aggTransitions oldAggregate- }+ aggregateFoldSurface oldSpec oldAggregate+ /= aggregateFoldSurface+ newSpec+ newAggregate+ { aggTransitions = aggTransitions oldAggregate+ } decodeSurfaceAffected :: Spec -> Spec -> Aggregate -> Aggregate -> Set Name decodeSurfaceAffected oldSpec newSpec oldAggregate newAggregate =- removedOrChanged <> wireAffected+ removedOrChanged <> wireAffected where newEvents = Map.fromList [(evName event, event) | event <- aggEvents newAggregate] removedOrChanged =- Set.fromList- [ evName oldEvent- | oldEvent <- aggEvents oldAggregate- , maybe True ((/= eventSurface oldSpec oldAggregate oldEvent) . eventSurface newSpec newAggregate) (Map.lookup (evName oldEvent) newEvents)- ]+ Set.fromList+ [ evName oldEvent+ | oldEvent <- aggEvents oldAggregate,+ maybe True ((/= eventSurface oldSpec oldAggregate oldEvent) . eventSurface newSpec newAggregate) (Map.lookup (evName oldEvent) newEvents)+ ] wireAffected- | aggWire oldAggregate == aggWire newAggregate = Set.empty- | otherwise = Set.fromList (evName <$> aggEvents oldAggregate)+ | aggWire oldAggregate == aggWire newAggregate = Set.empty+ | otherwise = Set.fromList (evName <$> aggEvents oldAggregate) eventDecodeSurface :: Event -> (EventBody, Int, Maybe (Int, Hole)) eventDecodeSurface event =- (evBody event, evVersion event, evUpcastFrom event)+ (evBody event, evVersion event, evUpcastFrom event) eventSurface :: Spec -> Aggregate -> Event -> ((EventBody, Int, Maybe (Int, Hole)), [(Name, Text)]) eventSurface spec aggregate event =- (eventDecodeSurface event, mappedFieldSurface spec aggregate event)+ (eventDecodeSurface event, mappedFieldSurface spec aggregate event) mappedFieldSurface :: Spec -> Aggregate -> Event -> [(Name, Text)]-mappedFieldSurface spec aggregate event = case resolveTypeGraph spec of- Left _ -> []- Right graph ->- [ (fieldName field, wireFingerprint graph typeName)- | field <- eventFields aggregate event- , typeName <- maybeToList (fieldType field)- , Map.member (MappedKey typeName) (tgDeclarations graph)+mappedFieldSurface spec aggregate event = mapped <> nominal+ where+ mapped = case resolveTypeGraph spec of+ Left _ -> []+ Right graph ->+ [ (aggregateFieldName field, wireFingerprint graph typeName)+ | field <- eventFields aggregate event,+ TRef typeName <- maybeToList (aggregateFieldType field),+ Map.member (MappedKey typeName) (tgDeclarations graph) ]+ symbols = aggregateSymbols spec+ nominal =+ [ (aggregateFieldName field, nominalSurface resolved)+ | field <- eventFields aggregate event,+ Right (AggregateNominal resolved) <- [inferAggregateFieldType symbols aggregate EventFieldUse field]+ ] mappedRegisterSurface :: Spec -> Aggregate -> [(Name, Name, Text)]-mappedRegisterSurface spec aggregate = case resolveTypeGraph spec of- Left _ -> []- Right graph ->- [ (regName register, regType register, wireFingerprint graph (regType register))- | register <- aggRegs aggregate- , Map.member (MappedKey (regType register)) (tgDeclarations graph)+mappedRegisterSurface spec aggregate = mapped <> nominal+ where+ mapped = case resolveTypeGraph spec of+ Left _ -> []+ Right graph ->+ [ (regName register, typeName, wireFingerprint graph typeName)+ | register <- aggRegs aggregate,+ TRef typeName <- [regType register],+ Map.member (MappedKey typeName) (tgDeclarations graph) ]+ symbols = aggregateSymbols spec+ nominal =+ [ (regName register, resolvedNominalName resolved, nominalSurface resolved)+ | register <- aggRegs aggregate,+ Right (AggregateNominal resolved) <- [resolveAggregateType symbols (regLoc register) RegisterUse (regType register)]+ ] -eventFields :: Aggregate -> Event -> [Field]+nominalSurface :: ResolvedNominalType -> Text+nominalSurface nominal =+ nominalRepresentationSurface (resolvedNominalRepresentation nominal)+ <> case resolvedNominalOwnership nominal of+ GeneratedNominal -> "|ownership=generated"+ ConsumerNominal binding ->+ Text.concat+ [ "|ownership=consumer",+ "|canonical=" <> unCanonicalTypeId (consumerNominalCanonical binding),+ "|binding=" <> unQualifiedValueName (consumerNominalBinding binding),+ "|binding-version=" <> unBindingVersion (consumerNominalBindingVersion binding),+ "|initial=" <> maybe "(none)" unQualifiedValueName (consumerNominalInitial binding)+ ]++nominalRepresentationSurface :: NominalRepresentation -> Text+nominalRepresentationSurface representation = case representation of+ IdRepresentation prefix -> "id:" <> prefix+ EnumRepresentation constructors -> "enum:" <> Text.intercalate "," [constructor <> "=" <> wire | (constructor, wire) <- NE.toList constructors]+ ScalarRepresentation scalar -> case scalar of+ NominalText -> "scalar:Text"+ NominalInt -> "scalar:Int"+ NominalNatural -> "scalar:Natural"+ NominalBool -> "scalar:Bool"+ NominalTime -> "scalar:Time"++eventFields :: Aggregate -> Event -> [AggregateField] eventFields aggregate event = case evBody event of- EventFields fields -> fields- EventFromCommand commandName ->- concat [cmdFields command | command <- aggCommands aggregate, cmdName command == commandName]+ EventFields fields -> fields+ EventFromCommand commandName ->+ concat [cmdFields command | command <- aggCommands aggregate, cmdName command == commandName] maybeToList :: Maybe a -> [a] maybeToList = maybe [] pure changedTransitionEvents :: [Transition] -> [Transition] -> (Set Name, Bool) changedTransitionEvents oldTransitions newTransitions =- go oldTransitions newTransitions Set.empty False+ go oldTransitions newTransitions Set.empty False where go [] _ affected changed = (affected, changed) go (oldTransition : remainingOld) remainingNew affected changed =- case find (sameSurface oldTransition) remainingNew of- Just exact ->- go remainingOld (delete exact remainingNew) affected changed+ case find (sameSurface oldTransition) remainingNew of+ Just exact ->+ go remainingOld (delete exact remainingNew) affected changed+ Nothing ->+ case find (sameIdentity oldTransition) remainingNew of+ Just candidate+ | guardOnlyLoosening oldTransition candidate ->+ go remainingOld (delete candidate remainingNew) affected changed+ | otherwise ->+ go+ remainingOld+ (delete candidate remainingNew)+ (affected <> emittedBy oldTransition <> emittedBy candidate)+ True Nothing ->- case find (sameIdentity oldTransition) remainingNew of- Just candidate- | guardOnlyLoosening oldTransition candidate ->- go remainingOld (delete candidate remainingNew) affected changed- | otherwise ->- go- remainingOld- (delete candidate remainingNew)- (affected <> emittedBy oldTransition <> emittedBy candidate)- True- Nothing ->- go- remainingOld- remainingNew- (affected <> emittedBy oldTransition)- True+ go+ remainingOld+ remainingNew+ (affected <> emittedBy oldTransition)+ True sameSurface left right = renderTransition left == renderTransition right sameIdentity left right =- tMode left == tMode right- && tSource left == tSource right- && tCommand left == tCommand right+ tMode left == tMode right+ && tSource left == tSource right+ && tCommand left == tCommand right emittedBy = Set.fromList . tEmits -{- | A syntactically provable loosening preserves every old transition match.--Unknown shapes return 'False', deliberately over-approximating impact. The-recognized fragment proves @old => new@ through equality, true/false,-conjunction elimination, and disjunction introduction.--}+-- | A syntactically provable loosening preserves every old transition match.+--+-- Unknown shapes return 'False', deliberately over-approximating impact. The+-- recognized fragment proves @old => new@ through equality, true/false,+-- conjunction elimination, and disjunction introduction. guardOnlyLoosening :: Transition -> Transition -> Bool guardOnlyLoosening oldTransition newTransition =- oldTransition{tGuard = tGuard newTransition} == newTransition- && guardImplies (tGuard oldTransition) (tGuard newTransition)+ oldTransition {tGuard = tGuard newTransition} == newTransition+ && guardImplies (tGuard oldTransition) (tGuard newTransition) guardImplies :: Maybe Expr -> Maybe Expr -> Bool guardImplies _ Nothing = True@@ -213,7 +257,7 @@ guardImplies (Just oldGuard) (Just newGuard) = implies oldGuard newGuard where implies old new- | old == new = True+ | old == new = True implies (EAtom (ABool False)) _ = True implies _ (EAtom (ABool True)) = True implies (EAnd left right) new = implies left new || implies right new@@ -222,15 +266,15 @@ renderReplayImpact :: ReplayImpact -> Text renderReplayImpact ReplayNeutral =- "replay-neutral: stored-data replay is unchanged by this diff"+ "replay-neutral: stored-data replay is unchanged by this diff" renderReplayImpact (ReplayAffected aggregates) =- "replay-affected: run the candidate binary's targeted replay audit for "- <> Text.intercalate- "; "- [ aggregateName- <> " events=["- <> Text.intercalate "," (Set.toAscList (eventTypes impact))- <> "] snapshots="- <> if includeSnapshotStreams impact then "yes" else "no"- | (aggregateName, impact) <- Map.toAscList aggregates- ]+ "replay-affected: run the candidate binary's targeted replay audit for "+ <> Text.intercalate+ "; "+ [ aggregateName+ <> " events=["+ <> Text.intercalate "," (Set.toAscList (eventTypes impact))+ <> "] snapshots="+ <> if includeSnapshotStreams impact then "yes" else "no"+ | (aggregateName, impact) <- Map.toAscList aggregates+ ]
src/Keiro/Dsl/Scaffold.hs view
@@ -1,3440 +1,4420 @@-{- | The scaffold engine. Given an 'Aggregate' (and a 'Context' naming the-service and output module-namespace root), it emits the __symbol-free-deterministic layer__ as @-- \@generated@ modules plus a single create-if-absent-@Holes.hs@ holding the typed holes a human or coding agent must fill.--The load-bearing invariant of this module is the __firewall__: no @Generated@-module ever contains a keiki symbolic operator (@./=@, @.==@, @.||@, @lit@,-@B.slot@, @B.requireGuard@). Those live only in the hand-owned @Holes.hs@. A-test ('Generated' text scan) enforces it.--Scope (EP-1, recorded in the plan's Decision Log): the scaffolder does /not/-emit the symbolic transducer body — that is the @buildTransducer@ hole in-@Holes.hs@, pinned by the harness. It also does not emit the read-model SQL-(the projection @apply@), which is a DB-coupled hole delegated to @codd@/the-agent; the @Generated@ Projection module emits only the deterministic-@InlineProjection@ wiring and the pure event→status mapping. The decode emitted-here is /strict/ (every field required); lenient\/optional decode is EP-4's-concern.--}-module Keiro.Dsl.Scaffold (- ScaffoldModule (..),- ModuleKind (..),- Context (..),- Placement (..),- defaultContext,- genPrefixFor,- holePrefixFor,- scaffoldReplayAudit,- scaffoldStructural,- scaffoldStructuralOwners,- codecComparisonModule,- codecComparisonBanner,- bindingSkeletonModules,- bindingSkeletonOwners,- scaffoldAggregate,- scaffoldProcess,- scaffoldRouter,- scaffoldContract,- scaffoldIntake,- scaffoldPublisher,- scaffoldWorkqueue,- scaffoldReadModel,- scaffoldRefusals,- windowSeconds,-- -- * Firewall self-check (M3)- FirewallSurface (..),- firewallSurface,- firewallBreaches,-- -- * Internal resolution, shared with "Keiro.Dsl.Harness"- Agg (..),- ResolvedCtor (..),- StructuralProjection (..),- resolveAgg,- projectionSpecs,- resolveProjectionModules,- codecMappedDeclarations,- FieldCat (..),- fieldCat,- vertexCtor,- initialVertex,- firstEnumCtor,- lowerFirst,- pascal,- pascalFromKebab,- generatedBanner,-) where--import Data.Char (isAlpha, isAlphaNum, isUpper, ord, toLower, toUpper)-import Data.List (find, groupBy, nub, sort, sortOn)-import Data.Map.Strict qualified as Map-import Data.Maybe (fromMaybe, mapMaybe)-import Data.Text (Text)-import Data.Text qualified as T-import Keiro.Dsl.CodecCompare (BranchArm (..), BranchField (..), BranchSchema (..))-import Keiro.Dsl.ExplainBindings (BindingObligation (..), BindingObligationKind (..), bindingObligations)-import Keiro.Dsl.FoldFingerprint (aggregateFoldFingerprint)-import Keiro.Dsl.Grammar-import Keiro.Dsl.ReadModelShape (registryNameFor, subscriptionNameFor)-import Keiro.Dsl.TypeGraph-import Keiro.Dsl.Validate (sagaCategoryError)-import Numeric (showHex)-import Text.Read (readMaybe)--{- | One emitted module: its on-disk path (relative to the scaffold @--out@-directory), its full text, and whether it is overwritten every run-('Generated') or written only when absent ('HoleStub').--}-data ScaffoldModule = ScaffoldModule- { modulePath :: !FilePath- , moduleText :: !Text- , kind :: !ModuleKind- , origin :: !Text- }- deriving stock (Eq, Show)--data ModuleKind- = -- | @-- \@generated@; overwritten on every scaffold.- Generated- | -- | Hand-owned; created only when absent, never overwritten.- HoleStub- deriving stock (Eq, Show)--{- | The threading context: the spec's @context@ name, the chosen output-module-namespace root, and the placement style. Extended additively (never-re-shaped) by later verticals.--}-data Context = Context- { contextName :: !Text- , moduleRoot :: !Text- -- ^ @""@ means no namespace prefix (the historical default).- , placement :: !Placement- -- ^ 'GeneratedPrefix' is the historical default.- }- deriving stock (Eq, Show)--{- | A context with today's default placement ('GeneratedPrefix', no root prefix)-for the given @context@ name. Callers that do not care about placement (the-@parse@ path, tests) build their context with this.--}-defaultContext :: Text -> Context-defaultContext name = Context{contextName = name, moduleRoot = "", placement = GeneratedPrefix}--{- | The generated-layer namespace for a node, honouring the root prefix and the-placement style. The 'Text' argument is the already-pascalised node name (e.g.-@Reservation@, @HospitalSurge@). For 'GeneratedPrefix' this is-@\<root\>.Generated.\<Ctx\>.\<Node\>@ (identical to the historical layout); for-'CollocatedLeaf' it is @\<root\>.\<Ctx\>.\<Node\>.Generated@.--}-genPrefixFor :: Context -> Text -> Text-genPrefixFor ctx node = case placement ctx of- GeneratedPrefix -> rootPrefix ctx <> "Generated." <> ctxPascalOf ctx <> "." <> node- CollocatedLeaf -> rootPrefix ctx <> ctxPascalOf ctx <> "." <> node <> ".Generated"--{- | The hand-owned (hole) namespace for a node: @\<root\>.\<Ctx\>.\<Node\>@ —-the same for both placement styles (holes always sit beside the domain).--}-holePrefixFor :: Context -> Text -> Text-holePrefixFor ctx node = rootPrefix ctx <> ctxPascalOf ctx <> "." <> node---- | The root namespace prefix, dot-terminated, or @""@ when no root is set.-rootPrefix :: Context -> Text-rootPrefix ctx = case moduleRoot ctx of r | T.null r -> ""; r -> r <> "."---- | The context name in PascalCase, e.g. @hospital-capacity@ -> @HospitalCapacity@.-ctxPascalOf :: Context -> Text-ctxPascalOf = pascalFromKebab . contextName------------------------------------------------------------------------------------- Firewall self-check (M3)-----------------------------------------------------------------------------------{- | The canonical keiki surface forbidden in generated modules. Symbolic-operators are matched as maximal Haskell symbol tokens, identifiers as complete-tokens, qualifiers by their leading module alias, and imports structurally.--}-data FirewallSurface = FirewallSurface- { forbiddenSymbolic :: ![Text]- , forbiddenIdents :: ![Text]- , forbiddenQualifiers :: ![Text]- , forbiddenImports :: ![Text]- , restrictedImports :: ![(Text, [Text])]- }- deriving stock (Eq, Show)--firewallSurface :: FirewallSurface-firewallSurface =- FirewallSurface- { forbiddenSymbolic = [".==", "./=", ".<", ".<=", ".>", ".>=", ".&&", ".||", ".+", ".-", ".*", "=:", "*:"]- , forbiddenIdents = ["lit", "pnot", "tadd", "tsub", "tmul"]- , forbiddenQualifiers = ["B"]- , forbiddenImports = ["Keiki.Builder", "Keiki.Operators", "Keiki.Symbolic"]- , -- Generated aggregate modules use the first two names; generated- -- harnesses validate, step, and replay filled holes register by register.- restrictedImports =- [- ( "Keiki.Core"- ,- [ "RegFile"- , "HsPred"- , "FieldProjection"- , "FieldWitness"- , "fieldWitness"- , "fieldWitnessAgrees"- , "applyEventsEither"- , "defaultValidationOptions"- , "step"- , "validateTransducer"- , "!"- ]- )- ]- }--{- | Scan generated modules for firewall breaches, returning every offending-@(module path, token, 1-based line number)@. Only modules whose 'kind' is-'Generated' are scanned. Strings and comments are skipped, symbol runs use-maximal munch, and keiki imports are checked independently of token spelling.--}-firewallBreaches :: [ScaffoldModule] -> [(FilePath, Text, Int)]-firewallBreaches mods =- [ (modulePath m, breach, n)- | m <- mods- , kind m == Generated- , (n, line) <- zip [1 ..] (T.lines (moduleText m))- , breach <- lineBreaches line- ]--lineBreaches :: Text -> [Text]-lineBreaches line = case importModule line of- Just _ -> importBreaches line- Nothing -> tokenBreaches (codeTokens line)- where- tokenBreaches = mapMaybe breachFor- breachFor (IdentToken ident)- | ident `elem` forbiddenIdents firewallSurface = Just ident- breachFor (QualifiedToken qualifier)- | qualifier `elem` forbiddenQualifiers firewallSurface = Just (qualifier <> ".*")- breachFor (SymbolToken symbol)- | symbol `elem` forbiddenSymbolic firewallSurface = Just symbol- breachFor _ = Nothing--data CodeToken = IdentToken !Text | QualifiedToken !Text | SymbolToken !Text--codeTokens :: Text -> [CodeToken]-codeTokens = go . T.unpack- where- go [] = []- go ('-' : '-' : _) = []- go ('"' : rest) = go (dropString rest)- go ('\'' : rest) = go (dropChar rest)- go (c : rest)- | isIdentStart c =- let (identTail, afterIdent) = span isIdentContinue rest- ident = T.pack (c : identTail)- in case afterIdent of- '.' : next : more- | isUpper c && isIdentStart next ->- let (_member, afterMember) = span isIdentContinue more- in QualifiedToken ident : go afterMember- _ -> IdentToken ident : go afterIdent- | isSymbolChar c =- let (symbolTail, afterSymbol) = span isSymbolChar rest- in SymbolToken (T.pack (c : symbolTail)) : go afterSymbol- | otherwise = go rest- isIdentStart c = isAlpha c || c == '_'- isIdentContinue c = isAlphaNum c || c == '_' || c == '\''- isSymbolChar c = c `elem` ("!#$%&*+./<=>?@\\^|-~:" :: String)- dropString [] = []- dropString ('\\' : _escaped : rest) = dropString rest- dropString ('"' : rest) = rest- dropString (_ : rest) = dropString rest- dropChar [] = []- dropChar ('\\' : _escaped : rest) = dropChar rest- dropChar ('\'' : rest) = rest- dropChar (_ : rest) = dropChar rest--importBreaches :: Text -> [Text]-importBreaches line = case importModule line of- Nothing -> []- Just imported- | imported `elem` forbiddenImports firewallSurface -> ["import:" <> imported]- | Just allowed <- lookup imported (restrictedImports firewallSurface)- , not (hasAllowedExplicitImportList allowed line) ->- ["import:" <> imported]- | otherwise -> []--importModule :: Text -> Maybe Text-importModule line = case T.words (T.strip line) of- "import" : rest -> find (T.isPrefixOf "Keiki.") rest- _ -> Nothing--hasAllowedExplicitImportList :: [Text] -> Text -> Bool-hasAllowedExplicitImportList allowed line =- case (T.breakOn "(" line, T.breakOnEnd ")" line) of- ((_, open), (close, _))- | not (T.null open) && not (T.null close) ->- let inside = T.takeWhile (/= ')') (T.drop 1 open)- names = filter (not . T.null) (T.split (not . isAlphaNum) inside)- in all (`elem` allowed) names- _ -> False------------------------------------------------------------------------------------- Derived naming------------------------------------------------------------------------------------- | Resolved, denormalized view of an aggregate used by every emitter.-data Agg = Agg- { aContext :: !Context- , aCtxPascal :: !Text- , aName :: !Text- , aLoc :: !Loc- , aVertexType :: !Text- , aIds :: ![IdDecl]- , aEnums :: ![EnumDecl]- , aRegs :: ![RegDecl]- , aStates :: ![StateDecl]- , aCommands :: ![ResolvedCtor]- , aEvents :: ![ResolvedCtor]- , aTransitions :: ![Transition]- , aWire :: !WireSpec- , aProjection :: !(Maybe ProjectionSpec)- , aSnapshot :: !(Maybe SnapshotSpec)- , aFoldFingerprint :: !Text- , aReadModels :: ![ReadModelNode]- , aTypeGraph :: !(Maybe TypeGraph)- , aGenPrefix :: !Text- -- ^ e.g. @Generated.HospitalCapacity.Reservation@- , aHolePrefix :: !Text- -- ^ e.g. @HospitalCapacity.Reservation@- }---- | A command or event constructor with its fully-resolved field types.-data ResolvedCtor = ResolvedCtor- { rcName :: !Text- , rcFields :: ![(Text, Text)]- -- ^ (field name, resolved Haskell type)- , rcVersion :: !Int- -- ^ EP-2: schema version (1 for commands and unversioned events).- , rcUpcastFrom :: !(Maybe Int)- -- ^ EP-2: the source version this event migrates from (the upcaster step).- }--defaultWire :: WireSpec-defaultWire = WireSpec{wireKind = "ctorName", wireFields = "camelCase", wireSchemaVersion = 1}--resolveAgg :: Context -> Spec -> Aggregate -> Agg-resolveAgg ctx spec agg =- Agg- { aContext = ctx- , aCtxPascal = ctxPascal- , aName = nm- , aLoc = aggLoc agg- , aVertexType = vertexType- , aIds = specIds spec- , aEnums = specEnums spec- , aRegs = aggRegs agg- , aStates = aggStates agg- , aCommands = map resolveCommand (aggCommands agg)- , aEvents = map resolveEvent (aggEvents agg)- , aTransitions = aggTransitions agg- , aWire = fromMaybe defaultWire (aggWire agg)- , aProjection = aggProjection agg- , aSnapshot = aggSnapshot agg- , aFoldFingerprint = aggregateFoldFingerprint spec agg- , aReadModels = [readModel | NReadModel readModel <- specNodes spec]- , aTypeGraph = either (const Nothing) Just (resolveTypeGraph spec)- , aGenPrefix = genPrefixFor ctx nm- , aHolePrefix = holePrefixFor ctx nm- }- where- nm = aggName agg- ctxPascal = pascalFromKebab (contextName ctx)- vertexType = nm <> "Vertex"- commandFieldTypes = [(cmdName c, cmdFields c) | c <- aggCommands agg]- resolveCommand c = (mkCtor (cmdName c) (cmdFields c)){rcVersion = 1, rcUpcastFrom = Nothing}- resolveEvent e =- (mkCtor (evName e) (eventFields e))- { rcVersion = evVersion e- , rcUpcastFrom = fst <$> evUpcastFrom e- }- where- eventFields ev = case evBody ev of- EventFields fs -> fs- EventFromCommand cn -> fromMaybe [] (lookup cn commandFieldTypes)- mkCtor cn fs =- ResolvedCtor- { rcName = cn- , rcFields = map (\f -> (fieldName f, resolveFieldType f)) fs- , rcVersion = 1- , rcUpcastFrom = Nothing- }- regTypes = [(regName r, regType r) | r <- aggRegs agg]- idNames = map idName (specIds spec)- enumNames = map enumName (specEnums spec)- -- A bare field reuses a register's type if one shares its name; else it- -- Pascal-cases to a declared id/enum/vertex; else falls back to Text.- resolveFieldType f = case fieldType f of- Just ty -> ty- Nothing ->- let nme = fieldName f- pas = pascal nme- in case lookup nme regTypes of- Just ty -> ty- Nothing- | pas `elem` idNames -> pas- | pas `elem` enumNames -> pas- | pas == vertexType -> pas- | otherwise -> "Text"------------------------------------------------------------------------------------- Entry point-----------------------------------------------------------------------------------{- | Emit the context-level private structural stratum. Shape modules contain-only generated wire representations. The projection facade contains only-schema-derived Keiki field witnesses; neither layer owns consumer behavior.--}-scaffoldStructural :: Context -> Spec -> [ScaffoldModule]-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 ->- [(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"- }- , []- )- | not (null (projectionSpecs graph))- ]--{- | Plan one opt-in, non-production historical-codec comparison module.--The module is intentionally absent from 'scaffoldStructural' and therefore-from production manifests and scaffold records. It must be requested by name-and is compiled only by consumer-owned test/tool components.--}-codecComparisonModule :: Context -> Spec -> Name -> Either Text ScaffoldModule-codecComparisonModule ctx spec requestedName = do- graph <- either (Left . ("mapped type graph did not resolve: " <>) . T.pack . show) Right (resolveTypeGraph spec)- (declaration, shape) <- case Map.lookup (MappedKey requestedName) (tgDeclarations graph) of- Nothing -> Left ("codec comparison target is not a mapped declaration: " <> requestedName)- Just (ResolvedOpaque _) ->- Left- ( "codec comparison target "- <> requestedName- <> " is opaque; finite evidence must never upgrade an opaque declaration to a structural claim"- )- Just (ResolvedStructural declaration shape) -> Right (declaration, shape)- owner <- case sortOn aggName (comparisonOwners declaration) of- [] ->- Left- ( "codec comparison target "- <> requestedName- <> " is not reachable from a persisted private event payload"- )- aggregate : _ -> Right aggregate- let moduleName = structuralPrefix ctx <> ".CodecCompare." <> requestedName- pure- ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" moduleName <> ".hs")- , moduleText = emitCodecComparison ctx moduleName graph declaration shape owner- , kind = Generated- , origin = "non-production codec comparison " <> requestedName- }- where- comparisonOwners declaration =- [ aggregate- | NAggregate aggregate <- specNodes spec- , let resolved = resolveAgg ctx spec aggregate- , any ((== sdName declaration) . mappedName) (codecMappedDeclarations resolved)- ]- where- mappedName (ResolvedStructural structural _) = sdName structural- mappedName (ResolvedOpaque opaque) = odName opaque--codecComparisonBanner :: Text-codecComparisonBanner =- "-- @generated by keiro-dsl codec comparison; non-production migration evidence; do not edit."--emitCodecComparison :: Context -> Text -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Aggregate -> Text-emitCodecComparison ctx moduleName graph declaration shape owner =- nl- [ "{-# LANGUAGE ImportQualifiedPost #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- , ""- , codecComparisonBanner- , "-- This module compares historical and generated codecs in consumer-owned tests only."- , "-- It is never a runtime fallback and never changes the generated codec's authority."- , "module " <> moduleName <> " (compareWithHistorical) where"- , ""- , "import Control.Monad (filterM)"- , "import Data.Aeson (Value)"- , "import Data.Aeson qualified as Aeson"- , "import Data.List (sort)"- , "import Data.List.NonEmpty qualified as NonEmpty"- , "import Data.Text (Text)"- , "import Data.Text qualified"- , "import " <> codecModule <> " qualified as GeneratedCodec"- , "import Keiro.Codec.Structural (FixtureCases (..))"- , "import Keiro.Dsl.CodecCompare"- , "import Keiro.Dsl.TypeGraph (BindingVersion (..), CanonicalTypeId (..), QualifiedValueName (..))"- , "import System.Directory (doesFileExist, listDirectory)"- , "import System.FilePath (takeExtension, (</>))"- , ""- , "import " <> fixtureModule <> " qualified as ConsumerFixtures"- , "import " <> hsModule (sdHaskell declaration) <> " qualified as ConsumerDomain"- , ""- , "compareWithHistorical :: HistoricalCodec " <> domainType <> " -> FilePath -> IO CompareReport"- , "compareWithHistorical historicalCodec goldenDirectory = do"- , " names <- sort . filter ((== \".json\") . takeExtension) <$> listDirectory goldenDirectory"- , " files <- filterM doesFileExist [goldenDirectory </> name | name <- names]"- , " loaded <- traverse (loadGolden historicalCodec) files"- , " let inputIssues = [issue | Left issue <- loaded]"- , " entries = [entry | Right entry <- loaded]"- , " typedCases = NonEmpty.toList (fixtureCases ConsumerFixtures." <> fixtureSymbol <> ")"- , " encodeObservations ="- , " [ EncodeObservation label (hcEncode historicalCodec value) (GeneratedCodec.encode" <> name <> "Mapped value)"- , " | (label, value) <- typedCases"- , " ]"- , " decodeObservations = [observation | (observation, _) <- entries]"- , " typedObserved ="- , " concat"- , " [ observedBranchesFor FromBinding branchSchema (GeneratedCodec.encode" <> name <> "Mapped value)"- , " | (_, value) <- typedCases"- , " ]"- , " historicalObserved ="- , " concat [observedBranchesFor HistoricalGolden branchSchema value | (_, values) <- entries, value <- values]"- , " declared = declaredBranchesFor FromBinding branchSchema <> declaredBranchesFor HistoricalGolden branchSchema"- , " provenance ="- , " CompareProvenance"- , " { cpHistoricalCodecIdentity = hcIdentity historicalCodec"- , " , cpHistoricalCodecVersion = hcVersion historicalCodec"- , " , cpCanonicalType = CanonicalTypeId " <> tshow (unCanonicalTypeId (sdCanonical declaration))- , " , cpBindingSymbol = QualifiedValueName " <> tshow (unQualifiedValueName (sdBinding declaration))- , " , cpBindingVersion = BindingVersion " <> tshow (unBindingVersion (sdBindingVersion declaration))- , " , cpWireFingerprint = " <> tshow (wireFingerprint graph name)- , " }"- , " pure (compareReport provenance inputIssues (encodeObservations <> decodeObservations) declared (typedObserved <> historicalObserved))"- , ""- , "loadGolden :: HistoricalCodec " <> domainType <> " -> FilePath -> IO (Either CompareInputIssue (CompareObservation, [Value]))"- , "loadGolden historicalCodec path = do"- , " decoded <- Aeson.eitherDecodeFileStrict path"- , " pure $ case decoded of"- , " Left reason -> Left (HistoricalGoldenUnreadable path (fromString reason))"- , " Right inputValue ->"- , " let historicalDecoded = hcDecode historicalCodec inputValue"- , " historicalOutcome = normalizeDecode historicalDecoded"- , " generatedOutcome = normalizeDecode (GeneratedCodec.decode" <> name <> "Mapped inputValue)"- , " observation = DecodeObservation path inputValue historicalOutcome generatedOutcome"- , " coveredValues = case historicalDecoded of"- , " Right value -> [inputValue, GeneratedCodec.encode" <> name <> "Mapped value]"- , " Left _ -> []"- , " in Right (observation, coveredValues)"- , ""- , "normalizeDecode :: Either Text " <> domainType <> " -> DecodeOutcome"- , "normalizeDecode = either DecodeFailed (DecodedShape . GeneratedCodec.encode" <> name <> "Mapped)"- , ""- , "fromString :: String -> Text"- , "fromString = Data.Text.pack"- , ""- , "branchSchema :: BranchSchema"- , "branchSchema = " <> renderBranchSchema (branchSchemaFor graph (ResolvedStructural declaration shape))- ]- where- name = sdName declaration- domainType = "ConsumerDomain." <> hsType (sdHaskell declaration)- codecModule = genPrefixFor ctx (aggName owner) <> ".Codec"- fixtureModule = qualifiedModule (sdFixtures declaration)- fixtureSymbol = lastSegment (unQualifiedValueName (sdFixtures declaration))--branchSchemaFor :: TypeGraph -> ResolvedMappedDecl -> BranchSchema-branchSchemaFor graph =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ shape ->- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields ->- BranchRecord- [ BranchField- (rwfKey field)- (rwfPresence field == POptional)- (branchExpr graph (rwfType field))- | field <- fields- ]- , onEnum = const BranchScalar- , onUnion = \encoding arms ->- BranchUnion- (ueTagField encoding)- (ueContentsField encoding)- [BranchArm (rwaTag arm) (branchExpr graph <$> rwaPayload arm) | arm <- arms]- }- shape- , onOpaqueDecl = const BranchScalar- }--branchExpr :: TypeGraph -> ResolvedTypeExpr -> BranchSchema-branchExpr graph =- foldTypeExpr- TypeExprAlgebra- { onText = BranchScalar- , onInt = BranchScalar- , onBool = BranchScalar- , onNatural = BranchScalar- , onTime = BranchScalar- , onJson = BranchScalar- , onOptional = BranchOptional- , onList = BranchList- , onMap = BranchMap- , onRef = \key -> maybe BranchScalar (branchSchemaFor graph) (Map.lookup key (tgDeclarations graph))- }--renderBranchSchema :: BranchSchema -> Text-renderBranchSchema schema = case schema of- BranchScalar -> "BranchScalar"- BranchOptional nested -> "BranchOptional (" <> renderBranchSchema nested <> ")"- BranchList nested -> "BranchList (" <> renderBranchSchema nested <> ")"- BranchMap nested -> "BranchMap (" <> renderBranchSchema nested <> ")"- BranchRecord fields ->- "BranchRecord ["- <> T.intercalate- ", "- [ "BranchField "- <> tshow (bfWireKey field)- <> " "- <> (if bfPresenceOptional field then "True" else "False")- <> " ("- <> renderBranchSchema (bfSchema field)- <> ")"- | field <- fields- ]- <> "]"- BranchUnion tagField contentsField arms ->- "BranchUnion "- <> tshow tagField- <> " "- <> tshow contentsField- <> " ["- <> T.intercalate- ", "- [ "BranchArm "- <> tshow (baWireTag arm)- <> " "- <> maybe "Nothing" (\nested -> "(Just (" <> renderBranchSchema nested <> "))") (baPayloadSchema arm)- | arm <- arms- ]- <> "]"--{- | Emit one create-once consumer module per distinct qualified obligation-owner. Multiple mapped declarations may intentionally share a leaf binding-module, so grouping happens by module rather than by declaration.--}-bindingSkeletonModules :: Context -> Spec -> TypeGraph -> [ScaffoldModule]-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, nub (map obligationMappedName entries))- | (owner, entries) <- Map.toAscList (Map.fromListWith (<>) [(obligationModule obligation, [obligation]) | obligation <- obligations])- ]--emitBindingSkeleton :: Context -> TypeGraph -> Text -> [BindingObligation] -> ScaffoldModule-emitBindingSkeleton ctx graph owner obligations =- ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" owner <> ".hs")- , moduleText =- nl $- [ "{-# LANGUAGE LambdaCase #-}"- , ""- , "-- This is a HAND-OWNED structural binding skeleton. keiro-dsl creates it once"- , "-- and never overwrites it. Fill each HOLE and run the generated harness."- , "module " <> owner <> " ("- ]- <> exportLines- <> [") where", ""]- <> map ("import " <>) imports- <> [""]- <> intercalateBlank (map renderObligation obligations)- , kind = HoleStub- , origin = "mapped structural binding skeleton " <> owner- }- where- exportLines =- [ (if index == (0 :: Int) then " " else " , ") <> obligationSymbol obligation- | (index, obligation) <- zip [0 ..] obligations- ]- imports =- sort . nub $- [ hsModule (sdHaskell declaration) <> " qualified"- | obligation <- obligations- , Just (declaration, _) <- [structuralFor obligation]- ]- <> [ structuralShapeModule ctx (sdName declaration) <> " qualified"- | obligation <- obligations- , obligationKind obligation == BindingValue- , Just (declaration, _) <- [structuralFor obligation]- ]- <> [ "Keiro.Codec.Structural (FixtureCases, StructuralBinding (..))"- | any ((`elem` [BindingValue, FixtureValue]) . obligationKind) obligations- ]- renderObligation obligation = case structuralFor obligation of- Nothing -> ["-- HOLE: declaration disappeared before skeleton rendering"]- Just (declaration, shape) -> case obligationKind obligation of- BindingValue -> renderBinding ctx declaration shape obligation- FixtureValue ->- [ "-- HOLE: provide deterministic labelled conformance fixtures for " <> sdName declaration- , obligationSignature obligation- , obligationSymbol obligation <> " = error " <> tshow ("HOLE: fill " <> sdName declaration <> " fixtures")- ]- InitialValue ->- [ "-- HOLE: provide the initial register value for " <> sdName declaration- , obligationSignature obligation- , obligationSymbol obligation <> " = error " <> tshow ("HOLE: fill " <> sdName declaration <> " initial value")- ]- structuralFor obligation = case Map.lookup (MappedKey (obligationMappedName obligation)) (tgDeclarations graph) of- Just (ResolvedStructural declaration shape) -> Just (declaration, shape)- _ -> Nothing- intercalateBlank [] = []- intercalateBlank (section : rest) = section <> concatMap ("" :) rest--renderBinding :: Context -> StructuralDecl -> ResolvedMappedShape -> BindingObligation -> [Text]-renderBinding ctx declaration shape obligation =- [ "-- HOLE: complete both total directions; wire policy remains in the generated codec."- , obligationSymbol obligation <> " :: StructuralBinding " <> domainType <> " " <> shapeType- , obligationSymbol obligation <> " ="- , " StructuralBinding"- , " { bindingToShape = \\case"- ]- <> indentCases (bindingCases True)- <> [" , bindingFromShape = \\case"]- <> indentCases (bindingCases False)- <> [" }"]- where- domainModule = hsModule (sdHaskell declaration)- domainType = domainModule <> "." <> hsType (sdHaskell declaration)- shapeModuleName = structuralShapeModule ctx (sdName declaration)- shapeType = shapeModuleName <> "." <> sdName declaration <> "Shape"- domainCtor constructor = domainModule <> "." <> constructor- shapeCtor constructor = shapeModuleName <> "." <> constructor- indentCases = map (" " <>)- bindingCases toShapeDirection =- foldMappedShape- MappedShapeAlgebra- { onRecord = \constructor _ fields -> [recordCase toShapeDirection constructor fields]- , onEnum = \entries -> map (enumCase toShapeDirection . weCtor) entries- , onUnion = \_ arms -> map (unionCase toShapeDirection) arms- }- shape- recordCase toShapeDirection constructor fields =- sourceCtor- <> arguments variables- <> " -> "- <> targetCtor- <> arguments (map (holeFor toShapeDirection . rwfHaskell) fields)- where- variables = map (("_" <>) . (<> "Value") . rwfHaskell) fields- sourceCtor = if toShapeDirection then domainCtor constructor else shapeCtor constructor- targetCtor = if toShapeDirection then shapeCtor constructor else domainCtor constructor- enumCase toShapeDirection constructor =- sourceCtor <> " -> " <> holeFor toShapeDirection constructor- where- sourceCtor = if toShapeDirection then domainCtor constructor else shapeCtor constructor- unionCase toShapeDirection arm =- sourceCtor- <> maybe "" (const " _payloadValue") (rwaPayload arm)- <> " -> "- <> case rwaPayload arm of- Nothing -> holeFor toShapeDirection (rwaCtor arm)- Just _ -> targetCtor <> " " <> holeFor toShapeDirection (rwaCtor arm <> ".payload")- where- sourceCtor = if toShapeDirection then domainCtor (rwaCtor arm) else shapeCtor (rwaCtor arm)- targetCtor = if toShapeDirection then shapeCtor (rwaCtor arm) else domainCtor (rwaCtor arm)- arguments [] = ""- arguments values = " " <> T.unwords values- holeFor toShapeDirection fieldName =- "(error "- <> tshow- ( "HOLE: fill "- <> sdName declaration- <> (if toShapeDirection then " bindingToShape." else " bindingFromShape.")- <> fieldName- )- <> ")"--shapeModule :: Context -> TypeGraph -> (StructuralDecl, ResolvedMappedShape) -> ScaffoldModule-shapeModule ctx graph (declaration, shape) =- ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" (structuralShapeModule ctx (sdName declaration)) <> ".hs")- , moduleText = emitShape ctx graph declaration shape- , kind = Generated- , origin = nodeOrigin "mapped structural" (sdName declaration) (sdLoc declaration)- }--structuralPrefix :: Context -> Text-structuralPrefix ctx = case placement ctx of- GeneratedPrefix -> rootPrefix ctx <> "Generated." <> ctxPascalOf ctx <> ".Structural"- CollocatedLeaf -> rootPrefix ctx <> ctxPascalOf ctx <> ".Generated.Structural"--structuralShapeModule :: Context -> Name -> Text-structuralShapeModule ctx name = structuralPrefix ctx <> ".Shape." <> name--structuralProjectionModule :: Context -> Text-structuralProjectionModule ctx = structuralPrefix ctx <> "Projections"--emitShape :: Context -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text-emitShape ctx graph declaration shape =- nl $- languagePragmas- <> [ generatedBanner- , "module " <> moduleName <> " (" <> shapeType <> " (..)) where"- , ""- ]- <> map ("import " <>) imports- <> ["" | not (null imports)]- <> [shapeDeclaration]- where- moduleName = structuralShapeModule ctx (sdName declaration)- shapeType = sdName declaration <> "Shape"- requirements = shapeRequirements ctx graph shape- languagePragmas =- ["{-# LANGUAGE DeriveGeneric #-}"]- <> ["{-# LANGUAGE DuplicateRecordFields #-}" | shapeHasRecord shape]- imports =- sort . nub $- ["Data.Aeson (Value)" | ReqJson `elem` requirements]- <> ["Data.Map.Strict (Map)" | ReqMap `elem` requirements]- <> ["Data.Text (Text)" | ReqText `elem` requirements]- <> ["Data.Time (UTCTime)" | ReqTime `elem` requirements]- <> ["GHC.Generics (Generic)"]- <> ["Numeric.Natural (Natural)" | ReqNatural `elem` requirements]- <> [m <> " qualified" | ReqModule m <- requirements]- shapeDeclaration =- foldMappedShape- MappedShapeAlgebra- { onRecord = \constructor _ fields ->- nl $- ["data " <> shapeType <> " = " <> constructor]- <> recordFields- [ (rwfHaskell field, renderShapeType ctx graph (rwfType field))- | field <- fields- ]- <> [" deriving stock (Eq, Generic, Show)"]- , onEnum = \entries ->- "data "- <> shapeType- <> " = "- <> T.intercalate " | " (map weCtor entries)- <> "\n deriving stock (Eq, Generic, Show)"- , onUnion = \_ arms ->- nl $- case arms of- [] -> ["data " <> shapeType <> " = " <> shapeType <> "Empty", " deriving stock (Eq, Generic, Show)"]- firstArm : rest ->- ["data " <> shapeType <> " = " <> renderArm firstArm]- <> [" | " <> renderArm arm | arm <- rest]- <> [" deriving stock (Eq, Generic, Show)"]- }- shape- renderArm arm = rwaCtor arm <> maybe "" ((" !" <>) . renderShapeType ctx graph) (rwaPayload arm)--data ShapeRequirement- = ReqJson- | ReqMap- | ReqText- | ReqTime- | ReqNatural- | ReqModule !Text- deriving stock (Eq, Ord, Show)--shapeHasRecord :: ResolvedMappedShape -> Bool-shapeHasRecord =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ _ -> True- , onEnum = const False- , onUnion = \_ _ -> False- }--shapeRequirements :: Context -> TypeGraph -> ResolvedMappedShape -> [ShapeRequirement]-shapeRequirements ctx graph =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields -> concatMap (exprRequirements ctx graph . rwfType) fields- , onEnum = const []- , onUnion = \_ arms -> concatMap (maybe [] (exprRequirements ctx graph) . rwaPayload) arms- }--exprRequirements :: Context -> TypeGraph -> ResolvedTypeExpr -> [ShapeRequirement]-exprRequirements ctx graph =- foldTypeExpr- TypeExprAlgebra- { onText = [ReqText]- , onInt = []- , onBool = []- , onNatural = [ReqNatural]- , onTime = [ReqTime]- , onJson = [ReqJson]- , onOptional = id- , onList = id- , onMap = (ReqMap :) . (ReqText :)- , onRef = \key -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural declaration _) -> [ReqModule (structuralShapeModule ctx (sdName declaration))]- Just (ResolvedOpaque declaration) -> [ReqModule (hsModule (odHaskell declaration))]- Nothing -> []- }--renderShapeType :: Context -> TypeGraph -> ResolvedTypeExpr -> Text-renderShapeType ctx graph =- foldTypeExpr- TypeExprAlgebra- { onText = "Text"- , onInt = "Int"- , onBool = "Bool"- , onNatural = "Natural"- , onTime = "UTCTime"- , onJson = "Value"- , onOptional = \value -> "(Maybe (" <> value <> "))"- , onList = \value -> "([" <> value <> "])"- , onMap = \value -> "(Map Text (" <> value <> "))"- , onRef = \key -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural nested _) ->- structuralShapeModule ctx (sdName nested) <> "." <> sdName nested <> "Shape"- Just (ResolvedOpaque opaque) ->- hsModule (odHaskell opaque) <> "." <> hsType (odHaskell opaque)- Nothing -> "()"- }--data StructuralProjection = StructuralProjection- { spTag :: !Text- , spWitness :: !Text- , spPointer :: !Text- , spOwner :: !HaskellSource- , spResult :: !Text- , spCanonical :: !CanonicalTypeId- , spBinding :: !QualifiedValueName- , spSelectors :: ![(Text, Text)]- }- deriving stock (Eq, Show)--projectionSpecs :: TypeGraph -> [StructuralProjection]-projectionSpecs graph =- sortOn spTag . concat $- [ projectionsForRoot graph declaration shape- | ResolvedStructural declaration shape <- Map.elems (tgDeclarations graph)- ]--projectionsForRoot :: TypeGraph -> StructuralDecl -> ResolvedMappedShape -> [StructuralProjection]-projectionsForRoot graph root rootShape = case rootShape of- RRecord _ _ fields -> concatMap (walkField [] []) fields- REnum{} -> []- RUnion{} -> []- where- walkField keys selectors field- | rwfPresence field /= PRequired = []- | otherwise = case projectionScalar (rwfType field) of- Just result -> [mkProjection (keys <> [rwfKey field]) (selectors <> [(shapeModuleForOwner, rwfHaskell field)]) result]- Nothing -> case rwfType field of- RRef key -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural nested (RRecord _ _ nestedFields)) ->- concatMap- (walkNested nested (keys <> [rwfKey field]) (selectors <> [(shapeModuleForOwner, rwfHaskell field)]))- nestedFields- _ -> []- _ -> []- where- shapeModuleForOwner = "__SHAPE__." <> sdName root-- walkNested owner keys selectors field- | rwfPresence field /= PRequired = []- | otherwise = case projectionScalar (rwfType field) of- Just result -> [mkProjection (keys <> [rwfKey field]) (selectors <> [(shapeModuleFor owner, rwfHaskell field)]) result]- Nothing -> case rwfType field of- RRef key -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural nested (RRecord _ _ nestedFields)) ->- concatMap- (walkNested nested (keys <> [rwfKey field]) (selectors <> [(shapeModuleFor owner, rwfHaskell field)]))- nestedFields- _ -> []- _ -> []-- -- Context is supplied when rendering; this marker is replaced there.- shapeModuleFor declaration = "__SHAPE__." <> sdName declaration- mkProjection keys selectors result =- StructuralProjection- { spTag = projectionTag (sdName root) pointer- , spWitness = lowerFirst (projectionTag (sdName root) pointer) <> "Witness"- , spPointer = pointer- , spOwner = sdHaskell root- , spResult = result- , spCanonical = sdCanonical root- , spBinding = sdBinding root- , spSelectors = selectors- }- where- pointer = T.concat ["/" <> escapePointer key | key <- keys]--projectionScalar :: ResolvedTypeExpr -> Maybe Text-projectionScalar = \case- RText -> Just "Text"- RInt -> Just "Int"- RBool -> Just "Bool"- RTime -> Just "UTCTime"- RNatural -> Nothing- RJson -> Nothing- ROptional{} -> Nothing- RList{} -> Nothing- RMap{} -> Nothing- RRef{} -> Nothing--escapePointer :: Text -> Text-escapePointer = T.replace "/" "~1" . T.replace "~" "~0"--projectionTag :: Name -> Text -> Text-projectionTag owner pointer = "StructuralProjection" <> encodeIdentifier (owner <> pointer)--encodeIdentifier :: Text -> Text-encodeIdentifier = T.concatMap (\character -> "C" <> T.pack (showHex (ord character) "") <> "Z")--emitStructuralProjections :: Context -> TypeGraph -> Text-emitStructuralProjections ctx graph =- nl $- [ "{-# LANGUAGE DataKinds #-}"- , "{-# LANGUAGE TypeApplications #-}"- , "{-# LANGUAGE TypeFamilies #-}"- , generatedBanner- , "-- Equality witnesses are emitted for Text, Int, Bool, and UTCTime."- , "-- Only Int and UTCTime belong to Keiki's v1 ordered subset."- , "module " <> moduleName- , " ( " <> T.intercalate "\n , " (map spWitness specs)- , " ) where"- , ""- , "import Data.Text (Text)"- , "import Data.Time (UTCTime)"- , "import Keiro.Codec.Structural (bindingToShape)"- , "import Keiki.Core (FieldProjection (..), FieldWitness, fieldWitness)"- ]- <> map ("import " <>) imports- <> concatMap renderProjection specs- where- moduleName = structuralProjectionModule ctx- specs = map (resolveProjectionModules ctx) (projectionSpecs graph)- imports =- sort . nub $- [hsModule (spOwner spec) <> " qualified" | spec <- specs]- <> [qualifiedModule (spBinding spec) <> " qualified" | spec <- specs]- <> [shapeModuleName <> " qualified" | spec <- specs, (shapeModuleName, _) <- spSelectors spec]- renderProjection spec =- [ ""- , "data " <> spTag spec- , ""- , "instance FieldProjection " <> spTag spec <> " where"- , " type FieldName " <> spTag spec <> " = " <> tshow (spPointer spec)- , " type FieldOwner " <> spTag spec <> " = " <> renderHaskellSource (spOwner spec)- , " type FieldResult " <> spTag spec <> " = " <> spResult spec- , " fieldShapeId _ = " <> tshow (unCanonicalTypeId (spCanonical spec))- , " projectFieldValue _ owner = " <> renderGetter spec- , ""- , spWitness spec <> " :: FieldWitness " <> spTag spec- , spWitness spec <> " = fieldWitness @" <> spTag spec- ]- renderGetter spec =- foldl- (\value (shapeModuleName, selector) -> shapeModuleName <> "." <> selector <> " (" <> value <> ")")- ("bindingToShape " <> unQualifiedValueName (spBinding spec) <> " owner")- (spSelectors spec)--resolveProjectionModules :: Context -> StructuralProjection -> StructuralProjection-resolveProjectionModules ctx spec =- spec- { spSelectors =- [ (replaceModule marker, selector)- | (marker, selector) <- spSelectors spec- ]- }- where- replaceModule marker- | Just name <- T.stripPrefix "__SHAPE__." marker = structuralShapeModule ctx name- | otherwise = structuralShapeModule ctx (lastSegment marker)--qualifiedModule :: QualifiedValueName -> Text-qualifiedModule = fst . splitQualified . unQualifiedValueName--renderHaskellSource :: HaskellSource -> Text-renderHaskellSource source = hsModule source <> "." <> hsType source--splitQualified :: Text -> (Text, Text)-splitQualified value =- let (prefix, name) = T.breakOnEnd "." value- in (T.dropEnd 1 prefix, name)--lastSegment :: Text -> Text-lastSegment = snd . T.breakOnEnd "."--{- | Emit all modules for one aggregate. The 'Spec' is needed for the shared-id\/enum declarations.--}-scaffoldAggregate :: Context -> Spec -> Aggregate -> [ScaffoldModule]-scaffoldAggregate ctx spec agg =- [ genModule a "Domain" (emitDomain a)- , genModule a "Codec" (emitCodec a)- , genModule a "EventStream" (emitEventStream a)- , genModule a "Projection" (emitProjection a)- , holeModule a (emitHoles a)- ]- where- a = resolveAgg ctx spec agg--{- | Emit the context-wide replay-audit target assembly.--There is one existential target per aggregate declaration. Process saga-aggregates are ordinary aggregate nodes referenced by 'SagaRef', so they are-included by the same single source of truth rather than being duplicated from-the process declaration.--}-scaffoldReplayAudit :: Context -> Spec -> [ScaffoldModule]-scaffoldReplayAudit ctx spec- | null aggregates = []- | otherwise =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" moduleName <> ".hs")- , moduleText = emitReplayAudit- , kind = Generated- , origin = "context " <> specContext spec <> " replay-audit assembly"- }- ]- where- aggregates = [aggregate | NAggregate aggregate <- specNodes spec]- moduleName = contextGeneratedPrefix ctx <> ".ReplayAudit"- contextGeneratedPrefix context = case placement context of- GeneratedPrefix -> rootPrefix context <> "Generated." <> ctxPascalOf context- CollocatedLeaf -> rootPrefix context <> ctxPascalOf context <> ".Generated"- emitReplayAudit =- nl $- [ "{-# LANGUAGE GADTs #-}"- , generatedBanner- , "--"- , "-- Deployment contract:"- , "-- * replay-neutral diff: no data audit is required;"- , "-- * affected diff: run AuditTargeted with the emitted affected set"- , "-- against a production copy under the candidate binary;"- , "-- * one-time runtime cutover: run AuditFull;"- , "-- * any non-zero audit exit blocks deployment."- , "module " <> moduleName <> " (auditTargets) where"- , ""- ]- ++ [ "import " <> genPrefixFor ctx (aggName aggregate) <> ".EventStream qualified as " <> aggName aggregate- | aggregate <- aggregates- ]- ++ [ "import Keiro.ReplayAudit (AuditTarget (..), SomeAuditTarget (..), streamInCategory)"- , "import Keiro.Stream qualified as Stream"- , ""- , "auditTargets :: [SomeAuditTarget]"- , "auditTargets ="- ]- ++ concat- [ [ if index == (0 :: Int) then " [ SomeAuditTarget" else " , SomeAuditTarget"- , " AuditTarget"- , " { eventStream = " <> aggregateName <> "." <> lowerFirst aggregateName <> "EventStream"- , " , category = Stream.categoryText " <> aggregateName <> "." <> lowerFirst aggregateName <> "Category"- , " , mkStream = streamInCategory (Stream.categoryText " <> aggregateName <> "." <> lowerFirst aggregateName <> "Category)"- , " }"- ]- | (index, aggregate) <- zip [0 ..] aggregates- , let aggregateName = aggName aggregate- ]- ++ [" ]"]--genModule :: Agg -> Text -> Text -> ScaffoldModule-genModule a name body =- ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" (aGenPrefix a) <> "/" <> name <> ".hs")- , moduleText = body- , kind = Generated- , origin = nodeOrigin "aggregate" (aName a) (aLoc a)- }--holeModule :: Agg -> Text -> ScaffoldModule-holeModule a body =- ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" (aHolePrefix a) <> "/" <> "Holes.hs")- , moduleText = body- , kind = HoleStub- , origin = nodeOrigin "aggregate" (aName a) (aLoc a)- }------------------------------------------------------------------------------------- Integration contract (EP-4): a self-contained payload ADT + codec-----------------------------------------------------------------------------------{- | Emit the deterministic, symbol-free contract layer: a payload ADT-(per-event records), the topic constants, the @messageType@ discriminator, and a-strict encode\/decode keyed by it. Self-contained (base\/text\/aeson), so it-compiles standalone — the cross-service schema both producer and consumer agree-on. No keiki symbolic operator (firewall holds).--}-scaffoldContract :: Context -> ContractNode -> [ScaffoldModule]-scaffoldContract ctx c =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Contract.hs")- , moduleText = emitContractGen genPrefix c- , kind = Generated- , origin = nodeOrigin "contract" (ctrName c) (ctrLoc c)- }- ]- where- genPrefix = genPrefixFor ctx (pascal (ctrName c))--emitContractGen :: Text -> ContractNode -> Text-emitContractGen genPrefix c =- nl $- [ "{-# LANGUAGE DuplicateRecordFields #-}"- , "{-# LANGUAGE OverloadedRecordDot #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- , "{-# OPTIONS_GHC -Wno-unused-top-binds #-}"- , generatedBanner- , "module " <> genPrefix <> ".Contract"- , " ( " <> payloadTy <> " (..)"- , nl [" , " <> ceName e <> "Data (..)" | e <- ctrEvents c]- , " , messageTypeOf"- , " , encode" <> payloadTy- , " , parse" <> payloadTy- , " ) where"- , ""- , "import Data.Aeson (Value, object, withObject, (.:), (.=))"- , "import Data.Aeson.Types (Parser, parseEither)"- , "import Data.Text (Text)"- , "import qualified Data.Text as T"- , ""- , "-- topic constants"- ]- ++ [lowerFirst alias <> "Topic :: Text\n" <> lowerFirst alias <> "Topic = " <> tshow t | (alias, t) <- ctrTopics c]- ++ [ ""- , "-- the closed payload set (discriminated by " <> tshow (ctrDiscriminator c) <> ")"- ]- ++ [emitPayloadAdt payloadTy (ctrEvents c)]- ++ [ ""- , "messageTypeOf :: " <> payloadTy <> " -> Text"- , "messageTypeOf = \\case"- ]- ++ [" " <> ceName e <> " {} -> " <> tshow (ceName e) | e <- ctrEvents c]- ++ [ ""- , "encode" <> payloadTy <> " :: " <> payloadTy <> " -> Value"- , "encode" <> payloadTy <> " = \\case"- ]- ++ concatMap encodeArm (ctrEvents c)- ++ [ ""- , "parse" <> payloadTy <> " :: Value -> Either Text " <> payloadTy- , "parse" <> payloadTy <> " = mapLeftText . parseEither (withObject " <> tshow payloadTy <> " go)"- , " where"- , " go o = do"- , " kind <- o .: " <> tshow (ctrDiscriminator c) <> " :: Parser Text"- , " case kind of"- ]- ++ concatMap decodeArm (ctrEvents c)- ++ [ " _ -> fail \"unknown message type\""- , ""- , "mapLeftText :: Either String b -> Either Text b"- , "mapLeftText = either (Left . T.pack) Right"- ]- where- payloadTy = pascal (ctrName c) <> "Payload"- encodeArm e =- [ " " <> ceName e <> " payload ->"- , " object"- ]- ++ [lead i kv | (i, kv) <- zip [(0 :: Int) ..] ((tshow (ctrDiscriminator c) <> " .= (" <> tshow (ceName e) <> " :: Text)") : [tshow (cfName f) <> " .= payload." <> cfName f | f <- ceFields e])]- ++ [" ]"]- lead 0 kv = " [ " <> kv- lead _ kv = " , " <> kv- decodeArm e =- [ " " <> tshow (ceName e) <> " ->"- , " " <> ceName e <> " <$> (" <> ceName e <> "Data" <> fieldApps (ceFields e) <> ")"- ]- fieldApps [] = ""- fieldApps fs = " <$> " <> T.intercalate " <*> " ["o .: " <> tshow (cfName f) | f <- fs]--emitPayloadAdt :: Text -> [ContractEvent] -> Text-emitPayloadAdt tyName events =- sectionsOf [map dataRecord events, [sumDecl]]- where- hsType CText = "Text"- hsType CInt = "Int"- hsType (CTypeId _) = "Text"- dataRecord e =- "data "- <> ceName e- <> "Data = "- <> ceName e- <> "Data { "- <> T.intercalate ", " [cfName f <> " :: !" <> hsType (cfType f) | f <- ceFields e]- <> " }\n deriving stock (Eq, Show)"- arm e = ceName e <> " !" <> ceName e <> "Data"- sumDecl = case events of- [] -> "data " <> tyName <> " = " <> tyName <> "Empty\n deriving stock (Eq, Show)"- (e : es) ->- nl $- ["data " <> tyName <> " = " <> arm e]- ++ [" | " <> arm e2 | e2 <- es]- ++ [" deriving stock (Eq, Show)"]------------------------------------------------------------------------------------- Integration intake (EP-4): inbox disposition vs the live Keiro.Inbox runtime-----------------------------------------------------------------------------------{- | Emit the inbox node's deterministic disposition wiring compiled against the-LIVE @Keiro.Inbox.Types@: the dedupe policy (a real 'InboxDedupePolicy') and a-disposition function over the real @InboxResult@ (Processed\/Duplicate\/-InProgress\/PreviouslyFailed). This pins the dangerous inversions-(duplicate ⇒ ackOk, previouslyFailed ⇒ deadLetter) as compiled code over the-runtime types. The handler-level decode\/dedupe\/store failures are noted but not-part of @InboxResult@. Firewall holds (no keiki symbolic operator).--}-scaffoldIntake :: Context -> IntakeNode -> [ScaffoldModule]-scaffoldIntake ctx i =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Inbox.hs")- , moduleText = emitIntakeGen genPrefix i- , kind = Generated- , origin = nodeOrigin "intake" (inkName i) (inkLoc i)- }- ]- where- genPrefix = genPrefixFor ctx (pascal (inkName i))--emitIntakeGen :: Text -> IntakeNode -> Text-emitIntakeGen genPrefix i =- nl- [ "{-# OPTIONS_GHC -Wno-unused-top-binds #-}"- , generatedBanner- , "module " <> genPrefix <> ".Inbox"- , " ( InboxAck (..)"- , " , inboxDedupePolicy"- , " , inboxPersistence"- , " , inboxDisposition"- , " ) where"- , ""- , "import Keiro.Inbox.Types (InboxDedupePolicy (..), InboxPersistence (..), InboxResult (..))"- , ""- , "-- The dedupe policy (hole-kind 4), lowered to the live InboxDedupePolicy."- , "inboxDedupePolicy :: InboxDedupePolicy"- , "inboxDedupePolicy = " <> inkDedupePolicy i- , ""- , "{- | Success-path envelope retention passed to runInboxTransactionWith."- , "Failures always retain their full operator-facing dead-letter envelope."- , "Dedupe-only success rows decode with an empty payload."- , "-}"- , "inboxPersistence :: InboxPersistence"- , "inboxPersistence = " <> persistenceCtor (inkPersist i)- , ""- , "-- The service's ack decision for each inbox classification."- , "data InboxAck = InboxAckOk | InboxRetry | InboxDeadLetter"- , " deriving stock (Eq, Show)"- , ""- , "-- The disposition table (hole-kind 2) over the LIVE Keiro.Inbox.Types.InboxResult."- , "-- duplicate => ackOk and previouslyFailed => deadLetter are the dangerous"- , "-- inversions the spec states explicitly."- , "inboxDisposition :: InboxResult a -> InboxAck"- , "inboxDisposition r = case r of"- , " InboxProcessed _ -> " <> ackFor "processed"- , " InboxDuplicate -> " <> ackFor "duplicate"- , " InboxInProgress -> " <> ackFor "inProgress"- , " InboxPreviouslyFailed _ -> " <> ackFor "previouslyFailed"- , ""- , "-- handler-level failures (not InboxResult): decodeFailed => "- <> ackText "decodeFailed"- <> ", dedupeFailed => "- <> ackText "dedupeFailed"- <> ", storeFailed => "- <> ackText "storeFailed"- ]- where- act o = lookup o [(drOutcome r, drAction r) | r <- inkDisposition i]- ackFor o = case act o of- Just IAckOk -> "InboxAckOk"- Just (IRetry _) -> "InboxRetry"- Just (IDeadLetter _) -> "InboxDeadLetter"- Nothing -> "InboxRetry"- ackText o = case act o of- Just IAckOk -> "ackOk"- Just (IRetry _) -> "retry"- Just (IDeadLetter _) -> "deadLetter"- Nothing -> "retry"- persistenceCtor InkPersistFull = "PersistFullEnvelope"- persistenceCtor InkPersistDedupeOnly = "PersistDedupeOnly"------------------------------------------------------------------------------------- Integration publisher (EP-4): config vs the live Keiro.Outbox runtime-----------------------------------------------------------------------------------{- | Emit the publisher's at-least-once policy compiled against the LIVE-@Keiro.Outbox.Types@: the ordering policy (a real 'OrderingPolicy'), the backoff-curve (a real 'BackoffSchedule'), and the max-attempts ceiling. Firewall holds.--}-scaffoldPublisher :: Context -> PublisherNode -> [ScaffoldModule]-scaffoldPublisher ctx pb =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Publisher.hs")- , moduleText = emitPublisherGen genPrefix pb- , kind = Generated- , origin = nodeOrigin "publisher" (pubName pb) (pubLoc pb)- }- ]- where- genPrefix = genPrefixFor ctx (pascal (pubName pb))--emitPublisherGen :: Text -> PublisherNode -> Text-emitPublisherGen genPrefix pb =- nl- [ "{-# OPTIONS_GHC -Wno-unused-top-binds #-}"- , generatedBanner- , "module " <> genPrefix <> ".Publisher"- , " ( publisherOrdering"- , " , publisherBackoff"- , " , publisherMaxAttempts"- , " ) where"- , ""- , "import Keiro.Outbox.Types (BackoffSchedule (..), ExponentialBackoffOptions (..), OrderingPolicy (..))"- , ""- , "publisherOrdering :: OrderingPolicy"- , "publisherOrdering = " <> pubOrdering pb- , ""- , "publisherBackoff :: BackoffSchedule"- , "publisherBackoff = " <> backoffExpr (pubBackoff pb)- , ""- , "publisherMaxAttempts :: Int"- , "publisherMaxAttempts = " <> tshow' (pubMaxAttempts pb)- ]- where- backoffExpr b = case boKind b of- "constant" -> "ConstantBackoff " <> windowText (boWindow b)- "exponential" ->- "ExponentialBackoff ExponentialBackoffOptions { initial = "- <> windowText (boWindow b)- <> ", maxDelay = "- <> maybe "0" windowText (boMax b)- <> ", multiplier = "- <> fromMaybe "0" (boMultiplier b)- <> " }"- _ -> "error \"keiro-dsl: unlowerable backoff kind\""------------------------------------------------------------------------------------- pgmq workqueue (EP-5): a self-contained Job payload record + codec-----------------------------------------------------------------------------------{- | Emit the deterministic, symbol-free pgmq layer: the Job payload record, the-field→wire-name JSON codec, and the captured physical\/dlq\/table name constants.-Self-contained (base\/text\/aeson). The fan-out body and the raw-SQL dedup-predicate are holes (not emitted). Firewall holds.--}-scaffoldWorkqueue :: Context -> WorkqueueNode -> [ScaffoldModule]-scaffoldWorkqueue ctx w =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Queue.hs")- , moduleText = emitWorkqueueGen genPrefix w- , kind = Generated- , origin = nodeOrigin "workqueue" (wqName w) (wqLoc w)- }- , ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/QueuePolicy.hs")- , moduleText = emitQueuePolicy genPrefix w- , kind = Generated- , origin = nodeOrigin "workqueue" (wqName w) (wqLoc w)- }- , ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/QueueCodec.hs")- , moduleText = emitQueueCodec genPrefix w- , kind = Generated- , origin = nodeOrigin "workqueue" (wqName w) (wqLoc w)- }- ]- where- genPrefix = genPrefixFor ctx (pascal (wqName w))--emitWorkqueueGen :: Text -> WorkqueueNode -> Text-emitWorkqueueGen genPrefix w =- nl $- [ "{-# LANGUAGE OverloadedRecordDot #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- , "{-# OPTIONS_GHC -Wno-unused-top-binds #-}"- , generatedBanner- , "module " <> genPrefix <> ".Queue"- , " ( " <> payloadTy <> " (..)"- , " , encode" <> payloadTy- , " , parse" <> payloadTy- , " , queuePhysical, queueDlq, queueTable"- , groupKeyExport- , " ) where"- , ""- , "import Data.Aeson (Value, object, withObject, (.:), (.=))"- , "import Data.Aeson.Types (parseEither)"- , "import Data.Text (Text)"- , "import qualified Data.Text as T"- , ""- , "queuePhysical, queueDlq, queueTable :: Text"- , "queuePhysical = " <> tshow (wqPhysical w)- , "queueDlq = " <> tshow (wqDlq w)- , "queueTable = " <> tshow (wqTable w)- , ""- ]- ++ groupKeyLines- ++ [ "data " <> payloadTy <> " = " <> payloadTy- , " { " <> T.intercalate "\n , " [wqfName f <> " :: !" <> hsType (wqfType f) | f <- wqPayload w]- , " }"- , " deriving stock (Eq, Show)"- , ""- , "encode" <> payloadTy <> " :: " <> payloadTy <> " -> Value"- , "encode" <> payloadTy <> " p ="- , " object"- ]- ++ [lead i (tshow (wqfWire f) <> " .= p." <> wqfName f) | (i, f) <- zip [(0 :: Int) ..] (wqPayload w)]- ++ [ " ]"- , ""- , "parse" <> payloadTy <> " :: Value -> Either Text " <> payloadTy- , "parse" <> payloadTy <> " = mapLeftText . parseEither (withObject " <> tshow payloadTy <> " go)"- , " where"- , " go o = " <> payloadTy <> fieldApps (wqPayload w)- , ""- , "mapLeftText :: Either String b -> Either Text b"- , "mapLeftText = either (Left . T.pack) Right"- ]- where- payloadTy = wqPayloadName w- groupKeyExport = case wqGroupKey w of- Nothing -> ""- Just groupKey- | gkVia groupKey == "raw" -> " , groupKeyField, groupKeyFor"- | otherwise -> " , groupKeyField"- groupKeyLines = case wqGroupKey w of- Nothing -> []- Just groupKey -> common <> derivationLines groupKey- where- common =- [ "groupKeyField :: Text"- , "groupKeyField = " <> tshow (gkField groupKey)- , ""- ]- derivationLines key- | gkVia key == "raw" =- [ "groupKeyFor :: " <> payloadTy <> " -> Text"- , "groupKeyFor payload = payload." <> gkField key- , ""- ]- | otherwise =- [ "-- Opaque group-key derivation '" <> gkVia key <> "' remains hand-owned."- , "-- Captured fixture: " <> fromMaybe "<missing>" (gkFixture key)- , ""- ]- hsType "bool" = "Bool"- hsType "int" = "Int"- hsType _ = "Text"- lead 0 kv = " [ " <> kv- lead _ kv = " , " <> kv- fieldApps [] = ""- fieldApps fs = " <$> " <> T.intercalate " <*> " ["o .: " <> tshow (wqfWire f) | f <- fs]--{- | Emit the versioned PGMQ envelope adapter. The payload record remains-symbol-free and dependency-light in Queue.hs; this runtime-facing module is-the opt-in assembly point applications import into their Job values.--}-emitQueueCodec :: Text -> WorkqueueNode -> Text-emitQueueCodec genPrefix w =- nl- [ "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "{- | Versioned job payload envelope: @{\\\"v\\\",\\\"t\\\",\\\"data\\\"}@."- , ""- , "Deploy workers before producers when raising its schema version. Do not"- , "adopt this codec on a non-empty bare-payload queue without draining it"- , "(or supplying a transitional codec), or in-flight messages will"- , "dead-letter. This is telemetry-neutral:"- , "docs/adr/0001-keiro-pgmq-job-processing-telemetry-contract.md owns"- , "spans and acknowledgement vocabulary."- , "-}"- , "module " <> genPrefix <> ".QueueCodec (" <> stem <> "PayloadCodec, " <> stem <> "JobCodec) where"- , ""- , "import Data.List.NonEmpty (NonEmpty (..))"- , "import Keiro.Codec (Codec (..), EventType (..))"- , "import Keiro.PGMQ.Codec (JobCodec, keiroJobCodec)"- , "import " <> genPrefix <> ".Queue (" <> payloadTy <> ", encode" <> payloadTy <> ", parse" <> payloadTy <> ")"- , ""- , stem <> "PayloadCodec :: Codec " <> payloadTy- , stem <> "PayloadCodec ="- , " Codec"- , " { eventTypes = EventType " <> tshow payloadTy <> " :| []"- , " , eventType = \\_ -> EventType " <> tshow payloadTy- , " , schemaVersion = 1"- , " , encode = encode" <> payloadTy- , " , decode = \\_ -> parse" <> payloadTy- , " , upcasters = []"- , " }"- , ""- , stem <> "JobCodec :: JobCodec " <> payloadTy- , stem <> "JobCodec = keiroJobCodec " <> stem <> "PayloadCodec"- ]- where- payloadTy = wqPayloadName w- stem = lowerFirst (T.concat (map pascal (T.splitOn "_" (wqName w))))--{- | Emit the pgmq retry policy + JobOutcome disposition compiled against the-LIVE @Keiro.PGMQ.Job@ runtime (RetryPolicy / JobOutcome / RetryDelay). This pins-the dangerous inversions over the runtime types: storeFailure ⇒ Retry (transient)-and decodeFailure ⇒ Dead (poison).--}-emitQueuePolicy :: Text -> WorkqueueNode -> Text-emitQueuePolicy genPrefix w =- nl $- [ "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> genPrefix <> ".QueuePolicy"- , " ( retryPolicy, jobOutcomeFor"- , " , jobOrdering, jobTuningFor, queueProvision"- , " ) where"- , ""- , "import Data.Text (Text)"- , "import Keiro.PGMQ.Job (JobOrdering (..), JobOutcome (..), JobTuning, PartitionSpec (..), QueueProvision, RetryDelay (..), RetryPolicy (..), partitionedProvision, standardProvision, unloggedProvision, withFifoIndexProvision, withOrdering)"- , ""- , "jobOrdering :: JobOrdering"- , "jobOrdering = " <> orderingCtor- , ""- , "-- Deployment owns visibility timeout, batch size, and polling; the spec owns ordering."- , "jobTuningFor :: JobTuning -> JobTuning"- , "jobTuningFor = withOrdering jobOrdering"- , ""- , "-- Pass this to ensureJobQueueWith at worker startup. FIFO adds the required GIN index; the DLQ remains standard."- , "queueProvision :: QueueProvision"- , "queueProvision = " <> provisionExpr- , ""- , "retryPolicy :: RetryPolicy"- , "retryPolicy ="- , " RetryPolicy"- , " { maxRetries = " <> tshow' (wqMaxRetries w)- , " , defaultRetryDelay = RetryDelay " <> windowText (wqDelay w)- , " , useDeadLetter = " <> (if wqDlqOn w then "True" else "False")- , " }"- , ""- , "-- The consumer JobOutcome disposition over the spec's named domain outcomes,"- , "-- lowered to the live Keiro.PGMQ.Job.JobOutcome."- , "jobOutcomeFor :: Text -> JobOutcome"- , "jobOutcomeFor o = case o of"- ]- ++ [" " <> tshow (wqdOutcome r) <> " -> " <> outcome (wqdAction r) | r <- wqDisposition w]- ++ [" _ -> Retry (RetryDelay " <> windowText (wqDelay w) <> ")"]- where- orderingCtor = case wqOrdering w of- WqUnordered -> "Unordered"- WqFifoThroughput -> "FifoThroughput"- WqFifoRoundRobin -> "FifoRoundRobin"- provisionExpr = fifoWrap baseProvision- fifoWrap expression = case wqOrdering w of- WqUnordered -> expression- _ -> "withFifoIndexProvision (" <> expression <> ")"- baseProvision = case wqProvision w of- WqStandard -> "standardProvision"- WqUnlogged -> "unloggedProvision"- WqPartitioned interval retention ->- "partitionedProvision (PartitionSpec { partitionInterval = "- <> tshow interval- <> ", retentionInterval = "- <> tshow retention- <> " })"- outcome IAckOk = "Done"- outcome (IRetry win) = "Retry (RetryDelay " <> windowText win <> ")"- outcome (IDeadLetter mr) = "Dead " <> tshow (fromMaybe "dead-lettered" mr)------------------------------------------------------------------------------------- First-class read models (EP-107)-----------------------------------------------------------------------------------{- | Emit an acyclic three-module read-model vertical. @ReadModelTable@ owns the-qualified-table constant shared by the hand-owned query and the generated-runtime record; @ReadModel@ re-exports it as part of the public surface.--}-scaffoldReadModel :: Context -> ReadModelNode -> [ScaffoldModule]-scaffoldReadModel ctx readModel =- [ generated "ReadModelTable" (emitReadModelTable tableModule stem readModel)- , generated "ReadModel" (emitReadModelGen ctx readModelModule tableModule readModelHolePrefix stem readModel)- , ScaffoldModule- { modulePath = modulePathFor readModelHolePrefix "ReadModelHoles"- , moduleText = emitReadModelHoles tableModule readModelHolePrefix stem readModel- , kind = HoleStub- , origin = readModelOrigin- }- ]- where- nodeSegment = pascal (rmName readModel)- stem = readModelStem readModel- readModelModule = genPrefixFor ctx nodeSegment- tableModule = readModelModule <> ".ReadModelTable"- readModelHolePrefix = holePrefixFor ctx nodeSegment- readModelOrigin = nodeOrigin "readmodel" (rmName readModel) (rmLoc readModel)- generated leaf body =- ScaffoldModule- { modulePath = modulePathFor readModelModule leaf- , moduleText = body- , kind = Generated- , origin = readModelOrigin- }--modulePathFor :: Text -> Text -> FilePath-modulePathFor prefix leaf = T.unpack (T.replace "." "/" prefix <> "/" <> leaf <> ".hs")--readModelStem :: ReadModelNode -> Text-readModelStem = lowerFirst . T.concat . map pascal . T.splitOn "_" . rmName--emitReadModelTable :: Text -> Text -> ReadModelNode -> Text-emitReadModelTable tableModule stem readModel =- nl- [ "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> tableModule <> " (" <> qualifiedName <> ") where"- , ""- , "import Data.Text (Text)"- , "import Keiro.Connection (qualifyTable)"- , ""- , "-- The fully-qualified, double-quoted data-table reference."- , qualifiedName <> " :: Text"- , qualifiedName <> " = qualifyTable " <> tshow (rmSchema readModel) <> " " <> tshow (rmTable readModel)- ]- where- qualifiedName = stem <> "QualifiedTable"--emitReadModelGen :: Context -> Text -> Text -> Text -> Text -> ReadModelNode -> Text-emitReadModelGen ctx readModelModule tableModule readModelHolePrefix stem readModel =- nl $- [ "{-# LANGUAGE OverloadedRecordDot #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> readModelModule <> ".ReadModel"- , " ( " <> T.intercalate "\n , " exports- , " ) where"- , ""- , "import Data.Functor (void)"- , "import Effectful (Eff, (:>))"- , "import " <> tableModule <> " (" <> qualifiedName <> ")"- , "import " <> readModelHolePrefix <> ".ReadModelHoles (" <> T.intercalate ", " holeImports <> ")"- ]- ++ asyncImports- ++ [ "import Keiro.ReadModel (ConsistencyMode (..), ReadModel (..), ReadModelMetadata, StrongScope (..), registerReadModel)"- , "import Keiro.ReadModel.Rebuild qualified as Rebuild"- , "import Kiroku.Store.Effect (Store)"- , "import Kiroku.Store.Types (" <> kirokuTypes <> ")"- , ""- , readModelName <> " :: ReadModel " <> queryInputType <> " " <> queryResultType- , readModelName <> " ="- , " ReadModel"- , " { name = " <> tshow registryName- , " , tableName = " <> tshow (rmTable readModel)- , " , schema = " <> tshow (rmSchema readModel)- , " , subscriptionName = " <> tshow subscriptionName- , " , version = " <> tshow' (rmVersion readModel)- , " , shapeHash = " <> tshow (rmShape readModel)- , " , defaultConsistency = " <> consistencyExpr (rmConsistency readModel)- , " , strongScope = " <> scopeExpr (rmScope readModel)- , " , query = " <> queryName- , " }"- , ""- , "-- Call once at projection startup before serving queries."- , registerName <> " :: (Store :> es) => Eff es ()"- , registerName <> " ="- , " void (registerReadModel " <> tshow registryName <> " " <> tshow' (rmVersion readModel) <> " " <> tshow (rmShape readModel) <> ")"- , ""- , startName <> " :: (Store :> es) => GlobalPosition -> Eff es ReadModelMetadata"- , startName <> " ="- , " Rebuild.startRebuild " <> readModelName <> " " <> projectionNames- , ""- , finishName <> " :: (Store :> es) => GlobalPosition -> Eff es (Either Rebuild.RebuildError ReadModelMetadata)"- , finishName <> " ="- , " Rebuild.finishRebuild " <> readModelName <> " " <> projectionNames- , ""- , abandonName <> " :: (Store :> es) => Eff es ReadModelMetadata"- , abandonName <> " = Rebuild.abandonRebuild " <> readModelName- ]- ++ asyncDefinition- where- registryName = registryNameFor (contextName ctx) readModel- subscriptionName = subscriptionNameFor (contextName ctx) readModel- asyncName = registryName <> "-async"- readModelName = stem <> "ReadModel"- qualifiedName = stem <> "QualifiedTable"- registerName = "register" <> pascal stem- startName = "start" <> pascal stem <> "Rebuild"- finishName = "finish" <> pascal stem <> "Rebuild"- abandonName = "abandon" <> pascal stem <> "Rebuild"- asyncValueName = stem <> "AsyncProjection"- queryInputType = pascal stem <> "QueryInput"- queryResultType = pascal stem <> "QueryResult"- queryName = stem <> "Query"- applyName = "apply" <> pascal stem- exports =- [ readModelName- , qualifiedName- , registerName- , startName- , finishName- , abandonName- ]- ++ [asyncValueName | rmFeed readModel == RmSubscription]- holeImports = [queryInputType, queryResultType, queryName] ++ [applyName | rmFeed readModel == RmSubscription]- asyncImports = case rmFeed readModel of- RmInline -> []- RmSubscription -> ["import Keiro.Projection (AsyncProjection (..))"]- kirokuTypes = case rmFeed readModel of- RmInline -> "GlobalPosition"- RmSubscription -> "GlobalPosition, RecordedEvent (..)"- projectionNames = case rmFeed readModel of- RmInline -> "[]"- RmSubscription -> "[" <> tshow asyncName <> "]"- asyncDefinition = case rmFeed readModel of- RmInline -> []- RmSubscription ->- [ ""- , asyncValueName <> " :: AsyncProjection"- , asyncValueName <> " ="- , " AsyncProjection"- , " { name = " <> tshow asyncName- , " , readModelName = " <> tshow registryName- , " , subscriptionName = " <> tshow subscriptionName- , " , applyRecorded = " <> applyName- , " , idempotencyKey = \\recorded -> recorded.eventId"- , " }"- ]- consistencyExpr Strong = "Strong"- consistencyExpr Eventual = "Eventual"- scopeExpr Nothing = "EntireLog"- scopeExpr (Just RmEntireLog) = "EntireLog"- scopeExpr (Just (RmCategory categoryName)) = "CategoryHead " <> tshow categoryName--emitReadModelHoles :: Text -> Text -> Text -> ReadModelNode -> Text-emitReadModelHoles tableModule readModelHolePrefix stem readModel =- nl $- [ "-- This is a HAND-OWNED hole module. keiro-dsl creates it once and never overwrites it."- , "module " <> readModelHolePrefix <> ".ReadModelHoles"- , " ( " <> T.intercalate "\n , " exports- , " ) where"- , ""- , "import " <> tableModule <> " (" <> qualifiedName <> ")"- , "import Hasql.Transaction qualified as Tx"- ]- ++ ["import Kiroku.Store.Types (RecordedEvent(..))" | rmFeed readModel == RmSubscription]- ++ [ ""- , "-- HOLE: replace these aliases with the real query input and result types."- , "type " <> queryInputType <> " = ()"- , "type " <> queryResultType <> " = ()"- , ""- , "-- HOLE: query " <> qualifiedTableLiteral readModel <> " via " <> qualifiedName <> "; never rely on search_path."- , "-- Declared columns:"- ]- ++ map (("-- " <>) . readModelColumnDoc) (rmColumns readModel)- ++ [ queryName <> " :: " <> queryInputType <> " -> Tx.Transaction " <> queryResultType- , queryName <> " _input = " <> qualifiedName <> " `seq` error " <> tshow ("HOLE: fill " <> rmName readModel <> " query")- ]- ++ applyStub- where- qualifiedName = stem <> "QualifiedTable"- queryInputType = pascal stem <> "QueryInput"- queryResultType = pascal stem <> "QueryResult"- queryName = stem <> "Query"- applyName = "apply" <> pascal stem- exports = [queryInputType, queryResultType, queryName] ++ [applyName | rmFeed readModel == RmSubscription]- applyStub = case rmFeed readModel of- RmInline -> []- RmSubscription ->- [ ""- , "-- HOLE: apply one recorded event; runtime deduplication makes redelivery safe."- , applyName <> " :: RecordedEvent -> Tx.Transaction ()"- , applyName <> " _recorded = error " <> tshow ("HOLE: fill " <> rmName readModel <> " async apply")- ]--qualifiedTableLiteral :: ReadModelNode -> Text-qualifiedTableLiteral readModel = quoteSqlIdentifier (rmSchema readModel) <> "." <> quoteSqlIdentifier (rmTable readModel)--quoteSqlIdentifier :: Text -> Text-quoteSqlIdentifier identifier = "\"" <> T.replace "\"" "\"\"" identifier <> "\""--readModelColumnDoc :: RmColumn -> Text-readModelColumnDoc columnDecl =- rmcName columnDecl- <> " "- <> rmcType columnDecl- <> if rmcRequired columnDecl then " NOT NULL" else ""------------------------------------------------------------------------------------- Router + shared worker-policy lowering (EP-108)-----------------------------------------------------------------------------------scaffoldRouter :: Context -> RouterNode -> [ScaffoldModule]-scaffoldRouter ctx router =- [ ScaffoldModule- { modulePath = modulePathFor genPrefix "Router"- , moduleText = emitRouterGen genPrefix router- , kind = Generated- , origin = routerOrigin- }- , ScaffoldModule- { modulePath = modulePathFor holePrefix "RouterHoles"- , moduleText = emitRouterHoles holePrefix router- , kind = HoleStub- , origin = routerOrigin- }- ]- where- genPrefix = genPrefixFor ctx (rtId router)- holePrefix = holePrefixFor ctx (rtId router)- routerOrigin = nodeOrigin "router" (rtId router) (rtLoc router)--emitRouterGen :: Text -> RouterNode -> Text-emitRouterGen genPrefix router =- nl $- [ "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> genPrefix <> ".Router"- , " ( " <> stem <> "Name"- , " , " <> stem <> "WorkerOptions"- , " ) where"- , ""- , "import Data.Text (Text)"- ]- ++ workerPolicyImports (rtPoison router)- ++ [ ""- , "-- The STABLE router name. It participates in every target-keyed"- , "-- deterministicRouterCommandId; renaming it re-keys replayed dispatches."- , stem <> "Name :: Text"- , stem <> "Name = " <> tshow (rtName router)- , ""- , "-- Runtime-owned dispatch id inputs: (name, key, sourceEventId,"- , "-- targetStreamName, occurrence). Target-keyed, not positional."- , ""- , "-- Node-level worker policy lowered from the spec. Pass this value to"- , "-- Keiro.Router.runRouterWorkerWith; do not silently use defaultWorkerOptions."- ]- ++ workerOptionsLines (stem <> "WorkerOptions") (rtRejected router) (rtPoison router)- where- stem = lowerFirst (rtId router)--emitRouterHoles :: Text -> RouterNode -> Text-emitRouterHoles holePrefix router =- nl- [ "-- HAND-OWNED hole module for the router's behaviour-bearing bodies."- , "-- keiro-dsl creates it once and never overwrites it."- , "module " <> holePrefix <> ".RouterHoles () where"- , ""- , "-- HOLE resolve :: " <> inName (rtInput router) <> " -> Eff es [PMCommand targetCommand]"- , "-- Spec source: " <> resolveSourceText (rvSource (rtResolve router)) <> "."- , "-- The spec's 'stable' keyword acknowledges that retry attempts accumulate"- , "-- the UNION of resolved target identities. Keep the recipient set stable"- , "-- for a source event whenever an exact recipient set matters."- , "-- HOLE router value: assemble Keiro.Router.Router with name = " <> lowerFirst (rtId router) <> "Name,"- , "-- key, resolve, targetEventStream, and targetProjections; run it with"- , "-- runRouterWorkerWith " <> lowerFirst (rtId router) <> "WorkerOptions."- , "-- HOLE targetProjections: spec projections = " <> renderNames (rtProjections router) <> "."- , "-- NOTE on-duplicate AckOk is sound because Keiro.Router confirms a duplicate"- , "-- event id against the TARGET stream via confirmBenignDuplicate before"- , "-- returning PMCommandDuplicate. Hand-rolled dispatch paths must do likewise."- ]- where- renderNames names = "[" <> T.intercalate ", " names <> "]"--resolveSourceText :: ResolveSource -> Text-resolveSourceText (ResolveReadModel name) = "read-model " <> name <> " (typically Keiro.ReadModel.runQuery)"-resolveSourceText ResolveHole = "typed resolver hole"--workerPolicyImports :: PolicyChoice -> [Text]-workerPolicyImports poison =- [ "import Keiro.ProcessManager (PoisonPolicy (..), RejectedCommandPolicy (..), WorkerOptions (..))"- , "import Shibuya.Core.Ack (RetryDelay (..))"- ]- ++ if poison == PolHalt- then []- else ["import Effectful (Eff)", "import Shibuya.Core.Types (Envelope)"]--workerOptionsLines :: Text -> PolicyChoice -> PolicyChoice -> [Text]-workerOptionsLines valueName rejected poison =- [ valueName <> signature- , valueName <> argument <> " ="- , " WorkerOptions"- , " { poisonPolicy = " <> poisonExpr- , " , rejectedCommandPolicy = " <> rejectedExpr rejected- , " , transientRetryDelay = RetryDelay 5 -- matches defaultWorkerOptions; runtime tuning"- , " , metrics = Nothing -- runtime configuration; install at call site"- , " }"- ]- where- signature = case poison of- PolHalt -> " :: WorkerOptions es msg"- _ -> " :: (Envelope msg -> Eff es ()) -> WorkerOptions es msg"- argument = case poison of- PolHalt -> ""- _ -> " poisonCallback"- poisonExpr = case poison of- PolHalt -> "PoisonHalt"- PolDeadLetter -> "PoisonDeadLetter poisonCallback"- PolSkip -> "PoisonSkip poisonCallback"- rejectedExpr = \case- PolHalt -> "RejectedHalt"- PolDeadLetter -> "RejectedDeadLetter"- PolSkip -> "RejectedSkip"------------------------------------------------------------------------------------- Process manager + durable timer (EP-3)-----------------------------------------------------------------------------------{- | Emit the symbol-free deterministic wiring for a process manager + its timer-into a @Generated@ module, plus a create-if-absent @ProcessHoles@ module for the-behaviour-bearing bodies (the @handle@ reaction, the deadline window, and the-fire command). The @Generated@ module contains no keiki symbolic operator (the-saga's transducer is the separate aggregate hole), so the firewall invariant-holds. The timer worker uses the spec's @max-attempts@ ceiling, never the-dangerous @defaultTimerWorkerOptions@ (@Nothing@) default.--}-scaffoldProcess :: Context -> ProcessNode -> [ScaffoldModule]-scaffoldProcess ctx p =- [ ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Process.hs")- , moduleText = emitProcessGen ctxPascal genPrefix holePrefix p- , kind = Generated- , origin = nodeOrigin "process" (procId p) (procLoc p)- }- , ScaffoldModule- { modulePath = T.unpack (T.replace "." "/" holePrefix <> "/ProcessHoles.hs")- , moduleText = emitProcessHoles genPrefix holePrefix p- , kind = HoleStub- , origin = nodeOrigin "process" (procId p) (procLoc p)- }- ]- where- ctxPascal = pascalFromKebab (contextName ctx)- genPrefix = genPrefixFor ctx (procId p)- holePrefix = holePrefixFor ctx (procId p)--emitProcessGen :: Text -> Text -> Text -> ProcessNode -> Text-emitProcessGen _ctxPascal genPrefix _holePrefix p =- nl $- [ "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> genPrefix <> ".Process"- , " ( " <> lo <> "ProcessName"- , " , " <> lo <> "Category"- , " , " <> lo <> "ProcessWorkerOptions"- , " , " <> lo <> "TimerRequest"- , " , " <> lo <> "FireOutcome"- , " ) where"- , ""- , "import Data.Aeson (Value, object, (.=))"- , "import Data.Text (Text)"- , "import qualified Data.Text as T"- , "import Data.Time (UTCTime)"- , "import Data.UUID (UUID)"- , "import qualified Data.UUID.V5 as UUID.V5"- , "import Keiro.Command (CommandError (..))"- , "import Keiro.Stream qualified as Stream"- , "import Keiro.Timer (TimerId (..), TimerRequest (..))"- ]- ++ workerPolicyImports (procPoison p)- ++ [ ""- , "-- The define-once ProcessManager name (hole-kind 5: referenced, never retyped)."- , lo <> "ProcessName :: Text"- , lo <> "ProcessName = " <> tshow (procName p)- , ""- , "-- The validated saga stream category (hole-kind 5: referenced, never retyped)."- , "-- Saga streams are '<category>-<correlationId>' via Keiro.Stream.entityStream."- , "-- categoryUnsafe is safe here because keiro-dsl check proved the literal legal."- , lo <> "Category :: Stream.StreamCategory a"- , lo <> "Category = Stream.categoryUnsafe " <> tshow categoryName- , ""- , "-- Node-level worker policy lowered from the spec. Pass this value to"- , "-- Keiro.ProcessManager.runProcessManagerWorkerWith."- ]- ++ workerOptionsLines (lo <> "ProcessWorkerOptions") (procRejected p) (procPoison p)- ++ [ ""- , "-- The deterministic timer-request builder: id derived from the correlation"- , "-- key (hole-kind 1), processManagerName referenced, payload from the spec."- , "-- (timer id derived as uuidv5 of " <> tshow (idePrefix (tmId timer)) <> " <> correlationId)"- , lo <> "TimerRequest :: Text -> UTCTime -> TimerRequest"- , lo <> "TimerRequest correlationId fireAtTime ="- , " TimerRequest"- , " { timerId = TimerId (namedUuid (" <> tshow (idePrefix (tmId timer)) <> " <> correlationId))"- , " , processManagerName = " <> lo <> "ProcessName"- , " , correlationId = correlationId"- , " , fireAt = fireAtTime"- , " , payload = " <> payloadExpr (tmPayload timer)- , " }"- , ""- , "-- The timer-fire disposition table (hole-kind 2), derived from the spec."- , "-- on-reject => " <> showOutcome (onReject fd) <> " is the benign inversion."- , "-- A duplicate append reaches on-error unless it is confirmed against the"- , "-- target stream. Use Keiro.ProcessManager.confirmBenignDuplicate:"- , "-- StreamName -> EventId -> CommandError -> Eff es Bool"- , "-- Fold True into the duplicate result and surface False as the failure."- , lo <> "FireOutcome :: Either CommandError a -> Maybe ()"- , lo <> "FireOutcome result = case result of"- , " Right{} -> " <> outcomeToMaybe (onOk fd)- , " Left CommandRejected -> " <> outcomeToMaybe (onReject fd)- , " Left (CommandAmbiguous _) -> " <> outcomeToMaybe (onAmbiguous fd) <> " -- explicit definition-bug arm"- , " Left{} -> " <> outcomeToMaybe (onError fd)- , ""- , "-- max-attempts = " <> tshow' (tmMaxAttempts timer) <> ", dead-letter = " <> tshow (tmDeadLetter timer)- , "-- (the timer worker must pass Just " <> tshow' (tmMaxAttempts timer) <> " to runTimerWorkerWith, never the"- , "-- defaultTimerWorkerOptions Nothing ceiling that retries forever)."- , ""- , "-- deterministic v5 UUID of a correlation-keyed string (hole-kind 1)."- , "namedUuid :: Text -> UUID"- , "namedUuid v = UUID.V5.generateNamed UUID.V5.namespaceURL (map (fromIntegral . fromEnum) (T.unpack v))"- ]- where- lo = lowerFirst (procId p)- categoryName = staticCategory ("process " <> procId p) (sagaCategory (procSaga p))- timer = procTimer p- fd = fireDisposition (tmFire timer)--{- | The timer payload, restricted to the spec's literal (@name=\"value\"@)-bindings so it compiles in the deterministic builder. Bare fields and-ref-valued bindings are input-driven (the agent-written hole), not emitted.--}-payloadExpr :: [FieldBinding] -> Text-payloadExpr fs = case [b | b <- fs, isLiteral b] of- [] -> "object []"- lits -> "object [ " <> T.intercalate ", " (map kv lits) <> " ]"- where- isLiteral b = maybe False (const True) (fbValue b >>= stripWrappingQuotes)- kv b = tshow (fbName b) <> " .= (" <> maybe "\"\"" tshow (fbValue b >>= stripWrappingQuotes) <> " :: Value)"- stripWrappingQuotes value = T.stripPrefix "\"" value >>= T.stripSuffix "\""--showOutcome :: FireOutcome -> Text-showOutcome OFired = "Fired"-showOutcome ORetry = "Retry"--outcomeToMaybe :: FireOutcome -> Text-outcomeToMaybe OFired = "Just () -- Fired"-outcomeToMaybe ORetry = "Nothing -- Retry"--emitProcessHoles :: Text -> Text -> ProcessNode -> Text-emitProcessHoles _genPrefix holePrefix p =- nl- [ "-- HAND-OWNED hole module for the process manager's behaviour-bearing bodies."- , "-- keiro-dsl creates it once and never overwrites it."- , "module " <> holePrefix <> ".ProcessHoles () where"- , ""- , "-- HOLE handle: build the ProcessManagerAction (the self-advance"- , "-- '" <> advCommand (hAdvance (procHandle p)) <> "', the dispatch(es), and the timer) from the input."- , "-- HOLE streams: build streamFor with entityStream " <> lowerFirst (procId p) <> "Category;"- , "-- build target streams with entityStream " <> lowerFirst (procTarget p) <> "Category. Never concatenate raw stream names."- , "-- HOLE window: the deadline policy, e.g. surgeWindow :: NominalDiffTime;"- , "-- surgeDeadline observedAt = addUTCTime surgeWindow observedAt (TIME INJECTED)."- , "-- HOLE fire command: construct " <> fireCommand (tmFire (procTimer p)) <> " for the timer fire,"- , "-- keyed by correlationId; the fired-event-id is the deterministic uuidv5 of"- , "-- " <> tshow (idePrefix (fireFiredEventId (tmFire (procTimer p)))) <> " <> correlationId."- , "-- NOTE on-duplicate AckOk is sound because the runtime confirms a duplicate"- , "-- event id against the TARGET stream via confirmBenignDuplicate before"- , "-- returning PMCommandDuplicate. Its effective signature is:"- , "-- StreamName -> EventId -> CommandError -> Eff es Bool"- , "-- Hand-rolled paths must call it with the target stream and attempted event id,"- , "-- fold True into the duplicate result, and surface False as the original failure."- , "-- Never pattern-match DuplicateEvent as success: event ids are globally unique."- ]------------------------------------------------------------------------------------- Domain module-----------------------------------------------------------------------------------emitDomain :: Agg -> Text-emitDomain a =- nl $- [ "{-# LANGUAGE DataKinds #-}"- , "{-# LANGUAGE DuplicateRecordFields #-}"- ]- ++ ["{-# LANGUAGE DeriveAnyClass #-}" | hasSnapshot a]- ++ [ "{-# LANGUAGE OverloadedStrings #-}"- , "{-# LANGUAGE TemplateHaskell #-}"- , "{-# LANGUAGE TypeApplications #-}"- , "{-# OPTIONS_GHC -Wno-unused-top-binds #-}"- , generatedBanner- , "module " <> aGenPrefix a <> ".Domain where"- , ""- ]- ++ ["import Data.Aeson (FromJSON, ToJSON)" | hasSnapshot a]- ++ [ "import Data.Proxy (Proxy (..))"- , "import Data.Text (Text)"- , "import GHC.Generics (Generic)"- , "import Keiki.Core (RegFile (..))"- ]- ++ ["import Keiki.Shape (CanonicalStateShape, CanonicalTypeName)" | hasSnapshot a]- ++ map ("import " <>) (domainConsumerImports a)- ++ [ "import Keiki.Generics.TH (deriveAggregateCtorsAll, deriveWireCtorsAll)"- , ""- , sectionsOf- [ map (emitId a) (aIds a)- , map (emitEnum a) (aEnums a)- , [emitVertex a]- , map (emitRecord a) (aCommands a)- , [emitSum (aName a <> "Command") (aCommands a)]- , map (emitRecord a) (aEvents a)- , [emitSum (aName a <> "Event") (aEvents a)]- , [emitRegsType a, emitInitialRegs a]- ,- [ "$(deriveAggregateCtorsAll ''" <> aName a <> "Command ''" <> aName a <> "Regs)"- , ""- , "$(deriveWireCtorsAll ''" <> aName a <> "Event)"- ]- ]- ]--hasSnapshot :: Agg -> Bool-hasSnapshot = maybe False (const True) . aSnapshot--emitId :: Agg -> IdDecl -> Text-emitId a d =- nl $- [ "newtype " <> idName d <> " = " <> idName d <> " Text"- , " deriving stock (Generic, Eq, Ord, Show)"- ]- ++ [" deriving anyclass (ToJSON, FromJSON)" | hasSnapshot a]- ++ ["instance CanonicalTypeName " <> idName d | hasSnapshot a]- ++ [ ""- , lowerFirst (idName d) <> "Text :: " <> idName d <> " -> Text"- , lowerFirst (idName d) <> "Text (" <> idName d <> " t) = t"- ]--emitEnum :: Agg -> EnumDecl -> Text-emitEnum a d =- nl $- [ "data " <> enumName d <> " = " <> T.intercalate " | " (map fst (enumCtors d))- , " deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)"- ]- ++ [" deriving anyclass (ToJSON, FromJSON)" | hasSnapshot a]- ++ ["instance CanonicalTypeName " <> enumName d | hasSnapshot a]- ++ [ ""- , lowerFirst (enumName d) <> "Text :: " <> enumName d <> " -> Text"- , lowerFirst (enumName d) <> "Text = \\case"- , nl [" " <> c <> " -> " <> tshow w | (c, w) <- enumCtors d]- ]--emitVertex :: Agg -> Text-emitVertex a =- nl $- [ "data " <> aVertexType a <> " = " <> T.intercalate " | " (map (vertexCtor a . stName) (aStates a))- , " deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)"- ]- ++ [" deriving anyclass (ToJSON, FromJSON)" | hasSnapshot a]- ++ [ line- | hasSnapshot a- , line <-- [ "instance CanonicalStateShape " <> aVertexType a- , "instance CanonicalTypeName " <> aVertexType a- ]- ]--emitRecord :: Agg -> ResolvedCtor -> Text-emitRecord a rc =- nl $- [ "data " <> rcName rc <> "Data = " <> rcName rc <> "Data"- ]- ++ recordFields [(name, renderDomainType a fieldType) | (name, fieldType) <- rcFields rc]- ++ [" deriving stock (Generic, Eq, Show)"]--recordFields :: [(Text, Text)] -> [Text]-recordFields [] =- [" {"]- <> [" }"]-recordFields fs =- [ lead i <> n <> " :: !" <> ty- | (i, (n, ty)) <- zip [(0 :: Int) ..] fs- ]- ++ [" }"]- where- lead 0 = " { "- lead _ = " , "--emitSum :: Text -> [ResolvedCtor] -> Text-emitSum tyName ctors =- nl $- [firstLine] ++ restLines ++ [" deriving stock (Generic, Eq, Show)"]- where- arm rc = rc' rc- rc' rc = rcName rc <> " !" <> rcName rc <> "Data"- (firstLine, restLines) = case ctors of- [] -> ("data " <> tyName <> " = ()", [])- (c : cs) ->- ( "data " <> tyName <> " = " <> arm c- , [" | " <> arm c2 | c2 <- cs]- )--emitRegsType :: Agg -> Text-emitRegsType a =- nl $- ["type " <> aName a <> "Regs ="]- ++ regListLines a (aRegs a)--regListLines :: Agg -> [RegDecl] -> [Text]-regListLines _ [] = [" '[]"]-regListLines a rs =- [ lead i <> "'(" <> tshow (regName r) <> ", " <> renderDomainType a (regType r) <> ")"- | (i, r) <- zip [(0 :: Int) ..] rs- ]- ++ [" ]"]- where- lead 0 = " '[ "- lead _ = " , "--emitInitialRegs :: Agg -> Text-emitInitialRegs a =- nl $- [ "initial" <> aName a <> "Regs :: RegFile " <> aName a <> "Regs"- , "initial" <> aName a <> "Regs ="- ]- ++ chain (aRegs a)- where- chain [] = [" RNil"]- chain rs =- [ " RCons (Proxy @" <> tshow (regName r) <> ") " <> regInitialValue a r <> " $"- | r <- init rs- ]- ++ [" RCons (Proxy @" <> tshow (regName lastR) <> ") " <> regInitialValue a lastR <> " RNil"]- where- lastR = last rs---- | The Haskell initial value for a register, by the category of its type.-regInitialValue :: Agg -> RegDecl -> Text-regInitialValue a r- | Just declaration <- mappedDeclFor a (regType r) = case mappedInitial declaration of- Just initialValue -> unQualifiedValueName initialValue- Nothing -> "(error \"mapped register initial rejected before generation\")"- | regType r `elem` idNames = "(" <> regType r <> " \"\")"- | regType r == aVertexType a = maybe "(error \"invalid vertex initial\")" (vertexCtor a) (bareInitial r)- | regType r == "Text" = maybe "(error \"Text initial must be quoted\")" tshow (textInitial r)- | otherwise = maybe "(error \"invalid register initial\")" id (bareInitial r)- where- idNames = map idName (aIds a)- bareInitial reg = case regInitial reg of- RegInitBare value -> Just value- RegInitText _ -> Nothing- textInitial reg = case regInitial reg of- RegInitText value -> Just value- RegInitBare _ -> Nothing--domainConsumerImports :: Agg -> [Text]-domainConsumerImports a =- sort . nub $- [ hsModule (mappedHaskell declaration) <> " qualified"- | declaration <- mappedUses a- ]- <> [ qualifiedModule initialValue <> " qualified"- | declaration <- mappedUses a- , initialValue <- maybeToListText (mappedInitial declaration)- ]--mappedUses :: Agg -> [ResolvedMappedDecl]-mappedUses a =- [ declaration- | fieldType <-- map snd (concatMap rcFields (aCommands a <> aEvents a))- <> map regType (aRegs a)- , declaration <- maybeToListText (mappedDeclFor a fieldType)- ]--mappedDeclFor :: Agg -> Text -> Maybe ResolvedMappedDecl-mappedDeclFor a name = do- graph <- aTypeGraph a- Map.lookup (MappedKey name) (tgDeclarations graph)--mappedHaskell :: ResolvedMappedDecl -> HaskellSource-mappedHaskell (ResolvedStructural declaration _) = sdHaskell declaration-mappedHaskell (ResolvedOpaque declaration) = odHaskell declaration--mappedInitial :: ResolvedMappedDecl -> Maybe QualifiedValueName-mappedInitial (ResolvedStructural declaration _) = sdInitial declaration-mappedInitial (ResolvedOpaque declaration) = odInitial declaration--renderDomainType :: Agg -> Text -> Text-renderDomainType a fieldType =- maybe fieldType (renderHaskellSource . mappedHaskell) (mappedDeclFor a fieldType)--maybeToListText :: Maybe value -> [value]-maybeToListText = maybe [] pure------------------------------------------------------------------------------------- Codec module-----------------------------------------------------------------------------------emitCodec :: Agg -> Text-emitCodec a =- nl $- [ "{-# LANGUAGE OverloadedRecordDot #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> aGenPrefix a <> ".Codec ("- , " " <> lowerFirst (aName a) <> "Codec,"- , " parse" <> aName a <> "Event,"- , " encode" <> aName a <> "Event,"- ]- ++ concatMap mappedExports (codecMappedDeclarations a)- ++ [ ") where"- , ""- , "import " <> aGenPrefix a <> ".Domain"- ]- ++ ( if hasMappedCodec a- then- [ "import Control.Monad (unless)"- , "import Data.Aeson (Value (..), object, parseJSON, toJSON, withObject, withText, (.:), (.=))"- , "import Data.Aeson.Key qualified as Key"- , "import Data.Aeson.KeyMap qualified as KeyMap"- ]- else ["import Data.Aeson (Value, object, withObject, (.:), (.=))"]- )- ++ [ "import Data.Aeson.Types (Parser, parseEither)"- , "import Data.List.NonEmpty (NonEmpty (..))"- ]- ++ ( if hasMappedCodec a- then ["import Data.Map.Strict (Map)", "import Data.Map.Strict qualified as Map"]- else []- )- ++ [ "import Data.Text (Text)"- , "import qualified Data.Text as T"- ]- ++ ["import Keiro.Codec.Structural (bindingFromShape, bindingToShape)" | hasMappedCodec a]- ++ [ "import Keiro.Codec (Codec (..), EventType (..))"- , upcasterImport a- ]- ++ [nl (map ("import " <>) (codecMappedImports a)) | hasMappedCodec a]- ++ [ ""- , emitEnumParsers a- ]- ++ [emitMappedCodecs a | hasMappedCodec a]- ++ [ ""- , emitCodecValue a- , ""- , emitEncode a- , ""- , emitDecode a- , ""- , "mapLeftText :: Either String b -> Either Text b"- , "mapLeftText = either (Left . T.pack) Right"- ]- ++ ( if hasMappedCodec a- then- [ ""- , "rejectUnknownFields :: String -> [Text] -> KeyMap.KeyMap Value -> Parser ()"- , "rejectUnknownFields label allowed objectValue ="- , " unless (null extras) (fail (label <> \" contains unknown fields: \" <> show extras))"- , " where"- , " extras = filter (`notElem` allowed) (map Key.toText (KeyMap.keys objectValue))"- ]- else []- )- where- mappedExports (ResolvedStructural declaration _) =- [ " encode" <> sdName declaration <> "Mapped,"- , " decode" <> sdName declaration <> "Mapped,"- ]- mappedExports ResolvedOpaque{} = []--hasMappedCodec :: Agg -> Bool-hasMappedCodec = not . null . codecMappedDeclarations--emitEnumParsers :: Agg -> Text-emitEnumParsers a = sectionsOf [[emitEnumParser e | e <- aEnums a]]--emitEnumParser :: EnumDecl -> Text-emitEnumParser d =- nl $- [ "parse" <> enumName d <> " :: Text -> Parser " <> enumName d- , "parse" <> enumName d <> " = \\case"- ]- ++ [" " <> tshow w <> " -> pure " <> c | (c, w) <- enumCtors d]- ++ [" _ -> fail " <> tshow ("unknown " <> enumName d)]--emitCodecValue :: Agg -> Text-emitCodecValue a =- nl $- [ lowerFirst (aName a) <> "Codec :: Codec " <> aName a <> "Event"- , lowerFirst (aName a) <> "Codec ="- , " Codec"- , " { eventTypes = " <> eventTypesExpr- , " , eventType = \\case"- ]- ++ [" " <> rcName e <> "{} -> EventType " <> tshow (rcName e) | e <- aEvents a]- ++ [ " , schemaVersion = " <> tshow' (maxEventVersion a)- , " , encode = encode" <> aName a <> "Event"- , " , decode = parse" <> aName a <> "Event"- , " , upcasters = " <> upcastersExpr a- , " }"- ]- ++ upcasterRungDecls a- where- eventTypesExpr = case map rcName (aEvents a) of- [] -> "error \"no events\""- (e : es) -> "EventType " <> tshow e <> " :| [" <> T.intercalate ", " (map (("EventType " <>) . tshow) es) <> "]"---- | The codec's @schemaVersion@: the maximum declared event version (EP-2).-maxEventVersion :: Agg -> Int-maxEventVersion a = maximum (1 : map rcVersion (aEvents a))--{- | One @(sourceVersion, upcasterName)@ entry per event that declares an-@upcast from@. The upcaster name is per-event (e.g. @upcastFooV1@) and its-body is a hole in the hand-owned Holes module.--}-upcasterEntries :: Agg -> [(Int, Text, Text)]-upcasterEntries a =- [ (m, rcName e, "upcast" <> rcName e <> "V" <> tshow' m)- | e <- aEvents a- , Just m <- [rcUpcastFrom e]- ]--upcastersExpr :: Agg -> Text-upcastersExpr a =- "[" <> T.intercalate ", " ["(" <> tshow' m <> ", upcastRungV" <> tshow' m <> ")" | (m, _) <- upcasterRungs a] <> "]"--{- | Group event-specific holes into one migration rung per aggregate-global-source version. Event metadata stamps every kind with the aggregate's-schema version, so a rung must explicitly pass foreign event kinds through.--}-upcasterRungs :: Agg -> [(Int, [(Text, Text)])]-upcasterRungs a =- [ (source, [(eventName, fn) | (_, eventName, fn) <- entries])- | entries@((source, _, _) : _) <- groupBy sameSource (sortOn firstSource (upcasterEntries a))- ]- where- firstSource (source, _, _) = source- sameSource (source, _, _) (otherSource, _, _) = source == otherSource--upcasterRungDecls :: Agg -> [Text]-upcasterRungDecls a = concatMap rung (upcasterRungs a)- where- rung (source, entries) =- [ ""- , "upcastRungV" <> tshow' source <> " :: EventType -> Value -> Either Text Value"- ]- ++ [ "upcastRungV" <> tshow' source <> " (EventType " <> tshow eventName <> ") value = " <> fn <> " value"- | (eventName, fn) <- entries- ]- ++ [ "-- Kinds whose shape did not change at this rung pass through unchanged; their"- , "-- stamped version is aggregate-global, not their own shape history."- , "upcastRungV" <> tshow' source <> " _ value = Right value"- ]--{- | When the codec references upcasters, it imports their (hole) definitions-from the hand-owned Holes module.--}-upcasterImport :: Agg -> Text-upcasterImport a = case upcasterEntries a of- [] -> ""- es -> "import " <> aHolePrefix a <> ".Holes (" <> T.intercalate ", " [fn | (_, _, fn) <- es] <> ")"--emitEncode :: Agg -> Text-emitEncode a =- nl $- [ "encode" <> aName a <> "Event :: " <> aName a <> "Event -> Value"- , "encode" <> aName a <> "Event = \\case"- ]- ++ concatMap encodeArm (aEvents a)- where- encodeArm e =- [ " " <> rcName e <> " payload ->"- , " object"- ]- ++ [ lead i <> kv- | (i, kv) <- zip [(0 :: Int) ..] (("\"kind\" .= (" <> tshow (rcName e) <> " :: Text)") : map encodeField (rcFields e))- ]- ++ [" ]"]- lead 0 = " [ "- lead _ = " , "- encodeField (n, ty) =- tshow n- <> " .= "- <> case fieldCat a ty of- IdCat -> lowerFirst ty <> "Text payload." <> n- EnumCat -> lowerFirst ty <> "Text payload." <> n- MappedStructuralCat declaration _ -> "encode" <> sdName declaration <> "Mapped payload." <> n- MappedOpaqueCat{} -> "toJSON payload." <> n- _ -> "payload." <> n--emitDecode :: Agg -> Text-emitDecode a =- nl $- [ "parse" <> aName a <> "Event :: EventType -> Value -> Either Text " <> aName a <> "Event"- , "parse" <> aName a <> "Event (EventType tag) = mapLeftText . parseEither (withObject " <> tshow (aName a <> "Event") <> " go)"- , " where"- , " go o = do"- , " case tag of"- ]- ++ concatMap decodeArm (aEvents a)- ++ [" _ -> fail \"unknown event type\""]- where- decodeArm e =- [ " " <> tshow (rcName e) <> " ->"- , " " <> rcName e <> " <$> (" <> rcName e <> "Data" <> fieldApps (rcFields e) <> ")"- ]- fieldApps [] = ""- fieldApps fs = " <$> " <> T.intercalate " <*> " (map decodeField fs)- -- The first field uses <$> (handled above), the rest <*>. We instead build- -- a uniform list and join; for an empty record there are no fields.- decodeField (n, ty) = case fieldCat a ty of- IdCat -> "(" <> ty <> " <$> o .: " <> tshow n <> ")"- EnumCat -> "(o .: " <> tshow n <> " >>= parse" <> ty <> ")"- MappedStructuralCat declaration _ -> "(o .: " <> tshow n <> " >>= parse" <> sdName declaration <> "Mapped)"- MappedOpaqueCat{} -> "o .: " <> tshow n- _ -> "o .: " <> tshow n--codecMappedImports :: Agg -> [Text]-codecMappedImports a = case aTypeGraph a of- Nothing -> []- Just graph ->- sort . nub $- [ structuralShapeModule (aContext a) (sdName declaration) <> " qualified"- | ResolvedStructural declaration _ <- codecMappedDeclarations a- ]- <> [ hsModule (sdHaskell declaration) <> " qualified"- | ResolvedStructural declaration _ <- codecMappedDeclarations a- ]- <> [ qualifiedModule (sdBinding declaration) <> " qualified"- | ResolvedStructural declaration _ <- codecMappedDeclarations a- ]- <> [ hsModule (odHaskell declaration) <> " qualified"- | ResolvedOpaque declaration <- codecMappedDeclarations a- ]- <> [ hsModule (odHaskell declaration) <> " qualified"- | ResolvedStructural _ shape <- codecMappedDeclarations a- , key <- directShapeRefs shape- , Just (ResolvedOpaque declaration) <- [Map.lookup key (tgDeclarations graph)]- ]--codecMappedDeclarations :: Agg -> [ResolvedMappedDecl]-codecMappedDeclarations a = case aTypeGraph a of- Nothing -> []- Just graph ->- mapMaybe (\key -> Map.lookup key (tgDeclarations graph)) (sort (Map.keys selected))- where- roots =- [ MappedKey fieldType- | event <- aEvents a- , (_, fieldType) <- rcFields event- , Map.member (MappedKey fieldType) (tgDeclarations graph)- ]- selected =- Map.fromList- [ (key, ())- | root <- roots- , key <- root : maybe [] (Map.keys . Map.fromSet (const ())) (Map.lookup root (tgReachability graph))- ]--directShapeRefs :: ResolvedMappedShape -> [MappedKey]-directShapeRefs =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields -> concatMap (exprRefs . rwfType) fields- , onEnum = const []- , onUnion = \_ arms -> concatMap (maybe [] exprRefs . rwaPayload) arms- }--exprRefs :: ResolvedTypeExpr -> [MappedKey]-exprRefs =- foldTypeExpr- TypeExprAlgebra- { onText = []- , onInt = []- , onBool = []- , onNatural = []- , onTime = []- , onJson = []- , onOptional = id- , onList = id- , onMap = id- , onRef = pure- }--emitMappedCodecs :: Agg -> Text-emitMappedCodecs a = case aTypeGraph a of- Nothing -> ""- Just graph ->- T.intercalate- "\n\n"- [ emitStructuralCodec a graph declaration shape- | ResolvedStructural declaration shape <- codecMappedDeclarations a- ]--emitStructuralCodec :: Agg -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text-emitStructuralCodec a graph declaration shape =- nl- [ "encode" <> name <> "Mapped :: " <> consumerType <> " -> Value"- , "encode" <> name <> "Mapped = encode" <> name <> "Shape . bindingToShape " <> binding- , ""- , "parse" <> name <> "Mapped :: Value -> Parser " <> consumerType- , "parse" <> name <> "Mapped value = bindingFromShape " <> binding <> " <$> parse" <> name <> "Shape value"- , ""- , "decode" <> name <> "Mapped :: Value -> Either Text " <> consumerType- , "decode" <> name <> "Mapped = mapLeftText . parseEither parse" <> name <> "Mapped"- , ""- , "encode" <> name <> "Shape :: " <> shapeType <> " -> Value"- , emitShapeEncoder a graph declaration shape- , ""- , "parse" <> name <> "Shape :: Value -> Parser " <> shapeType- , emitShapeDecoder a graph declaration shape- ]- where- name = sdName declaration- consumerType = renderHaskellSource (sdHaskell declaration)- shapeType = structuralShapeModule (aContext a) name <> "." <> name <> "Shape"- binding = unQualifiedValueName (sdBinding declaration)--emitShapeEncoder :: Agg -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text-emitShapeEncoder a graph declaration =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields ->- nl $- ["encode" <> name <> "Shape shape =", " object"]- <> objectEntries- [ tshow (rwfKey field)- <> " .= "- <> encodeShapeExpr a graph (rwfType field) (shapeModuleName <> "." <> rwfHaskell field <> " shape")- | field <- fields- ]- , onEnum = \entries ->- nl $- ["encode" <> name <> "Shape = \\case"]- <> [" " <> shapeModuleName <> "." <> weCtor entry <> " -> String " <> tshow (weTag entry) | entry <- entries]- , onUnion = \encoding arms ->- nl $- ["encode" <> name <> "Shape = \\case"]- <> concatMap (unionEncodeArm encoding) arms- }- where- name = sdName declaration- shapeModuleName = structuralShapeModule (aContext a) name- unionEncodeArm encoding arm =- [ " " <> shapeModuleName <> "." <> rwaCtor arm <> payloadPattern <> " ->"- , " object"- ]- <> objectEntries- ( [tshow (ueTagField encoding) <> " .= (" <> tshow (rwaTag arm) <> " :: Text)"]- <> [ tshow (ueContentsField encoding) <> " .= " <> encodeShapeExpr a graph payload "payload"- | payload <- maybeToListText (rwaPayload arm)- ]- )- where- payloadPattern = maybe "" (const " payload") (rwaPayload arm)--emitShapeDecoder :: Agg -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text-emitShapeDecoder a graph declaration =- foldMappedShape- MappedShapeAlgebra- { onRecord = \constructor unknownFields fields ->- nl $- [ "parse" <> name <> "Shape = withObject " <> tshow (name <> "Shape") <> " $ \\objectValue -> do"- ]- <> rejectLine " " unknownFields (map rwfKey fields) "objectValue"- <> [ " " <> shapeModuleName <> "." <> constructor- , " <$> " <> T.intercalate "\n <*> " (map (decodeRecordField a graph) fields)- ]- , onEnum = \entries ->- nl $- [ "parse" <> name <> "Shape = withText " <> tshow (name <> "Shape") <> " $ \\tag -> case tag of"- ]- <> [" " <> tshow (weTag entry) <> " -> pure " <> shapeModuleName <> "." <> weCtor entry | entry <- entries]- <> [" _ -> fail " <> tshow ("unknown " <> name <> " wire value")]- , onUnion = \encoding arms ->- nl $- [ "parse" <> name <> "Shape = withObject " <> tshow (name <> "Shape") <> " $ \\objectValue -> do"- , " tag <- objectValue .: " <> tshow (ueTagField encoding) <> " :: Parser Text"- , " case tag of"- ]- <> concatMap (unionDecodeArm encoding) arms- <> [" _ -> fail " <> tshow ("unknown " <> name <> " union tag")]- }- where- name = sdName declaration- shapeModuleName = structuralShapeModule (aContext a) name- rejectLine _ IgnoreUnknown _ _ = []- rejectLine indent RejectUnknown allowed objectName =- [indent <> "rejectUnknownFields " <> tshow name <> " " <> renderTextList allowed <> " " <> objectName]- unionDecodeArm encoding arm =- [" " <> tshow (rwaTag arm) <> " -> do"]- <> rejectLine " " (ueUnknownFields encoding) allowed "objectValue"- <> [ case rwaPayload arm of- Nothing -> " pure " <> shapeModuleName <> "." <> rwaCtor arm- Just payload ->- " "- <> shapeModuleName- <> "."- <> rwaCtor arm- <> " <$> (objectValue .: "- <> tshow (ueContentsField encoding)- <> " >>= ("- <> decodeShapeExpr a graph payload- <> "))"- ]- where- allowed = ueTagField encoding : [ueContentsField encoding | rwaPayload arm /= Nothing]--decodeRecordField :: Agg -> TypeGraph -> ResolvedWireField -> Text-decodeRecordField a graph field = case rwfPresence field of- PRequired ->- "((objectValue .: " <> key <> " :: Parser Value) >>= (" <> decoder <> "))"- POptional ->- "(case KeyMap.lookup (Key.fromText "- <> key- <> ") objectValue of Nothing -> "- <> missing- <> "; Just presentValue -> "- <> "("- <> decoder- <> ") presentValue)"- where- key = tshow (rwfKey field)- decoder = decodeShapeExpr a graph (rwfType field)- missing = case rwfOnMissing field of- Nothing -> "fail " <> tshow ("missing optional field without default: " <> rwfKey field)- Just onMissing -> "pure " <> renderMissingDefault a graph (rwfType field) onMissing--encodeShapeExpr :: Agg -> TypeGraph -> ResolvedTypeExpr -> Text -> Text-encodeShapeExpr _a graph expression value =- foldTypeExpr- TypeExprAlgebra- { onText = \v -> "toJSON (" <> v <> ")"- , onInt = \v -> "toJSON (" <> v <> ")"- , onBool = \v -> "toJSON (" <> v <> ")"- , onNatural = \v -> "toJSON (" <> v <> ")"- , onTime = \v -> "toJSON (" <> v <> ")"- , onJson = id- , onOptional = \encode v -> "maybe Null (\\item -> " <> encode "item" <> ") (" <> v <> ")"- , onList = \encode v -> "toJSON (map (\\item -> " <> encode "item" <> ") (" <> v <> "))"- , onMap = \encode v -> "toJSON (Map.map (\\item -> " <> encode "item" <> ") (" <> v <> "))"- , onRef = \key v -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural nested _) -> "encode" <> sdName nested <> "Shape (" <> v <> ")"- Just (ResolvedOpaque _) -> "toJSON (" <> v <> ")"- Nothing -> "toJSON (" <> v <> ")"- }- expression- value--decodeShapeExpr :: Agg -> TypeGraph -> ResolvedTypeExpr -> Text-decodeShapeExpr _a graph =- foldTypeExpr- TypeExprAlgebra- { onText = "parseJSON"- , onInt = "parseJSON"- , onBool = "parseJSON"- , onNatural = "parseJSON"- , onTime = "parseJSON"- , onJson = "pure"- , onOptional = \decode -> "\\value -> case value of Null -> pure Nothing; other -> Just <$> " <> decode <> " other"- , onList = \decode -> "\\value -> (parseJSON value :: Parser [Value]) >>= traverse (" <> decode <> ")"- , onMap = \decode -> "\\value -> (parseJSON value :: Parser (Map Text Value)) >>= traverse (" <> decode <> ")"- , onRef = \key -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural nested _) -> "parse" <> sdName nested <> "Shape"- Just (ResolvedOpaque _) -> "parseJSON"- Nothing -> "parseJSON"- }--renderMissingDefault :: Agg -> TypeGraph -> ResolvedTypeExpr -> OnMissing -> Text-renderMissingDefault a graph expression = \case- OmNull -> "Nothing"- OmText value -> tshow value- OmInt value -> T.pack (show value)- OmBool value -> if value then "True" else "False"- OmEmptyList -> "[]"- OmEmptyMap -> "Map.empty"- OmCtor constructor -> case expression of- RRef key -> case Map.lookup key (tgDeclarations graph) of- Just (ResolvedStructural declaration _) -> structuralShapeModule (aContext a) (sdName declaration) <> "." <> constructor- _ -> constructor- _ -> constructor--objectEntries :: [Text] -> [Text]-objectEntries entries =- [lead index <> entry | (index, entry) <- zip [(0 :: Int) ..] entries]- <> [" ]"]- where- lead 0 = " [ "- lead _ = " , "--renderTextList :: [Text] -> Text-renderTextList values = "[" <> T.intercalate ", " (map tshow values) <> "]"------------------------------------------------------------------------------------- EventStream module-----------------------------------------------------------------------------------emitEventStream :: Agg -> Text-emitEventStream a =- nl $- [ generatedBanner- , "module " <> aGenPrefix a <> ".EventStream"- , " ( " <> lowerFirst (aName a) <> "Category"- , " , " <> lowerFirst (aName a) <> "EventStream"- , " , " <> lowerFirst (aName a) <> "EventStreamDef"- , " , " <> aName a <> "EventStream"- , " , " <> aName a <> "EventStreamDef"- ]- ++ [" , " <> lowerFirst (aName a) <> "SnapshotFixture" | hasSnapshot a]- ++ [ " ) where"- , ""- , "import " <> aGenPrefix a <> ".Domain"- , "import " <> aGenPrefix a <> ".Codec (" <> lowerFirst (aName a) <> "Codec)"- , "import " <> aHolePrefix a <> ".Holes (" <> lowerFirst (aName a) <> "Transducer)"- , "import Keiki.Core (HsPred)"- , "import Keiro.EventStream (EventStream (..), SnapshotPolicy (..))"- , "import Keiro.EventStream.Validate (ValidatedEventStream, mkEventStreamOrThrow)"- ]- ++ ["import Data.Text (Text)" | hasSnapshot a]- ++ ["import Keiro.Snapshot.Codec (defaultStateCodec, withFoldFingerprint)" | hasSnapshot a]- ++ [ "import Keiro.Stream qualified as Stream"- , ""- , "-- The validated aggregate stream category (hole-kind 5: referenced, never retyped)."- , "-- Entity streams are '<category>-<id>' via Keiro.Stream.entityStream."- , "-- categoryUnsafe is safe here because this generated literal passed the DSL category proof."- , lowerFirst (aName a) <> "Category :: Stream.StreamCategory a"- , lowerFirst (aName a) <> "Category = Stream.categoryUnsafe " <> tshow categoryName- , ""- , "type " <> aName a <> "EventStreamDef ="- , " EventStream (HsPred " <> aName a <> "Regs " <> aName a <> "Command) " <> aName a <> "Regs " <> aVertexType a <> " " <> aName a <> "Command " <> aName a <> "Event"- , ""- , "type " <> aName a <> "EventStream ="- , " ValidatedEventStream (HsPred " <> aName a <> "Regs " <> aName a <> "Command) " <> aName a <> "Regs " <> aVertexType a <> " " <> aName a <> "Command " <> aName a <> "Event"- , ""- , lowerFirst (aName a) <> "EventStreamDef :: " <> aName a <> "EventStreamDef"- , lowerFirst (aName a) <> "EventStreamDef ="- , " EventStream"- , " { transducer = " <> lowerFirst (aName a) <> "Transducer"- , " , initialState = " <> initialVertex a- , " , initialRegisters = initial" <> aName a <> "Regs"- , " , eventCodec = " <> lowerFirst (aName a) <> "Codec"- , " , resolveStreamName = Stream.streamName"- , " , snapshotPolicy = " <> snapshotPolicyExpr a- ]- ++ stateCodecFieldLines a- ++ [ " }"- , ""- ]- ++ snapshotFixtureLines a- ++ [ lowerFirst (aName a) <> "EventStream :: " <> aName a <> "EventStream"- , lowerFirst (aName a) <> "EventStream ="- , " mkEventStreamOrThrow " <> tshow (aName a) <> " " <> lowerFirst (aName a) <> "EventStreamDef"- ]- where- categoryName = staticCategory ("aggregate " <> aName a) (lowerFirst (aName a))--snapshotPolicyExpr :: Agg -> Text-snapshotPolicyExpr aggregate = case aSnapshot aggregate of- Nothing -> "Never"- Just snapshot -> case snapPolicy snapshot of- SnapEvery interval -> "Every " <> tshow' interval- SnapOnTerminal -> "OnTerminal"--stateCodecExpr :: Agg -> Text-stateCodecExpr aggregate = case aSnapshot aggregate of- Nothing -> "Nothing"- Just snapshot ->- "Just (withFoldFingerprint "- <> tshow (aFoldFingerprint aggregate)- <> " (defaultStateCodec "- <> tshow' (snapCodecVersion snapshot)- <> "))"--stateCodecFieldLines :: Agg -> [Text]-stateCodecFieldLines aggregate = case aSnapshot aggregate of- Nothing -> [" , stateCodec = Nothing"]- Just _ ->- [ " -- The snapshot discriminator composes: the spec's state-codec version (bump it"- , " -- in the spec's `state-codec version=` clause), keiki's register and"- , " -- control-state shape hashes, and this fold fingerprint derived from the"- , " -- spec's transition surface (guards, writes, emits, states, register"- , " -- initials, referenced rules). Spec-visible fold changes invalidate old"- , " -- snapshots automatically. Fold changes made ONLY in the hand-owned Holes"- , " -- module are invisible here: bump `state-codec version=` manually or old"- , " -- snapshots will be served stale."- , " , stateCodec = " <> stateCodecExpr aggregate- ]--snapshotFixtureLines :: Agg -> [Text]-snapshotFixtureLines aggregate = case aSnapshot aggregate of- Nothing -> []- Just snapshot ->- [ lowerFirst (aName aggregate) <> "SnapshotFixture :: (Int, Text)"- , lowerFirst (aName aggregate) <> "SnapshotFixture = (" <> tshow' (snapCodecVersion snapshot) <> ", " <> tshow (snapShapeHash snapshot) <> ")"- , ""- ]------------------------------------------------------------------------------------- Projection module-----------------------------------------------------------------------------------emitProjection :: Agg -> Text-emitProjection a = case aProjection a of- Nothing -> nl [generatedBanner, "module " <> aGenPrefix a <> ".Projection () where"]- Just p ->- nl- [ "{-# LANGUAGE OverloadedRecordDot #-}"- , "{-# LANGUAGE OverloadedStrings #-}"- , generatedBanner- , "module " <> aGenPrefix a <> ".Projection"- , " ( " <> lowerFirst (projTable p) <> "Projection"- , " , " <> lowerFirst (projTable p) <> "StatusFor"- , " ) where"- , ""- , "import " <> aGenPrefix a <> ".Domain"- , "import " <> aHolePrefix a <> ".Holes (apply" <> pascal (projTable p) <> ")"- , "import Data.Text (Text)"- , "import Keiro.Projection (InlineProjection (..))"- , ""- , "-- The deterministic event->status mapping (hole-kind 3, /mapping/), derived"- , "-- from the spec's status-map. The read-model SQL that consumes it lives in"- , "-- the hand-owned Holes module (a DB-coupled hole, delegated to codd)."- , projectionTableComment a p- , lowerFirst (projTable p) <> "StatusFor :: " <> aName a <> "Event -> Maybe Text"- , lowerFirst (projTable p) <> "StatusFor = \\case"- , nl (statusArms a p)- , ""- , lowerFirst (projTable p) <> "Projection :: InlineProjection " <> aName a <> "Event"- , lowerFirst (projTable p) <> "Projection ="- , " InlineProjection"- , " { name = " <> tshow (contextNameToProjName a p)- , " , apply = apply" <> pascal (projTable p)- , " }"- ]--statusArms :: Agg -> ProjectionSpec -> [Text]-statusArms a p =- [ " " <> rcName e <> " {} -> " <> statusFor e- | e <- aEvents a- ]- ++ [" _ -> Nothing" | hasWildcard]- where- pairs = maybe [] mapPairs (projStatusMap p)- statusFor e = case lookup (rcName e) pairs of- Just value -> "Just " <> tshow value- Nothing -> "Nothing"- -- A wildcard is only needed if some event is uncovered; otherwise every arm- -- is explicit and a wildcard would be redundant (and -Wall would warn).- hasWildcard = False--contextNameToProjName :: Agg -> ProjectionSpec -> Text-contextNameToProjName a p = contextKebab a <> "-" <> projTable p <> "-inline"--contextKebab :: Agg -> Text-contextKebab = kebabFromPascal . aCtxPascal--projectionReadModel :: Agg -> Maybe ReadModelNode-projectionReadModel aggregate = do- projection <- aProjection aggregate- find ((== projTable projection) . rmName) (aReadModels aggregate)--projectionTableComment :: Agg -> ProjectionSpec -> Text-projectionTableComment aggregate projection = case projectionReadModel aggregate of- Nothing ->- "-- WARNING: no readmodel node declares '"- <> projTable projection- <> "'; unqualified SQL depends on search_path."- Just readModel ->- "-- Qualified table "- <> qualifiedTableLiteral readModel- <> "; use "- <> genPrefixFor (aContext aggregate) (pascal (rmName readModel))- <> ".ReadModelTable."- <> readModelStem readModel- <> "QualifiedTable."------------------------------------------------------------------------------------- Holes module (create-if-absent)-----------------------------------------------------------------------------------emitHoles :: Agg -> Text-emitHoles a =- nl- [ "{-# LANGUAGE BlockArguments #-}"- , "{-# LANGUAGE DataKinds #-}"- , "{-# LANGUAGE OverloadedRecordDot #-}"- , "{-# LANGUAGE QualifiedDo #-}"- , "{-# LANGUAGE TypeApplications #-}"- , "-- This is a HAND-OWNED hole module. keiro-dsl creates it once and never"- , "-- overwrites it. Fill the transducer body (and any other holes) against the"- , "-- generated signatures, then run the harness to confirm behaviour."- , "module " <> aHolePrefix a <> ".Holes"- , " ( " <> lowerFirst (aName a) <> "Transducer"- , holeProjectionExport a- , holeUpcasterExports a- , " ) where"- , ""- , "import " <> aGenPrefix a <> ".Domain"- , "import Keiki.Builder ((=:))"- , "import qualified Keiki.Builder as B"- , "import Keiki.Core (HsPred, RegFile, SymTransducer, lit, (.==), (./=), (.||))"- , holeUpcasterImports a- , holeProjectionImports a- , ""- , "-- HOLE: the transducer body. Reproduce the structure below, replacing each"- , "-- `-- HOLE` line with the keiki symbolic operators it describes."- , lowerFirst (aName a) <> "Transducer"- , " :: SymTransducer"- , " (HsPred " <> aName a <> "Regs " <> aName a <> "Command)"- , " " <> aName a <> "Regs"- , " " <> aVertexType a- , " " <> aName a <> "Command"- , " " <> aName a <> "Event"- , lowerFirst (aName a) <> "Transducer ="- , " B.buildTransducer " <> initialVertex a <> " initial" <> aName a <> "Regs isTerminal do"- , nl (concatMap (fromBlock a) (groupBySource a))- , " where"- , " isTerminal = \\case"- , nl [" " <> vertexCtor a (stName s) <> " -> True" | s <- aStates a, stTerminal s]- , " _ -> False"- , holeProjectionStub a- , holeUpcasterStubs a- ]---- | Export, import, and stub the per-event upcaster holes (EP-2 evolution).-holeUpcasterExports :: Agg -> Text-holeUpcasterExports a = case upcasterEntries a of- [] -> ""- es -> nl [" , " <> fn | (_, _, fn) <- es]--holeUpcasterImports :: Agg -> Text-holeUpcasterImports a = case upcasterEntries a of- [] -> ""- _ -> nl ["import Data.Aeson (Value)", "import Data.Text (Text)"]--holeUpcasterStubs :: Agg -> Text-holeUpcasterStubs a = case upcasterEntries a of- [] -> ""- es ->- nl $- concat- [ [ ""- , "-- HOLE upcaster: this hole receives ONLY " <> eventName <> " payloads stored at"- , "-- aggregate schema version " <> tshow' source <> "; other event kinds pass through the"- , "-- generated rung dispatch automatically. Bring this payload up one version and decide"- , "-- the default/derivation for any field added at the new version here."- , fn <> " :: Value -> Either Text Value"- , fn <> " _ = Left \"HOLE: upcaster not implemented\""- ]- | (source, eventName, fn) <- es- ]--holeProjectionExport :: Agg -> Text-holeProjectionExport a = case aProjection a of- Nothing -> " -- (no projection)"- Just p -> " , apply" <> pascal (projTable p)--holeProjectionImports :: Agg -> Text-holeProjectionImports aggregate = case projectionReadModel aggregate of- Nothing -> ""- Just readModel ->- "import "- <> genPrefixFor (aContext aggregate) (pascal (rmName readModel))- <> ".ReadModelTable ("- <> readModelStem readModel- <> "QualifiedTable)"--holeProjectionStub :: Agg -> Text-holeProjectionStub a = case aProjection a of- Nothing -> ""- Just p ->- nl- ( [ ""- , "-- HOLE: the read-model SQL for the projection (a DB-coupled hole; the"- , "-- pure event->status mapping is generated as " <> lowerFirst (projTable p) <> "StatusFor)."- ]- ++ projectionGuidance- ++ [ "apply" <> pascal (projTable p) <> " :: " <> aName a <> "Event -> recorded -> txn ()"- , "apply" <> pascal (projTable p) <> " _event _recorded = " <> projectionTableUse <> "error \"HOLE: fill " <> projTable p <> " projection apply\""- ]- )- where- projectionGuidance = case projectionReadModel a of- Nothing ->- ["-- WARNING: no readmodel node declares this table's schema; unqualified SQL depends on search_path."]- Just readModel ->- [ "-- Table: " <> qualifiedTableLiteral readModel <> ". Use " <> readModelStem readModel <> "QualifiedTable; never rely on search_path."- , "-- Declared columns:"- ]- ++ map (("-- " <>) . readModelColumnDoc) (rmColumns readModel)- projectionTableUse = case projectionReadModel a of- Nothing -> ""- Just readModel -> readModelStem readModel <> "QualifiedTable `seq` "---- Group transitions by source state, preserving order, for the B.from blocks.-groupBySource :: Agg -> [(Text, [Transition])]-groupBySource a = go [] (transitionsOf a)- where- go acc [] = reverse acc- go acc (t : ts) =- let src = tSource t- (same, rest) = span ((== src) . tSource) ts- in go ((src, t : same) : acc) rest---- We don't keep the original Aggregate around in Agg, so reconstruct--- transitions from a stored field. (Filled in resolveAgg via aTransitions.)-transitionsOf :: Agg -> [Transition]-transitionsOf = aTransitions--fromBlock :: Agg -> (Text, [Transition]) -> [Text]-fromBlock a (src, ts) =- [ " B.from " <> vertexCtor a src <> " do"- ]- ++ concatMap (onCmdBlock a) ts--onCmdBlock :: Agg -> Transition -> [Text]-onCmdBlock a t =- [ " B.onCmd inCtor" <> tCommand t <> " $ \\d -> B.do"- ]- -- Plan 143: the mode is structural, not hole-owned — a replay-only- -- transition lowers to B.replayOnly (keiki ReplayOnly edge).- ++ [" B.replayOnly" | tMode t == TmReplayOnly]- ++ maybe [] (\g -> [" -- HOLE guard: " <> renderGuard g]) (tGuard t)- ++ [" -- HOLE write " <> r <> " := " <> renderGuard e | (r, e) <- tWrites t]- ++ [" -- HOLE emit " <> ev <> " (B.emit wire" <> ev <> " ...)" | ev <- tEmits t]- ++ [" B.goto " <> vertexCtor a (tGoto t)]------------------------------------------------------------------------------------- Field categories and shared helpers-----------------------------------------------------------------------------------data FieldCat- = IdCat- | EnumCat- | MappedStructuralCat !StructuralDecl !ResolvedMappedShape- | MappedOpaqueCat !OpaqueDecl- | OtherCat- deriving stock (Eq, Show)--fieldCat :: Agg -> Text -> FieldCat-fieldCat a ty- | ty `elem` map idName (aIds a) = IdCat- | ty `elem` map enumName (aEnums a) = EnumCat- | Just (ResolvedStructural declaration shape) <- mappedDeclFor a ty = MappedStructuralCat declaration shape- | Just (ResolvedOpaque declaration) <- mappedDeclFor a ty = MappedOpaqueCat declaration- | otherwise = OtherCat---- | The first constructor of a declared enum, used to build sample values.-firstEnumCtor :: Agg -> Text -> Maybe Text-firstEnumCtor a ty =- case [c | e <- aEnums a, enumName e == ty, (c, _) <- take 1 (enumCtors e)] of- (c : _) -> Just c- [] -> Nothing--vertexCtor :: Agg -> Text -> Text-vertexCtor a s = aName a <> s--initialVertex :: Agg -> Text-initialVertex a = case aStates a of- (s : _) -> vertexCtor a (stName s)- [] -> aName a <> "Init"--generatedBanner :: Text-generatedBanner = "-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec."--nodeOrigin :: Text -> Text -> Loc -> Text-nodeOrigin nodeKind nodeName loc =- nodeKind <> " " <> nodeName <> case unLoc loc of- 0 -> ""- line -> " (line " <> tshow' line <> ")"--{- | Conditions that the deterministic emitters cannot lower faithfully. The-pre-write scaffold pipeline treats each returned message as a refusal. The-list is extended alongside the policy and type lowering milestones.--}-scaffoldRefusals :: Spec -> [Text]-scaffoldRefusals spec =- concatMap aggregateRefusals aggregates- <> concatMap contractRefusals contracts- <> concatMap publisherRefusals publishers- where- aggregates = [aggregate | NAggregate aggregate <- specNodes spec]- contracts = [contract | NContract contract <- specNodes spec]- publishers = [publisher | NPublisher publisher <- specNodes spec]- idTypes = map idName (specIds spec)- enumTypes = map enumName (specEnums spec)- mappedTypes = case resolveTypeGraph spec of- Left _ -> []- Right graph -> map unMappedKey (Map.keys (tgDeclarations graph))- mappedDeclaration typeName = case resolveTypeGraph spec of- Left _ -> Nothing- Right graph -> Map.lookup (MappedKey typeName) (tgDeclarations graph)- enumCtorsFor ty = case [map fst (enumCtors enum) | enum <- specEnums spec, enumName enum == ty] of- ctors : _ -> ctors- [] -> []- aggregateRefusals aggregate =- [ "AggregateEmpty: aggregate '" <> aggName aggregate <> "' must declare at least one command, event, and transition"- | null (aggCommands aggregate) || null (aggEvents aggregate) || null (aggTransitions aggregate)- ]- <> concatMap (registerRefusals aggregate) (aggRegs aggregate)- <> [ "FieldTypeUnrepresentable: aggregate '" <> aggName aggregate <> "' field '" <> fieldName field <> "' has unsupported explicit type '" <> ty <> "'"- | field <- aggregateFields aggregate- , Just ty <- [fieldType field]- , not (supportedType aggregate ty)- ]- registerRefusals aggregate reg =- [ "RegTypeUnsupported: aggregate '" <> aggName aggregate <> "' register '" <> regName reg <> "' has unsupported type '" <> regType reg <> "'"- | not (supportedType aggregate (regType reg))- ]- <> [ "RegTextInitialNotQuoted: aggregate '" <> aggName aggregate <> "' Text register '" <> regName reg <> "' must use a quoted initial"- | regType reg == "Text"- , RegInitBare _ <- [regInitial reg]- ]- <> [ "RegInitialNotEnumCtor: aggregate '" <> aggName aggregate <> "' register '" <> regName reg <> "' must start at a constructor of enum '" <> regType reg <> "'"- | regType reg `elem` enumTypes- , case regInitial reg of- RegInitBare value -> value `notElem` enumCtorsFor (regType reg)- RegInitText _ -> True- ]- <> [ "RegInitialInvalidLiteral: aggregate '" <> aggName aggregate <> "' Bool register '" <> regName reg <> "' must start at True or False"- | regType reg == "Bool"- , case regInitial reg of RegInitBare value -> value `notElem` ["True", "False"]; RegInitText _ -> True- ]- <> [ "RegInitialInvalidLiteral: aggregate '" <> aggName aggregate <> "' Int register '" <> regName reg <> "' must start at an integer literal"- | regType reg == "Int"- , case regInitial reg of RegInitBare value -> (readMaybe (T.unpack value) :: Maybe Int) == Nothing; RegInitText _ -> True- ]- <> [ "MappedRegisterInitialMissing: aggregate '" <> aggName aggregate <> "' register '" <> regName reg <> "' requires the mapped declaration's initial symbol"- | Just declaration <- [mappedDeclaration (regType reg)]- , mappedInitial declaration == Nothing- ]- aggregateFields aggregate =- concatMap cmdFields (aggCommands aggregate)- <> concat [fields | event <- aggEvents aggregate, EventFields fields <- [evBody event]]- supportedType aggregate ty =- ty `elem` (["Text", "Int", "Bool", aggName aggregate <> "Vertex"] <> idTypes <> enumTypes <> mappedTypes)- contractRefusals contract =- [ "ContractEmpty: contract '" <> ctrName contract <> "' must declare at least one event"- | null (ctrEvents contract)- ]- publisherRefusals publisher =- let backoff = pubBackoff publisher- label message = message <> ": publisher '" <> pubName publisher <> "'"- in case boKind backoff of- "constant" -> []- "exponential" -> case (boMax backoff, boMultiplier backoff) of- (Just maximumWindow, Just multiplierText) ->- case (windowSeconds (boWindow backoff), windowSeconds maximumWindow, readMaybe (T.unpack multiplierText) :: Maybe Double) of- (Right initialSeconds, Right maximumSeconds, Just multiplier)- | initialSeconds > 0 && maximumSeconds >= initialSeconds && multiplier >= 1 -> []- _ -> [label "BackoffInvalidExponential"]- _ -> [label "BackoffExponentialIncomplete"]- other -> [label ("BackoffUnknownKind '" <> other <> "'")]--windowSeconds :: Text -> Either Text Int-windowSeconds window = case T.unsnoc window of- Just (digits, unit)- | not (T.null digits)- , Just amount <- readMaybe (T.unpack digits) -> case unit of- 's' -> Right amount- 'm' -> Right (amount * 60)- 'h' -> Right (amount * 3600)- _ -> Left invalid- _ -> Left invalid- where- invalid = "invalid window '" <> window <> "' (expected digits followed by s, m, or h)"--windowText :: Text -> Text-windowText = either (const "0") tshow' . windowSeconds---- | Render an Expr back to source-ish text for a hole annotation.-renderGuard :: Expr -> Text-renderGuard = go (0 :: Int)- where- go ctx e = parenIf (prec e < ctx) (body e)- body (EOr l r) = go 1 l <> " || " <> go 2 r- body (EAnd l r) = go 2 l <> " && " <> go 3 r- body (ECmp op l r) = go 4 l <> " " <> cmp op <> " " <> go 4 r- body (EAtom (AName n)) = n- body (EAtom (ABool True)) = "true"- body (EAtom (ABool False)) = "false"- prec :: Expr -> Int- prec EOr{} = 1- prec EAnd{} = 2- prec ECmp{} = 3- prec EAtom{} = 4- parenIf True s = "(" <> s <> ")"- parenIf False s = s- cmp OpEq = "=="- cmp OpNeq = "!="- cmp OpLt = "<"- cmp OpLe = "<="- cmp OpGt = ">"- cmp OpGe = ">="------------------------------------------------------------------------------------- Text helpers-----------------------------------------------------------------------------------nl :: [Text] -> Text-nl = T.intercalate "\n"---- | Join groups of declarations, blank-line-separated, dropping empties.-sectionsOf :: [[Text]] -> Text-sectionsOf = T.intercalate "\n\n" . filter (not . T.null) . map (T.intercalate "\n\n")--lowerFirst :: Text -> Text-lowerFirst t = case T.uncons t of- Just (c, rest) -> T.cons (toLower c) rest- Nothing -> t--{- | Assert the shared category proof at emission time as a belt-and-braces-guard for callers that bypass the CLI's normal validate-before-scaffold path.--}-staticCategory :: Text -> Text -> Text-staticCategory owner value = case sagaCategoryError value of- Nothing -> value- Just reason -> error (T.unpack ("keiro-dsl scaffold: illegal " <> owner <> " category " <> tshow value <> " " <> reason))--pascal :: Text -> Text-pascal t = case T.uncons t of- Just (c, rest) -> T.cons (toUpper c) rest- Nothing -> t--pascalFromKebab :: Text -> Text-pascalFromKebab = T.concat . map pascal . T.splitOn "-"--kebabFromPascal :: Text -> Text-kebabFromPascal = T.intercalate "-" . map T.toLower . splitCamel---- | Split CamelCase into its words (best-effort, for the projection name).-splitCamel :: Text -> [Text]-splitCamel = go . T.unpack- where- go [] = []- go (c : cs) =- let (rest, more) = break' cs- in T.pack (c : rest) : go more- break' [] = ([], [])- break' (x : xs)- | x `elem` ['A' .. 'Z'] = ([], x : xs)- | otherwise = let (r, m) = break' xs in (x : r, m)+-- | The scaffold engine. Given an 'Aggregate' (and a 'Context' naming the+-- service and output module-namespace root), it emits the __symbol-free+-- deterministic layer__ as @-- \@generated@ modules plus a single create-if-absent+-- @Holes.hs@ holding the typed holes a human or coding agent must fill.+--+-- The load-bearing invariant of this module is the __firewall__: no @Generated@+-- module ever contains a keiki symbolic operator (@./=@, @.==@, @.||@, @lit@,+-- @B.slot@, @B.requireGuard@). Those live only in the hand-owned @Holes.hs@. A+-- test ('Generated' text scan) enforces it.+--+-- Scope (EP-1, recorded in the plan's Decision Log): the scaffolder does /not/+-- emit the symbolic transducer body — that is the @buildTransducer@ hole in+-- @Holes.hs@, pinned by the harness. It also does not emit the read-model SQL+-- (the projection @apply@), which is a DB-coupled hole delegated to @codd@/the+-- agent; the @Generated@ Projection module emits only the deterministic+-- @InlineProjection@ wiring and the pure event→status mapping. The decode emitted+-- here is /strict/ (every field required); lenient\/optional decode is EP-4's+-- concern.+module Keiro.Dsl.Scaffold+ ( ScaffoldModule (..),+ ModuleKind (..),+ Context (..),+ Placement (..),+ defaultContext,+ genPrefixFor,+ holePrefixFor,+ scaffoldReplayAudit,+ scaffoldStructural,+ scaffoldStructuralOwners,+ codecComparisonModule,+ codecComparisonBanner,+ bindingSkeletonModules,+ bindingSkeletonOwners,+ scaffoldAggregate,+ scaffoldProcess,+ scaffoldRouter,+ scaffoldContract,+ scaffoldIntake,+ scaffoldPublisher,+ scaffoldWorkqueue,+ scaffoldReadModel,+ scaffoldRefusals,+ windowSeconds,++ -- * Firewall self-check (M3)+ FirewallSurface (..),+ firewallSurface,+ firewallBreaches,++ -- * Internal resolution, shared with "Keiro.Dsl.Harness"+ Agg (..),+ ResolvedRegister (..),+ ResolvedCtor (..),+ StructuralProjection (..),+ resolveAgg,+ projectionSpecs,+ resolveProjectionModules,+ nominalProjectionModule,+ codecMappedDeclarations,+ FieldCat (..),+ fieldCat,+ vertexCtor,+ initialVertex,+ firstEnumCtor,+ lowerFirst,+ pascal,+ pascalFromKebab,+ generatedBanner,+ )+where++import Data.Char (isAlpha, isAlphaNum, isUpper, ord, toLower, toUpper)+import Data.List (find, groupBy, isSuffixOf, nub, sort, sortOn)+import Data.List.NonEmpty (NonEmpty)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Keiro.Dsl.AggregateType+import Keiro.Dsl.CodecCompare (BranchArm (..), BranchField (..), BranchSchema (..))+import Keiro.Dsl.ExplainBindings (BindingObligation (..), BindingObligationKind (..), bindingObligations)+import Keiro.Dsl.Expression+import Keiro.Dsl.FoldFingerprint (aggregateFoldFingerprint)+import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType+import Keiro.Dsl.PrettyPrint (renderExpr)+import Keiro.Dsl.ReadModelShape (registryNameFor, subscriptionNameFor)+import Keiro.Dsl.TypeGraph+import Keiro.Dsl.Validate (sagaCategoryError)+import Numeric (showHex)+import Text.Read (readMaybe)++-- | One emitted module: its on-disk path (relative to the scaffold @--out@+-- directory), its full text, and whether it is overwritten every run+-- ('Generated') or written only when absent ('HoleStub').+data ScaffoldModule = ScaffoldModule+ { modulePath :: !FilePath,+ moduleText :: !Text,+ kind :: !ModuleKind,+ origin :: !Text+ }+ deriving stock (Eq, Show)++data ModuleKind+ = -- | @-- \@generated@; overwritten on every scaffold.+ Generated+ | -- | Hand-owned; created only when absent, never overwritten.+ HoleStub+ deriving stock (Eq, Show)++-- | The threading context: the spec's @context@ name, the chosen output+-- module-namespace root, and the placement style. Extended additively (never+-- re-shaped) by later verticals.+data Context = Context+ { contextName :: !Text,+ -- | @""@ means no namespace prefix (the historical default).+ moduleRoot :: !Text,+ -- | 'GeneratedPrefix' is the historical default.+ placement :: !Placement+ }+ deriving stock (Eq, Show)++-- | A context with today's default placement ('GeneratedPrefix', no root prefix)+-- for the given @context@ name. Callers that do not care about placement (the+-- @parse@ path, tests) build their context with this.+defaultContext :: Text -> Context+defaultContext name = Context {contextName = name, moduleRoot = "", placement = GeneratedPrefix}++-- | The generated-layer namespace for a node, honouring the root prefix and the+-- placement style. The 'Text' argument is the already-pascalised node name (e.g.+-- @Reservation@, @HospitalSurge@). For 'GeneratedPrefix' this is+-- @\<root\>.Generated.\<Ctx\>.\<Node\>@ (identical to the historical layout); for+-- 'CollocatedLeaf' it is @\<root\>.\<Ctx\>.\<Node\>.Generated@.+genPrefixFor :: Context -> Text -> Text+genPrefixFor ctx node = case placement ctx of+ GeneratedPrefix -> rootPrefix ctx <> "Generated." <> ctxPascalOf ctx <> "." <> node+ CollocatedLeaf -> rootPrefix ctx <> ctxPascalOf ctx <> "." <> node <> ".Generated"++-- | The hand-owned (hole) namespace for a node: @\<root\>.\<Ctx\>.\<Node\>@ —+-- the same for both placement styles (holes always sit beside the domain).+holePrefixFor :: Context -> Text -> Text+holePrefixFor ctx node = rootPrefix ctx <> ctxPascalOf ctx <> "." <> node++-- | The root namespace prefix, dot-terminated, or @""@ when no root is set.+rootPrefix :: Context -> Text+rootPrefix ctx = case moduleRoot ctx of r | T.null r -> ""; r -> r <> "."++-- | The context name in PascalCase, e.g. @hospital-capacity@ -> @HospitalCapacity@.+ctxPascalOf :: Context -> Text+ctxPascalOf = pascalFromKebab . contextName++--------------------------------------------------------------------------------+-- Firewall self-check (M3)+--------------------------------------------------------------------------------++-- | The canonical keiki surface forbidden in generated modules. Symbolic+-- operators are matched as maximal Haskell symbol tokens, identifiers as complete+-- tokens, qualifiers by their leading module alias, and imports structurally.+data FirewallSurface = FirewallSurface+ { forbiddenSymbolic :: ![Text],+ forbiddenIdents :: ![Text],+ forbiddenQualifiers :: ![Text],+ forbiddenImports :: ![Text],+ restrictedImports :: ![(Text, [Text])]+ }+ deriving stock (Eq, Show)++firewallSurface :: FirewallSurface+firewallSurface =+ FirewallSurface+ { forbiddenSymbolic = [".==", "./=", ".<", ".<=", ".>", ".>=", ".&&", ".||", ".+", ".-", ".*", "=:", "*:"],+ forbiddenIdents = ["lit", "pnot", "tadd", "tsub", "tmul"],+ forbiddenQualifiers = ["B"],+ forbiddenImports = ["Keiki.Builder", "Keiki.Operators", "Keiki.Symbolic"],+ -- Generated aggregate modules use the first two names; generated+ -- harnesses validate, step, and replay filled holes register by register.+ restrictedImports =+ [ ( "Keiki.Core",+ [ "RegFile",+ "HsPred",+ "FieldProjection",+ "FieldWitness",+ "fieldWitness",+ "fieldWitnessAgrees",+ "applyEventsEither",+ "defaultValidationOptions",+ "step",+ "validateTransducer",+ "!"+ ]+ )+ ]+ }++-- | Scan generated modules for firewall breaches, returning every offending+-- @(module path, token, 1-based line number)@. Only modules whose 'kind' is+-- 'Generated' are scanned. Strings and comments are skipped, symbol runs use+-- maximal munch, and keiki imports are checked independently of token spelling.+firewallBreaches :: [ScaffoldModule] -> [(FilePath, Text, Int)]+firewallBreaches mods =+ [ (modulePath m, breach, n)+ | m <- mods,+ kind m == Generated,+ not (authoritativeScalarModule (modulePath m)),+ (n, line) <- zip [1 ..] (T.lines (moduleText m)),+ breach <- lineBreaches line+ ]++-- Version-2 aggregate expression and transducer modules are the narrow,+-- intentional exception to the generated symbolic-operator firewall: they+-- are precisely the generated authority that constructs Keiki terms. Every+-- other generated module remains subject to the original firewall.+authoritativeScalarModule :: FilePath -> Bool+authoritativeScalarModule path =+ any (`isSuffixOf` path) ["/Expressions.hs", "/Transducer.hs"]++lineBreaches :: Text -> [Text]+lineBreaches line = case importModule line of+ Just _ -> importBreaches line+ Nothing -> tokenBreaches (codeTokens line)+ where+ tokenBreaches = mapMaybe breachFor+ breachFor (IdentToken ident)+ | ident `elem` forbiddenIdents firewallSurface = Just ident+ breachFor (QualifiedToken qualifier)+ | qualifier `elem` forbiddenQualifiers firewallSurface = Just (qualifier <> ".*")+ breachFor (SymbolToken symbol)+ | symbol `elem` forbiddenSymbolic firewallSurface = Just symbol+ breachFor _ = Nothing++data CodeToken = IdentToken !Text | QualifiedToken !Text | SymbolToken !Text++codeTokens :: Text -> [CodeToken]+codeTokens = go . T.unpack+ where+ go [] = []+ go ('-' : '-' : _) = []+ go ('"' : rest) = go (dropString rest)+ go ('\'' : rest) = go (dropChar rest)+ go (c : rest)+ | isIdentStart c =+ let (identTail, afterIdent) = span isIdentContinue rest+ ident = T.pack (c : identTail)+ in case afterIdent of+ '.' : next : more+ | isUpper c && isIdentStart next ->+ let (_member, afterMember) = span isIdentContinue more+ in QualifiedToken ident : go afterMember+ _ -> IdentToken ident : go afterIdent+ | isSymbolChar c =+ let (symbolTail, afterSymbol) = span isSymbolChar rest+ in SymbolToken (T.pack (c : symbolTail)) : go afterSymbol+ | otherwise = go rest+ isIdentStart c = isAlpha c || c == '_'+ isIdentContinue c = isAlphaNum c || c == '_' || c == '\''+ isSymbolChar c = c `elem` ("!#$%&*+./<=>?@\\^|-~:" :: String)+ dropString [] = []+ dropString ('\\' : _escaped : rest) = dropString rest+ dropString ('"' : rest) = rest+ dropString (_ : rest) = dropString rest+ dropChar [] = []+ dropChar ('\\' : _escaped : rest) = dropChar rest+ dropChar ('\'' : rest) = rest+ dropChar (_ : rest) = dropChar rest++importBreaches :: Text -> [Text]+importBreaches line = case importModule line of+ Nothing -> []+ Just imported+ | imported `elem` forbiddenImports firewallSurface -> ["import:" <> imported]+ | Just allowed <- lookup imported (restrictedImports firewallSurface),+ not (hasAllowedExplicitImportList allowed line) ->+ ["import:" <> imported]+ | otherwise -> []++importModule :: Text -> Maybe Text+importModule line = case T.words (T.strip line) of+ "import" : rest -> find (T.isPrefixOf "Keiki.") rest+ _ -> Nothing++hasAllowedExplicitImportList :: [Text] -> Text -> Bool+hasAllowedExplicitImportList allowed line =+ case (T.breakOn "(" line, T.breakOnEnd ")" line) of+ ((_, open), (close, _))+ | not (T.null open) && not (T.null close) ->+ let inside = T.takeWhile (/= ')') (T.drop 1 open)+ names = filter (not . T.null) (T.split (not . isAlphaNum) inside)+ in all (`elem` allowed) names+ _ -> False++--------------------------------------------------------------------------------+-- Derived naming+--------------------------------------------------------------------------------++-- | Resolved, denormalized view of an aggregate used by every emitter.+data Agg = Agg+ { aContext :: !Context,+ aSpec :: !Spec,+ aAggregate :: !Aggregate,+ aCtxPascal :: !Text,+ aName :: !Text,+ aLoc :: !Loc,+ aVertexType :: !Text,+ aIds :: ![IdDecl],+ aEnums :: ![EnumDecl],+ aRegs :: ![ResolvedRegister],+ aStates :: ![StateDecl],+ aCommands :: ![ResolvedCtor],+ aEvents :: ![ResolvedCtor],+ aTransitions :: ![Transition],+ aWire :: !WireSpec,+ aProjection :: !(Maybe ProjectionSpec),+ aSnapshot :: !(Maybe SnapshotSpec),+ aFoldFingerprint :: !Text,+ aReadModels :: ![ReadModelNode],+ aTypeGraph :: !(Maybe TypeGraph),+ aSymbols :: !AggregateSymbols,+ -- | e.g. @Generated.HospitalCapacity.Reservation@+ aGenPrefix :: !Text,+ -- | e.g. @HospitalCapacity.Reservation@+ aHolePrefix :: !Text+ }++data ResolvedRegister = ResolvedRegister+ { rrName :: !Name,+ rrType :: !ResolvedAggregateType,+ rrInitial :: !ResolvedRegisterInitial,+ rrLoc :: !Loc+ }+ deriving stock (Eq, Show)++-- | A command or event constructor with its fully-resolved field types.+data ResolvedCtor = ResolvedCtor+ { rcName :: !Text,+ -- | (field name, canonical aggregate type)+ rcFields :: ![(Text, ResolvedAggregateType)],+ -- | EP-2: schema version (1 for commands and unversioned events).+ rcVersion :: !Int,+ -- | EP-2: the source version this event migrates from (the upcaster step).+ rcUpcastFrom :: !(Maybe Int)+ }++defaultWire :: WireSpec+defaultWire = WireSpec {wireKind = "ctorName", wireFields = "camelCase", wireSchemaVersion = 1}++resolveAgg :: Context -> Spec -> Aggregate -> Agg+resolveAgg ctx spec agg =+ Agg+ { aContext = ctx,+ aSpec = spec,+ aAggregate = agg,+ aCtxPascal = ctxPascal,+ aName = nm,+ aLoc = aggLoc agg,+ aVertexType = vertexType,+ aIds = specIds spec,+ aEnums = specEnums spec,+ aRegs = map resolveRegister (aggRegs agg),+ aStates = aggStates agg,+ aCommands = map resolveCommand (aggCommands agg),+ aEvents = map resolveEvent (aggEvents agg),+ aTransitions = aggTransitions agg,+ aWire = fromMaybe defaultWire (aggWire agg),+ aProjection = aggProjection agg,+ aSnapshot = aggSnapshot agg,+ aFoldFingerprint = aggregateFoldFingerprint spec agg,+ aReadModels = [readModel | NReadModel readModel <- specNodes spec],+ aTypeGraph = either (const Nothing) Just (resolveTypeGraph spec),+ aSymbols = symbols,+ aGenPrefix = genPrefixFor ctx nm,+ aHolePrefix = holePrefixFor ctx nm+ }+ where+ nm = aggName agg+ symbols = aggregateSymbols spec+ ctxPascal = pascalFromKebab (contextName ctx)+ vertexType = nm <> "Vertex"+ commandFieldTypes = [(cmdName c, cmdFields c) | c <- aggCommands agg]+ resolveCommand c = (mkCtor CommandFieldUse (cmdName c) (cmdFields c)) {rcVersion = 1, rcUpcastFrom = Nothing}+ resolveEvent e =+ (mkCtor EventFieldUse (evName e) (eventFields e))+ { rcVersion = evVersion e,+ rcUpcastFrom = fst <$> evUpcastFrom e+ }+ where+ eventFields ev = case evBody ev of+ EventFields fs -> fs+ EventFromCommand cn -> fromMaybe [] (lookup cn commandFieldTypes)+ mkCtor useSite cn fs =+ ResolvedCtor+ { rcName = cn,+ rcFields = map (\field -> (aggregateFieldName field, orDie (inferAggregateFieldType symbols agg useSite field))) fs,+ rcVersion = 1,+ rcUpcastFrom = Nothing+ }+ resolveRegister register =+ let resolvedType = orDie (resolveAggregateType symbols (regLoc register) RegisterUse (regType register))+ resolvedInitial = orDie (resolveRegisterInitial symbols (regLoc register) resolvedType (regInitial register))+ in ResolvedRegister+ { rrName = regName register,+ rrType = resolvedType,+ rrInitial = resolvedInitial,+ rrLoc = regLoc register+ }+ orDie = either (error . ("validated aggregate resolution failed: " <>) . show) id++--------------------------------------------------------------------------------+-- Entry point+--------------------------------------------------------------------------------++-- | Emit the context-level private structural stratum. Shape modules contain+-- only generated wire representations. The projection facade contains only+-- schema-derived Keiki field witnesses; neither layer owns consumer behavior.+scaffoldStructural :: Context -> Spec -> [ScaffoldModule]+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 ->+ [(shapeModule ctx graph entry, [sdName (fst entry)]) | entry <- structural]+ <> projectionModules+ <> nominalRepresentationOwners ctx spec+ <> nominalProjectionOwners ctx spec+ <> 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"+ },+ []+ )+ | not (null (projectionSpecs graph))+ ]++-- | Plan one opt-in, non-production historical-codec comparison module.+--+-- The module is intentionally absent from 'scaffoldStructural' and therefore+-- from production manifests and scaffold records. It must be requested by name+-- and is compiled only by consumer-owned test/tool components.+codecComparisonModule :: Context -> Spec -> Name -> Either Text ScaffoldModule+codecComparisonModule ctx spec requestedName = do+ graph <- either (Left . ("mapped type graph did not resolve: " <>) . T.pack . show) Right (resolveTypeGraph spec)+ (declaration, shape) <- case Map.lookup (MappedKey requestedName) (tgDeclarations graph) of+ Nothing -> Left ("codec comparison target is not a mapped declaration: " <> requestedName)+ Just (ResolvedOpaque _) ->+ Left+ ( "codec comparison target "+ <> requestedName+ <> " is opaque; finite evidence must never upgrade an opaque declaration to a structural claim"+ )+ Just (ResolvedStructural declaration shape) -> Right (declaration, shape)+ owner <- case sortOn aggName (comparisonOwners declaration) of+ [] ->+ Left+ ( "codec comparison target "+ <> requestedName+ <> " is not reachable from a persisted private event payload"+ )+ aggregate : _ -> Right aggregate+ let moduleName = structuralPrefix ctx <> ".CodecCompare." <> requestedName+ pure+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" moduleName <> ".hs"),+ moduleText = emitCodecComparison ctx moduleName graph declaration shape owner,+ kind = Generated,+ origin = "non-production codec comparison " <> requestedName+ }+ where+ comparisonOwners declaration =+ [ aggregate+ | NAggregate aggregate <- specNodes spec,+ let resolved = resolveAgg ctx spec aggregate,+ any ((== sdName declaration) . mappedName) (codecMappedDeclarations resolved)+ ]+ where+ mappedName (ResolvedStructural structural _) = sdName structural+ mappedName (ResolvedOpaque opaque) = odName opaque++codecComparisonBanner :: Text+codecComparisonBanner =+ "-- @generated by keiro-dsl codec comparison; non-production migration evidence; do not edit."++emitCodecComparison :: Context -> Text -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Aggregate -> Text+emitCodecComparison ctx moduleName graph declaration shape owner =+ nl+ [ "",+ codecComparisonBanner,+ "-- This module compares historical and generated codecs in consumer-owned tests only.",+ "-- It is never a runtime fallback and never changes the generated codec's authority.",+ "module " <> moduleName <> " (compareWithHistorical) where",+ "",+ "import Control.Monad (filterM)",+ "import Data.Aeson (Value)",+ "import Data.Aeson qualified as Aeson",+ "import Data.List (sort)",+ "import Data.List.NonEmpty qualified as NonEmpty",+ "import Data.Text (Text)",+ "import Data.Text qualified",+ "import " <> codecModule <> " qualified as GeneratedCodec",+ "import Keiro.Codec.Structural (FixtureCases (..))",+ "import Keiro.Dsl.CodecCompare",+ "import Keiro.Dsl.TypeGraph (BindingVersion (..), CanonicalTypeId (..), QualifiedValueName (..))",+ "import System.Directory (doesFileExist, listDirectory)",+ "import System.FilePath (takeExtension, (</>))",+ "",+ "import " <> fixtureModule <> " qualified as ConsumerFixtures",+ "import " <> hsModule (sdHaskell declaration) <> " qualified as ConsumerDomain",+ "",+ "compareWithHistorical :: HistoricalCodec " <> domainType <> " -> FilePath -> IO CompareReport",+ "compareWithHistorical historicalCodec goldenDirectory = do",+ " names <- sort . filter ((== \".json\") . takeExtension) <$> listDirectory goldenDirectory",+ " files <- filterM doesFileExist [goldenDirectory </> name | name <- names]",+ " loaded <- traverse (loadGolden historicalCodec) files",+ " let inputIssues = [issue | Left issue <- loaded]",+ " entries = [entry | Right entry <- loaded]",+ " typedCases = NonEmpty.toList (fixtureCases ConsumerFixtures." <> fixtureSymbol <> ")",+ " encodeObservations =",+ " [ EncodeObservation label (hcEncode historicalCodec value) (GeneratedCodec.encode" <> name <> "Mapped value)",+ " | (label, value) <- typedCases",+ " ]",+ " decodeObservations = [observation | (observation, _) <- entries]",+ " typedObserved =",+ " concat",+ " [ observedBranchesFor FromBinding branchSchema (GeneratedCodec.encode" <> name <> "Mapped value)",+ " | (_, value) <- typedCases",+ " ]",+ " historicalObserved =",+ " concat [observedBranchesFor HistoricalGolden branchSchema value | (_, values) <- entries, value <- values]",+ " declared = declaredBranchesFor FromBinding branchSchema <> declaredBranchesFor HistoricalGolden branchSchema",+ " provenance =",+ " CompareProvenance",+ " { cpHistoricalCodecIdentity = hcIdentity historicalCodec",+ " , cpHistoricalCodecVersion = hcVersion historicalCodec",+ " , cpCanonicalType = CanonicalTypeId " <> tshow (unCanonicalTypeId (sdCanonical declaration)),+ " , cpBindingSymbol = QualifiedValueName " <> tshow (unQualifiedValueName (sdBinding declaration)),+ " , cpBindingVersion = BindingVersion " <> tshow (unBindingVersion (sdBindingVersion declaration)),+ " , cpWireFingerprint = " <> tshow (wireFingerprint graph name),+ " }",+ " pure (compareReport provenance inputIssues (encodeObservations <> decodeObservations) declared (typedObserved <> historicalObserved))",+ "",+ "loadGolden :: HistoricalCodec " <> domainType <> " -> FilePath -> IO (Either CompareInputIssue (CompareObservation, [Value]))",+ "loadGolden historicalCodec path = do",+ " decoded <- Aeson.eitherDecodeFileStrict path",+ " pure $ case decoded of",+ " Left reason -> Left (HistoricalGoldenUnreadable path (fromString reason))",+ " Right inputValue ->",+ " let historicalDecoded = hcDecode historicalCodec inputValue",+ " historicalOutcome = normalizeDecode historicalDecoded",+ " generatedOutcome = normalizeDecode (GeneratedCodec.decode" <> name <> "Mapped inputValue)",+ " observation = DecodeObservation path inputValue historicalOutcome generatedOutcome",+ " coveredValues = case historicalDecoded of",+ " Right value -> [inputValue, GeneratedCodec.encode" <> name <> "Mapped value]",+ " Left _ -> []",+ " in Right (observation, coveredValues)",+ "",+ "normalizeDecode :: Either Text " <> domainType <> " -> DecodeOutcome",+ "normalizeDecode = either DecodeFailed (DecodedShape . GeneratedCodec.encode" <> name <> "Mapped)",+ "",+ "fromString :: String -> Text",+ "fromString = Data.Text.pack",+ "",+ "branchSchema :: BranchSchema",+ "branchSchema = " <> renderBranchSchema (branchSchemaFor graph (ResolvedStructural declaration shape))+ ]+ where+ name = sdName declaration+ domainType = "ConsumerDomain." <> hsType (sdHaskell declaration)+ codecModule = genPrefixFor ctx (aggName owner) <> ".Codec"+ fixtureModule = qualifiedModule (sdFixtures declaration)+ fixtureSymbol = lastSegment (unQualifiedValueName (sdFixtures declaration))++branchSchemaFor :: TypeGraph -> ResolvedMappedDecl -> BranchSchema+branchSchemaFor graph =+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ shape ->+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields ->+ BranchRecord+ [ BranchField+ (rwfKey field)+ (rwfPresence field == POptional)+ (branchExpr graph (rwfType field))+ | field <- fields+ ],+ onEnum = const BranchScalar,+ onUnion = \encoding arms ->+ BranchUnion+ (ueTagField encoding)+ (ueContentsField encoding)+ [BranchArm (rwaTag arm) (branchExpr graph <$> rwaPayload arm) | arm <- arms]+ }+ shape,+ onOpaqueDecl = const BranchScalar+ }++branchExpr :: TypeGraph -> ResolvedTypeExpr -> BranchSchema+branchExpr graph =+ foldTypeExpr+ TypeExprAlgebra+ { onText = BranchScalar,+ onInt = BranchScalar,+ onInteger = BranchScalar,+ onBool = BranchScalar,+ onNatural = BranchScalar,+ onTime = BranchScalar,+ onJson = BranchScalar,+ onOptional = BranchOptional,+ onList = BranchList,+ onMap = BranchMap,+ onRef = \key -> maybe BranchScalar (branchSchemaFor graph) (Map.lookup key (tgDeclarations graph))+ }++renderBranchSchema :: BranchSchema -> Text+renderBranchSchema schema = case schema of+ BranchScalar -> "BranchScalar"+ BranchOptional nested -> "BranchOptional (" <> renderBranchSchema nested <> ")"+ BranchList nested -> "BranchList (" <> renderBranchSchema nested <> ")"+ BranchMap nested -> "BranchMap (" <> renderBranchSchema nested <> ")"+ BranchRecord fields ->+ "BranchRecord ["+ <> T.intercalate+ ", "+ [ "BranchField "+ <> tshow (bfWireKey field)+ <> " "+ <> (if bfPresenceOptional field then "True" else "False")+ <> " ("+ <> renderBranchSchema (bfSchema field)+ <> ")"+ | field <- fields+ ]+ <> "]"+ BranchUnion tagField contentsField arms ->+ "BranchUnion "+ <> tshow tagField+ <> " "+ <> tshow contentsField+ <> " ["+ <> T.intercalate+ ", "+ [ "BranchArm "+ <> tshow (baWireTag arm)+ <> " "+ <> maybe "Nothing" (\nested -> "(Just (" <> renderBranchSchema nested <> "))") (baPayloadSchema arm)+ | arm <- arms+ ]+ <> "]"++-- | Emit one create-once consumer module per distinct qualified obligation+-- owner. Multiple mapped declarations may intentionally share a leaf binding+-- module, so grouping happens by module rather than by declaration.+bindingSkeletonModules :: Context -> Spec -> TypeGraph -> [ScaffoldModule]+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 spec graph owner entries, nub (map obligationMappedName entries))+ | (owner, entries) <- Map.toAscList (Map.fromListWith (<>) [(obligationModule obligation, [obligation]) | obligation <- obligations])+ ]++emitBindingSkeleton :: Context -> Spec -> TypeGraph -> Text -> [BindingObligation] -> ScaffoldModule+emitBindingSkeleton ctx spec graph owner obligations =+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" owner <> ".hs"),+ moduleText =+ nl $+ [ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE LambdaCase #-}",+ "",+ "-- This is a HAND-OWNED consumer binding skeleton. keiro-dsl creates it once",+ "-- and never overwrites it. Fill each HOLE and run the generated harness.",+ "module " <> owner <> " ("+ ]+ <> exportLines+ <> [") where", ""]+ <> map ("import " <>) imports+ <> [""]+ <> intercalateBlank (map renderObligation obligations),+ kind = HoleStub,+ origin = "consumer binding skeleton " <> owner+ }+ where+ exportLines =+ [ (if index == (0 :: Int) then " " else " , ") <> obligationSymbol obligation+ | (index, obligation) <- zip [0 ..] obligations+ ]+ imports =+ sort . nub $+ [ hsModule (sdHaskell declaration) <> " qualified"+ | obligation <- obligations,+ Just (declaration, _) <- [structuralFor obligation]+ ]+ <> [ structuralShapeModule ctx (sdName declaration) <> " qualified"+ | obligation <- obligations,+ obligationKind obligation == BindingValue,+ Just (declaration, _) <- [structuralFor obligation]+ ]+ <> [ "Keiro.Codec.Structural (FixtureCases, StructuralBinding (..))"+ | any (\obligation -> obligationCategory obligation == "structural" && obligationKind obligation `elem` [BindingValue, FixtureValue]) obligations+ ]+ <> [ hsModule (consumerNominalHaskell binding) <> " qualified"+ | obligation <- obligations,+ Just (_, binding) <- [nominalFor obligation]+ ]+ <> [ nominalRepresentationModule ctx (resolvedNominalName nominal) <> " qualified"+ | obligation <- obligations,+ obligationKind obligation == BindingValue,+ Just (nominal, _) <- [nominalFor obligation],+ EnumRepresentation {} <- [resolvedNominalRepresentation nominal]+ ]+ <> [ "Keiro.Codec.Nominal (NominalBinding (..), NominalFixtureCases)"+ | any ((/= "structural") . obligationCategory) obligations+ ]+ <> [ "Data.KindID (KindID)"+ | obligation <- obligations,+ Just (nominal, _) <- [nominalFor obligation],+ IdRepresentation {} <- [resolvedNominalRepresentation nominal]+ ]+ <> [ "Data.Text (Text)"+ | obligation <- obligations,+ Just (nominal, _) <- [nominalFor obligation],+ ScalarRepresentation NominalText <- [resolvedNominalRepresentation nominal]+ ]+ <> [ "Data.Time (UTCTime)"+ | obligation <- obligations,+ Just (nominal, _) <- [nominalFor obligation],+ ScalarRepresentation NominalTime <- [resolvedNominalRepresentation nominal]+ ]+ <> [ "Numeric.Natural (Natural)"+ | obligation <- obligations,+ Just (nominal, _) <- [nominalFor obligation],+ ScalarRepresentation NominalNatural <- [resolvedNominalRepresentation nominal]+ ]+ renderObligation obligation = case structuralFor obligation of+ Nothing -> case nominalFor obligation of+ Just (nominal, _) -> renderNominalObligation nominal obligation+ Nothing -> ["-- HOLE: declaration disappeared before skeleton rendering"]+ Just (declaration, shape) -> case obligationKind obligation of+ BindingValue -> renderBinding ctx declaration shape obligation+ FixtureValue ->+ [ "-- HOLE: provide deterministic labelled conformance fixtures for " <> sdName declaration,+ obligationSignature obligation,+ obligationSymbol obligation <> " = error " <> tshow ("HOLE: fill " <> sdName declaration <> " fixtures")+ ]+ InitialValue ->+ [ "-- HOLE: provide the initial register value for " <> sdName declaration,+ obligationSignature obligation,+ obligationSymbol obligation <> " = error " <> tshow ("HOLE: fill " <> sdName declaration <> " initial value")+ ]+ structuralFor obligation = case Map.lookup (MappedKey (obligationMappedName obligation)) (tgDeclarations graph) of+ Just (ResolvedStructural declaration shape) -> Just (declaration, shape)+ _ -> Nothing+ nominalFor obligation = do+ registry <- either (const Nothing) Just (resolveNominalTypes spec)+ nominal <- lookupNominalType (obligationMappedName obligation) registry+ binding <- case resolvedNominalOwnership nominal of+ ConsumerNominal value -> Just value+ GeneratedNominal -> Nothing+ pure (nominal, binding)+ renderNominalObligation nominal obligation = case obligationKind obligation of+ BindingValue ->+ [ "-- HOLE: complete both total directions; the generated codec remains wire authority.",+ obligationSignature obligation,+ obligationSymbol obligation <> " =",+ " NominalBinding",+ " { nominalToRepresentation = \\_domainValue -> error " <> tshow ("HOLE: fill " <> resolvedNominalName nominal <> " nominalToRepresentation"),+ " , nominalFromRepresentation = \\_representationValue -> error " <> tshow ("HOLE: fill " <> resolvedNominalName nominal <> " nominalFromRepresentation"),+ " }"+ ]+ FixtureValue ->+ [ "-- HOLE: provide deterministic labelled expected-wire fixtures for " <> resolvedNominalName nominal,+ obligationSignature obligation,+ obligationSymbol obligation <> " = error " <> tshow ("HOLE: fill " <> resolvedNominalName nominal <> " fixtures")+ ]+ InitialValue ->+ [ "-- HOLE: provide the initial register value for " <> resolvedNominalName nominal,+ obligationSignature obligation,+ obligationSymbol obligation <> " = error " <> tshow ("HOLE: fill " <> resolvedNominalName nominal <> " initial value")+ ]+ intercalateBlank [] = []+ intercalateBlank (section : rest) = section <> concatMap ("" :) rest++renderBinding :: Context -> StructuralDecl -> ResolvedMappedShape -> BindingObligation -> [Text]+renderBinding ctx declaration shape obligation =+ [ "-- HOLE: complete both total directions; wire policy remains in the generated codec.",+ obligationSymbol obligation <> " :: StructuralBinding " <> domainType <> " " <> shapeType,+ obligationSymbol obligation <> " =",+ " StructuralBinding",+ " { bindingToShape = \\case"+ ]+ <> indentCases (bindingCases True)+ <> [" , bindingFromShape = \\case"]+ <> indentCases (bindingCases False)+ <> [" }"]+ where+ domainModule = hsModule (sdHaskell declaration)+ domainType = domainModule <> "." <> hsType (sdHaskell declaration)+ shapeModuleName = structuralShapeModule ctx (sdName declaration)+ shapeType = shapeModuleName <> "." <> sdName declaration <> "Shape"+ domainCtor constructor = domainModule <> "." <> constructor+ shapeCtor constructor = shapeModuleName <> "." <> constructor+ indentCases = map (" " <>)+ bindingCases toShapeDirection =+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \constructor _ fields -> [recordCase toShapeDirection constructor fields],+ onEnum = \entries -> map (enumCase toShapeDirection . weCtor) entries,+ onUnion = \_ arms -> map (unionCase toShapeDirection) arms+ }+ shape+ recordCase toShapeDirection constructor fields =+ sourceCtor+ <> arguments variables+ <> " -> "+ <> targetCtor+ <> arguments (map (holeFor toShapeDirection . rwfHaskell) fields)+ where+ variables = map (("_" <>) . (<> "Value") . rwfHaskell) fields+ sourceCtor = if toShapeDirection then domainCtor constructor else shapeCtor constructor+ targetCtor = if toShapeDirection then shapeCtor constructor else domainCtor constructor+ enumCase toShapeDirection constructor =+ sourceCtor <> " -> " <> holeFor toShapeDirection constructor+ where+ sourceCtor = if toShapeDirection then domainCtor constructor else shapeCtor constructor+ unionCase toShapeDirection arm =+ sourceCtor+ <> maybe "" (const " _payloadValue") (rwaPayload arm)+ <> " -> "+ <> case rwaPayload arm of+ Nothing -> holeFor toShapeDirection (rwaCtor arm)+ Just _ -> targetCtor <> " " <> holeFor toShapeDirection (rwaCtor arm <> ".payload")+ where+ sourceCtor = if toShapeDirection then domainCtor (rwaCtor arm) else shapeCtor (rwaCtor arm)+ targetCtor = if toShapeDirection then shapeCtor (rwaCtor arm) else domainCtor (rwaCtor arm)+ arguments [] = ""+ arguments values = " " <> T.unwords values+ holeFor toShapeDirection fieldName =+ "(error "+ <> tshow+ ( "HOLE: fill "+ <> sdName declaration+ <> (if toShapeDirection then " bindingToShape." else " bindingFromShape.")+ <> fieldName+ )+ <> ")"++shapeModule :: Context -> TypeGraph -> (StructuralDecl, ResolvedMappedShape) -> ScaffoldModule+shapeModule ctx graph (declaration, shape) =+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" (structuralShapeModule ctx (sdName declaration)) <> ".hs"),+ moduleText = emitShape ctx graph declaration shape,+ kind = Generated,+ origin = nodeOrigin "mapped structural" (sdName declaration) (sdLoc declaration)+ }++structuralPrefix :: Context -> Text+structuralPrefix ctx = case placement ctx of+ GeneratedPrefix -> rootPrefix ctx <> "Generated." <> ctxPascalOf ctx <> ".Structural"+ CollocatedLeaf -> rootPrefix ctx <> ctxPascalOf ctx <> ".Generated.Structural"++structuralShapeModule :: Context -> Name -> Text+structuralShapeModule ctx name = structuralPrefix ctx <> ".Shape." <> name++nominalRepresentationModule :: Context -> Name -> Text+nominalRepresentationModule ctx name = case placement ctx of+ GeneratedPrefix -> rootPrefix ctx <> "Generated." <> ctxPascalOf ctx <> ".Nominal.Shape." <> name+ CollocatedLeaf -> rootPrefix ctx <> ctxPascalOf ctx <> ".Nominal.Shape." <> name <> ".Generated"++nominalRepresentationOwners :: Context -> Spec -> [(ScaffoldModule, [Name])]+nominalRepresentationOwners ctx spec = case resolveNominalTypes spec of+ Left _ -> []+ Right registry ->+ [ (nominalRepresentationModuleValue ctx nominal constructors, [resolvedNominalName nominal])+ | nominal <- Map.elems (nominalTypes registry),+ ConsumerNominal {} <- [resolvedNominalOwnership nominal],+ EnumRepresentation constructors <- [resolvedNominalRepresentation nominal]+ ]++nominalRepresentationModuleValue :: Context -> ResolvedNominalType -> NonEmpty (Name, Text) -> ScaffoldModule+nominalRepresentationModuleValue ctx nominal constructors =+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" moduleName <> ".hs"),+ moduleText =+ nl+ [ "{-# LANGUAGE DeriveGeneric #-}",+ "{-# LANGUAGE LambdaCase #-}",+ generatedBanner,+ "module " <> moduleName <> " (" <> representationType <> " (..), " <> encoderName <> ") where",+ "",+ "import Data.Text (Text)",+ "import GHC.Generics (Generic)",+ "",+ "data " <> representationType <> " = " <> T.intercalate " | " (map fst (NE.toList constructors)),+ " deriving stock (Eq, Generic, Ord, Show, Enum, Bounded)",+ "",+ encoderName <> " :: " <> representationType <> " -> Text",+ encoderName <> " = \\case",+ nl [" " <> constructor <> " -> " <> tshow wire | (constructor, wire) <- NE.toList constructors]+ ],+ kind = Generated,+ origin = nodeOrigin "bound nominal enum representation" (resolvedNominalName nominal) (resolvedNominalLoc nominal)+ }+ where+ moduleName = nominalRepresentationModule ctx (resolvedNominalName nominal)+ representationType = resolvedNominalName nominal <> "Representation"+ encoderName = lowerFirst (resolvedNominalName nominal) <> "RepresentationText"++nominalProjectionModule :: Context -> Text+nominalProjectionModule ctx = case placement ctx of+ GeneratedPrefix -> rootPrefix ctx <> "Generated." <> ctxPascalOf ctx <> ".NominalProjections"+ CollocatedLeaf -> rootPrefix ctx <> ctxPascalOf ctx <> ".Generated.NominalProjections"++nominalProjectionOwners :: Context -> Spec -> [(ScaffoldModule, [Name])]+nominalProjectionOwners ctx spec = case nominalProjectionTypes spec of+ [] -> []+ nominals ->+ [ ( ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" (nominalProjectionModule ctx) <> ".hs"),+ moduleText = emitNominalProjections ctx nominals,+ kind = Generated,+ origin = "context " <> specContext spec <> " nominal scalar projection facade"+ },+ []+ )+ ]++nominalProjectionTypes :: Spec -> [ResolvedNominalType]+nominalProjectionTypes spec =+ Map.elems . Map.fromList $+ [ (resolvedNominalName nominal, nominal)+ | aggregate <- [value | NAggregate value <- specNodes spec],+ resolved <- registerTypes aggregate <> commandTypes aggregate,+ AggregateNominal nominal <- [resolved],+ ConsumerNominal {} <- [resolvedNominalOwnership nominal],+ ScalarRepresentation {} <- [resolvedNominalRepresentation nominal]+ ]+ where+ symbols = aggregateSymbols spec+ registerTypes aggregate =+ [ resolved+ | register <- aggRegs aggregate,+ Right resolved <- [resolveAggregateType symbols (regLoc register) RegisterUse (regType register)]+ ]+ commandTypes aggregate =+ [ resolved+ | command <- aggCommands aggregate,+ field <- cmdFields command,+ Right resolved <- [inferAggregateFieldType symbols aggregate CommandFieldUse field]+ ]++emitNominalProjections :: Context -> [ResolvedNominalType] -> Text+emitNominalProjections ctx nominals =+ nl $+ [ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE TypeApplications #-}",+ "{-# LANGUAGE TypeFamilies #-}",+ generatedBanner,+ "module " <> moduleName <> " where",+ ""+ ]+ <> map ("import " <>) imports+ <> [""]+ <> [T.intercalate "\n\n" (map emitNominalProjection nominals)]+ where+ moduleName = nominalProjectionModule ctx+ imports =+ sort . nub $+ [ "Keiki.Core (FieldProjection (..), FieldWitness, fieldWitness)",+ "Keiro.Codec.Nominal (nominalToRepresentation)"+ ]+ <> [hsModule (consumerNominalHaskell binding) <> " qualified" | nominal <- nominals, ConsumerNominal binding <- [resolvedNominalOwnership nominal]]+ <> [qualifiedModule (consumerNominalBinding binding) <> " qualified" | nominal <- nominals, ConsumerNominal binding <- [resolvedNominalOwnership nominal]]+ <> ["Data.Text (Text)" | any (hasScalar NominalText) nominals]+ <> ["Data.Time (UTCTime)" | any (hasScalar NominalTime) nominals]+ <> ["Numeric.Natural (Natural)" | any (hasScalar NominalNatural) nominals]+ hasScalar wanted nominal = resolvedNominalRepresentation nominal == ScalarRepresentation wanted+ emitNominalProjection nominal = case resolvedNominalOwnership nominal of+ GeneratedNominal -> ""+ ConsumerNominal binding ->+ nl+ [ "data " <> tagName,+ "",+ "instance FieldProjection " <> tagName <> " where",+ " type FieldName " <> tagName <> " = " <> tshow name,+ " type FieldOwner " <> tagName <> " = " <> renderHaskellSource (consumerNominalHaskell binding),+ " type FieldResult " <> tagName <> " = " <> scalarHaskellType (resolvedNominalRepresentation nominal),+ " fieldShapeId _ = " <> tshow (unCanonicalTypeId (consumerNominalCanonical binding)),+ " projectFieldValue _ = nominalToRepresentation " <> unQualifiedValueName (consumerNominalBinding binding),+ "",+ witnessName <> " :: FieldWitness " <> tagName,+ witnessName <> " = fieldWitness @" <> tagName+ ]+ where+ name = resolvedNominalName nominal+ tagName = name <> "NominalProjection"+ witnessName = lowerFirst name <> "Witness"+ scalarHaskellType representation = case representation of+ ScalarRepresentation NominalText -> "Text"+ ScalarRepresentation NominalInt -> "Int"+ ScalarRepresentation NominalNatural -> "Natural"+ ScalarRepresentation NominalBool -> "Bool"+ ScalarRepresentation NominalTime -> "UTCTime"+ IdRepresentation {} -> "()"+ EnumRepresentation {} -> "()"++structuralProjectionModule :: Context -> Text+structuralProjectionModule ctx = structuralPrefix ctx <> "Projections"++emitShape :: Context -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text+emitShape ctx graph declaration shape =+ nl $+ languagePragmas+ <> [ generatedBanner,+ "module " <> moduleName <> " (" <> shapeType <> " (..)) where",+ ""+ ]+ <> map ("import " <>) imports+ <> ["" | not (null imports)]+ <> [shapeDeclaration]+ where+ moduleName = structuralShapeModule ctx (sdName declaration)+ shapeType = sdName declaration <> "Shape"+ requirements = shapeRequirements ctx graph shape+ languagePragmas =+ ["{-# LANGUAGE DeriveGeneric #-}"]+ <> ["{-# LANGUAGE DuplicateRecordFields #-}" | shapeHasRecord shape]+ imports =+ sort . nub $+ ["Data.Aeson (Value)" | ReqJson `elem` requirements]+ <> ["Data.Map.Strict (Map)" | ReqMap `elem` requirements]+ <> ["Data.Text (Text)" | ReqText `elem` requirements]+ <> ["Data.Time (UTCTime)" | ReqTime `elem` requirements]+ <> ["GHC.Generics (Generic)"]+ <> ["Numeric.Natural (Natural)" | ReqNatural `elem` requirements]+ <> [m <> " qualified" | ReqModule m <- requirements]+ shapeDeclaration =+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \constructor _ fields ->+ nl $+ ["data " <> shapeType <> " = " <> constructor]+ <> recordFields+ [ (rwfHaskell field, renderShapeType ctx graph (rwfType field))+ | field <- fields+ ]+ <> [" deriving stock (Eq, Generic, Show)"],+ onEnum = \entries ->+ "data "+ <> shapeType+ <> " = "+ <> T.intercalate " | " (map weCtor entries)+ <> "\n deriving stock (Eq, Generic, Show)",+ onUnion = \_ arms ->+ nl $+ case arms of+ [] -> ["data " <> shapeType <> " = " <> shapeType <> "Empty", " deriving stock (Eq, Generic, Show)"]+ firstArm : rest ->+ ["data " <> shapeType <> " = " <> renderArm firstArm]+ <> [" | " <> renderArm arm | arm <- rest]+ <> [" deriving stock (Eq, Generic, Show)"]+ }+ shape+ renderArm arm = rwaCtor arm <> maybe "" ((" !" <>) . renderShapeType ctx graph) (rwaPayload arm)++data ShapeRequirement+ = ReqJson+ | ReqMap+ | ReqText+ | ReqTime+ | ReqNatural+ | ReqModule !Text+ deriving stock (Eq, Ord, Show)++shapeHasRecord :: ResolvedMappedShape -> Bool+shapeHasRecord =+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ _ -> True,+ onEnum = const False,+ onUnion = \_ _ -> False+ }++shapeRequirements :: Context -> TypeGraph -> ResolvedMappedShape -> [ShapeRequirement]+shapeRequirements ctx graph =+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields -> concatMap (exprRequirements ctx graph . rwfType) fields,+ onEnum = const [],+ onUnion = \_ arms -> concatMap (maybe [] (exprRequirements ctx graph) . rwaPayload) arms+ }++exprRequirements :: Context -> TypeGraph -> ResolvedTypeExpr -> [ShapeRequirement]+exprRequirements ctx graph =+ foldTypeExpr+ TypeExprAlgebra+ { onText = [ReqText],+ onInt = [],+ onInteger = [],+ onBool = [],+ onNatural = [ReqNatural],+ onTime = [ReqTime],+ onJson = [ReqJson],+ onOptional = id,+ onList = id,+ onMap = (ReqMap :) . (ReqText :),+ onRef = \key -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural declaration _) -> [ReqModule (structuralShapeModule ctx (sdName declaration))]+ Just (ResolvedOpaque declaration) -> [ReqModule (hsModule (odHaskell declaration))]+ Nothing -> []+ }++renderShapeType :: Context -> TypeGraph -> ResolvedTypeExpr -> Text+renderShapeType ctx graph =+ foldTypeExpr+ TypeExprAlgebra+ { onText = "Text",+ onInt = "Int",+ onInteger = "Integer",+ onBool = "Bool",+ onNatural = "Natural",+ onTime = "UTCTime",+ onJson = "Value",+ onOptional = \value -> "(Maybe (" <> value <> "))",+ onList = \value -> "([" <> value <> "])",+ onMap = \value -> "(Map Text (" <> value <> "))",+ onRef = \key -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural nested _) ->+ structuralShapeModule ctx (sdName nested) <> "." <> sdName nested <> "Shape"+ Just (ResolvedOpaque opaque) ->+ hsModule (odHaskell opaque) <> "." <> hsType (odHaskell opaque)+ Nothing -> "()"+ }++data StructuralProjection = StructuralProjection+ { spTag :: !Text,+ spWitness :: !Text,+ spPointer :: !Text,+ spOwner :: !HaskellSource,+ spResult :: !Text,+ spCanonical :: !CanonicalTypeId,+ spBinding :: !QualifiedValueName,+ spSelectors :: ![(Text, Text)]+ }+ deriving stock (Eq, Show)++projectionSpecs :: TypeGraph -> [StructuralProjection]+projectionSpecs graph =+ sortOn spTag . concat $+ [ projectionsForRoot graph declaration shape+ | ResolvedStructural declaration shape <- Map.elems (tgDeclarations graph)+ ]++projectionsForRoot :: TypeGraph -> StructuralDecl -> ResolvedMappedShape -> [StructuralProjection]+projectionsForRoot graph root rootShape = case rootShape of+ RRecord _ _ fields -> concatMap (walkField [] []) fields+ REnum {} -> []+ RUnion {} -> []+ where+ walkField keys selectors field+ | rwfPresence field /= PRequired = []+ | otherwise = case projectionScalar (rwfType field) of+ Just result -> [mkProjection (keys <> [rwfKey field]) (selectors <> [(shapeModuleForOwner, rwfHaskell field)]) result]+ Nothing -> case rwfType field of+ RRef key -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural nested (RRecord _ _ nestedFields)) ->+ concatMap+ (walkNested nested (keys <> [rwfKey field]) (selectors <> [(shapeModuleForOwner, rwfHaskell field)]))+ nestedFields+ _ -> []+ _ -> []+ where+ shapeModuleForOwner = "__SHAPE__." <> sdName root++ walkNested owner keys selectors field+ | rwfPresence field /= PRequired = []+ | otherwise = case projectionScalar (rwfType field) of+ Just result -> [mkProjection (keys <> [rwfKey field]) (selectors <> [(shapeModuleFor owner, rwfHaskell field)]) result]+ Nothing -> case rwfType field of+ RRef key -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural nested (RRecord _ _ nestedFields)) ->+ concatMap+ (walkNested nested (keys <> [rwfKey field]) (selectors <> [(shapeModuleFor owner, rwfHaskell field)]))+ nestedFields+ _ -> []+ _ -> []++ -- Context is supplied when rendering; this marker is replaced there.+ shapeModuleFor declaration = "__SHAPE__." <> sdName declaration+ mkProjection keys selectors result =+ StructuralProjection+ { spTag = projectionTag (sdName root) pointer,+ spWitness = lowerFirst (projectionTag (sdName root) pointer) <> "Witness",+ spPointer = pointer,+ spOwner = sdHaskell root,+ spResult = result,+ spCanonical = sdCanonical root,+ spBinding = sdBinding root,+ spSelectors = selectors+ }+ where+ pointer = T.concat ["/" <> escapePointer key | key <- keys]++projectionScalar :: ResolvedTypeExpr -> Maybe Text+projectionScalar = \case+ RText -> Just "Text"+ RInt -> Just "Int"+ RInteger -> Just "Integer"+ RBool -> Just "Bool"+ RTime -> Just "UTCTime"+ RNatural -> Just "Natural"+ RJson -> Nothing+ ROptional {} -> Nothing+ RList {} -> Nothing+ RMap {} -> Nothing+ RRef {} -> Nothing++escapePointer :: Text -> Text+escapePointer = T.replace "/" "~1" . T.replace "~" "~0"++projectionTag :: Name -> Text -> Text+projectionTag owner pointer = "StructuralProjection" <> encodeIdentifier (owner <> pointer)++encodeIdentifier :: Text -> Text+encodeIdentifier = T.concatMap (\character -> "C" <> T.pack (showHex (ord character) "") <> "Z")++emitStructuralProjections :: Context -> TypeGraph -> Text+emitStructuralProjections ctx graph =+ nl $+ [ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE TypeApplications #-}",+ "{-# LANGUAGE TypeFamilies #-}",+ generatedBanner,+ "-- Equality witnesses are emitted for Text, Int, Bool, Natural, and UTCTime.",+ "-- Int, Natural, and UTCTime belong to Keiki's ordered subset.",+ "module " <> moduleName,+ " ( " <> T.intercalate "\n , " (map spWitness specs),+ " ) where",+ "",+ "import Data.Text (Text)",+ "import Data.Time (UTCTime)",+ "import Numeric.Natural (Natural)",+ "import Keiro.Codec.Structural (bindingToShape)",+ "import Keiki.Core (FieldProjection (..), FieldWitness, fieldWitness)"+ ]+ <> map ("import " <>) imports+ <> concatMap renderProjection specs+ where+ moduleName = structuralProjectionModule ctx+ specs = map (resolveProjectionModules ctx) (projectionSpecs graph)+ imports =+ sort . nub $+ [hsModule (spOwner spec) <> " qualified" | spec <- specs]+ <> [qualifiedModule (spBinding spec) <> " qualified" | spec <- specs]+ <> [shapeModuleName <> " qualified" | spec <- specs, (shapeModuleName, _) <- spSelectors spec]+ renderProjection spec =+ [ "",+ "data " <> spTag spec,+ "",+ "instance FieldProjection " <> spTag spec <> " where",+ " type FieldName " <> spTag spec <> " = " <> tshow (spPointer spec),+ " type FieldOwner " <> spTag spec <> " = " <> renderHaskellSource (spOwner spec),+ " type FieldResult " <> spTag spec <> " = " <> spResult spec,+ " fieldShapeId _ = " <> tshow (unCanonicalTypeId (spCanonical spec)),+ " projectFieldValue _ owner = " <> renderGetter spec,+ "",+ spWitness spec <> " :: FieldWitness " <> spTag spec,+ spWitness spec <> " = fieldWitness @" <> spTag spec+ ]+ renderGetter spec =+ foldl+ (\value (shapeModuleName, selector) -> shapeModuleName <> "." <> selector <> " (" <> value <> ")")+ ("bindingToShape " <> unQualifiedValueName (spBinding spec) <> " owner")+ (spSelectors spec)++resolveProjectionModules :: Context -> StructuralProjection -> StructuralProjection+resolveProjectionModules ctx spec =+ spec+ { spSelectors =+ [ (replaceModule marker, selector)+ | (marker, selector) <- spSelectors spec+ ]+ }+ where+ replaceModule marker+ | Just name <- T.stripPrefix "__SHAPE__." marker = structuralShapeModule ctx name+ | otherwise = structuralShapeModule ctx (lastSegment marker)++qualifiedModule :: QualifiedValueName -> Text+qualifiedModule = fst . splitQualified . unQualifiedValueName++renderHaskellSource :: HaskellSource -> Text+renderHaskellSource source = hsModule source <> "." <> hsType source++splitQualified :: Text -> (Text, Text)+splitQualified value =+ let (prefix, name) = T.breakOnEnd "." value+ in (T.dropEnd 1 prefix, name)++lastSegment :: Text -> Text+lastSegment = snd . T.breakOnEnd "."++-- | Emit all modules for one aggregate. The 'Spec' is needed for the shared+-- id\/enum declarations.+scaffoldAggregate :: Context -> Spec -> Aggregate -> [ScaffoldModule]+scaffoldAggregate ctx spec agg =+ [ genModule a "Domain" (emitDomain a),+ genModule a "Codec" (emitCodec a)+ ]+ ++ ( if hasVersion2Ownership a+ then+ [ genModule a "Expressions" (emitExpressions a),+ genModule a "Transducer" (emitGeneratedTransducer a)+ ]+ else []+ )+ ++ [ genModule a "EventStream" (emitEventStream a),+ genModule a "Projection" (emitProjection a),+ holeModule a (emitHoles a)+ ]+ where+ a = resolveAgg ctx spec agg++-- | Emit the context-wide replay-audit target assembly.+--+-- There is one existential target per aggregate declaration. Process saga+-- aggregates are ordinary aggregate nodes referenced by 'SagaRef', so they are+-- included by the same single source of truth rather than being duplicated from+-- the process declaration.+scaffoldReplayAudit :: Context -> Spec -> [ScaffoldModule]+scaffoldReplayAudit ctx spec+ | null aggregates = []+ | otherwise =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" moduleName <> ".hs"),+ moduleText = emitReplayAudit,+ kind = Generated,+ origin = "context " <> specContext spec <> " replay-audit assembly"+ }+ ]+ where+ aggregates = [aggregate | NAggregate aggregate <- specNodes spec]+ moduleName = contextGeneratedPrefix ctx <> ".ReplayAudit"+ contextGeneratedPrefix context = case placement context of+ GeneratedPrefix -> rootPrefix context <> "Generated." <> ctxPascalOf context+ CollocatedLeaf -> rootPrefix context <> ctxPascalOf context <> ".Generated"+ emitReplayAudit =+ nl $+ [ "{-# LANGUAGE GADTs #-}",+ generatedBanner,+ "--",+ "-- Deployment contract:",+ "-- * replay-neutral diff: no data audit is required;",+ "-- * affected diff: run AuditTargeted with the emitted affected set",+ "-- against a production copy under the candidate binary;",+ "-- * one-time runtime cutover: run AuditFull;",+ "-- * any non-zero audit exit blocks deployment.",+ "module " <> moduleName <> " (auditTargets) where",+ ""+ ]+ ++ [ "import " <> genPrefixFor ctx (aggName aggregate) <> ".EventStream qualified as " <> aggName aggregate+ | aggregate <- aggregates+ ]+ ++ [ "import Keiro.ReplayAudit (AuditTarget (..), SomeAuditTarget (..), streamInCategory)",+ "import Keiro.Stream qualified as Stream",+ "",+ "auditTargets :: [SomeAuditTarget]",+ "auditTargets ="+ ]+ ++ concat+ [ [ if index == (0 :: Int) then " [ SomeAuditTarget" else " , SomeAuditTarget",+ " AuditTarget",+ " { eventStream = " <> aggregateName <> "." <> lowerFirst aggregateName <> "EventStream",+ " , category = Stream.categoryText " <> aggregateName <> "." <> lowerFirst aggregateName <> "Category",+ " , mkStream = streamInCategory (Stream.categoryText " <> aggregateName <> "." <> lowerFirst aggregateName <> "Category)",+ " }"+ ]+ | (index, aggregate) <- zip [0 ..] aggregates,+ let aggregateName = aggName aggregate+ ]+ ++ [" ]"]++genModule :: Agg -> Text -> Text -> ScaffoldModule+genModule a name body =+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" (aGenPrefix a) <> "/" <> name <> ".hs"),+ moduleText = body,+ kind = Generated,+ origin = nodeOrigin "aggregate" (aName a) (aLoc a)+ }++holeModule :: Agg -> Text -> ScaffoldModule+holeModule a body =+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" (aHolePrefix a) <> "/" <> "Holes.hs"),+ moduleText = body,+ kind = HoleStub,+ origin = nodeOrigin "aggregate" (aName a) (aLoc a)+ }++--------------------------------------------------------------------------------+-- Integration contract (EP-4): a self-contained payload ADT + codec+--------------------------------------------------------------------------------++-- | Emit the deterministic, symbol-free contract layer: a payload ADT+-- (per-event records), the topic constants, the @messageType@ discriminator, and a+-- strict encode\/decode keyed by it. Self-contained (base\/text\/aeson), so it+-- compiles standalone — the cross-service schema both producer and consumer agree+-- on. No keiki symbolic operator (firewall holds).+scaffoldContract :: Context -> ContractNode -> [ScaffoldModule]+scaffoldContract ctx c =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Contract.hs"),+ moduleText = emitContractGen genPrefix c,+ kind = Generated,+ origin = nodeOrigin "contract" (ctrName c) (ctrLoc c)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (pascal (ctrName c))++emitContractGen :: Text -> ContractNode -> Text+emitContractGen genPrefix c =+ nl $+ [ "{-# LANGUAGE DuplicateRecordFields #-}",+ "{-# LANGUAGE OverloadedRecordDot #-}",+ "{-# OPTIONS_GHC -Wno-unused-top-binds #-}",+ generatedBanner,+ "module " <> genPrefix <> ".Contract",+ " ( " <> payloadTy <> " (..)",+ nl [" , " <> ceName e <> "Data (..)" | e <- ctrEvents c],+ " , messageTypeOf",+ " , encode" <> payloadTy,+ " , parse" <> payloadTy,+ " ) where",+ "",+ "import Data.Aeson (Value, object, withObject, (.:), (.=))",+ "import Data.Aeson.Types (Parser, parseEither)",+ "import Data.Text (Text)",+ "import qualified Data.Text as T",+ "",+ "-- topic constants"+ ]+ ++ [lowerFirst alias <> "Topic :: Text\n" <> lowerFirst alias <> "Topic = " <> tshow t | (alias, t) <- ctrTopics c]+ ++ [ "",+ "-- the closed payload set (discriminated by " <> tshow (ctrDiscriminator c) <> ")"+ ]+ ++ [emitPayloadAdt payloadTy (ctrEvents c)]+ ++ [ "",+ "messageTypeOf :: " <> payloadTy <> " -> Text",+ "messageTypeOf = \\case"+ ]+ ++ [" " <> ceName e <> " {} -> " <> tshow (ceName e) | e <- ctrEvents c]+ ++ [ "",+ "encode" <> payloadTy <> " :: " <> payloadTy <> " -> Value",+ "encode" <> payloadTy <> " = \\case"+ ]+ ++ concatMap encodeArm (ctrEvents c)+ ++ [ "",+ "parse" <> payloadTy <> " :: Value -> Either Text " <> payloadTy,+ "parse" <> payloadTy <> " = mapLeftText . parseEither (withObject " <> tshow payloadTy <> " go)",+ " where",+ " go o = do",+ " kind <- o .: " <> tshow (ctrDiscriminator c) <> " :: Parser Text",+ " case kind of"+ ]+ ++ concatMap decodeArm (ctrEvents c)+ ++ [ " _ -> fail \"unknown message type\"",+ "",+ "mapLeftText :: Either String b -> Either Text b",+ "mapLeftText = either (Left . T.pack) Right"+ ]+ where+ payloadTy = pascal (ctrName c) <> "Payload"+ encodeArm e =+ [ " " <> ceName e <> " payload ->",+ " object"+ ]+ ++ [lead i kv | (i, kv) <- zip [(0 :: Int) ..] ((tshow (ctrDiscriminator c) <> " .= (" <> tshow (ceName e) <> " :: Text)") : [tshow (cfName f) <> " .= payload." <> cfName f | f <- ceFields e])]+ ++ [" ]"]+ lead 0 kv = " [ " <> kv+ lead _ kv = " , " <> kv+ decodeArm e =+ [ " " <> tshow (ceName e) <> " ->",+ " " <> ceName e <> " <$> (" <> ceName e <> "Data" <> fieldApps (ceFields e) <> ")"+ ]+ fieldApps [] = ""+ fieldApps fs = " <$> " <> T.intercalate " <*> " ["o .: " <> tshow (cfName f) | f <- fs]++emitPayloadAdt :: Text -> [ContractEvent] -> Text+emitPayloadAdt tyName events =+ sectionsOf [map dataRecord events, [sumDecl]]+ where+ hsType CText = "Text"+ hsType CInt = "Int"+ hsType (CTypeId _) = "Text"+ dataRecord e =+ "data "+ <> ceName e+ <> "Data = "+ <> ceName e+ <> "Data { "+ <> T.intercalate ", " [cfName f <> " :: !" <> hsType (cfType f) | f <- ceFields e]+ <> " }\n deriving stock (Eq, Show)"+ arm e = ceName e <> " !" <> ceName e <> "Data"+ sumDecl = case events of+ [] -> "data " <> tyName <> " = " <> tyName <> "Empty\n deriving stock (Eq, Show)"+ (e : es) ->+ nl $+ ["data " <> tyName <> " = " <> arm e]+ ++ [" | " <> arm e2 | e2 <- es]+ ++ [" deriving stock (Eq, Show)"]++--------------------------------------------------------------------------------+-- Integration intake (EP-4): inbox disposition vs the live Keiro.Inbox runtime+--------------------------------------------------------------------------------++-- | Emit the inbox node's deterministic disposition wiring compiled against the+-- LIVE @Keiro.Inbox.Types@: the dedupe policy (a real 'InboxDedupePolicy') and a+-- disposition function over the real @InboxResult@ (Processed\/Duplicate\/+-- InProgress\/PreviouslyFailed). This pins the dangerous inversions+-- (duplicate ⇒ ackOk, previouslyFailed ⇒ deadLetter) as compiled code over the+-- runtime types. The handler-level decode\/dedupe\/store failures are noted but not+-- part of @InboxResult@. Firewall holds (no keiki symbolic operator).+scaffoldIntake :: Context -> IntakeNode -> [ScaffoldModule]+scaffoldIntake ctx i =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Inbox.hs"),+ moduleText = emitIntakeGen genPrefix i,+ kind = Generated,+ origin = nodeOrigin "intake" (inkName i) (inkLoc i)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (pascal (inkName i))++emitIntakeGen :: Text -> IntakeNode -> Text+emitIntakeGen genPrefix i =+ nl+ [ "{-# OPTIONS_GHC -Wno-unused-top-binds #-}",+ generatedBanner,+ "module " <> genPrefix <> ".Inbox",+ " ( InboxAck (..)",+ " , inboxDedupePolicy",+ " , inboxPersistence",+ " , inboxDisposition",+ " ) where",+ "",+ "import Keiro.Inbox.Types (InboxDedupePolicy (..), InboxPersistence (..), InboxResult (..))",+ "",+ "-- The dedupe policy (hole-kind 4), lowered to the live InboxDedupePolicy.",+ "inboxDedupePolicy :: InboxDedupePolicy",+ "inboxDedupePolicy = " <> inkDedupePolicy i,+ "",+ "{- | Success-path envelope retention passed to runInboxTransactionWith.",+ "Failures always retain their full operator-facing dead-letter envelope.",+ "Dedupe-only success rows decode with an empty payload.",+ "-}",+ "inboxPersistence :: InboxPersistence",+ "inboxPersistence = " <> persistenceCtor (inkPersist i),+ "",+ "-- The service's ack decision for each inbox classification.",+ "data InboxAck = InboxAckOk | InboxRetry | InboxDeadLetter",+ " deriving stock (Eq, Show)",+ "",+ "-- The disposition table (hole-kind 2) over the LIVE Keiro.Inbox.Types.InboxResult.",+ "-- duplicate => ackOk and previouslyFailed => deadLetter are the dangerous",+ "-- inversions the spec states explicitly.",+ "inboxDisposition :: InboxResult a -> InboxAck",+ "inboxDisposition r = case r of",+ " InboxProcessed _ -> " <> ackFor "processed",+ " InboxDuplicate -> " <> ackFor "duplicate",+ " InboxInProgress -> " <> ackFor "inProgress",+ " InboxPreviouslyFailed _ -> " <> ackFor "previouslyFailed",+ "",+ "-- handler-level failures (not InboxResult): decodeFailed => "+ <> ackText "decodeFailed"+ <> ", dedupeFailed => "+ <> ackText "dedupeFailed"+ <> ", storeFailed => "+ <> ackText "storeFailed"+ ]+ where+ act o = lookup o [(drOutcome r, drAction r) | r <- inkDisposition i]+ ackFor o = case act o of+ Just IAckOk -> "InboxAckOk"+ Just (IRetry _) -> "InboxRetry"+ Just (IDeadLetter _) -> "InboxDeadLetter"+ Nothing -> "InboxRetry"+ ackText o = case act o of+ Just IAckOk -> "ackOk"+ Just (IRetry _) -> "retry"+ Just (IDeadLetter _) -> "deadLetter"+ Nothing -> "retry"+ persistenceCtor InkPersistFull = "PersistFullEnvelope"+ persistenceCtor InkPersistDedupeOnly = "PersistDedupeOnly"++--------------------------------------------------------------------------------+-- Integration publisher (EP-4): config vs the live Keiro.Outbox runtime+--------------------------------------------------------------------------------++-- | Emit the publisher's at-least-once policy compiled against the LIVE+-- @Keiro.Outbox.Types@: the ordering policy (a real 'OrderingPolicy'), the backoff+-- curve (a real 'BackoffSchedule'), and the max-attempts ceiling. Firewall holds.+scaffoldPublisher :: Context -> PublisherNode -> [ScaffoldModule]+scaffoldPublisher ctx pb =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Publisher.hs"),+ moduleText = emitPublisherGen genPrefix pb,+ kind = Generated,+ origin = nodeOrigin "publisher" (pubName pb) (pubLoc pb)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (pascal (pubName pb))++emitPublisherGen :: Text -> PublisherNode -> Text+emitPublisherGen genPrefix pb =+ nl+ [ "{-# OPTIONS_GHC -Wno-unused-top-binds #-}",+ generatedBanner,+ "module " <> genPrefix <> ".Publisher",+ " ( publisherOrdering",+ " , publisherBackoff",+ " , publisherMaxAttempts",+ " ) where",+ "",+ "import Keiro.Outbox.Types (BackoffSchedule (..), ExponentialBackoffOptions (..), OrderingPolicy (..))",+ "",+ "publisherOrdering :: OrderingPolicy",+ "publisherOrdering = " <> pubOrdering pb,+ "",+ "publisherBackoff :: BackoffSchedule",+ "publisherBackoff = " <> backoffExpr (pubBackoff pb),+ "",+ "publisherMaxAttempts :: Int",+ "publisherMaxAttempts = " <> tshow' (pubMaxAttempts pb)+ ]+ where+ backoffExpr b = case boKind b of+ "constant" -> "ConstantBackoff " <> windowText (boWindow b)+ "exponential" ->+ "ExponentialBackoff ExponentialBackoffOptions { initial = "+ <> windowText (boWindow b)+ <> ", maxDelay = "+ <> maybe "0" windowText (boMax b)+ <> ", multiplier = "+ <> fromMaybe "0" (boMultiplier b)+ <> " }"+ _ -> "error \"keiro-dsl: unlowerable backoff kind\""++--------------------------------------------------------------------------------+-- pgmq workqueue (EP-5): a self-contained Job payload record + codec+--------------------------------------------------------------------------------++-- | Emit the deterministic, symbol-free pgmq layer: the Job payload record, the+-- field→wire-name JSON codec, and the captured physical\/dlq\/table name constants.+-- Self-contained (base\/text\/aeson). The fan-out body and the raw-SQL dedup+-- predicate are holes (not emitted). Firewall holds.+scaffoldWorkqueue :: Context -> WorkqueueNode -> [ScaffoldModule]+scaffoldWorkqueue ctx w =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Queue.hs"),+ moduleText = emitWorkqueueGen genPrefix w,+ kind = Generated,+ origin = nodeOrigin "workqueue" (wqName w) (wqLoc w)+ },+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/QueuePolicy.hs"),+ moduleText = emitQueuePolicy genPrefix w,+ kind = Generated,+ origin = nodeOrigin "workqueue" (wqName w) (wqLoc w)+ },+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/QueueCodec.hs"),+ moduleText = emitQueueCodec genPrefix w,+ kind = Generated,+ origin = nodeOrigin "workqueue" (wqName w) (wqLoc w)+ }+ ]+ where+ genPrefix = genPrefixFor ctx (pascal (wqName w))++emitWorkqueueGen :: Text -> WorkqueueNode -> Text+emitWorkqueueGen genPrefix w =+ nl $+ [ "{-# LANGUAGE OverloadedRecordDot #-}",+ "{-# OPTIONS_GHC -Wno-unused-top-binds #-}",+ generatedBanner,+ "module " <> genPrefix <> ".Queue",+ " ( " <> payloadTy <> " (..)",+ " , encode" <> payloadTy,+ " , parse" <> payloadTy,+ " , queuePhysical, queueDlq, queueTable",+ groupKeyExport,+ " ) where",+ "",+ "import Data.Aeson (Value, object, withObject, (.:), (.=))",+ "import Data.Aeson.Types (parseEither)",+ "import Data.Text (Text)",+ "import qualified Data.Text as T",+ "",+ "queuePhysical, queueDlq, queueTable :: Text",+ "queuePhysical = " <> tshow (wqPhysical w),+ "queueDlq = " <> tshow (wqDlq w),+ "queueTable = " <> tshow (wqTable w),+ ""+ ]+ ++ groupKeyLines+ ++ [ "data " <> payloadTy <> " = " <> payloadTy,+ " { " <> T.intercalate "\n , " [wqfName f <> " :: !" <> hsType (wqfType f) | f <- wqPayload w],+ " }",+ " deriving stock (Eq, Show)",+ "",+ "encode" <> payloadTy <> " :: " <> payloadTy <> " -> Value",+ "encode" <> payloadTy <> " p =",+ " object"+ ]+ ++ [lead i (tshow (wqfWire f) <> " .= p." <> wqfName f) | (i, f) <- zip [(0 :: Int) ..] (wqPayload w)]+ ++ [ " ]",+ "",+ "parse" <> payloadTy <> " :: Value -> Either Text " <> payloadTy,+ "parse" <> payloadTy <> " = mapLeftText . parseEither (withObject " <> tshow payloadTy <> " go)",+ " where",+ " go o = " <> payloadTy <> fieldApps (wqPayload w),+ "",+ "mapLeftText :: Either String b -> Either Text b",+ "mapLeftText = either (Left . T.pack) Right"+ ]+ where+ payloadTy = wqPayloadName w+ groupKeyExport = case wqGroupKey w of+ Nothing -> ""+ Just groupKey+ | gkVia groupKey == "raw" -> " , groupKeyField, groupKeyFor"+ | otherwise -> " , groupKeyField"+ groupKeyLines = case wqGroupKey w of+ Nothing -> []+ Just groupKey -> common <> derivationLines groupKey+ where+ common =+ [ "groupKeyField :: Text",+ "groupKeyField = " <> tshow (gkField groupKey),+ ""+ ]+ derivationLines key+ | gkVia key == "raw" =+ [ "groupKeyFor :: " <> payloadTy <> " -> Text",+ "groupKeyFor payload = payload." <> gkField key,+ ""+ ]+ | otherwise =+ [ "-- Opaque group-key derivation '" <> gkVia key <> "' remains hand-owned.",+ "-- Captured fixture: " <> fromMaybe "<missing>" (gkFixture key),+ ""+ ]+ hsType "bool" = "Bool"+ hsType "int" = "Int"+ hsType _ = "Text"+ lead 0 kv = " [ " <> kv+ lead _ kv = " , " <> kv+ fieldApps [] = ""+ fieldApps fs = " <$> " <> T.intercalate " <*> " ["o .: " <> tshow (wqfWire f) | f <- fs]++-- | Emit the versioned PGMQ envelope adapter. The payload record remains+-- symbol-free and dependency-light in Queue.hs; this runtime-facing module is+-- the opt-in assembly point applications import into their Job values.+emitQueueCodec :: Text -> WorkqueueNode -> Text+emitQueueCodec genPrefix w =+ nl+ [ generatedBanner,+ "{- | Versioned job payload envelope: @{\\\"v\\\",\\\"t\\\",\\\"data\\\"}@.",+ "",+ "Deploy workers before producers when raising its schema version. Do not",+ "adopt this codec on a non-empty bare-payload queue without draining it",+ "(or supplying a transitional codec), or in-flight messages will",+ "dead-letter. This is telemetry-neutral:",+ "docs/adr/0001-keiro-pgmq-job-processing-telemetry-contract.md owns",+ "spans and acknowledgement vocabulary.",+ "-}",+ "module " <> genPrefix <> ".QueueCodec (" <> stem <> "PayloadCodec, " <> stem <> "JobCodec) where",+ "",+ "import Data.List.NonEmpty (NonEmpty (..))",+ "import Keiro.Codec (Codec (..), EventType (..))",+ "import Keiro.PGMQ.Codec (JobCodec, keiroJobCodec)",+ "import " <> genPrefix <> ".Queue (" <> payloadTy <> ", encode" <> payloadTy <> ", parse" <> payloadTy <> ")",+ "",+ stem <> "PayloadCodec :: Codec " <> payloadTy,+ stem <> "PayloadCodec =",+ " Codec",+ " { eventTypes = EventType " <> tshow payloadTy <> " :| []",+ " , eventType = \\_ -> EventType " <> tshow payloadTy,+ " , schemaVersion = 1",+ " , encode = encode" <> payloadTy,+ " , decode = \\_ -> parse" <> payloadTy,+ " , upcasters = []",+ " }",+ "",+ stem <> "JobCodec :: JobCodec " <> payloadTy,+ stem <> "JobCodec = keiroJobCodec " <> stem <> "PayloadCodec"+ ]+ where+ payloadTy = wqPayloadName w+ stem = lowerFirst (T.concat (map pascal (T.splitOn "_" (wqName w))))++-- | Emit the pgmq retry policy + JobOutcome disposition compiled against the+-- LIVE @Keiro.PGMQ.Job@ runtime (RetryPolicy / JobOutcome / RetryDelay). This pins+-- the dangerous inversions over the runtime types: storeFailure ⇒ Retry (transient)+-- and decodeFailure ⇒ Dead (poison).+emitQueuePolicy :: Text -> WorkqueueNode -> Text+emitQueuePolicy genPrefix w =+ nl $+ [ generatedBanner,+ "module " <> genPrefix <> ".QueuePolicy",+ " ( retryPolicy, jobOutcomeFor",+ " , jobOrdering, jobTuningFor, queueProvision",+ " ) where",+ "",+ "import Data.Text (Text)",+ "import Keiro.PGMQ.Job (JobOrdering (..), JobOutcome (..), JobTuning, PartitionSpec (..), QueueProvision, RetryDelay (..), RetryPolicy (..), partitionedProvision, standardProvision, unloggedProvision, withFifoIndexProvision, withOrdering)",+ "",+ "jobOrdering :: JobOrdering",+ "jobOrdering = " <> orderingCtor,+ "",+ "-- Deployment owns visibility timeout, batch size, and polling; the spec owns ordering.",+ "jobTuningFor :: JobTuning -> JobTuning",+ "jobTuningFor = withOrdering jobOrdering",+ "",+ "-- Pass this to ensureJobQueueWith at worker startup. FIFO adds the required GIN index; the DLQ remains standard.",+ "queueProvision :: QueueProvision",+ "queueProvision = " <> provisionExpr,+ "",+ "retryPolicy :: RetryPolicy",+ "retryPolicy =",+ " RetryPolicy",+ " { maxRetries = " <> tshow' (wqMaxRetries w),+ " , defaultRetryDelay = RetryDelay " <> windowText (wqDelay w),+ " , useDeadLetter = " <> (if wqDlqOn w then "True" else "False"),+ " }",+ "",+ "-- The consumer JobOutcome disposition over the spec's named domain outcomes,",+ "-- lowered to the live Keiro.PGMQ.Job.JobOutcome.",+ "jobOutcomeFor :: Text -> JobOutcome",+ "jobOutcomeFor o = case o of"+ ]+ ++ [" " <> tshow (wqdOutcome r) <> " -> " <> outcome (wqdAction r) | r <- wqDisposition w]+ ++ [" _ -> Retry (RetryDelay " <> windowText (wqDelay w) <> ")"]+ where+ orderingCtor = case wqOrdering w of+ WqUnordered -> "Unordered"+ WqFifoThroughput -> "FifoThroughput"+ WqFifoRoundRobin -> "FifoRoundRobin"+ provisionExpr = fifoWrap baseProvision+ fifoWrap expression = case wqOrdering w of+ WqUnordered -> expression+ _ -> "withFifoIndexProvision (" <> expression <> ")"+ baseProvision = case wqProvision w of+ WqStandard -> "standardProvision"+ WqUnlogged -> "unloggedProvision"+ WqPartitioned interval retention ->+ "partitionedProvision (PartitionSpec { partitionInterval = "+ <> tshow interval+ <> ", retentionInterval = "+ <> tshow retention+ <> " })"+ outcome IAckOk = "Done"+ outcome (IRetry win) = "Retry (RetryDelay " <> windowText win <> ")"+ outcome (IDeadLetter mr) = "Dead " <> tshow (fromMaybe "dead-lettered" mr)++--------------------------------------------------------------------------------+-- First-class read models (EP-107)+--------------------------------------------------------------------------------++-- | Emit an acyclic three-module read-model vertical. @ReadModelTable@ owns the+-- qualified-table constant shared by the hand-owned query and the generated+-- runtime record; @ReadModel@ re-exports it as part of the public surface.+scaffoldReadModel :: Context -> ReadModelNode -> [ScaffoldModule]+scaffoldReadModel ctx readModel =+ [ generated "ReadModelTable" (emitReadModelTable tableModule stem readModel),+ generated "ReadModel" (emitReadModelGen ctx readModelModule tableModule readModelHolePrefix stem readModel),+ ScaffoldModule+ { modulePath = modulePathFor readModelHolePrefix "ReadModelHoles",+ moduleText = emitReadModelHoles tableModule readModelHolePrefix stem readModel,+ kind = HoleStub,+ origin = readModelOrigin+ }+ ]+ where+ nodeSegment = pascal (rmName readModel)+ stem = readModelStem readModel+ readModelModule = genPrefixFor ctx nodeSegment+ tableModule = readModelModule <> ".ReadModelTable"+ readModelHolePrefix = holePrefixFor ctx nodeSegment+ readModelOrigin = nodeOrigin "readmodel" (rmName readModel) (rmLoc readModel)+ generated leaf body =+ ScaffoldModule+ { modulePath = modulePathFor readModelModule leaf,+ moduleText = body,+ kind = Generated,+ origin = readModelOrigin+ }++modulePathFor :: Text -> Text -> FilePath+modulePathFor prefix leaf = T.unpack (T.replace "." "/" prefix <> "/" <> leaf <> ".hs")++readModelStem :: ReadModelNode -> Text+readModelStem = lowerFirst . T.concat . map pascal . T.splitOn "_" . rmName++emitReadModelTable :: Text -> Text -> ReadModelNode -> Text+emitReadModelTable tableModule stem readModel =+ nl+ [ generatedBanner,+ "module " <> tableModule <> " (" <> qualifiedName <> ") where",+ "",+ "import Data.Text (Text)",+ "import Keiro.Connection (qualifyTable)",+ "",+ "-- The fully-qualified, double-quoted data-table reference.",+ qualifiedName <> " :: Text",+ qualifiedName <> " = qualifyTable " <> tshow (rmSchema readModel) <> " " <> tshow (rmTable readModel)+ ]+ where+ qualifiedName = stem <> "QualifiedTable"++emitReadModelGen :: Context -> Text -> Text -> Text -> Text -> ReadModelNode -> Text+emitReadModelGen ctx readModelModule tableModule readModelHolePrefix stem readModel =+ nl $+ [ "{-# LANGUAGE OverloadedRecordDot #-}",+ generatedBanner,+ "module " <> readModelModule <> ".ReadModel",+ " ( " <> T.intercalate "\n , " exports,+ " ) where",+ "",+ "import Data.Functor (void)",+ "import Effectful (Eff, (:>))",+ "import " <> tableModule <> " (" <> qualifiedName <> ")",+ "import " <> readModelHolePrefix <> ".ReadModelHoles (" <> T.intercalate ", " holeImports <> ")"+ ]+ ++ asyncImports+ ++ [ "import Keiro.ReadModel (ConsistencyMode (..), ReadModel (..), ReadModelMetadata, StrongScope (..), registerReadModel)",+ "import Keiro.ReadModel.Rebuild qualified as Rebuild",+ "import Kiroku.Store.Effect (Store)",+ "import Kiroku.Store.Types (" <> kirokuTypes <> ")",+ "",+ readModelName <> " :: ReadModel " <> queryInputType <> " " <> queryResultType,+ readModelName <> " =",+ " ReadModel",+ " { name = " <> tshow registryName,+ " , tableName = " <> tshow (rmTable readModel),+ " , schema = " <> tshow (rmSchema readModel),+ " , subscriptionName = " <> tshow subscriptionName,+ " , version = " <> tshow' (rmVersion readModel),+ " , shapeHash = " <> tshow (rmShape readModel),+ " , defaultConsistency = " <> consistencyExpr (rmConsistency readModel),+ " , strongScope = " <> scopeExpr (rmScope readModel),+ " , query = " <> queryName,+ " }",+ "",+ "-- Call once at projection startup before serving queries.",+ registerName <> " :: (Store :> es) => Eff es ()",+ registerName <> " =",+ " void (registerReadModel " <> tshow registryName <> " " <> tshow' (rmVersion readModel) <> " " <> tshow (rmShape readModel) <> ")",+ "",+ startName <> " :: (Store :> es) => GlobalPosition -> Eff es ReadModelMetadata",+ startName <> " =",+ " Rebuild.startRebuild " <> readModelName <> " " <> projectionNames,+ "",+ finishName <> " :: (Store :> es) => GlobalPosition -> Eff es (Either Rebuild.RebuildError ReadModelMetadata)",+ finishName <> " =",+ " Rebuild.finishRebuild " <> readModelName <> " " <> projectionNames,+ "",+ abandonName <> " :: (Store :> es) => Eff es ReadModelMetadata",+ abandonName <> " = Rebuild.abandonRebuild " <> readModelName+ ]+ ++ asyncDefinition+ where+ registryName = registryNameFor (contextName ctx) readModel+ subscriptionName = subscriptionNameFor (contextName ctx) readModel+ asyncName = registryName <> "-async"+ readModelName = stem <> "ReadModel"+ qualifiedName = stem <> "QualifiedTable"+ registerName = "register" <> pascal stem+ startName = "start" <> pascal stem <> "Rebuild"+ finishName = "finish" <> pascal stem <> "Rebuild"+ abandonName = "abandon" <> pascal stem <> "Rebuild"+ asyncValueName = stem <> "AsyncProjection"+ queryInputType = pascal stem <> "QueryInput"+ queryResultType = pascal stem <> "QueryResult"+ queryName = stem <> "Query"+ applyName = "apply" <> pascal stem+ exports =+ [ readModelName,+ qualifiedName,+ registerName,+ startName,+ finishName,+ abandonName+ ]+ ++ [asyncValueName | rmFeed readModel == RmSubscription]+ holeImports = [queryInputType, queryResultType, queryName] ++ [applyName | rmFeed readModel == RmSubscription]+ asyncImports = case rmFeed readModel of+ RmInline -> []+ RmSubscription -> ["import Keiro.Projection (AsyncProjection (..))"]+ kirokuTypes = case rmFeed readModel of+ RmInline -> "GlobalPosition"+ RmSubscription -> "GlobalPosition, RecordedEvent (..)"+ projectionNames = case rmFeed readModel of+ RmInline -> "[]"+ RmSubscription -> "[" <> tshow asyncName <> "]"+ asyncDefinition = case rmFeed readModel of+ RmInline -> []+ RmSubscription ->+ [ "",+ asyncValueName <> " :: AsyncProjection",+ asyncValueName <> " =",+ " AsyncProjection",+ " { name = " <> tshow asyncName,+ " , readModelName = " <> tshow registryName,+ " , subscriptionName = " <> tshow subscriptionName,+ " , applyRecorded = " <> applyName,+ " , idempotencyKey = \\recorded -> recorded.eventId",+ " }"+ ]+ consistencyExpr Strong = "Strong"+ consistencyExpr Eventual = "Eventual"+ scopeExpr Nothing = "EntireLog"+ scopeExpr (Just RmEntireLog) = "EntireLog"+ scopeExpr (Just (RmCategory categoryName)) = "CategoryHead " <> tshow categoryName++emitReadModelHoles :: Text -> Text -> Text -> ReadModelNode -> Text+emitReadModelHoles tableModule readModelHolePrefix stem readModel =+ nl $+ [ "-- This is a HAND-OWNED hole module. keiro-dsl creates it once and never overwrites it.",+ "module " <> readModelHolePrefix <> ".ReadModelHoles",+ " ( " <> T.intercalate "\n , " exports,+ " ) where",+ "",+ "import " <> tableModule <> " (" <> qualifiedName <> ")",+ "import Hasql.Transaction qualified as Tx"+ ]+ ++ ["import Kiroku.Store.Types (RecordedEvent(..))" | rmFeed readModel == RmSubscription]+ ++ [ "",+ "-- HOLE: replace these aliases with the real query input and result types.",+ "type " <> queryInputType <> " = ()",+ "type " <> queryResultType <> " = ()",+ "",+ "-- HOLE: query " <> qualifiedTableLiteral readModel <> " via " <> qualifiedName <> "; never rely on search_path.",+ "-- Declared columns:"+ ]+ ++ map (("-- " <>) . readModelColumnDoc) (rmColumns readModel)+ ++ [ queryName <> " :: " <> queryInputType <> " -> Tx.Transaction " <> queryResultType,+ queryName <> " _input = " <> qualifiedName <> " `seq` error " <> tshow ("HOLE: fill " <> rmName readModel <> " query")+ ]+ ++ applyStub+ where+ qualifiedName = stem <> "QualifiedTable"+ queryInputType = pascal stem <> "QueryInput"+ queryResultType = pascal stem <> "QueryResult"+ queryName = stem <> "Query"+ applyName = "apply" <> pascal stem+ exports = [queryInputType, queryResultType, queryName] ++ [applyName | rmFeed readModel == RmSubscription]+ applyStub = case rmFeed readModel of+ RmInline -> []+ RmSubscription ->+ [ "",+ "-- HOLE: apply one recorded event; runtime deduplication makes redelivery safe.",+ applyName <> " :: RecordedEvent -> Tx.Transaction ()",+ applyName <> " _recorded = error " <> tshow ("HOLE: fill " <> rmName readModel <> " async apply")+ ]++qualifiedTableLiteral :: ReadModelNode -> Text+qualifiedTableLiteral readModel = quoteSqlIdentifier (rmSchema readModel) <> "." <> quoteSqlIdentifier (rmTable readModel)++quoteSqlIdentifier :: Text -> Text+quoteSqlIdentifier identifier = "\"" <> T.replace "\"" "\"\"" identifier <> "\""++readModelColumnDoc :: RmColumn -> Text+readModelColumnDoc columnDecl =+ rmcName columnDecl+ <> " "+ <> rmcType columnDecl+ <> if rmcRequired columnDecl then " NOT NULL" else ""++--------------------------------------------------------------------------------+-- Router + shared worker-policy lowering (EP-108)+--------------------------------------------------------------------------------++scaffoldRouter :: Context -> RouterNode -> [ScaffoldModule]+scaffoldRouter ctx router =+ [ ScaffoldModule+ { modulePath = modulePathFor genPrefix "Router",+ moduleText = emitRouterGen genPrefix router,+ kind = Generated,+ origin = routerOrigin+ },+ ScaffoldModule+ { modulePath = modulePathFor holePrefix "RouterHoles",+ moduleText = emitRouterHoles holePrefix router,+ kind = HoleStub,+ origin = routerOrigin+ }+ ]+ where+ genPrefix = genPrefixFor ctx (rtId router)+ holePrefix = holePrefixFor ctx (rtId router)+ routerOrigin = nodeOrigin "router" (rtId router) (rtLoc router)++emitRouterGen :: Text -> RouterNode -> Text+emitRouterGen genPrefix router =+ nl $+ [ generatedBanner,+ "module " <> genPrefix <> ".Router",+ " ( " <> stem <> "Name",+ " , " <> stem <> "WorkerOptions",+ " ) where",+ "",+ "import Data.Text (Text)"+ ]+ ++ workerPolicyImports (rtPoison router)+ ++ [ "",+ "-- The STABLE router name. It participates in every target-keyed",+ "-- deterministicRouterCommandId; renaming it re-keys replayed dispatches.",+ stem <> "Name :: Text",+ stem <> "Name = " <> tshow (rtName router),+ "",+ "-- Runtime-owned dispatch id inputs: (name, key, sourceEventId,",+ "-- targetStreamName, occurrence). Target-keyed, not positional.",+ "",+ "-- Node-level worker policy lowered from the spec. Pass this value to",+ "-- Keiro.Router.runRouterWorkerWith; do not silently use defaultWorkerOptions."+ ]+ ++ workerOptionsLines (stem <> "WorkerOptions") (rtRejected router) (rtPoison router)+ where+ stem = lowerFirst (rtId router)++emitRouterHoles :: Text -> RouterNode -> Text+emitRouterHoles holePrefix router =+ nl+ [ "-- HAND-OWNED hole module for the router's behaviour-bearing bodies.",+ "-- keiro-dsl creates it once and never overwrites it.",+ "module " <> holePrefix <> ".RouterHoles () where",+ "",+ "-- HOLE resolve :: " <> inName (rtInput router) <> " -> Eff es [PMCommand targetCommand]",+ "-- Spec source: " <> resolveSourceText (rvSource (rtResolve router)) <> ".",+ "-- The spec's 'stable' keyword acknowledges that retry attempts accumulate",+ "-- the UNION of resolved target identities. Keep the recipient set stable",+ "-- for a source event whenever an exact recipient set matters.",+ "-- HOLE router value: assemble Keiro.Router.Router with name = " <> lowerFirst (rtId router) <> "Name,",+ "-- key, resolve, targetEventStream, and targetProjections; run it with",+ "-- runRouterWorkerWith " <> lowerFirst (rtId router) <> "WorkerOptions.",+ "-- HOLE targetProjections: spec projections = " <> renderNames (rtProjections router) <> ".",+ "-- NOTE on-duplicate AckOk is sound because Keiro.Router confirms a duplicate",+ "-- event id against the TARGET stream via confirmBenignDuplicate before",+ "-- returning PMCommandDuplicate. Hand-rolled dispatch paths must do likewise."+ ]+ where+ renderNames names = "[" <> T.intercalate ", " names <> "]"++resolveSourceText :: ResolveSource -> Text+resolveSourceText (ResolveReadModel name) = "read-model " <> name <> " (typically Keiro.ReadModel.runQuery)"+resolveSourceText ResolveHole = "typed resolver hole"++workerPolicyImports :: PolicyChoice -> [Text]+workerPolicyImports poison =+ [ "import Keiro.ProcessManager (PoisonPolicy (..), RejectedCommandPolicy (..), WorkerOptions (..))",+ "import Shibuya.Core.Ack (RetryDelay (..))"+ ]+ ++ if poison == PolHalt+ then []+ else ["import Effectful (Eff)", "import Shibuya.Core.Types (Envelope)"]++workerOptionsLines :: Text -> PolicyChoice -> PolicyChoice -> [Text]+workerOptionsLines valueName rejected poison =+ [ valueName <> signature,+ valueName <> argument <> " =",+ " WorkerOptions",+ " { poisonPolicy = " <> poisonExpr,+ " , rejectedCommandPolicy = " <> rejectedExpr rejected,+ " , transientRetryDelay = RetryDelay 5 -- matches defaultWorkerOptions; runtime tuning",+ " , metrics = Nothing -- runtime configuration; install at call site",+ " }"+ ]+ where+ signature = case poison of+ PolHalt -> " :: WorkerOptions es msg"+ _ -> " :: (Envelope msg -> Eff es ()) -> WorkerOptions es msg"+ argument = case poison of+ PolHalt -> ""+ _ -> " poisonCallback"+ poisonExpr = case poison of+ PolHalt -> "PoisonHalt"+ PolDeadLetter -> "PoisonDeadLetter poisonCallback"+ PolSkip -> "PoisonSkip poisonCallback"+ rejectedExpr = \case+ PolHalt -> "RejectedHalt"+ PolDeadLetter -> "RejectedDeadLetter"+ PolSkip -> "RejectedSkip"++--------------------------------------------------------------------------------+-- Process manager + durable timer (EP-3)+--------------------------------------------------------------------------------++-- | Emit the symbol-free deterministic wiring for a process manager + its timer+-- into a @Generated@ module, plus a create-if-absent @ProcessHoles@ module for the+-- behaviour-bearing bodies (the @handle@ reaction, the deadline window, and the+-- fire command). The @Generated@ module contains no keiki symbolic operator (the+-- saga's transducer is the separate aggregate hole), so the firewall invariant+-- holds. The timer worker uses the spec's @max-attempts@ ceiling, never the+-- dangerous @defaultTimerWorkerOptions@ (@Nothing@) default.+scaffoldProcess :: Context -> ProcessNode -> [ScaffoldModule]+scaffoldProcess ctx p =+ [ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" genPrefix <> "/Process.hs"),+ moduleText = emitProcessGen ctxPascal genPrefix holePrefix p,+ kind = Generated,+ origin = nodeOrigin "process" (procId p) (procLoc p)+ },+ ScaffoldModule+ { modulePath = T.unpack (T.replace "." "/" holePrefix <> "/ProcessHoles.hs"),+ moduleText = emitProcessHoles genPrefix holePrefix p,+ kind = HoleStub,+ origin = nodeOrigin "process" (procId p) (procLoc p)+ }+ ]+ where+ ctxPascal = pascalFromKebab (contextName ctx)+ genPrefix = genPrefixFor ctx (procId p)+ holePrefix = holePrefixFor ctx (procId p)++emitProcessGen :: Text -> Text -> Text -> ProcessNode -> Text+emitProcessGen _ctxPascal genPrefix _holePrefix p =+ nl $+ [ generatedBanner,+ "module " <> genPrefix <> ".Process",+ " ( " <> lo <> "ProcessName",+ " , " <> lo <> "Category",+ " , " <> lo <> "ProcessWorkerOptions",+ " , " <> lo <> "TimerRequest",+ " , " <> lo <> "FireOutcome",+ " ) where",+ "",+ "import Data.Aeson (Value, object, (.=))",+ "import Data.Text (Text)",+ "import qualified Data.Text as T",+ "import Data.Time (UTCTime)",+ "import Data.UUID (UUID)",+ "import qualified Data.UUID.V5 as UUID.V5",+ "import Keiro.Command (CommandError (..))",+ "import Keiro.Stream qualified as Stream",+ "import Keiro.Timer (TimerId (..), TimerRequest (..))"+ ]+ ++ workerPolicyImports (procPoison p)+ ++ [ "",+ "-- The define-once ProcessManager name (hole-kind 5: referenced, never retyped).",+ lo <> "ProcessName :: Text",+ lo <> "ProcessName = " <> tshow (procName p),+ "",+ "-- The validated saga stream category (hole-kind 5: referenced, never retyped).",+ "-- Saga streams are '<category>-<correlationId>' via Keiro.Stream.entityStream.",+ "-- categoryUnsafe is safe here because keiro-dsl check proved the literal legal.",+ lo <> "Category :: Stream.StreamCategory a",+ lo <> "Category = Stream.categoryUnsafe " <> tshow categoryName,+ "",+ "-- Node-level worker policy lowered from the spec. Pass this value to",+ "-- Keiro.ProcessManager.runProcessManagerWorkerWith."+ ]+ ++ workerOptionsLines (lo <> "ProcessWorkerOptions") (procRejected p) (procPoison p)+ ++ [ "",+ "-- The deterministic timer-request builder: id derived from the correlation",+ "-- key (hole-kind 1), processManagerName referenced, payload from the spec.",+ "-- (timer id derived as uuidv5 of " <> tshow (idePrefix (tmId timer)) <> " <> correlationId)",+ lo <> "TimerRequest :: Text -> UTCTime -> TimerRequest",+ lo <> "TimerRequest correlationId fireAtTime =",+ " TimerRequest",+ " { timerId = TimerId (namedUuid (" <> tshow (idePrefix (tmId timer)) <> " <> correlationId))",+ " , processManagerName = " <> lo <> "ProcessName",+ " , correlationId = correlationId",+ " , fireAt = fireAtTime",+ " , payload = " <> payloadExpr (tmPayload timer),+ " }",+ "",+ "-- The timer-fire disposition table (hole-kind 2), derived from the spec.",+ "-- on-reject => " <> showOutcome (onReject fd) <> " is the benign inversion.",+ "-- A duplicate append reaches on-error unless it is confirmed against the",+ "-- target stream. Use Keiro.ProcessManager.confirmBenignDuplicate:",+ "-- StreamName -> EventId -> CommandError -> Eff es Bool",+ "-- Fold True into the duplicate result and surface False as the failure.",+ lo <> "FireOutcome :: Either CommandError a -> Maybe ()",+ lo <> "FireOutcome result = case result of",+ " Right{} -> " <> outcomeToMaybe (onOk fd),+ " Left CommandRejected -> " <> outcomeToMaybe (onReject fd),+ " Left (CommandAmbiguous _) -> " <> outcomeToMaybe (onAmbiguous fd) <> " -- explicit definition-bug arm",+ " Left{} -> " <> outcomeToMaybe (onError fd),+ "",+ "-- max-attempts = " <> tshow' (tmMaxAttempts timer) <> ", dead-letter = " <> tshow (tmDeadLetter timer),+ "-- (the timer worker must pass Just " <> tshow' (tmMaxAttempts timer) <> " to runTimerWorkerWith, never the",+ "-- defaultTimerWorkerOptions Nothing ceiling that retries forever).",+ "",+ "-- deterministic v5 UUID of a correlation-keyed string (hole-kind 1).",+ "namedUuid :: Text -> UUID",+ "namedUuid v = UUID.V5.generateNamed UUID.V5.namespaceURL (map (fromIntegral . fromEnum) (T.unpack v))"+ ]+ where+ lo = lowerFirst (procId p)+ categoryName = staticCategory ("process " <> procId p) (sagaCategory (procSaga p))+ timer = procTimer p+ fd = fireDisposition (tmFire timer)++-- | The timer payload, restricted to the spec's literal (@name=\"value\"@)+-- bindings so it compiles in the deterministic builder. Bare fields and+-- ref-valued bindings are input-driven (the agent-written hole), not emitted.+payloadExpr :: [FieldBinding] -> Text+payloadExpr fs = case [b | b <- fs, isLiteral b] of+ [] -> "object []"+ lits -> "object [ " <> T.intercalate ", " (map kv lits) <> " ]"+ where+ isLiteral b = maybe False (const True) (fbValue b >>= stripWrappingQuotes)+ kv b = tshow (fbName b) <> " .= (" <> maybe "\"\"" tshow (fbValue b >>= stripWrappingQuotes) <> " :: Value)"+ stripWrappingQuotes value = T.stripPrefix "\"" value >>= T.stripSuffix "\""++showOutcome :: FireOutcome -> Text+showOutcome OFired = "Fired"+showOutcome ORetry = "Retry"++outcomeToMaybe :: FireOutcome -> Text+outcomeToMaybe OFired = "Just () -- Fired"+outcomeToMaybe ORetry = "Nothing -- Retry"++emitProcessHoles :: Text -> Text -> ProcessNode -> Text+emitProcessHoles _genPrefix holePrefix p =+ nl+ [ "-- HAND-OWNED hole module for the process manager's behaviour-bearing bodies.",+ "-- keiro-dsl creates it once and never overwrites it.",+ "module " <> holePrefix <> ".ProcessHoles () where",+ "",+ "-- HOLE handle: build the ProcessManagerAction (the self-advance",+ "-- '" <> advCommand (hAdvance (procHandle p)) <> "', the dispatch(es), and the timer) from the input.",+ "-- HOLE streams: build streamFor with entityStream " <> lowerFirst (procId p) <> "Category;",+ "-- build target streams with entityStream " <> lowerFirst (procTarget p) <> "Category. Never concatenate raw stream names.",+ "-- HOLE window: the deadline policy, e.g. surgeWindow :: NominalDiffTime;",+ "-- surgeDeadline observedAt = addUTCTime surgeWindow observedAt (TIME INJECTED).",+ "-- HOLE fire command: construct " <> fireCommand (tmFire (procTimer p)) <> " for the timer fire,",+ "-- keyed by correlationId; the fired-event-id is the deterministic uuidv5 of",+ "-- " <> tshow (idePrefix (fireFiredEventId (tmFire (procTimer p)))) <> " <> correlationId.",+ "-- NOTE on-duplicate AckOk is sound because the runtime confirms a duplicate",+ "-- event id against the TARGET stream via confirmBenignDuplicate before",+ "-- returning PMCommandDuplicate. Its effective signature is:",+ "-- StreamName -> EventId -> CommandError -> Eff es Bool",+ "-- Hand-rolled paths must call it with the target stream and attempted event id,",+ "-- fold True into the duplicate result, and surface False as the original failure.",+ "-- Never pattern-match DuplicateEvent as success: event ids are globally unique."+ ]++--------------------------------------------------------------------------------+-- Domain module+--------------------------------------------------------------------------------++emitDomain :: Agg -> Text+emitDomain a =+ nl $+ [ "{-# LANGUAGE DataKinds #-}"+ ]+ ++ ["{-# LANGUAGE DeriveAnyClass #-}" | hasSnapshot a]+ ++ [ "{-# LANGUAGE DuplicateRecordFields #-}",+ "{-# LANGUAGE TemplateHaskell #-}",+ "{-# LANGUAGE TypeApplications #-}",+ "{-# OPTIONS_GHC -Wno-unused-top-binds #-}",+ generatedBanner,+ "module " <> aGenPrefix a <> ".Domain where",+ ""+ ]+ ++ ["import Data.Aeson (FromJSON, ToJSON)" | hasSnapshot a]+ ++ [ "import Data.Proxy (Proxy (..))",+ "import Data.Text (Text)",+ "import GHC.Generics (Generic)",+ "import Keiki.Core (RegFile (..))"+ ]+ ++ ["import Keiki.Shape (CanonicalStateShape, CanonicalTypeName)" | hasSnapshot a]+ ++ map ("import " <>) (domainConsumerImports a)+ ++ [ "import Keiki.Generics.TH (deriveAggregateCtorsAll, deriveWireCtorsAll)",+ "",+ sectionsOf+ [ map (emitId a) [declaration | declaration <- aIds a, idBinding declaration == Nothing],+ map (emitEnum a) [declaration | declaration <- aEnums a, enumBinding declaration == Nothing],+ [emitVertex a],+ map (emitRecord a) (aCommands a),+ [emitSum (aName a <> "Command") (aCommands a)],+ map (emitRecord a) (aEvents a),+ [emitSum (aName a <> "Event") (aEvents a)],+ [emitRegsType a, emitInitialRegs a],+ [ "$(deriveAggregateCtorsAll ''" <> aName a <> "Command ''" <> aName a <> "Regs)",+ "",+ "$(deriveWireCtorsAll ''" <> aName a <> "Event)"+ ]+ ]+ ]++hasSnapshot :: Agg -> Bool+hasSnapshot = maybe False (const True) . aSnapshot++emitId :: Agg -> IdDecl -> Text+emitId a d =+ nl $+ [ "newtype " <> idName d <> " = " <> idName d <> " Text",+ " deriving stock (Generic, Eq, Ord, Show)"+ ]+ ++ [" deriving anyclass (ToJSON, FromJSON)" | hasSnapshot a]+ ++ ["instance CanonicalTypeName " <> idName d | hasSnapshot a]+ ++ [ "",+ lowerFirst (idName d) <> "Text :: " <> idName d <> " -> Text",+ lowerFirst (idName d) <> "Text (" <> idName d <> " t) = t"+ ]++emitEnum :: Agg -> EnumDecl -> Text+emitEnum a d =+ nl $+ [ "data " <> enumName d <> " = " <> T.intercalate " | " (map fst (enumCtors d)),+ " deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)"+ ]+ ++ [" deriving anyclass (ToJSON, FromJSON)" | hasSnapshot a]+ ++ ["instance CanonicalTypeName " <> enumName d | hasSnapshot a]+ ++ [ "",+ lowerFirst (enumName d) <> "Text :: " <> enumName d <> " -> Text",+ lowerFirst (enumName d) <> "Text = \\case",+ nl [" " <> c <> " -> " <> tshow w | (c, w) <- enumCtors d]+ ]++emitVertex :: Agg -> Text+emitVertex a =+ nl $+ [ "data " <> aVertexType a <> " = " <> T.intercalate " | " (map (vertexCtor a . stName) (aStates a)),+ " deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)"+ ]+ ++ [" deriving anyclass (ToJSON, FromJSON)" | hasSnapshot a]+ ++ [ line+ | hasSnapshot a,+ line <-+ [ "instance CanonicalStateShape " <> aVertexType a,+ "instance CanonicalTypeName " <> aVertexType a+ ]+ ]++emitRecord :: Agg -> ResolvedCtor -> Text+emitRecord a rc =+ nl $+ [ "data " <> rcName rc <> "Data = " <> rcName rc <> "Data"+ ]+ ++ recordFields [(name, renderDomainType a fieldType) | (name, fieldType) <- rcFields rc]+ ++ [" deriving stock (Generic, Eq, Show)"]++recordFields :: [(Text, Text)] -> [Text]+recordFields [] =+ [" {"]+ <> [" }"]+recordFields fs =+ [ lead i <> n <> " :: !" <> ty+ | (i, (n, ty)) <- zip [(0 :: Int) ..] fs+ ]+ ++ [" }"]+ where+ lead 0 = " { "+ lead _ = " , "++emitSum :: Text -> [ResolvedCtor] -> Text+emitSum tyName ctors =+ nl $+ [firstLine] ++ restLines ++ [" deriving stock (Generic, Eq, Show)"]+ where+ arm rc = rc' rc+ rc' rc = rcName rc <> " !" <> rcName rc <> "Data"+ (firstLine, restLines) = case ctors of+ [] -> ("data " <> tyName <> " = ()", [])+ (c : cs) ->+ ( "data " <> tyName <> " = " <> arm c,+ [" | " <> arm c2 | c2 <- cs]+ )++emitRegsType :: Agg -> Text+emitRegsType a =+ nl $+ ["type " <> aName a <> "Regs ="]+ ++ regListLines a (aRegs a)++regListLines :: Agg -> [ResolvedRegister] -> [Text]+regListLines _ [] = [" '[]"]+regListLines a rs =+ [ lead i <> "'(" <> tshow (rrName r) <> ", " <> renderDomainType a (rrType r) <> ")"+ | (i, r) <- zip [(0 :: Int) ..] rs+ ]+ ++ [" ]"]+ where+ lead 0 = " '[ "+ lead _ = " , "++emitInitialRegs :: Agg -> Text+emitInitialRegs a =+ nl $+ [ "initial" <> aName a <> "Regs :: RegFile " <> aName a <> "Regs",+ "initial" <> aName a <> "Regs ="+ ]+ ++ chain (aRegs a)+ where+ chain [] = [" RNil"]+ chain rs =+ [ " RCons (Proxy @" <> tshow (rrName r) <> ") " <> regInitialValue r <> " $"+ | r <- init rs+ ]+ ++ [" RCons (Proxy @" <> tshow (rrName lastR) <> ") " <> regInitialValue lastR <> " RNil"]+ where+ lastR = last rs++-- | The Haskell initial value for a register, by the category of its type.+regInitialValue :: ResolvedRegister -> Text+regInitialValue = renderRegisterInitial . rrInitial++domainConsumerImports :: Agg -> [Text]+domainConsumerImports a =+ sort . nub $+ Set.toList (Set.unions [aggregateImports (aSymbols a) resolved | resolved <- aggregateTypes])+ <> [ qualifiedModule initialValue <> " qualified"+ | declaration <- mappedUses a,+ initialValue <- maybeToListText (mappedInitial declaration)+ ]+ <> [ qualifiedModule initialValue <> " qualified"+ | resolvedType <- aggregateTypes,+ AggregateNominal nominal <- [resolvedType],+ ConsumerNominal binding <- [resolvedNominalOwnership nominal],+ initialValue <- maybeToListText (consumerNominalInitial binding)+ ]+ where+ aggregateTypes = map snd (concatMap rcFields (aCommands a <> aEvents a)) <> map rrType (aRegs a)++mappedUses :: Agg -> [ResolvedMappedDecl]+mappedUses a =+ [ declaration+ | resolvedType <-+ map snd (concatMap rcFields (aCommands a <> aEvents a))+ <> map rrType (aRegs a),+ declaration <- maybeToListText (mappedDeclFor a resolvedType)+ ]++mappedDeclFor :: Agg -> ResolvedAggregateType -> Maybe ResolvedMappedDecl+mappedDeclFor a resolvedType = do+ key <- case resolvedType of+ AggregateMapped mappedKey -> Just mappedKey+ _ -> Nothing+ graph <- aTypeGraph a+ Map.lookup key (tgDeclarations graph)++mappedInitial :: ResolvedMappedDecl -> Maybe QualifiedValueName+mappedInitial (ResolvedStructural declaration _) = sdInitial declaration+mappedInitial (ResolvedOpaque declaration) = odInitial declaration++renderDomainType :: Agg -> ResolvedAggregateType -> Text+renderDomainType a = aggregateHaskellType (aSymbols a)++maybeToListText :: Maybe value -> [value]+maybeToListText = maybe [] pure++--------------------------------------------------------------------------------+-- Codec module+--------------------------------------------------------------------------------++emitCodec :: Agg -> Text+emitCodec a =+ nl $+ ["{-# LANGUAGE DataKinds #-}" | hasConsumerNominalIdCodec a]+ ++ ["{-# LANGUAGE TypeApplications #-}" | hasConsumerNominalIdCodec a]+ ++ ["{-# LANGUAGE LambdaCase #-}" | hasConsumerNominalCodec a]+ ++ [ "{-# LANGUAGE OverloadedRecordDot #-}",+ generatedBanner,+ "module " <> aGenPrefix a <> ".Codec (",+ " " <> lowerFirst (aName a) <> "Codec,",+ " parse" <> aName a <> "Event,",+ " encode" <> aName a <> "Event,"+ ]+ ++ concatMap mappedExports (codecMappedDeclarations a)+ ++ [ ") where",+ "",+ "import " <> aGenPrefix a <> ".Domain"+ ]+ ++ ( if hasMappedCodec a+ then+ [ "import Control.Monad (unless)",+ "import Data.Aeson (Value (..), object, parseJSON, toJSON, withObject, withText, (.:), (.=))",+ "import Data.Aeson.Key qualified as Key",+ "import Data.Aeson.KeyMap qualified as KeyMap"+ ]+ else ["import Data.Aeson (Value, object, withObject, (.:), (.=))"]+ )+ ++ [ "import Data.Aeson.Types (Parser, parseEither)",+ "import Data.List.NonEmpty (NonEmpty (..))"+ ]+ ++ ( if hasMappedCodec a+ then ["import Data.Map.Strict (Map)", "import Data.Map.Strict qualified as Map"]+ else []+ )+ ++ [ "import Data.Text (Text)",+ "import qualified Data.Text as T"+ ]+ ++ ["import Data.KindID qualified as KindID" | hasConsumerNominalIdCodec a]+ ++ ["import Keiro.Codec.Nominal (nominalFromRepresentation, nominalToRepresentation)" | hasConsumerNominalCodec a]+ ++ ["import Keiro.Codec.Structural (bindingFromShape, bindingToShape)" | hasMappedCodec a]+ ++ [ "import Keiro.Codec (Codec (..), EventType (..))",+ upcasterImport a+ ]+ ++ [nl (map ("import " <>) (codecMappedImports a)) | hasMappedCodec a]+ ++ [nl (map ("import " <>) (codecNominalImports a)) | hasConsumerNominalCodec a]+ ++ [ "",+ emitEnumParsers a,+ emitConsumerNominalParsers a+ ]+ ++ [emitMappedCodecs a | hasMappedCodec a]+ ++ [ "",+ emitCodecValue a,+ "",+ emitEncode a,+ "",+ emitDecode a,+ "",+ "mapLeftText :: Either String b -> Either Text b",+ "mapLeftText = either (Left . T.pack) Right"+ ]+ ++ ( if hasMappedCodec a+ then+ [ "",+ "rejectUnknownFields :: String -> [Text] -> KeyMap.KeyMap Value -> Parser ()",+ "rejectUnknownFields label allowed objectValue =",+ " unless (null extras) (fail (label <> \" contains unknown fields: \" <> show extras))",+ " where",+ " extras = filter (`notElem` allowed) (map Key.toText (KeyMap.keys objectValue))"+ ]+ else []+ )+ where+ mappedExports (ResolvedStructural declaration _) =+ [ " encode" <> sdName declaration <> "Mapped,",+ " decode" <> sdName declaration <> "Mapped,"+ ]+ mappedExports ResolvedOpaque {} = []++hasMappedCodec :: Agg -> Bool+hasMappedCodec = not . null . codecMappedDeclarations++hasConsumerNominalCodec :: Agg -> Bool+hasConsumerNominalCodec = not . null . codecConsumerNominals++hasConsumerNominalIdCodec :: Agg -> Bool+hasConsumerNominalIdCodec aggregate =+ any+ (\nominal -> case resolvedNominalRepresentation nominal of IdRepresentation {} -> True; _ -> False)+ (codecConsumerNominals aggregate)++emitEnumParsers :: Agg -> Text+emitEnumParsers a = sectionsOf [[emitEnumParser declaration | declaration <- aEnums a, enumBinding declaration == Nothing]]++emitEnumParser :: EnumDecl -> Text+emitEnumParser d =+ nl $+ [ "parse" <> enumName d <> " :: Text -> Parser " <> enumName d,+ "parse" <> enumName d <> " = \\case"+ ]+ ++ [" " <> tshow w <> " -> pure " <> c | (c, w) <- enumCtors d]+ ++ [" _ -> fail " <> tshow ("unknown " <> enumName d)]++emitConsumerNominalParsers :: Agg -> Text+emitConsumerNominalParsers aggregate = sectionsOf [map emitParser (codecConsumerNominals aggregate)]+ where+ emitParser nominal = case (resolvedNominalRepresentation nominal, resolvedNominalOwnership nominal) of+ (IdRepresentation prefix, ConsumerNominal binding) ->+ nl+ [ parserName nominal <> " :: Text -> Parser " <> renderHaskellSource (consumerNominalHaskell binding),+ parserName nominal <> " input = case KindID.parseText @" <> tshow prefix <> " input of",+ " Left reason -> fail (show reason)",+ " Right representation -> pure (nominalFromRepresentation " <> unQualifiedValueName (consumerNominalBinding binding) <> " representation)"+ ]+ (EnumRepresentation constructors, ConsumerNominal binding) ->+ nl $+ [ parserName nominal <> " :: Text -> Parser " <> renderHaskellSource (consumerNominalHaskell binding),+ parserName nominal <> " = \\case"+ ]+ <> [ " "+ <> tshow wire+ <> " -> pure (nominalFromRepresentation "+ <> unQualifiedValueName (consumerNominalBinding binding)+ <> " "+ <> nominalRepresentationModule (aContext aggregate) (resolvedNominalName nominal)+ <> "."+ <> constructor+ <> ")"+ | (constructor, wire) <- NE.toList constructors+ ]+ <> [" _ -> fail " <> tshow ("unknown " <> resolvedNominalName nominal <> " wire value")]+ _ -> ""+ parserName nominal = "parse" <> resolvedNominalName nominal <> "Nominal"++emitCodecValue :: Agg -> Text+emitCodecValue a =+ nl $+ [ lowerFirst (aName a) <> "Codec :: Codec " <> aName a <> "Event",+ lowerFirst (aName a) <> "Codec =",+ " Codec",+ " { eventTypes = " <> eventTypesExpr,+ " , eventType = \\case"+ ]+ ++ [" " <> rcName e <> "{} -> EventType " <> tshow (rcName e) | e <- aEvents a]+ ++ [ " , schemaVersion = " <> tshow' (maxEventVersion a),+ " , encode = encode" <> aName a <> "Event",+ " , decode = parse" <> aName a <> "Event",+ " , upcasters = " <> upcastersExpr a,+ " }"+ ]+ ++ upcasterRungDecls a+ where+ eventTypesExpr = case map rcName (aEvents a) of+ [] -> "error \"no events\""+ (e : es) -> "EventType " <> tshow e <> " :| [" <> T.intercalate ", " (map (("EventType " <>) . tshow) es) <> "]"++-- | The codec's @schemaVersion@: the maximum declared event version (EP-2).+maxEventVersion :: Agg -> Int+maxEventVersion a = maximum (1 : map rcVersion (aEvents a))++-- | One @(sourceVersion, upcasterName)@ entry per event that declares an+-- @upcast from@. The upcaster name is per-event (e.g. @upcastFooV1@) and its+-- body is a hole in the hand-owned Holes module.+upcasterEntries :: Agg -> [(Int, Text, Text)]+upcasterEntries a =+ [ (m, rcName e, "upcast" <> rcName e <> "V" <> tshow' m)+ | e <- aEvents a,+ Just m <- [rcUpcastFrom e]+ ]++upcastersExpr :: Agg -> Text+upcastersExpr a =+ "[" <> T.intercalate ", " ["(" <> tshow' m <> ", upcastRungV" <> tshow' m <> ")" | (m, _) <- upcasterRungs a] <> "]"++-- | Group event-specific holes into one migration rung per aggregate-global+-- source version. Event metadata stamps every kind with the aggregate's+-- schema version, so a rung must explicitly pass foreign event kinds through.+upcasterRungs :: Agg -> [(Int, [(Text, Text)])]+upcasterRungs a =+ [ (source, [(eventName, fn) | (_, eventName, fn) <- entries])+ | entries@((source, _, _) : _) <- groupBy sameSource (sortOn firstSource (upcasterEntries a))+ ]+ where+ firstSource (source, _, _) = source+ sameSource (source, _, _) (otherSource, _, _) = source == otherSource++upcasterRungDecls :: Agg -> [Text]+upcasterRungDecls a = concatMap rung (upcasterRungs a)+ where+ rung (source, entries) =+ [ "",+ "upcastRungV" <> tshow' source <> " :: EventType -> Value -> Either Text Value"+ ]+ ++ [ "upcastRungV" <> tshow' source <> " (EventType " <> tshow eventName <> ") value = " <> fn <> " value"+ | (eventName, fn) <- entries+ ]+ ++ [ "-- Kinds whose shape did not change at this rung pass through unchanged; their",+ "-- stamped version is aggregate-global, not their own shape history.",+ "upcastRungV" <> tshow' source <> " _ value = Right value"+ ]++-- | When the codec references upcasters, it imports their (hole) definitions+-- from the hand-owned Holes module.+upcasterImport :: Agg -> Text+upcasterImport a = case upcasterEntries a of+ [] -> ""+ es -> "import " <> aHolePrefix a <> ".Holes (" <> T.intercalate ", " [fn | (_, _, fn) <- es] <> ")"++emitEncode :: Agg -> Text+emitEncode a =+ nl $+ [ "encode" <> aName a <> "Event :: " <> aName a <> "Event -> Value",+ "encode" <> aName a <> "Event = \\case"+ ]+ ++ concatMap encodeArm (aEvents a)+ where+ encodeArm e =+ [ " " <> rcName e <> " payload ->",+ " object"+ ]+ ++ [ lead i <> kv+ | (i, kv) <- zip [(0 :: Int) ..] (("\"kind\" .= (" <> tshow (rcName e) <> " :: Text)") : map encodeField (rcFields e))+ ]+ ++ [" ]"]+ lead 0 = " [ "+ lead _ = " , "+ encodeField (n, ty) =+ tshow n+ <> " .= "+ <> encodeFieldValue n ty+ encodeFieldValue name ty = case ty of+ AggregateNominal nominal -> encodeNominalValue nominal ("payload." <> name)+ _ -> case fieldCat a ty of+ MappedStructuralCat declaration _ -> "encode" <> sdName declaration <> "Mapped payload." <> name+ MappedOpaqueCat {} -> "toJSON payload." <> name+ _ -> "payload." <> name+ encodeNominalValue nominal value = case resolvedNominalOwnership nominal of+ GeneratedNominal -> case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> lowerFirst (resolvedNominalName nominal) <> "Text " <> value+ EnumRepresentation {} -> lowerFirst (resolvedNominalName nominal) <> "Text " <> value+ ScalarRepresentation {} -> value+ ConsumerNominal binding -> case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> "KindID.toText (nominalToRepresentation " <> bindingName binding <> " " <> value <> ")"+ EnumRepresentation {} ->+ nominalRepresentationModule (aContext a) (resolvedNominalName nominal)+ <> "."+ <> lowerFirst (resolvedNominalName nominal)+ <> "RepresentationText (nominalToRepresentation "+ <> bindingName binding+ <> " "+ <> value+ <> ")"+ ScalarRepresentation {} -> "nominalToRepresentation " <> bindingName binding <> " " <> value+ bindingName = unQualifiedValueName . consumerNominalBinding++emitDecode :: Agg -> Text+emitDecode a =+ nl $+ [ "parse" <> aName a <> "Event :: EventType -> Value -> Either Text " <> aName a <> "Event",+ "parse" <> aName a <> "Event (EventType tag) = mapLeftText . parseEither (withObject " <> tshow (aName a <> "Event") <> " go)",+ " where",+ " go o = do",+ " case tag of"+ ]+ ++ concatMap decodeArm (aEvents a)+ ++ [" _ -> fail \"unknown event type\""]+ where+ decodeArm e =+ [ " " <> tshow (rcName e) <> " ->",+ " " <> rcName e <> " <$> (" <> rcName e <> "Data" <> fieldApps (rcFields e) <> ")"+ ]+ fieldApps [] = ""+ fieldApps fs = " <$> " <> T.intercalate " <*> " (map decodeField fs)+ -- The first field uses <$> (handled above), the rest <*>. We instead build+ -- a uniform list and join; for an empty record there are no fields.+ decodeField (n, ty) = case ty of+ AggregateNominal nominal -> decodeNominalField n nominal+ _ -> case fieldCat a ty of+ MappedStructuralCat declaration _ -> "(o .: " <> tshow n <> " >>= parse" <> sdName declaration <> "Mapped)"+ MappedOpaqueCat {} -> "o .: " <> tshow n+ _ -> "o .: " <> tshow n+ decodeNominalField name nominal = case resolvedNominalOwnership nominal of+ GeneratedNominal -> case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> "(" <> resolvedNominalName nominal <> " <$> o .: " <> tshow name <> ")"+ EnumRepresentation {} -> "(o .: " <> tshow name <> " >>= parse" <> resolvedNominalName nominal <> ")"+ ScalarRepresentation {} -> "o .: " <> tshow name+ ConsumerNominal binding -> case resolvedNominalRepresentation nominal of+ IdRepresentation {} -> "(o .: " <> tshow name <> " >>= parse" <> resolvedNominalName nominal <> "Nominal)"+ EnumRepresentation {} -> "(o .: " <> tshow name <> " >>= parse" <> resolvedNominalName nominal <> "Nominal)"+ ScalarRepresentation {} -> "(nominalFromRepresentation " <> unQualifiedValueName (consumerNominalBinding binding) <> " <$> o .: " <> tshow name <> ")"++codecConsumerNominals :: Agg -> [ResolvedNominalType]+codecConsumerNominals aggregate =+ Map.elems . Map.fromList $+ [ (resolvedNominalName nominal, nominal)+ | event <- aEvents aggregate,+ (_, AggregateNominal nominal) <- rcFields event,+ ConsumerNominal {} <- [resolvedNominalOwnership nominal]+ ]++codecNominalImports :: Agg -> [Text]+codecNominalImports aggregate =+ sort . nub $+ concat+ [ [ hsModule (consumerNominalHaskell binding) <> " qualified",+ qualifiedModule (consumerNominalBinding binding) <> " qualified"+ ]+ <> [ nominalRepresentationModule (aContext aggregate) (resolvedNominalName nominal) <> " qualified"+ | EnumRepresentation {} <- [resolvedNominalRepresentation nominal]+ ]+ | nominal <- codecConsumerNominals aggregate,+ ConsumerNominal binding <- [resolvedNominalOwnership nominal]+ ]++codecMappedImports :: Agg -> [Text]+codecMappedImports a = case aTypeGraph a of+ Nothing -> []+ Just graph ->+ sort . nub $+ [ structuralShapeModule (aContext a) (sdName declaration) <> " qualified"+ | ResolvedStructural declaration _ <- codecMappedDeclarations a+ ]+ <> [ hsModule (sdHaskell declaration) <> " qualified"+ | ResolvedStructural declaration _ <- codecMappedDeclarations a+ ]+ <> [ qualifiedModule (sdBinding declaration) <> " qualified"+ | ResolvedStructural declaration _ <- codecMappedDeclarations a+ ]+ <> [ hsModule (odHaskell declaration) <> " qualified"+ | ResolvedOpaque declaration <- codecMappedDeclarations a+ ]+ <> [ hsModule (odHaskell declaration) <> " qualified"+ | ResolvedStructural _ shape <- codecMappedDeclarations a,+ key <- directShapeRefs shape,+ Just (ResolvedOpaque declaration) <- [Map.lookup key (tgDeclarations graph)]+ ]++codecMappedDeclarations :: Agg -> [ResolvedMappedDecl]+codecMappedDeclarations a = case aTypeGraph a of+ Nothing -> []+ Just graph ->+ mapMaybe (\key -> Map.lookup key (tgDeclarations graph)) (sort (Map.keys selected))+ where+ roots =+ [ key+ | event <- aEvents a,+ (_, AggregateMapped key) <- rcFields event,+ Map.member key (tgDeclarations graph)+ ]+ selected =+ Map.fromList+ [ (key, ())+ | root <- roots,+ key <- root : maybe [] (Map.keys . Map.fromSet (const ())) (Map.lookup root (tgReachability graph))+ ]++directShapeRefs :: ResolvedMappedShape -> [MappedKey]+directShapeRefs =+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields -> concatMap (exprRefs . rwfType) fields,+ onEnum = const [],+ onUnion = \_ arms -> concatMap (maybe [] exprRefs . rwaPayload) arms+ }++exprRefs :: ResolvedTypeExpr -> [MappedKey]+exprRefs =+ foldTypeExpr+ TypeExprAlgebra+ { onText = [],+ onInt = [],+ onInteger = [],+ onBool = [],+ onNatural = [],+ onTime = [],+ onJson = [],+ onOptional = id,+ onList = id,+ onMap = id,+ onRef = pure+ }++emitMappedCodecs :: Agg -> Text+emitMappedCodecs a = case aTypeGraph a of+ Nothing -> ""+ Just graph ->+ T.intercalate+ "\n\n"+ [ emitStructuralCodec a graph declaration shape+ | ResolvedStructural declaration shape <- codecMappedDeclarations a+ ]++emitStructuralCodec :: Agg -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text+emitStructuralCodec a graph declaration shape =+ nl+ [ "encode" <> name <> "Mapped :: " <> consumerType <> " -> Value",+ "encode" <> name <> "Mapped = encode" <> name <> "Shape . bindingToShape " <> binding,+ "",+ "parse" <> name <> "Mapped :: Value -> Parser " <> consumerType,+ "parse" <> name <> "Mapped value = bindingFromShape " <> binding <> " <$> parse" <> name <> "Shape value",+ "",+ "decode" <> name <> "Mapped :: Value -> Either Text " <> consumerType,+ "decode" <> name <> "Mapped = mapLeftText . parseEither parse" <> name <> "Mapped",+ "",+ "encode" <> name <> "Shape :: " <> shapeType <> " -> Value",+ emitShapeEncoder a graph declaration shape,+ "",+ "parse" <> name <> "Shape :: Value -> Parser " <> shapeType,+ emitShapeDecoder a graph declaration shape+ ]+ where+ name = sdName declaration+ consumerType = renderHaskellSource (sdHaskell declaration)+ shapeType = structuralShapeModule (aContext a) name <> "." <> name <> "Shape"+ binding = unQualifiedValueName (sdBinding declaration)++emitShapeEncoder :: Agg -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text+emitShapeEncoder a graph declaration =+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields ->+ nl $+ ["encode" <> name <> "Shape shape =", " object"]+ <> objectEntries+ [ tshow (rwfKey field)+ <> " .= "+ <> encodeShapeExpr a graph (rwfType field) (shapeModuleName <> "." <> rwfHaskell field <> " shape")+ | field <- fields+ ],+ onEnum = \entries ->+ nl $+ ["encode" <> name <> "Shape = \\case"]+ <> [" " <> shapeModuleName <> "." <> weCtor entry <> " -> String " <> tshow (weTag entry) | entry <- entries],+ onUnion = \encoding arms ->+ nl $+ ["encode" <> name <> "Shape = \\case"]+ <> concatMap (unionEncodeArm encoding) arms+ }+ where+ name = sdName declaration+ shapeModuleName = structuralShapeModule (aContext a) name+ unionEncodeArm encoding arm =+ [ " " <> shapeModuleName <> "." <> rwaCtor arm <> payloadPattern <> " ->",+ " object"+ ]+ <> objectEntries+ ( [tshow (ueTagField encoding) <> " .= (" <> tshow (rwaTag arm) <> " :: Text)"]+ <> [ tshow (ueContentsField encoding) <> " .= " <> encodeShapeExpr a graph payload "payload"+ | payload <- maybeToListText (rwaPayload arm)+ ]+ )+ where+ payloadPattern = maybe "" (const " payload") (rwaPayload arm)++emitShapeDecoder :: Agg -> TypeGraph -> StructuralDecl -> ResolvedMappedShape -> Text+emitShapeDecoder a graph declaration =+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \constructor unknownFields fields ->+ nl $+ [ "parse" <> name <> "Shape = withObject " <> tshow (name <> "Shape") <> " $ \\objectValue -> do"+ ]+ <> rejectLine " " unknownFields (map rwfKey fields) "objectValue"+ <> [ " " <> shapeModuleName <> "." <> constructor,+ " <$> " <> T.intercalate "\n <*> " (map (decodeRecordField a graph) fields)+ ],+ onEnum = \entries ->+ nl $+ [ "parse" <> name <> "Shape = withText " <> tshow (name <> "Shape") <> " $ \\tag -> case tag of"+ ]+ <> [" " <> tshow (weTag entry) <> " -> pure " <> shapeModuleName <> "." <> weCtor entry | entry <- entries]+ <> [" _ -> fail " <> tshow ("unknown " <> name <> " wire value")],+ onUnion = \encoding arms ->+ nl $+ [ "parse" <> name <> "Shape = withObject " <> tshow (name <> "Shape") <> " $ \\objectValue -> do",+ " tag <- objectValue .: " <> tshow (ueTagField encoding) <> " :: Parser Text",+ " case tag of"+ ]+ <> concatMap (unionDecodeArm encoding) arms+ <> [" _ -> fail " <> tshow ("unknown " <> name <> " union tag")]+ }+ where+ name = sdName declaration+ shapeModuleName = structuralShapeModule (aContext a) name+ rejectLine _ IgnoreUnknown _ _ = []+ rejectLine indent RejectUnknown allowed objectName =+ [indent <> "rejectUnknownFields " <> tshow name <> " " <> renderTextList allowed <> " " <> objectName]+ unionDecodeArm encoding arm =+ [" " <> tshow (rwaTag arm) <> " -> do"]+ <> rejectLine " " (ueUnknownFields encoding) allowed "objectValue"+ <> [ case rwaPayload arm of+ Nothing -> " pure " <> shapeModuleName <> "." <> rwaCtor arm+ Just payload ->+ " "+ <> shapeModuleName+ <> "."+ <> rwaCtor arm+ <> " <$> (objectValue .: "+ <> tshow (ueContentsField encoding)+ <> " >>= ("+ <> decodeShapeExpr a graph payload+ <> "))"+ ]+ where+ allowed = ueTagField encoding : [ueContentsField encoding | rwaPayload arm /= Nothing]++decodeRecordField :: Agg -> TypeGraph -> ResolvedWireField -> Text+decodeRecordField a graph field = case rwfPresence field of+ PRequired ->+ "((objectValue .: " <> key <> " :: Parser Value) >>= (" <> decoder <> "))"+ POptional ->+ "(case KeyMap.lookup (Key.fromText "+ <> key+ <> ") objectValue of Nothing -> "+ <> missing+ <> "; Just presentValue -> "+ <> "("+ <> decoder+ <> ") presentValue)"+ where+ key = tshow (rwfKey field)+ decoder = decodeShapeExpr a graph (rwfType field)+ missing = case rwfOnMissing field of+ Nothing -> "fail " <> tshow ("missing optional field without default: " <> rwfKey field)+ Just onMissing -> "pure " <> renderMissingDefault a graph (rwfType field) onMissing++encodeShapeExpr :: Agg -> TypeGraph -> ResolvedTypeExpr -> Text -> Text+encodeShapeExpr _a graph expression value =+ foldTypeExpr+ TypeExprAlgebra+ { onText = \v -> "toJSON (" <> v <> ")",+ onInt = \v -> "toJSON (" <> v <> ")",+ onInteger = \v -> "toJSON (" <> v <> ")",+ onBool = \v -> "toJSON (" <> v <> ")",+ onNatural = \v -> "toJSON (" <> v <> ")",+ onTime = \v -> "toJSON (" <> v <> ")",+ onJson = id,+ onOptional = \encode v -> "maybe Null (\\item -> " <> encode "item" <> ") (" <> v <> ")",+ onList = \encode v -> "toJSON (map (\\item -> " <> encode "item" <> ") (" <> v <> "))",+ onMap = \encode v -> "toJSON (Map.map (\\item -> " <> encode "item" <> ") (" <> v <> "))",+ onRef = \key v -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural nested _) -> "encode" <> sdName nested <> "Shape (" <> v <> ")"+ Just (ResolvedOpaque _) -> "toJSON (" <> v <> ")"+ Nothing -> "toJSON (" <> v <> ")"+ }+ expression+ value++decodeShapeExpr :: Agg -> TypeGraph -> ResolvedTypeExpr -> Text+decodeShapeExpr _a graph =+ foldTypeExpr+ TypeExprAlgebra+ { onText = "parseJSON",+ onInt = "parseJSON",+ onInteger = "parseJSON",+ onBool = "parseJSON",+ onNatural = "parseJSON",+ onTime = "parseJSON",+ onJson = "pure",+ onOptional = \decode -> "\\value -> case value of Null -> pure Nothing; other -> Just <$> " <> decode <> " other",+ onList = \decode -> "\\value -> (parseJSON value :: Parser [Value]) >>= traverse (" <> decode <> ")",+ onMap = \decode -> "\\value -> (parseJSON value :: Parser (Map Text Value)) >>= traverse (" <> decode <> ")",+ onRef = \key -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural nested _) -> "parse" <> sdName nested <> "Shape"+ Just (ResolvedOpaque _) -> "parseJSON"+ Nothing -> "parseJSON"+ }++renderMissingDefault :: Agg -> TypeGraph -> ResolvedTypeExpr -> OnMissing -> Text+renderMissingDefault a graph expression = \case+ OmNull -> "Nothing"+ OmText value -> tshow value+ OmInt value -> T.pack (show value)+ OmBool value -> if value then "True" else "False"+ OmEmptyList -> "[]"+ OmEmptyMap -> "Map.empty"+ OmCtor constructor -> case expression of+ RRef key -> case Map.lookup key (tgDeclarations graph) of+ Just (ResolvedStructural declaration _) -> structuralShapeModule (aContext a) (sdName declaration) <> "." <> constructor+ _ -> constructor+ _ -> constructor++objectEntries :: [Text] -> [Text]+objectEntries entries =+ [lead index <> entry | (index, entry) <- zip [(0 :: Int) ..] entries]+ <> [" ]"]+ where+ lead 0 = " [ "+ lead _ = " , "++renderTextList :: [Text] -> Text+renderTextList values = "[" <> T.intercalate ", " (map tshow values) <> "]"++--------------------------------------------------------------------------------+-- Authoritative version-2 expressions and transducer+--------------------------------------------------------------------------------++hasVersion2Ownership :: Agg -> Bool+hasVersion2Ownership = any ((/= LegacyHoleImplementation) . tImplementation) . aTransitions++transitionEntries :: Agg -> [(Int, Transition)]+transitionEntries aggregate = zip [1 ..] (aTransitions aggregate)++transitionStem :: Int -> Transition -> Text+transitionStem index transition =+ "transition"+ <> tshow' index+ <> pascal (tSource transition)+ <> pascal (tCommand transition)++guardFunctionName :: Int -> Transition -> Text+guardFunctionName index transition = transitionStem index transition <> "Guard"++writeFunctionName :: Int -> Transition -> Name -> Text+writeFunctionName index transition registerName =+ transitionStem index transition <> "Write" <> pascal registerName++holeFunctionName :: Int -> Transition -> Text+holeFunctionName index transition = transitionStem index transition <> "Hole"++holeFoldVersionName :: Int -> Transition -> Text+holeFoldVersionName index transition = holeFunctionName index transition <> "FoldVersion"++outputFunctionName :: Int -> Transition -> Int -> Name -> Text+outputFunctionName transitionIndex transition emitIndex eventName =+ transitionStem transitionIndex transition+ <> "Output"+ <> tshow' emitIndex+ <> pascal eventName++commandForTransition :: Agg -> Transition -> ResolvedCtor+commandForTransition aggregate transition =+ fromMaybe+ (error ("validated aggregate command disappeared: " <> T.unpack (tCommand transition)))+ (find ((== tCommand transition) . rcName) (aCommands aggregate))++eventForName :: Agg -> Name -> ResolvedCtor+eventForName aggregate eventName =+ fromMaybe+ (error ("validated aggregate event disappeared: " <> T.unpack eventName))+ (find ((== eventName) . rcName) (aEvents aggregate))++commandFieldsType :: Transition -> Text+commandFieldsType transition = "RegFieldsOf " <> tCommand transition <> "Data"++payloadProjectionType :: Agg -> Transition -> Text+payloadProjectionType aggregate transition =+ "B.PayloadProj "+ <> aName aggregate+ <> "Regs "+ <> aName aggregate+ <> "Command ("+ <> commandFieldsType transition+ <> ")"++expressionFunctionNames :: Agg -> [Text]+expressionFunctionNames aggregate =+ concat+ [ maybe [] (const [guardFunctionName index transition]) (tGuard transition)+ <> [writeFunctionName index transition registerName | (registerName, _) <- tWrites transition]+ | (index, transition) <- transitionEntries aggregate,+ tImplementation transition == GeneratedImplementation+ ]++emitExpressions :: Agg -> Text+emitExpressions aggregate+ | null exports = nl [generatedBanner, "module " <> aGenPrefix aggregate <> ".Expressions () where"]+ | otherwise =+ nl $+ [ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE OverloadedLabels #-}",+ "{-# LANGUAGE OverloadedRecordDot #-}",+ "{-# LANGUAGE TypeApplications #-}",+ generatedBanner+ ]+ ++ moduleDeclaration+ ++ [ "",+ "import " <> aGenPrefix aggregate <> ".Domain",+ "import Keiki.Builder qualified as B",+ "import Keiki.Core qualified as K",+ "import Keiki.Generics (RegFieldsOf)"+ ]+ ++ ["import Data.Text (Text)" | expressionUsesType AggregateText]+ ++ ["import Data.Time.Clock (UTCTime)" | expressionUsesType AggregateTime && not expressionUsesTimeLiteral]+ ++ ["import Data.Time.Calendar (fromGregorian)" | expressionUsesTimeLiteral]+ ++ ["import Data.Time.Clock (UTCTime (..), picosecondsToDiffTime)" | expressionUsesTimeLiteral]+ ++ ["import Numeric.Natural (Natural)" | expressionUsesType AggregateNatural]+ ++ structuralProjectionImport+ ++ nominalProjectionImport+ ++ consumerImports+ ++ ["import Data.KindID qualified as KindID" | expressionUsesConsumerIdLiteral]+ ++ ["import Keiro.Codec.Nominal (nominalFromRepresentation)" | expressionUsesConsumerNominalLiteral]+ ++ consumerLiteralImports+ ++ concatMap (uncurry (emitTransitionExpressions aggregate)) (transitionEntries aggregate)+ where+ exports = expressionFunctionNames aggregate+ moduleDeclaration = case exports of+ firstExport : rest ->+ [ "module " <> aGenPrefix aggregate <> ".Expressions",+ " ( " <> firstExport+ ]+ ++ [" , " <> value | value <- rest]+ ++ [" ) where"]+ [] -> error "non-empty expression export invariant"+ structuralProjectionImport =+ [ "import " <> structuralProjectionModule (aContext aggregate) <> " qualified as StructuralProjections"+ | maybe False (not . null . projectionSpecs) (aTypeGraph aggregate)+ ]+ nominalProjectionImport =+ [ "import " <> nominalProjectionModule (aContext aggregate) <> " qualified as NominalProjections"+ | any expressionUsesNominalProjection resolvedExpressions+ ]+ consumerImports =+ map ("import " <>)+ . filter (not . builtinExpressionImport)+ . sort+ . nub+ . Set.toList+ . Set.unions+ $ [ aggregateImports (aSymbols aggregate) resolvedType+ | resolvedType <- map rrType (aRegs aggregate) <> map snd (concatMap rcFields (aCommands aggregate))+ ]+ consumerLiteralImports =+ [ "import " <> nominalRepresentationModule (aContext aggregate) (resolvedNominalName nominal) <> " qualified"+ | nominal <- consumerLiteralNominals,+ EnumRepresentation {} <- [resolvedNominalRepresentation nominal]+ ]+ <> [ "import " <> qualifiedModule (consumerNominalBinding binding) <> " qualified"+ | nominal <- consumerLiteralNominals,+ ConsumerNominal binding <- [resolvedNominalOwnership nominal]+ ]+ resolvedExpressions = resolvedGeneratedExpressions aggregate+ expressionUsesType wanted = any (anyTypedExpression ((== wanted) . typedScalarType)) resolvedExpressions+ expressionUsesTimeLiteral = any (anyTypedExpression isTimeLiteral) resolvedExpressions+ expressionUsesConsumerNominalLiteral = not (null consumerLiteralNominals)+ expressionUsesConsumerIdLiteral = any (isIdRepresentation . resolvedNominalRepresentation) consumerLiteralNominals+ consumerLiteralNominals = nub [nominal | expression <- resolvedExpressions, nominal <- typedConsumerLiteralNominals expression]+ isTimeLiteral expression = case typedScalarNode expression of+ TypedLiteral ScalarTimeValue {} -> True+ _ -> False+ isIdRepresentation IdRepresentation {} = True+ isIdRepresentation _ = False++builtinExpressionImport :: Text -> Bool+builtinExpressionImport imported =+ any (`T.isPrefixOf` imported) ["Data.Text", "Data.Time", "Numeric.Natural"]++resolvedGeneratedExpressions :: Agg -> [TypedScalarExpr]+resolvedGeneratedExpressions aggregate =+ concat+ [ maybe [] (pure . resolvedGuard index transition) (tGuard transition)+ <> [ resolvedWrite index transition registerName expression+ | (registerName, expression) <- tWrites transition+ ]+ | (index, transition) <- transitionEntries aggregate,+ tImplementation transition == GeneratedImplementation+ ]+ where+ environment transition = expressionEnvironment (aSpec aggregate) (aAggregate aggregate) transition+ resolvedGuard index transition expression =+ expressionOrDie (guardFunctionName index transition) (resolveGuardExpr (environment transition) expression)+ resolvedWrite index transition registerName expression =+ expressionOrDie (writeFunctionName index transition registerName) (resolveWriteExpr (environment transition) registerName expression)++anyTypedExpression :: (TypedScalarExpr -> Bool) -> TypedScalarExpr -> Bool+anyTypedExpression predicate expression =+ predicate expression || any (anyTypedExpression predicate) (typedExpressionChildren expression)++typedExpressionChildren :: TypedScalarExpr -> [TypedScalarExpr]+typedExpressionChildren expression = case typedScalarNode expression of+ TypedLiteral {} -> []+ TypedRoot {} -> []+ TypedProject {} -> []+ TypedAdd _ left right -> [left, right]+ TypedSubtract _ left right -> [left, right]+ TypedMultiply _ left right -> [left, right]+ TypedEqual left right -> [left, right]+ TypedNotEqual left right -> [left, right]+ TypedCompare _ left right -> [left, right]+ TypedAnd left right -> [left, right]+ TypedOr left right -> [left, right]++expressionUsesNominalProjection :: TypedScalarExpr -> Bool+expressionUsesNominalProjection = anyTypedExpression $ \expression -> case (typedScalarType expression, typedScalarNode expression) of+ (AggregateNominal nominal, TypedRoot {}) -> case (resolvedNominalOwnership nominal, resolvedNominalRepresentation nominal) of+ (ConsumerNominal {}, ScalarRepresentation {}) -> True+ _ -> False+ _ -> False++typedConsumerLiteralNominals :: TypedScalarExpr -> [ResolvedNominalType]+typedConsumerLiteralNominals expression = own <> concatMap typedConsumerLiteralNominals (typedExpressionChildren expression)+ where+ own = case (typedScalarType expression, typedScalarNode expression) of+ (AggregateNominal nominal, TypedLiteral ScalarEnumValue {})+ | ConsumerNominal {} <- resolvedNominalOwnership nominal -> [nominal]+ (AggregateNominal nominal, TypedLiteral ScalarIdValue {})+ | ConsumerNominal {} <- resolvedNominalOwnership nominal -> [nominal]+ _ -> []++emitTransitionExpressions :: Agg -> Int -> Transition -> [Text]+emitTransitionExpressions aggregate index transition+ | tImplementation transition /= GeneratedImplementation = []+ | otherwise =+ concat+ [ maybe [] (emitGuardDefinition aggregate index transition) (tGuard transition),+ concatMap (uncurry (emitWriteDefinition aggregate index transition)) (tWrites transition)+ ]++emitGuardDefinition :: Agg -> Int -> Transition -> Expr -> [Text]+emitGuardDefinition aggregate index transition expression =+ [ "",+ functionName <> " :: " <> payloadProjectionType aggregate transition <> " -> K.HsPred " <> aName aggregate <> "Regs " <> aName aggregate <> "Command",+ functionName <> " d = " <> renderKeikiPredicate aggregate transition resolved+ ]+ where+ functionName = guardFunctionName index transition+ resolved = expressionOrDie functionName (resolveGuardExpr (expressionEnvironment (aSpec aggregate) (aAggregate aggregate) transition) expression)++emitWriteDefinition :: Agg -> Int -> Transition -> Name -> Expr -> [Text]+emitWriteDefinition aggregate index transition registerName expression =+ [ "",+ functionName+ <> " :: "+ <> payloadProjectionType aggregate transition+ <> " -> K.Term "+ <> aName aggregate+ <> "Regs "+ <> aName aggregate+ <> "Command ("+ <> commandFieldsType transition+ <> ") "+ <> renderDomainType aggregate (typedScalarType resolved),+ functionName <> " d = " <> renderKeikiTerm aggregate transition resolved+ ]+ where+ functionName = writeFunctionName index transition registerName+ resolved = expressionOrDie functionName (resolveWriteExpr (expressionEnvironment (aSpec aggregate) (aAggregate aggregate) transition) registerName expression)++expressionOrDie :: Text -> Either (NonEmpty ExpressionDiagnostic) TypedScalarExpr -> TypedScalarExpr+expressionOrDie owner = either (error . (("validated expression disappeared for " <> T.unpack owner <> ": ") <>) . show) id++renderKeikiPredicate :: Agg -> Transition -> TypedScalarExpr -> Text+renderKeikiPredicate aggregate transition expression = case typedScalarNode expression of+ TypedEqual left right -> "K.PEq " <> atom (renderComparisonTerm aggregate transition left) <> " " <> atom (renderComparisonTerm aggregate transition right)+ TypedNotEqual left right -> "K.pnot (K.PEq " <> atom (renderComparisonTerm aggregate transition left) <> " " <> atom (renderComparisonTerm aggregate transition right) <> ")"+ TypedCompare operator left right ->+ "K.PCmp "+ <> renderKeikiCmp operator+ <> " "+ <> atom (renderComparisonTerm aggregate transition left)+ <> " "+ <> atom (renderComparisonTerm aggregate transition right)+ TypedAnd left right -> "K.PAnd " <> atom (renderKeikiPredicate aggregate transition left) <> " " <> atom (renderKeikiPredicate aggregate transition right)+ TypedOr left right -> "K.POr " <> atom (renderKeikiPredicate aggregate transition left) <> " " <> atom (renderKeikiPredicate aggregate transition right)+ _ -> "K.PEq " <> atom (renderKeikiTerm aggregate transition expression) <> " (K.lit True)"+ where+ atom value = "(" <> value <> ")"++renderKeikiCmp :: CmpOp -> Text+renderKeikiCmp = \case+ OpEq -> error "equality is rendered as PEq"+ OpNeq -> error "inequality is rendered as pnot PEq"+ OpLt -> "K.CmpLt"+ OpLe -> "K.CmpLe"+ OpGt -> "K.CmpGt"+ OpGe -> "K.CmpGe"++renderComparisonTerm :: Agg -> Transition -> TypedScalarExpr -> Text+renderComparisonTerm aggregate transition expression = case (typedScalarType expression, typedScalarNode expression) of+ (AggregateNominal nominal, TypedRoot provenance)+ | ConsumerNominal {} <- resolvedNominalOwnership nominal,+ ScalarRepresentation {} <- resolvedNominalRepresentation nominal ->+ renderNominalProjectionTerm aggregate transition nominal provenance+ _ -> renderKeikiTerm aggregate transition expression++renderNominalProjectionTerm :: Agg -> Transition -> ResolvedNominalType -> ScalarRootProvenance -> Text+renderNominalProjectionTerm aggregate transition nominal provenance = case provenance of+ ScalarRegisterRoot registerName ownerType ->+ "K.regProj NominalProjections."+ <> witness+ <> " (#"+ <> registerName+ <> " :: K.Index "+ <> aName aggregate+ <> "Regs "+ <> renderDomainType aggregate ownerType+ <> ")"+ ScalarCommandRoot fieldName ownerType ->+ "K.inpProj NominalProjections."+ <> witness+ <> " inCtor"+ <> tCommand transition+ <> " (#"+ <> fieldName+ <> " :: K.Index ("+ <> commandFieldsType transition+ <> ") "+ <> renderDomainType aggregate ownerType+ <> ")"+ where+ witness = lowerFirst (resolvedNominalName nominal) <> "Witness"++renderKeikiTerm :: Agg -> Transition -> TypedScalarExpr -> Text+renderKeikiTerm aggregate transition expression = case typedScalarNode expression of+ TypedLiteral value -> renderKeikiLiteral aggregate (typedScalarType expression) value+ TypedRoot (ScalarRegisterRoot registerName _) -> "B.reg @" <> tshow registerName+ TypedRoot (ScalarCommandRoot fieldName _) -> "d." <> fieldName+ TypedProject provenance projection -> renderStructuralProjectionTerm aggregate transition provenance projection+ TypedAdd _ left right -> binary "K.tadd" left right+ TypedSubtract _ left right -> binary "K.tsub" left right+ TypedMultiply _ left right -> binary "K.tmul" left right+ TypedEqual {} -> impossiblePredicate+ TypedNotEqual {} -> impossiblePredicate+ TypedCompare {} -> impossiblePredicate+ TypedAnd {} -> impossiblePredicate+ TypedOr {} -> impossiblePredicate+ where+ binary operator left right = operator <> " " <> parenthesized left <> " " <> parenthesized right+ parenthesized = (\value -> "(" <> value <> ")") . renderKeikiTerm aggregate transition+ impossiblePredicate = error "predicate-valued Boolean expressions cannot be lowered as register terms"++renderStructuralProjectionTerm :: Agg -> Transition -> ScalarRootProvenance -> ResolvedScalarProjection -> Text+renderStructuralProjectionTerm aggregate transition provenance projection = case provenance of+ ScalarRegisterRoot registerName ownerType ->+ "K.regProj StructuralProjections."+ <> witness+ <> " (#"+ <> registerName+ <> " :: K.Index "+ <> aName aggregate+ <> "Regs "+ <> renderDomainType aggregate ownerType+ <> ")"+ ScalarCommandRoot fieldName ownerType ->+ "K.inpProj StructuralProjections."+ <> witness+ <> " inCtor"+ <> tCommand transition+ <> " (#"+ <> fieldName+ <> " :: K.Index ("+ <> commandFieldsType transition+ <> ") "+ <> renderDomainType aggregate ownerType+ <> ")"+ where+ witness = lowerFirst (projectionTag (unMappedKey (scalarProjectionOwner projection)) (scalarProjectionPointer projection)) <> "Witness"++renderKeikiLiteral :: Agg -> ResolvedAggregateType -> ScalarValue -> Text+renderKeikiLiteral aggregate scalarType = \case+ ScalarTextValue value -> "K.lit (" <> tshow value <> " :: Text)"+ ScalarIntValue value -> "K.lit (" <> tshow' value <> " :: Int)"+ ScalarIntegerValue value -> "K.lit (" <> T.pack (show value) <> " :: Integer)"+ ScalarNaturalValue value -> "K.lit (" <> T.pack (show value) <> " :: Natural)"+ ScalarBoolValue value -> "K.lit " <> if value then "True" else "False"+ ScalarTimeValue value -> "K.lit " <> renderRegisterInitial (InitialTime value)+ ScalarEnumValue typeName constructor -> case scalarType of+ AggregateNominal nominal -> case resolvedNominalOwnership nominal of+ GeneratedNominal -> "K.lit " <> constructor+ ConsumerNominal binding ->+ "K.lit (nominalFromRepresentation "+ <> unQualifiedValueName (consumerNominalBinding binding)+ <> " "+ <> nominalRepresentationModule (aContext aggregate) typeName+ <> "."+ <> constructor+ <> ")"+ _ -> error "validated enum literal lost its nominal type"+ ScalarIdValue typeName value -> case scalarType of+ AggregateNominal nominal -> case resolvedNominalOwnership nominal of+ GeneratedNominal -> "K.lit (" <> typeName <> " " <> tshow value <> ")"+ ConsumerNominal binding -> case resolvedNominalRepresentation nominal of+ IdRepresentation prefix ->+ "K.lit (nominalFromRepresentation "+ <> unQualifiedValueName (consumerNominalBinding binding)+ <> " (case KindID.parseText @"+ <> tshow prefix+ <> " "+ <> tshow value+ <> " of Right parsed -> parsed; Left _ -> error \"validated ID literal failed to parse\"))"+ _ -> error "validated ID literal lost its ID representation"+ _ -> error "validated ID literal lost its nominal type"++emitGeneratedTransducer :: Agg -> Text+emitGeneratedTransducer aggregate =+ nl $+ [ "{-# LANGUAGE BlockArguments #-}",+ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE GADTs #-}",+ "{-# LANGUAGE OverloadedRecordDot #-}",+ "{-# LANGUAGE QualifiedDo #-}",+ "{-# LANGUAGE TypeApplications #-}",+ generatedBanner,+ "module " <> aGenPrefix aggregate <> ".Transducer",+ " ( " <> lowerFirst (aName aggregate) <> "Transducer",+ " , " <> lowerFirst (aName aggregate) <> "FoldFingerprint",+ " , BehaviorOwnership (..)",+ " , " <> lowerFirst (aName aggregate) <> "PredicateVerifications",+ " ) where",+ "",+ "import " <> aGenPrefix aggregate <> ".Domain",+ "import " <> aHolePrefix aggregate <> ".Holes qualified as Holes",+ "import Data.Text (Text)",+ "import Keiki.Builder qualified as B",+ "import Keiki.Core (HsPred, SymTransducer)",+ "import Keiki.Core qualified as K",+ "import Keiki.Symbolic qualified as S"+ ]+ ++ ["import " <> aGenPrefix aggregate <> ".Expressions qualified as Expressions" | not (null (expressionFunctionNames aggregate))]+ ++ ["import Data.Text qualified as T" | anyHoleOwned aggregate]+ ++ ["import Keiki.Builder ((=:))" | any (not . null . tWrites . snd) (transitionEntries aggregate)]+ ++ ["import Keiro.Snapshot.Codec (FoldVersion (..))" | anyHoleOwned aggregate]+ ++ [ "",+ lowerFirst (aName aggregate) <> "Transducer",+ " :: SymTransducer",+ " (HsPred " <> aName aggregate <> "Regs " <> aName aggregate <> "Command)",+ " " <> aName aggregate <> "Regs",+ " " <> aVertexType aggregate,+ " " <> aName aggregate <> "Command",+ " " <> aName aggregate <> "Event",+ lowerFirst (aName aggregate) <> "Transducer =",+ " B.buildTransducer " <> initialVertex aggregate <> " initial" <> aName aggregate <> "Regs isTerminal do",+ nl (concatMap (generatedFromBlock aggregate) (groupTransitionEntriesBySource aggregate)),+ " where",+ " isTerminal = \\case",+ nl [" " <> vertexCtor aggregate (stName state) <> " -> True" | state <- aStates aggregate, stTerminal state],+ " _ -> False",+ "",+ lowerFirst (aName aggregate) <> "FoldFingerprint :: Text",+ lowerFirst (aName aggregate) <> "FoldFingerprint = " <> foldFingerprintExpression aggregate,+ "",+ "data BehaviorOwnership = GeneratedOwned | HoleOwned",+ " deriving stock (Eq, Show)",+ "",+ "-- Every checked transition predicate is audited through Keiki's conservative",+ "-- symbolic verifier. Opaque Hole terms remain explicitly unverified.",+ lowerFirst (aName aggregate) <> "PredicateVerifications :: IO [(Text, BehaviorOwnership, S.PredicateVerification)]",+ lowerFirst (aName aggregate) <> "PredicateVerifications = sequence",+ nl (renderVerificationList aggregate),+ " where",+ " verifyTransition label owner source edgeIndex =",+ " case drop edgeIndex (K.edgesOut " <> lowerFirst (aName aggregate) <> "Transducer source) of",+ " K.Edge predicate _ _ _ _ : _ -> (\\result -> (label, owner, result)) <$> S.verifyPredicate predicate",+ " [] -> pure (label, owner, S.UnverifiedSolverFailure \"generated transition edge missing\")"+ ]++anyHoleOwned :: Agg -> Bool+anyHoleOwned = any ((== HoleImplementation) . tImplementation) . aTransitions++renderVerificationList :: Agg -> [Text]+renderVerificationList aggregate =+ [ (if listIndex == (0 :: Int) then " [ " else " , ")+ <> "verifyTransition "+ <> tshow (transitionStem transitionIndex transition)+ <> " "+ <> ownership+ <> " "+ <> vertexCtor aggregate source+ <> " "+ <> tshow' edgeIndex+ | (listIndex, (source, edgeIndex, transitionIndex, transition)) <- zip [0 ..] entries,+ let ownership = case tImplementation transition of+ GeneratedImplementation -> "GeneratedOwned"+ HoleImplementation -> "HoleOwned"+ LegacyHoleImplementation -> error "legacy transition reached version-2 verification generation"+ ]+ <> [" ]"]+ where+ entries =+ [ (source, edgeIndex, transitionIndex, transition)+ | (source, transitions) <- groupTransitionEntriesBySource aggregate,+ (edgeIndex, (transitionIndex, transition)) <- zip [0 ..] transitions+ ]++foldFingerprintExpression :: Agg -> Text+foldFingerprintExpression aggregate = case holeVersions of+ [] -> tshow (aFoldFingerprint aggregate)+ _ ->+ "T.intercalate \"|\" ("+ <> tshow (aFoldFingerprint aggregate)+ <> " : [foldToken "+ <> T.intercalate ", foldToken " holeVersions+ <> "] ) where foldToken (FoldVersion token) = T.pack (show (T.length token)) <> \":\" <> token"+ where+ holeVersions =+ [ "Holes." <> holeFoldVersionName index transition+ | (index, transition) <- transitionEntries aggregate,+ tImplementation transition == HoleImplementation+ ]++groupTransitionEntriesBySource :: Agg -> [(Text, [(Int, Transition)])]+groupTransitionEntriesBySource aggregate = go [] (transitionEntries aggregate)+ where+ go accumulated [] = reverse accumulated+ go accumulated (entry@(_, transition) : remaining) =+ let source = tSource transition+ (same, rest) = span ((== source) . tSource . snd) remaining+ in go ((source, entry : same) : accumulated) rest++generatedFromBlock :: Agg -> (Text, [(Int, Transition)]) -> [Text]+generatedFromBlock aggregate (source, transitions) =+ [" B.from " <> vertexCtor aggregate source <> " do"]+ ++ concatMap (uncurry (generatedOnCmdBlock aggregate)) transitions++generatedOnCmdBlock :: Agg -> Int -> Transition -> [Text]+generatedOnCmdBlock aggregate index transition =+ [" B.onCmd inCtor" <> tCommand transition <> " $ \\d -> B.do"]+ ++ [" B.replayOnly" | tMode transition == TmReplayOnly]+ ++ generatedBehavior+ ++ outputLines+ ++ [" B.noEmit" | null (tEmits transition)]+ ++ [" B.goto " <> vertexCtor aggregate (tGoto transition)]+ where+ generatedBehavior = case tImplementation transition of+ GeneratedImplementation ->+ maybe [] (const [" B.requireGuard (Expressions." <> guardFunctionName index transition <> " d)"]) (tGuard transition)+ ++ [ " B.slot @" <> tshow registerName <> " =: Expressions." <> writeFunctionName index transition registerName <> " d"+ | (registerName, _) <- tWrites transition+ ]+ HoleImplementation -> [" Holes." <> holeFunctionName index transition <> " d"]+ LegacyHoleImplementation -> error "legacy transition reached version-2 transducer generation"+ outputLines =+ [ " B.emit wire"+ <> eventName+ <> " (Holes."+ <> outputFunctionName index transition emitIndex eventName+ <> " d)"+ | (emitIndex, eventName) <- zip [1 ..] (tEmits transition)+ ]++--------------------------------------------------------------------------------+-- EventStream module+--------------------------------------------------------------------------------++emitEventStream :: Agg -> Text+emitEventStream a =+ nl $+ [ generatedBanner,+ "module " <> aGenPrefix a <> ".EventStream",+ " ( " <> lowerFirst (aName a) <> "Category",+ " , " <> lowerFirst (aName a) <> "EventStream",+ " , " <> lowerFirst (aName a) <> "EventStreamDef",+ " , " <> aName a <> "EventStream",+ " , " <> aName a <> "EventStreamDef"+ ]+ ++ [" , " <> lowerFirst (aName a) <> "SnapshotFixture" | hasSnapshot a]+ ++ [ " ) where",+ "",+ "import " <> aGenPrefix a <> ".Domain",+ "import " <> aGenPrefix a <> ".Codec (" <> lowerFirst (aName a) <> "Codec)",+ transducerImport a,+ "import Keiki.Core (HsPred)",+ "import Keiro.EventStream (EventStream (..), SnapshotPolicy (..))",+ "import Keiro.EventStream.Validate (ValidatedEventStream, mkEventStreamOrThrow)"+ ]+ ++ ["import Data.Text (Text)" | hasSnapshot a]+ ++ ["import Keiro.Snapshot.Codec (defaultStateCodec, withFoldFingerprint)" | hasSnapshot a]+ ++ [ "import Keiro.Stream qualified as Stream",+ "",+ "-- The validated aggregate stream category (hole-kind 5: referenced, never retyped).",+ "-- Entity streams are '<category>-<id>' via Keiro.Stream.entityStream.",+ "-- categoryUnsafe is safe here because this generated literal passed the DSL category proof.",+ lowerFirst (aName a) <> "Category :: Stream.StreamCategory a",+ lowerFirst (aName a) <> "Category = Stream.categoryUnsafe " <> tshow categoryName,+ "",+ "type " <> aName a <> "EventStreamDef =",+ " EventStream (HsPred " <> aName a <> "Regs " <> aName a <> "Command) " <> aName a <> "Regs " <> aVertexType a <> " " <> aName a <> "Command " <> aName a <> "Event",+ "",+ "type " <> aName a <> "EventStream =",+ " ValidatedEventStream (HsPred " <> aName a <> "Regs " <> aName a <> "Command) " <> aName a <> "Regs " <> aVertexType a <> " " <> aName a <> "Command " <> aName a <> "Event",+ "",+ lowerFirst (aName a) <> "EventStreamDef :: " <> aName a <> "EventStreamDef",+ lowerFirst (aName a) <> "EventStreamDef =",+ " EventStream",+ " { transducer = " <> lowerFirst (aName a) <> "Transducer",+ " , initialState = " <> initialVertex a,+ " , initialRegisters = initial" <> aName a <> "Regs",+ " , eventCodec = " <> lowerFirst (aName a) <> "Codec",+ " , resolveStreamName = Stream.streamName",+ " , snapshotPolicy = " <> snapshotPolicyExpr a+ ]+ ++ stateCodecFieldLines a+ ++ [ " }",+ ""+ ]+ ++ snapshotFixtureLines a+ ++ [ lowerFirst (aName a) <> "EventStream :: " <> aName a <> "EventStream",+ lowerFirst (aName a) <> "EventStream =",+ " mkEventStreamOrThrow " <> tshow (aName a) <> " " <> lowerFirst (aName a) <> "EventStreamDef"+ ]+ where+ categoryName = staticCategory ("aggregate " <> aName a) (lowerFirst (aName a))++snapshotPolicyExpr :: Agg -> Text+snapshotPolicyExpr aggregate = case aSnapshot aggregate of+ Nothing -> "Never"+ Just snapshot -> case snapPolicy snapshot of+ SnapEvery interval -> "Every " <> tshow' interval+ SnapOnTerminal -> "OnTerminal"++stateCodecExpr :: Agg -> Text+stateCodecExpr aggregate = case aSnapshot aggregate of+ Nothing -> "Nothing"+ Just snapshot ->+ "Just (withFoldFingerprint "+ <> foldFingerprintValue aggregate+ <> " (defaultStateCodec "+ <> tshow' (snapCodecVersion snapshot)+ <> "))"++transducerImport :: Agg -> Text+transducerImport aggregate+ | hasVersion2Ownership aggregate =+ "import "+ <> aGenPrefix aggregate+ <> ".Transducer ("+ <> lowerFirst (aName aggregate)+ <> "FoldFingerprint, "+ <> lowerFirst (aName aggregate)+ <> "Transducer)"+ | otherwise =+ "import "+ <> aHolePrefix aggregate+ <> ".Holes ("+ <> lowerFirst (aName aggregate)+ <> "Transducer)"++foldFingerprintValue :: Agg -> Text+foldFingerprintValue aggregate+ | hasVersion2Ownership aggregate = lowerFirst (aName aggregate) <> "FoldFingerprint"+ | otherwise = tshow (aFoldFingerprint aggregate)++stateCodecFieldLines :: Agg -> [Text]+stateCodecFieldLines aggregate = case aSnapshot aggregate of+ Nothing -> [" , stateCodec = Nothing"]+ Just _+ | hasVersion2Ownership aggregate ->+ [ " -- The snapshot discriminator composes: the spec's state-codec version (bump it",+ " -- in the spec's `state-codec version=` clause), keiki's register and",+ " -- control-state shape hashes, and this fold fingerprint derived from the",+ " -- spec's transition surface (guards, writes, emits, states, register",+ " -- initials, referenced rules). Spec-visible fold changes invalidate old",+ " -- snapshots automatically. Version-2 Hole-owned transitions additionally",+ " -- compose their explicit hand-owned FoldVersion tokens here; bump the",+ " -- corresponding token whenever that Hole behavior changes.",+ " , stateCodec = " <> stateCodecExpr aggregate+ ]+ | otherwise ->+ [ " -- The snapshot discriminator composes: the spec's state-codec version (bump it",+ " -- in the spec's `state-codec version=` clause), keiki's register and",+ " -- control-state shape hashes, and this fold fingerprint derived from the",+ " -- spec's transition surface (guards, writes, emits, states, register",+ " -- initials, referenced rules). Spec-visible fold changes invalidate old",+ " -- snapshots automatically. Fold changes made ONLY in the hand-owned Holes",+ " -- module are invisible here: bump `state-codec version=` manually or old",+ " -- snapshots will be served stale.",+ " , stateCodec = " <> stateCodecExpr aggregate+ ]++snapshotFixtureLines :: Agg -> [Text]+snapshotFixtureLines aggregate = case aSnapshot aggregate of+ Nothing -> []+ Just snapshot ->+ [ lowerFirst (aName aggregate) <> "SnapshotFixture :: (Int, Text)",+ lowerFirst (aName aggregate) <> "SnapshotFixture = (" <> tshow' (snapCodecVersion snapshot) <> ", " <> tshow (snapShapeHash snapshot) <> ")",+ ""+ ]++--------------------------------------------------------------------------------+-- Projection module+--------------------------------------------------------------------------------++emitProjection :: Agg -> Text+emitProjection a = case aProjection a of+ Nothing -> nl [generatedBanner, "module " <> aGenPrefix a <> ".Projection () where"]+ Just p ->+ nl+ [ "{-# LANGUAGE OverloadedRecordDot #-}",+ generatedBanner,+ "module " <> aGenPrefix a <> ".Projection",+ " ( " <> lowerFirst (projTable p) <> "Projection",+ " , " <> lowerFirst (projTable p) <> "StatusFor",+ " ) where",+ "",+ "import " <> aGenPrefix a <> ".Domain",+ "import " <> aHolePrefix a <> ".Holes (apply" <> pascal (projTable p) <> ")",+ "import Data.Text (Text)",+ "import Keiro.Projection (InlineProjection (..))",+ "",+ "-- The deterministic event->status mapping (hole-kind 3, /mapping/), derived",+ "-- from the spec's status-map. The read-model SQL that consumes it lives in",+ "-- the hand-owned Holes module (a DB-coupled hole, delegated to codd).",+ projectionTableComment a p,+ lowerFirst (projTable p) <> "StatusFor :: " <> aName a <> "Event -> Maybe Text",+ lowerFirst (projTable p) <> "StatusFor = \\case",+ nl (statusArms a p),+ "",+ lowerFirst (projTable p) <> "Projection :: InlineProjection " <> aName a <> "Event",+ lowerFirst (projTable p) <> "Projection =",+ " InlineProjection",+ " { name = " <> tshow (contextNameToProjName a p),+ " , apply = apply" <> pascal (projTable p),+ " }"+ ]++statusArms :: Agg -> ProjectionSpec -> [Text]+statusArms a p =+ [ " " <> rcName e <> " {} -> " <> statusFor e+ | e <- aEvents a+ ]+ ++ [" _ -> Nothing" | hasWildcard]+ where+ pairs = maybe [] mapPairs (projStatusMap p)+ statusFor e = case lookup (rcName e) pairs of+ Just value -> "Just " <> tshow value+ Nothing -> "Nothing"+ -- A wildcard is only needed if some event is uncovered; otherwise every arm+ -- is explicit and a wildcard would be redundant (and -Wall would warn).+ hasWildcard = False++contextNameToProjName :: Agg -> ProjectionSpec -> Text+contextNameToProjName a p = contextKebab a <> "-" <> projTable p <> "-inline"++contextKebab :: Agg -> Text+contextKebab = kebabFromPascal . aCtxPascal++projectionReadModel :: Agg -> Maybe ReadModelNode+projectionReadModel aggregate = do+ projection <- aProjection aggregate+ find ((== projTable projection) . rmName) (aReadModels aggregate)++projectionTableComment :: Agg -> ProjectionSpec -> Text+projectionTableComment aggregate projection = case projectionReadModel aggregate of+ Nothing ->+ "-- WARNING: no readmodel node declares '"+ <> projTable projection+ <> "'; unqualified SQL depends on search_path."+ Just readModel ->+ "-- Qualified table "+ <> qualifiedTableLiteral readModel+ <> "; use "+ <> genPrefixFor (aContext aggregate) (pascal (rmName readModel))+ <> ".ReadModelTable."+ <> readModelStem readModel+ <> "QualifiedTable."++--------------------------------------------------------------------------------+-- Holes module (create-if-absent)+--------------------------------------------------------------------------------++emitHoles :: Agg -> Text+emitHoles aggregate+ | hasVersion2Ownership aggregate = emitVersion2Holes aggregate+ | otherwise = emitLegacyHoles aggregate++emitLegacyHoles :: Agg -> Text+emitLegacyHoles a =+ nl+ [ "{-# LANGUAGE BlockArguments #-}",+ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE OverloadedRecordDot #-}",+ "{-# LANGUAGE QualifiedDo #-}",+ "{-# LANGUAGE TypeApplications #-}",+ "-- This is a HAND-OWNED hole module. keiro-dsl creates it once and never",+ "-- overwrites it. Fill the transducer body (and any other holes) against the",+ "-- generated signatures, then run the harness to confirm behaviour.",+ "module " <> aHolePrefix a <> ".Holes",+ " ( " <> lowerFirst (aName a) <> "Transducer",+ holeProjectionExport a,+ holeUpcasterExports a,+ " ) where",+ "",+ "import " <> aGenPrefix a <> ".Domain",+ "import Keiki.Builder ((=:))",+ "import qualified Keiki.Builder as B",+ "import Keiki.Core (HsPred, RegFile, SymTransducer, lit, (.==), (./=), (.||))",+ holeUpcasterImports a,+ holeProjectionImports a,+ "",+ "-- HOLE: the transducer body. Reproduce the structure below, replacing each",+ "-- `-- HOLE` line with the keiki symbolic operators it describes.",+ lowerFirst (aName a) <> "Transducer",+ " :: SymTransducer",+ " (HsPred " <> aName a <> "Regs " <> aName a <> "Command)",+ " " <> aName a <> "Regs",+ " " <> aVertexType a,+ " " <> aName a <> "Command",+ " " <> aName a <> "Event",+ lowerFirst (aName a) <> "Transducer =",+ " B.buildTransducer " <> initialVertex a <> " initial" <> aName a <> "Regs isTerminal do",+ nl (concatMap (fromBlock a) (groupBySource a)),+ " where",+ " isTerminal = \\case",+ nl [" " <> vertexCtor a (stName s) <> " -> True" | s <- aStates a, stTerminal s],+ " _ -> False",+ holeProjectionStub a,+ holeUpcasterStubs a+ ]++emitVersion2Holes :: Agg -> Text+emitVersion2Holes aggregate =+ nl $+ [ "{-# LANGUAGE BlockArguments #-}",+ "{-# LANGUAGE DataKinds #-}",+ "{-# LANGUAGE DuplicateRecordFields #-}",+ "{-# LANGUAGE OverloadedRecordDot #-}",+ "{-# LANGUAGE QualifiedDo #-}",+ "{-# LANGUAGE TypeApplications #-}",+ "-- This is a HAND-OWNED version-2 hook module. keiro-dsl creates it once",+ "-- and never overwrites it. Generated code owns every transition envelope",+ "-- and every declared guard/write; this module supplies event fields and",+ "-- explicitly selected Hole behavior only."+ ]+ ++ version2HoleModuleDeclaration aggregate+ ++ [ "",+ "import " <> aGenPrefix aggregate <> ".Domain",+ "import Keiki.Builder qualified as B",+ "import Keiki.Generics (RegFieldsOf)",+ holeUpcasterImports aggregate,+ holeProjectionImports aggregate+ ]+ ++ ["import Keiki.Core qualified as K" | anyHoleOwned aggregate || anyZeroFieldOutput aggregate]+ ++ ["import Keiro.Snapshot.Codec (FoldVersion (..))" | anyHoleOwned aggregate]+ ++ concatMap (uncurry (emitOutputHooks aggregate)) (transitionEntries aggregate)+ ++ concatMap (uncurry (emitHoleImplementation aggregate)) (transitionEntries aggregate)+ ++ [holeProjectionStub aggregate, holeUpcasterStubs aggregate]++anyZeroFieldOutput :: Agg -> Bool+anyZeroFieldOutput aggregate =+ any (null . rcFields . eventForName aggregate) (concatMap tEmits (aTransitions aggregate))++version2HoleModuleDeclaration :: Agg -> [Text]+version2HoleModuleDeclaration aggregate = case version2HoleExports aggregate of+ [] -> ["module " <> aHolePrefix aggregate <> ".Holes () where"]+ firstExport : rest ->+ [ "module " <> aHolePrefix aggregate <> ".Holes",+ " ( " <> firstExport+ ]+ ++ [" , " <> value | value <- rest]+ ++ [" ) where"]++version2HoleExports :: Agg -> [Text]+version2HoleExports aggregate =+ outputExports+ <> holeExports+ <> projectionExports+ <> [functionName | (_, _, functionName) <- upcasterEntries aggregate]+ where+ outputExports =+ [ outputFunctionName transitionIndex transition emitIndex eventName+ | (transitionIndex, transition) <- transitionEntries aggregate,+ (emitIndex, eventName) <- zip [1 ..] (tEmits transition)+ ]+ holeExports =+ concat+ [ [holeFunctionName index transition, holeFoldVersionName index transition]+ | (index, transition) <- transitionEntries aggregate,+ tImplementation transition == HoleImplementation+ ]+ projectionExports = case aProjection aggregate of+ Nothing -> []+ Just projection -> ["apply" <> pascal (projTable projection)]++emitOutputHooks :: Agg -> Int -> Transition -> [Text]+emitOutputHooks aggregate transitionIndex transition =+ concat+ [ emitOutputHook aggregate transitionIndex transition emitIndex (eventForName aggregate eventName)+ | (emitIndex, eventName) <- zip [1 ..] (tEmits transition)+ ]++emitOutputHook :: Agg -> Int -> Transition -> Int -> ResolvedCtor -> [Text]+emitOutputHook aggregate transitionIndex transition emitIndex event =+ [ "",+ "-- Hand-owned event-field hook inside the generated transition envelope.",+ functionName+ <> " :: "+ <> payloadProjectionType aggregate transition+ <> " -> "+ <> outputType,+ functionName <> " d = " <> outputValue+ ]+ where+ functionName = outputFunctionName transitionIndex transition emitIndex (rcName event)+ inputFields = "(" <> commandFieldsType transition <> ")"+ outputType+ | null (rcFields event) =+ "K.OutFields "+ <> aName aggregate+ <> "Regs "+ <> aName aggregate+ <> "Command "+ <> inputFields+ <> " ()"+ | otherwise =+ rcName event+ <> "TermFields "+ <> aName aggregate+ <> "Regs "+ <> aName aggregate+ <> "Command "+ <> inputFields+ outputValue+ | null (rcFields event) = "B.oNil"+ | otherwise =+ rcName event+ <> "TermFields\n"+ <> nl (valueRecord [(fieldName, outputFieldValue fieldName fieldType) | (fieldName, fieldType) <- rcFields event])+ command = commandForTransition aggregate transition+ outputFieldValue fieldName fieldType+ | Just commandType <- lookup fieldName (rcFields command),+ commandType == fieldType =+ "d." <> fieldName+ | Just register <- find ((== fieldName) . rrName) (aRegs aggregate),+ rrType register == fieldType =+ "B.reg @" <> tshow fieldName+ | otherwise = "error " <> tshow ("HOLE: fill output field " <> rcName event <> "." <> fieldName)+ valueRecord fields =+ [ lead fieldIndex <> fieldName <> " = " <> fieldValue+ | (fieldIndex, (fieldName, fieldValue)) <- zip [0 :: Int ..] fields+ ]+ ++ [" }"]+ lead 0 = " { "+ lead _ = " , "++emitHoleImplementation :: Agg -> Int -> Transition -> [Text]+emitHoleImplementation _ index transition+ | tImplementation transition /= HoleImplementation = []+ | otherwise =+ [ "",+ "-- HOLE: add the predicate and ordered register updates for this transition.",+ "-- The generated transducer still owns command matching, mode, emits, and goto.",+ holeFunctionName index transition <> " _d = B.requireGuard K.PTop",+ "",+ "-- Bump this token whenever the Hole predicate or updates change.",+ holeFoldVersionName index transition <> " :: FoldVersion",+ holeFoldVersionName index transition <> " = FoldVersion " <> tshow (transitionStem index transition <> "-fold-v1")+ ]++-- | Export, import, and stub the per-event upcaster holes (EP-2 evolution).+holeUpcasterExports :: Agg -> Text+holeUpcasterExports a = case upcasterEntries a of+ [] -> ""+ es -> nl [" , " <> fn | (_, _, fn) <- es]++holeUpcasterImports :: Agg -> Text+holeUpcasterImports a = case upcasterEntries a of+ [] -> ""+ _ -> nl ["import Data.Aeson (Value)", "import Data.Text (Text)"]++holeUpcasterStubs :: Agg -> Text+holeUpcasterStubs a = case upcasterEntries a of+ [] -> ""+ es ->+ nl $+ concat+ [ [ "",+ "-- HOLE upcaster: this hole receives ONLY " <> eventName <> " payloads stored at",+ "-- aggregate schema version " <> tshow' source <> "; other event kinds pass through the",+ "-- generated rung dispatch automatically. Bring this payload up one version and decide",+ "-- the default/derivation for any field added at the new version here.",+ fn <> " :: Value -> Either Text Value",+ fn <> " _ = Left \"HOLE: upcaster not implemented\""+ ]+ | (source, eventName, fn) <- es+ ]++holeProjectionExport :: Agg -> Text+holeProjectionExport a = case aProjection a of+ Nothing -> " -- (no projection)"+ Just p -> " , apply" <> pascal (projTable p)++holeProjectionImports :: Agg -> Text+holeProjectionImports aggregate = case projectionReadModel aggregate of+ Nothing -> ""+ Just readModel ->+ "import "+ <> genPrefixFor (aContext aggregate) (pascal (rmName readModel))+ <> ".ReadModelTable ("+ <> readModelStem readModel+ <> "QualifiedTable)"++holeProjectionStub :: Agg -> Text+holeProjectionStub a = case aProjection a of+ Nothing -> ""+ Just p ->+ nl+ ( [ "",+ "-- HOLE: the read-model SQL for the projection (a DB-coupled hole; the",+ "-- pure event->status mapping is generated as " <> lowerFirst (projTable p) <> "StatusFor)."+ ]+ ++ projectionGuidance+ ++ [ "apply" <> pascal (projTable p) <> " :: " <> aName a <> "Event -> recorded -> txn ()",+ "apply" <> pascal (projTable p) <> " _event _recorded = " <> projectionTableUse <> "error \"HOLE: fill " <> projTable p <> " projection apply\""+ ]+ )+ where+ projectionGuidance = case projectionReadModel a of+ Nothing ->+ ["-- WARNING: no readmodel node declares this table's schema; unqualified SQL depends on search_path."]+ Just readModel ->+ [ "-- Table: " <> qualifiedTableLiteral readModel <> ". Use " <> readModelStem readModel <> "QualifiedTable; never rely on search_path.",+ "-- Declared columns:"+ ]+ ++ map (("-- " <>) . readModelColumnDoc) (rmColumns readModel)+ projectionTableUse = case projectionReadModel a of+ Nothing -> ""+ Just readModel -> readModelStem readModel <> "QualifiedTable `seq` "++-- Group transitions by source state, preserving order, for the B.from blocks.+groupBySource :: Agg -> [(Text, [Transition])]+groupBySource a = go [] (transitionsOf a)+ where+ go acc [] = reverse acc+ go acc (t : ts) =+ let src = tSource t+ (same, rest) = span ((== src) . tSource) ts+ in go ((src, t : same) : acc) rest++-- We don't keep the original Aggregate around in Agg, so reconstruct+-- transitions from a stored field. (Filled in resolveAgg via aTransitions.)+transitionsOf :: Agg -> [Transition]+transitionsOf = aTransitions++fromBlock :: Agg -> (Text, [Transition]) -> [Text]+fromBlock a (src, ts) =+ [ " B.from " <> vertexCtor a src <> " do"+ ]+ ++ concatMap (onCmdBlock a) ts++onCmdBlock :: Agg -> Transition -> [Text]+onCmdBlock a t =+ [ " B.onCmd inCtor" <> tCommand t <> " $ \\d -> B.do"+ ]+ -- Plan 143: the mode is structural, not hole-owned — a replay-only+ -- transition lowers to B.replayOnly (keiki ReplayOnly edge).+ ++ [" B.replayOnly" | tMode t == TmReplayOnly]+ ++ maybe [] (\g -> [" -- HOLE guard: " <> renderGuard g]) (tGuard t)+ ++ [" -- HOLE write " <> r <> " := " <> renderGuard e | (r, e) <- tWrites t]+ ++ [" -- HOLE emit " <> ev <> " (B.emit wire" <> ev <> " ...)" | ev <- tEmits t]+ ++ [" B.goto " <> vertexCtor a (tGoto t)]++--------------------------------------------------------------------------------+-- Field categories and shared helpers+--------------------------------------------------------------------------------++data FieldCat+ = IdCat+ | EnumCat+ | MappedStructuralCat !StructuralDecl !ResolvedMappedShape+ | MappedOpaqueCat !OpaqueDecl+ | OtherCat+ deriving stock (Eq, Show)++fieldCat :: Agg -> ResolvedAggregateType -> FieldCat+fieldCat a ty+ | AggregateNominal nominal <- ty,+ IdRepresentation {} <- resolvedNominalRepresentation nominal =+ IdCat+ | AggregateNominal nominal <- ty,+ EnumRepresentation {} <- resolvedNominalRepresentation nominal =+ EnumCat+ | Just (ResolvedStructural declaration shape) <- mappedDeclFor a ty = MappedStructuralCat declaration shape+ | Just (ResolvedOpaque declaration) <- mappedDeclFor a ty = MappedOpaqueCat declaration+ | otherwise = OtherCat++-- | The first constructor of a declared enum, used to build sample values.+firstEnumCtor :: Agg -> Text -> Maybe Text+firstEnumCtor a ty =+ case [c | e <- aEnums a, enumName e == ty, (c, _) <- take 1 (enumCtors e)] of+ (c : _) -> Just c+ [] -> Nothing++vertexCtor :: Agg -> Text -> Text+vertexCtor a s = aName a <> s++initialVertex :: Agg -> Text+initialVertex a = case aStates a of+ (s : _) -> vertexCtor a (stName s)+ [] -> aName a <> "Init"++generatedBanner :: Text+generatedBanner = "-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec."++nodeOrigin :: Text -> Text -> Loc -> Text+nodeOrigin nodeKind nodeName loc =+ nodeKind <> " " <> nodeName <> case unLoc loc of+ 0 -> ""+ line -> " (line " <> tshow' line <> ")"++-- | Conditions that the deterministic emitters cannot lower faithfully. The+-- pre-write scaffold pipeline treats each returned message as a refusal. The+-- list is extended alongside the policy and type lowering milestones.+scaffoldRefusals :: Spec -> [Text]+scaffoldRefusals spec =+ concatMap aggregateRefusals aggregates+ <> concatMap contractRefusals contracts+ <> concatMap publisherRefusals publishers+ where+ aggregates = [aggregate | NAggregate aggregate <- specNodes spec]+ contracts = [contract | NContract contract <- specNodes spec]+ publishers = [publisher | NPublisher publisher <- specNodes spec]+ symbols = aggregateSymbols spec+ aggregateRefusals aggregate =+ [ "AggregateEmpty: aggregate '" <> aggName aggregate <> "' must declare at least one command, event, and transition"+ | null (aggCommands aggregate) || null (aggEvents aggregate) || null (aggTransitions aggregate)+ ]+ <> concatMap (registerRefusals aggregate) (aggRegs aggregate)+ <> concatMap (fieldRefusals aggregate CommandFieldUse) (concatMap cmdFields (aggCommands aggregate))+ <> concatMap (fieldRefusals aggregate EventFieldUse) [field | event <- aggEvents aggregate, EventFields fields <- [evBody event], field <- fields]+ fieldRefusals aggregate useSite field = case inferAggregateFieldType symbols aggregate useSite field of+ Right _ -> []+ Left _ ->+ [ "FieldTypeUnrepresentable: aggregate '"+ <> aggName aggregate+ <> "' field '"+ <> aggregateFieldName field+ <> "' has unsupported explicit type '"+ <> maybe "(inferred)" typeExprCanonicalName (aggregateFieldType field)+ <> "'"+ ]+ registerRefusals aggregate register =+ case resolveAggregateType symbols (regLoc register) RegisterUse (regType register) of+ Left _ ->+ [ "RegTypeUnsupported: aggregate '"+ <> aggName aggregate+ <> "' register '"+ <> regName register+ <> "' has unsupported type '"+ <> typeExprCanonicalName (regType register)+ <> "'"+ ]+ Right resolved -> case resolveRegisterInitial symbols (regLoc register) resolved (regInitial register) of+ Right _ -> []+ Left _ -> [initialRefusal aggregate register resolved]+ initialRefusal aggregate register resolved = case resolved of+ AggregateText -> label "RegTextInitialNotQuoted" "must use a quoted Text initial"+ AggregateNominal nominal+ | EnumRepresentation {} <- resolvedNominalRepresentation nominal ->+ label "RegInitialNotEnumCtor" ("must start at the declaration-owned initial for enum '" <> resolvedNominalName nominal <> "'")+ AggregateMapped {} -> label "MappedRegisterInitialMissing" "requires the mapped declaration's initial symbol"+ _ -> label "RegInitialInvalidLiteral" ("has an invalid " <> aggregateCanonicalName resolved <> " initial")+ where+ label codeName detail = codeName <> ": aggregate '" <> aggName aggregate <> "' register '" <> regName register <> "' " <> detail+ contractRefusals contract =+ [ "ContractEmpty: contract '" <> ctrName contract <> "' must declare at least one event"+ | null (ctrEvents contract)+ ]+ publisherRefusals publisher =+ let backoff = pubBackoff publisher+ label message = message <> ": publisher '" <> pubName publisher <> "'"+ in case boKind backoff of+ "constant" -> []+ "exponential" -> case (boMax backoff, boMultiplier backoff) of+ (Just maximumWindow, Just multiplierText) ->+ case (windowSeconds (boWindow backoff), windowSeconds maximumWindow, readMaybe (T.unpack multiplierText) :: Maybe Double) of+ (Right initialSeconds, Right maximumSeconds, Just multiplier)+ | initialSeconds > 0 && maximumSeconds >= initialSeconds && multiplier >= 1 -> []+ _ -> [label "BackoffInvalidExponential"]+ _ -> [label "BackoffExponentialIncomplete"]+ other -> [label ("BackoffUnknownKind '" <> other <> "'")]++windowSeconds :: Text -> Either Text Int+windowSeconds window = case T.unsnoc window of+ Just (digits, unit)+ | not (T.null digits),+ Just amount <- readMaybe (T.unpack digits) -> case unit of+ 's' -> Right amount+ 'm' -> Right (amount * 60)+ 'h' -> Right (amount * 3600)+ _ -> Left invalid+ _ -> Left invalid+ where+ invalid = "invalid window '" <> window <> "' (expected digits followed by s, m, or h)"++windowText :: Text -> Text+windowText = either (const "0") tshow' . windowSeconds++-- | Render an Expr back to source-ish text for a hole annotation.+renderGuard :: Expr -> Text+renderGuard = renderExpr++--------------------------------------------------------------------------------+-- Text helpers+--------------------------------------------------------------------------------++nl :: [Text] -> Text+nl = T.intercalate "\n"++-- | Join groups of declarations, blank-line-separated, dropping empties.+sectionsOf :: [[Text]] -> Text+sectionsOf = T.intercalate "\n\n" . filter (not . T.null) . map (T.intercalate "\n\n")++lowerFirst :: Text -> Text+lowerFirst t = case T.uncons t of+ Just (c, rest) -> T.cons (toLower c) rest+ Nothing -> t++-- | Assert the shared category proof at emission time as a belt-and-braces+-- guard for callers that bypass the CLI's normal validate-before-scaffold path.+staticCategory :: Text -> Text -> Text+staticCategory owner value = case sagaCategoryError value of+ Nothing -> value+ Just reason -> error (T.unpack ("keiro-dsl scaffold: illegal " <> owner <> " category " <> tshow value <> " " <> reason))++pascal :: Text -> Text+pascal t = case T.uncons t of+ Just (c, rest) -> T.cons (toUpper c) rest+ Nothing -> t++pascalFromKebab :: Text -> Text+pascalFromKebab = T.concat . map pascal . T.splitOn "-"++kebabFromPascal :: Text -> Text+kebabFromPascal = T.intercalate "-" . map T.toLower . splitCamel++-- | Split CamelCase into its words (best-effort, for the projection name).+splitCamel :: Text -> [Text]+splitCamel = go . T.unpack+ where+ go [] = []+ go (c : cs) =+ let (rest, more) = break' cs+ in T.pack (c : rest) : go more+ break' [] = ([], [])+ break' (x : xs)+ | x `elem` ['A' .. 'Z'] = ([], x : xs)+ | otherwise = let (r, m) = break' xs in (x : r, m) tshow :: Text -> Text tshow t = T.pack (show t)
src/Keiro/Dsl/ScaffoldRecord.hs view
@@ -1,14 +1,14 @@-{- | Versioned persistence for the files and mapped consumer identities used by-one successful scaffold run. Unknown header fields are ignored so v1 readers-can consume records extended by later tool versions. Mapping rows are canonical-single-line JSON after a @mapping @ prefix; old readers ignore that row kind.--}-module Keiro.Dsl.ScaffoldRecord (- ScaffoldRecord (..),+-- | Versioned persistence for the files and mapped consumer identities used by+-- one successful scaffold run. Unknown header fields are ignored so v1 readers+-- can consume records extended by later tool versions. Mapping rows are canonical+-- single-line JSON after a @mapping @ prefix; old readers ignore that row kind.+module Keiro.Dsl.ScaffoldRecord+ ( ScaffoldRecord (..), renderRecord, parseRecord, recordFileName,-) where+ )+where import Data.Aeson qualified as Aeson import Data.ByteString.Lazy qualified as BL@@ -17,100 +17,116 @@ import Data.Text qualified as T import Data.Text.Encoding qualified as Text import Keiro.Dsl.ExplainBindings (BindingHole (..))+import Keiro.Dsl.LanguageVersion (SourceLanguage (..)) import Keiro.Dsl.MappedConsumer (MappingIdentity (..)) import Keiro.Dsl.Scaffold (ModuleKind (..)) import System.FilePath (isAbsolute, splitDirectories) data ScaffoldRecord = ScaffoldRecord- { recSpecPath :: !Text- , recModuleRoot :: !Text- , recLayout :: !Text- , recFiles :: ![(ModuleKind, FilePath)]- , recMappings :: ![MappingIdentity]- , recBindingObligations :: ![BindingHole]- }- deriving stock (Eq, Show)+ { recSpecPath :: !Text,+ recModuleRoot :: !Text,+ recLayout :: !Text,+ recSourceLanguage :: !SourceLanguage,+ recFiles :: ![(ModuleKind, FilePath)],+ recMappings :: ![MappingIdentity],+ recBindingObligations :: ![BindingHole]+ }+ deriving stock (Eq, Show) renderRecord :: ScaffoldRecord -> Text renderRecord record =- T.unlines $- [ "keiro-dsl scaffold record v1"- , "spec: " <> recSpecPath record- , "module-root: " <> rootLabel- , "layout: " <> recLayout record- ]- <> map renderFile (recFiles record)- <> map renderMapping (recMappings record)- <> map renderBindingObligation (recBindingObligations record)+ T.unlines $+ [ "keiro-dsl scaffold record v1",+ "spec: " <> recSpecPath record,+ "module-root: " <> rootLabel,+ "layout: " <> recLayout record,+ "source-language " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode (recSourceLanguage record)))+ ]+ <> map renderFile (recFiles record)+ <> map renderMapping (recMappings record)+ <> map renderBindingObligation (recBindingObligations record) where rootLabel = if T.null (recModuleRoot record) then "(none)" else recModuleRoot record renderFile (Generated, path) = "generated " <> T.pack path renderFile (HoleStub, path) = "hole " <> T.pack path renderMapping mapping =- "mapping " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode mapping))+ mappingRowPrefix mapping <> Text.decodeUtf8 (BL.toStrict (Aeson.encode mapping)) renderBindingObligation obligation =- "binding " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode obligation))+ "binding " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode obligation)) -{- | Parse a v1 record. The version header and the three required fields must-be present exactly once. Unknown lines are ignored for forward compatibility;-unsafe file paths are rejected rather than joined to a scaffold output root.--}+-- | Parse a v1 record. The version header and the three required fields must+-- be present exactly once. Unknown lines are ignored for forward compatibility;+-- unsafe file paths are rejected rather than joined to a scaffold output root. parseRecord :: Text -> Maybe ScaffoldRecord parseRecord contents = case T.lines contents of- header : rows- | header == "keiro-dsl scaffold record v1" -> do- specPath <- exactlyOne "spec: " rows- rootLabel <- exactlyOne "module-root: " rows- layout <- exactlyOne "layout: " rows- files <- traverse parseFile (filter isFileRow rows)- mappings <- traverse parseMapping (filter ("mapping " `T.isPrefixOf`) rows)- bindingEntries <- traverse parseBindingObligation (filter ("binding " `T.isPrefixOf`) rows)- if hasDuplicateMappingNames mappings || hasDuplicateBindingObligations bindingEntries- then Nothing- else- pure- ScaffoldRecord- { recSpecPath = specPath- , recModuleRoot = if rootLabel == "(none)" then "" else rootLabel- , recLayout = layout- , recFiles = files- , recMappings = mappings- , recBindingObligations = bindingEntries- }- _ -> Nothing+ header : rows+ | header == "keiro-dsl scaffold record v1" -> do+ specPath <- exactlyOne "spec: " rows+ rootLabel <- exactlyOne "module-root: " rows+ layout <- exactlyOne "layout: " rows+ sourceLanguage <- parseSourceLanguage rows+ files <- traverse parseFile (filter isFileRow rows)+ ordinaryMappings <- traverse (parseMapping "mapping ") (filter ("mapping " `T.isPrefixOf`) rows)+ nominalMappings <- traverse (parseMapping "nominal-mapping ") (filter ("nominal-mapping " `T.isPrefixOf`) rows)+ let mappings = ordinaryMappings <> nominalMappings+ bindingEntries <- traverse parseBindingObligation (filter ("binding " `T.isPrefixOf`) rows)+ if hasDuplicateMappingNames mappings || hasDuplicateBindingObligations bindingEntries+ then Nothing+ else+ pure+ ScaffoldRecord+ { recSpecPath = specPath,+ recModuleRoot = if rootLabel == "(none)" then "" else rootLabel,+ recLayout = layout,+ recSourceLanguage = sourceLanguage,+ recFiles = files,+ recMappings = mappings,+ recBindingObligations = bindingEntries+ }+ _ -> Nothing where exactlyOne prefix rows = case [value | row <- rows, Just value <- [T.stripPrefix prefix row]] of- [value] -> Just value- _ -> Nothing+ [value] -> Just value+ _ -> Nothing isFileRow row = "generated " `T.isPrefixOf` row || "hole " `T.isPrefixOf` row parseFile row- | Just path <- T.stripPrefix "generated " row = checkedFile Generated path- | Just path <- T.stripPrefix "hole " row = checkedFile HoleStub path- | otherwise = Nothing+ | Just path <- T.stripPrefix "generated " row = checkedFile Generated path+ | Just path <- T.stripPrefix "hole " row = checkedFile HoleStub path+ | otherwise = Nothing checkedFile fileKind pathText =- let path = T.unpack pathText- in if null path || isAbsolute path || ".." `elem` splitDirectories path- then Nothing- else Just (fileKind, path)- parseMapping row = do- payload <- T.stripPrefix "mapping " row- Aeson.decodeStrict' (Text.encodeUtf8 payload)+ let path = T.unpack pathText+ in if null path || isAbsolute path || ".." `elem` splitDirectories path+ then Nothing+ else Just (fileKind, path)+ parseMapping prefix row = do+ payload <- T.stripPrefix prefix row+ Aeson.decodeStrict' (Text.encodeUtf8 payload) parseBindingObligation row = do- payload <- T.stripPrefix "binding " row+ payload <- T.stripPrefix "binding " row+ Aeson.decodeStrict' (Text.encodeUtf8 payload)+ parseSourceLanguage rows = case filter ("source-language " `T.isPrefixOf`) rows of+ [] -> Just LegacyUnversioned+ [row] -> do+ payload <- T.stripPrefix "source-language " row Aeson.decodeStrict' (Text.encodeUtf8 payload)+ _ -> Nothing hasDuplicateMappingNames mappings =- let names = map mappingSpecName mappings- in length names /= length (nub names)+ let names = map mappingSpecName mappings+ in length names /= length (nub names) hasDuplicateBindingObligations obligations =- let keys = map bindingKey obligations- in length keys /= length (nub keys)+ let keys = map bindingKey obligations+ in length keys /= length (nub keys) bindingKey hole =- ( holeMappedName hole- , holeModule hole- , holeSymbol hole- , holeKind hole- , holePath hole- )+ ( holeMappedName hole,+ holeModule hole,+ holeSymbol hole,+ holeKind hole,+ holePath hole+ ) recordFileName :: Text -> FilePath recordFileName context = "keiro-dsl-scaffold-record." <> T.unpack context <> ".txt"++mappingRowPrefix :: MappingIdentity -> Text+mappingRowPrefix NominalMapping {} = "nominal-mapping "+mappingRowPrefix _ = "mapping "
src/Keiro/Dsl/ScaffoldRun.hs view
@@ -1,17 +1,18 @@-{- | The filesystem-facing scaffold pipeline. It separates pure planning from-execution so every refusal is known before the first output byte is written.--}-module Keiro.Dsl.ScaffoldRun (- Refusal (..),+-- | The filesystem-facing scaffold pipeline. It separates pure planning from+-- execution so every refusal is known before the first output byte is written.+module Keiro.Dsl.ScaffoldRun+ ( Refusal (..), WriteDisposition (..), StaleModule (..), MappingDrift (..),+ SourceLanguageDrift (..), ScaffoldReport (..), scaffoldModules, scaffoldModulesWithGoldens, planScaffold, planScaffoldWithGoldens, executeScaffold,+ executeScaffoldWithLanguage, renderRefusals, renderScaffoldReport, @@ -27,7 +28,8 @@ newBindingObligations, obligationKindLabel, renderMappingIdentity,-) where+ )+where import Data.List (sortOn) import Data.Map.Strict qualified as Map@@ -39,6 +41,7 @@ import Keiro.Dsl.Goldens (GoldenPayload) import Keiro.Dsl.Grammar (Node (..), Spec (..)) import Keiro.Dsl.Harness (harnessForWithGoldens, harnessProcess, harnessReadModel, harnessRouter, harnessWorkflow)+import Keiro.Dsl.LanguageVersion (SourceLanguage (..), sourceFormText) import Keiro.Dsl.Manifest (moduleNameOf, renderManifest) import Keiro.Dsl.MappedConsumer (ConsumerPlan (..), MappingIdentity (..), consumerPlan) import Keiro.Dsl.Scaffold@@ -47,116 +50,120 @@ 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.--}+-- $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)+ = 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) -{- | 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.--}+-- | 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)+ deriving stock (Eq, Show) data StaleModule = StaleModule- { staleKind :: !ModuleKind- , stalePath :: !FilePath- }- deriving stock (Eq, Show)+ { staleKind :: !ModuleKind,+ stalePath :: !FilePath+ }+ deriving stock (Eq, Show) data MappingDrift = MappingDrift- { driftSpecName :: !Text- , driftPrevious :: !(Maybe MappingIdentity)- , driftCurrent :: !(Maybe MappingIdentity)- }- deriving stock (Eq, Show)+ { driftSpecName :: !Text,+ driftPrevious :: !(Maybe MappingIdentity),+ driftCurrent :: !(Maybe MappingIdentity)+ }+ deriving stock (Eq, Show) +data SourceLanguageDrift = SourceLanguageDrift+ { languageDriftPrevious :: !SourceLanguage,+ languageDriftCurrent :: !SourceLanguage+ }+ deriving stock (Eq, Show)+ data ScaffoldReport = ScaffoldReport- { reportSpecPath :: !FilePath- , reportOutDir :: !FilePath- , reportContext :: !Context- , reportDispositions :: ![(ScaffoldModule, WriteDisposition)]- , reportManifestPath :: !FilePath- , reportRecordPath :: !FilePath- , reportPreviousSpecPath :: !(Maybe Text)- , reportStale :: ![StaleModule]- , reportConsumerPlan :: !ConsumerPlan- , reportConstraintPlan :: ![Text]- , reportMappingDrift :: ![MappingDrift]- , reportNewHoles :: ![BindingHole]- }- deriving stock (Eq, Show)+ { reportSpecPath :: !FilePath,+ reportOutDir :: !FilePath,+ reportContext :: !Context,+ reportDispositions :: ![(ScaffoldModule, WriteDisposition)],+ reportManifestPath :: !FilePath,+ reportRecordPath :: !FilePath,+ reportPreviousSpecPath :: !(Maybe Text),+ reportStale :: ![StaleModule],+ reportConsumerPlan :: !ConsumerPlan,+ reportConstraintPlan :: ![Text],+ reportMappingDrift :: ![MappingDrift],+ reportSourceLanguageDrift :: !(Maybe SourceLanguageDrift),+ reportNewHoles :: ![BindingHole]+ }+ deriving stock (Eq, Show) -{- | Produce the complete in-memory module set for a specification. Keeping-this registry in one place prevents the CLI and tests from drifting apart.--}+-- | Produce the complete in-memory module set for a specification. Keeping+-- this registry in one place prevents the CLI and tests from drifting apart. scaffoldModules :: Context -> Spec -> [ScaffoldModule] scaffoldModules = scaffoldModulesWithGoldens [] scaffoldModulesWithGoldens :: [GoldenPayload] -> Context -> Spec -> [ScaffoldModule] scaffoldModulesWithGoldens goldens ctx spec =- scaffoldStructural ctx spec- <> scaffoldReplayAudit ctx spec- <> concat- [ case node of- NAggregate agg -> scaffoldAggregate ctx spec agg <> harnessForWithGoldens goldens ctx spec agg- 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 _ -> []- | node <- specNodes spec- ]+ scaffoldStructural ctx spec+ <> scaffoldReplayAudit ctx spec+ <> concat+ [ case node of+ NAggregate agg -> scaffoldAggregate ctx spec agg <> harnessForWithGoldens goldens ctx spec agg+ 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 _ -> []+ | node <- specNodes spec+ ] -{- | Run every pure refusal gate. A successful result is the exact write set;-a refusal has no write set and therefore cannot be accidentally executed.--}+-- | Run every pure refusal gate. A successful result is the exact write set;+-- a refusal has no write set and therefore cannot be accidentally executed. planScaffold :: Context -> Spec -> Either [Refusal] [ScaffoldModule] planScaffold = planScaffoldWithGoldens [] planScaffoldWithGoldens :: [GoldenPayload] -> Context -> Spec -> Either [Refusal] [ScaffoldModule] planScaffoldWithGoldens goldens ctx spec =- let modules = scaffoldModulesWithGoldens goldens ctx spec- in case pureRefusals ctx spec modules of+ case scaffoldRefusals spec of+ lowering@(_ : _) -> Left [LoweringRefusal lowering]+ [] ->+ let modules = scaffoldModulesWithGoldens goldens ctx spec+ 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.--}+-- | 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)]+ collisionRefusals modules+ <> dependencyRefusals ctx spec modules+ <> [FirewallBreach breaches | not (null breaches)]+ <> [LoweringRefusal lowering | let lowering = scaffoldRefusals spec, not (null lowering)] where breaches = firewallBreaches modules @@ -166,250 +173,262 @@ plan = consumerPlan spec generatedByName = Map.fromList [(moduleNameOf (modulePath moduleValue), moduleValue) | moduleValue <- modules, kind moduleValue == Generated] collisionWithConsumers =- [ PathCollision- (modulePath generated)- [origin generated, "consumer module " <> consumerModule]- | consumerModule <- consumerModules plan- , Just generated <- [Map.lookup consumerModule generatedByName]- ]+ [ PathCollision+ (modulePath generated)+ [origin generated, "consumer module " <> consumerModule]+ | consumerModule <- consumerModules plan,+ Just generated <- [Map.lookup consumerModule generatedByName]+ ] namespaceCycles =- [ ImportCycle [importer, consumerModule, importer]- | consumerModule <- consumerModules plan- , generatedNamespaceOwned ctx consumerModule- , importer <- take 1 (importersOf consumerModule modules <> [contextGeneratedRoot ctx])- ]+ [ ImportCycle [importer, consumerModule, importer]+ | consumerModule <- consumerModules plan,+ generatedNamespaceOwned ctx consumerModule,+ importer <- take 1 (importersOf consumerModule modules <> [contextGeneratedRoot ctx])+ ] generatedNamespaceOwned :: Context -> Text -> Bool generatedNamespaceOwned ctx consumerModule = case placement ctx of- GeneratedPrefix -> contextGeneratedRoot ctx `T.isPrefixOf` consumerModule- CollocatedLeaf ->- (root <> contextSegment <> ".") `T.isPrefixOf` consumerModule- && ".Generated" `T.isInfixOf` consumerModule+ GeneratedPrefix -> contextGeneratedRoot ctx `T.isPrefixOf` consumerModule+ CollocatedLeaf ->+ (root <> contextSegment <> ".") `T.isPrefixOf` consumerModule+ && ".Generated" `T.isInfixOf` consumerModule where root = if T.null (moduleRoot ctx) then "" else moduleRoot ctx <> "." contextSegment = pascalFromKebab (contextName ctx) contextGeneratedRoot :: Context -> Text contextGeneratedRoot ctx = case placement ctx of- GeneratedPrefix -> root <> "Generated." <> contextSegment- CollocatedLeaf -> root <> contextSegment <> ".Generated"+ GeneratedPrefix -> root <> "Generated." <> contextSegment+ CollocatedLeaf -> root <> contextSegment <> ".Generated" where root = if T.null (moduleRoot ctx) then "" else moduleRoot ctx <> "." contextSegment = pascalFromKebab (contextName ctx) importersOf :: Text -> [ScaffoldModule] -> [Text] importersOf imported =- map (moduleNameOf . modulePath)- . filter (any (importsModule imported) . T.lines . moduleText)+ map (moduleNameOf . modulePath)+ . filter (any (importsModule imported) . T.lines . moduleText) importsModule :: Text -> Text -> Bool importsModule expected line = case T.words (T.strip line) of- "import" : rest -> expected `elem` rest- _ -> False+ "import" : rest -> expected `elem` rest+ _ -> False collisionRefusals :: [ScaffoldModule] -> [Refusal] collisionRefusals modules =- [ PathCollision (modulePath first) (map origin (first : rest))- | first : rest <- Map.elems grouped- , not (null rest)- ]+ [ PathCollision (modulePath first) (map origin (first : rest))+ | first : rest <- Map.elems grouped,+ not (null rest)+ ] where grouped =- Map.fromListWith- (flip (<>))- [(T.toCaseFold (T.pack (modulePath m)), [m]) | m <- modules]+ Map.fromListWith+ (flip (<>))+ [(T.toCaseFold (T.pack (modulePath m)), [m]) | m <- modules] -{- | Check existing generated paths, then perform the deterministic writes and-manifest rewrite. Banner refusal is evaluated for the complete set before the-output directory is created or any file is changed.--}+-- | Check existing generated paths, then perform the deterministic writes and+-- manifest rewrite. Banner refusal is evaluated for the complete set before the+-- output directory is created or any file is changed. executeScaffold :: FilePath -> Bool -> FilePath -> Context -> Spec -> [ScaffoldModule] -> IO (Either [Refusal] ScaffoldReport)-executeScaffold out forceGeneratedOverwrite specPath ctx spec modules = do- bannerless <- if forceGeneratedOverwrite then pure [] else missingGeneratedBanners out modules- if not (null bannerless)- then pure (Left [MissingGeneratedBanner bannerless])- else do- let recordPath = out </> recordFileName (specContext spec)- previousRecord <- readRecord recordPath- stale <- maybe (pure []) (existingStale out modules) previousRecord- let currentConsumerPlan = consumerPlan spec- drift = maybe [] (mappingDrift (consumerMappings currentConsumerPlan) . recMappings) previousRecord- currentObligations = either (const []) id (bindingHoles spec)- newHoles = maybe [] (newBindingObligations currentObligations . recBindingObligations) previousRecord- createDirectoryIfMissing True out- dispositions <- mapM (writeModule out) modules- let manifestPath = out </> ("keiro-dsl-manifest." <> T.unpack (specContext spec) <> ".txt")- TIO.writeFile manifestPath (renderManifest (T.pack specPath) modules spec)- TIO.writeFile recordPath (renderRecord (currentRecord specPath ctx spec modules))- pure $- Right- ScaffoldReport- { reportSpecPath = specPath- , reportOutDir = out- , reportContext = ctx- , reportDispositions = dispositions- , reportManifestPath = manifestPath- , reportRecordPath = recordPath- , reportPreviousSpecPath = recSpecPath <$> previousRecord- , reportStale = stale- , reportConsumerPlan = currentConsumerPlan- , reportConstraintPlan = constraintPlan spec currentConsumerPlan- , reportMappingDrift = drift- , reportNewHoles = newHoles- }+executeScaffold out forceGeneratedOverwrite specPath ctx spec modules =+ executeScaffoldWithLanguage out forceGeneratedOverwrite specPath LegacyUnversioned ctx spec modules +-- | Source-aware execution used by the CLI; semantic planning still receives only 'Spec'.+executeScaffoldWithLanguage :: FilePath -> Bool -> FilePath -> SourceLanguage -> Context -> Spec -> [ScaffoldModule] -> IO (Either [Refusal] ScaffoldReport)+executeScaffoldWithLanguage out forceGeneratedOverwrite specPath sourceLanguage ctx spec modules = do+ bannerless <- if forceGeneratedOverwrite then pure [] else missingGeneratedBanners out modules+ if not (null bannerless)+ then pure (Left [MissingGeneratedBanner bannerless])+ else do+ let recordPath = out </> recordFileName (specContext spec)+ previousRecord <- readRecord recordPath+ stale <- maybe (pure []) (existingStale out modules) previousRecord+ let currentConsumerPlan = consumerPlan spec+ drift = maybe [] (mappingDrift (consumerMappings currentConsumerPlan) . recMappings) previousRecord+ languageDrift = do+ previous <- previousRecord+ if recSourceLanguage previous == sourceLanguage+ then Nothing+ else Just (SourceLanguageDrift (recSourceLanguage previous) sourceLanguage)+ currentObligations = either (const []) id (bindingHoles spec)+ newHoles = maybe [] (newBindingObligations currentObligations . recBindingObligations) previousRecord+ createDirectoryIfMissing True out+ dispositions <- mapM (writeModule out) modules+ let manifestPath = out </> ("keiro-dsl-manifest." <> T.unpack (specContext spec) <> ".txt")+ TIO.writeFile manifestPath (renderManifest (T.pack specPath) modules spec)+ TIO.writeFile recordPath (renderRecord (currentRecord specPath sourceLanguage ctx spec modules))+ pure $+ Right+ ScaffoldReport+ { reportSpecPath = specPath,+ reportOutDir = out,+ reportContext = ctx,+ reportDispositions = dispositions,+ reportManifestPath = manifestPath,+ reportRecordPath = recordPath,+ reportPreviousSpecPath = recSpecPath <$> previousRecord,+ reportStale = stale,+ reportConsumerPlan = currentConsumerPlan,+ reportConstraintPlan = constraintPlan spec currentConsumerPlan,+ reportMappingDrift = drift,+ reportSourceLanguageDrift = languageDrift,+ reportNewHoles = newHoles+ }+ constraintPlan :: Spec -> ConsumerPlan -> [Text] constraintPlan spec plan = case resolveTypeGraph spec of- Left _ -> []- Right graph ->- let registerRoots =- Set.fromList- [ key- | RootRegister _ _ key <- tgUseSites graph- ]- in map (constraintFor registerRoots) (consumerMappings plan)+ Left _ -> []+ Right graph ->+ let registerRoots =+ Set.fromList+ [ key+ | RootRegister _ _ key <- tgUseSites graph+ ]+ in map (constraintFor registerRoots) (consumerMappings plan) where constraintFor registerRoots mapping =- mappingSpecName mapping- <> ": "- <> T.intercalate ", " (baseConstraints mapping <> registerConstraints registerRoots mapping)- baseConstraints StructuralMapping{} = ["Eq", "Show", "CanonicalTypeName", "StructuralBinding"]- baseConstraints OpaqueMapping{} = ["Eq", "Show", "ToJSON", "FromJSON"]+ mappingSpecName mapping+ <> ": "+ <> T.intercalate ", " (baseConstraints mapping <> registerConstraints registerRoots mapping)+ baseConstraints StructuralMapping {} = ["Eq", "Show", "CanonicalTypeName", "StructuralBinding"]+ baseConstraints OpaqueMapping {} = ["Eq", "Show", "ToJSON", "FromJSON"]+ baseConstraints NominalMapping {} = ["Eq", "Show", "NominalBinding"] registerConstraints roots mapping- | MappedKey (mappingSpecName mapping) `Set.member` roots = ["register initial", "snapshot ToJSON", "snapshot FromJSON"]- | otherwise = []+ | MappedKey (mappingSpecName mapping) `Set.member` roots = ["register initial", "snapshot ToJSON", "snapshot FromJSON"]+ | otherwise = [] mappingDrift :: [MappingIdentity] -> [MappingIdentity] -> [MappingDrift] mappingDrift current previous =- [ MappingDrift name old new- | name <- Set.toAscList (Map.keysSet oldByName <> Map.keysSet newByName)- , let old = Map.lookup name oldByName- , let new = Map.lookup name newByName- , old /= new- ]+ [ MappingDrift name old new+ | name <- Set.toAscList (Map.keysSet oldByName <> Map.keysSet newByName),+ let old = Map.lookup name oldByName,+ let new = Map.lookup name newByName,+ old /= new+ ] where oldByName = Map.fromList [(mappingSpecName mapping, mapping) | mapping <- previous] newByName = Map.fromList [(mappingSpecName mapping, mapping) | mapping <- current] newBindingObligations :: [BindingHole] -> [BindingHole] -> [BindingHole] newBindingObligations current previous =- [ obligation- | obligation <- current- , obligation `Set.notMember` previousSet- ]+ [ obligation+ | obligation <- current,+ obligation `Set.notMember` previousSet+ ] where previousSet = Set.fromList previous readRecord :: FilePath -> IO (Maybe ScaffoldRecord) readRecord path = do- exists <- doesFileExist path- if exists then parseRecord <$> TIO.readFile path else pure Nothing+ exists <- doesFileExist path+ if exists then parseRecord <$> TIO.readFile path else pure Nothing existingStale :: FilePath -> [ScaffoldModule] -> ScaffoldRecord -> IO [StaleModule] 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.--}+-- | 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 currentPathList removed = [(fileKind, path) | (fileKind, path) <- previous, path `Set.notMember` currentPaths] stillExists (fileKind, path) = do- exists <- doesFileExist (out </> path)- pure [StaleModule fileKind path | exists]+ exists <- doesFileExist (out </> path)+ pure [StaleModule fileKind path | exists] -currentRecord :: FilePath -> Context -> Spec -> [ScaffoldModule] -> ScaffoldRecord-currentRecord specPath ctx spec modules =- ScaffoldRecord- { recSpecPath = T.pack specPath- , recModuleRoot = moduleRoot ctx- , recLayout = case placement ctx of GeneratedPrefix -> "prefixed"; CollocatedLeaf -> "collocated"- , recFiles = [(kind m, modulePath m) | m <- modules]- , recMappings = consumerMappings (consumerPlan spec)- , recBindingObligations = either (const []) id (bindingHoles spec)- }+currentRecord :: FilePath -> SourceLanguage -> Context -> Spec -> [ScaffoldModule] -> ScaffoldRecord+currentRecord specPath sourceLanguage ctx spec modules =+ ScaffoldRecord+ { recSpecPath = T.pack specPath,+ recModuleRoot = moduleRoot ctx,+ recLayout = case placement ctx of GeneratedPrefix -> "prefixed"; CollocatedLeaf -> "collocated",+ recSourceLanguage = sourceLanguage,+ recFiles = [(kind m, modulePath m) | m <- modules],+ recMappings = consumerMappings (consumerPlan spec),+ recBindingObligations = either (const []) id (bindingHoles spec)+ } missingGeneratedBanners :: FilePath -> [ScaffoldModule] -> IO [FilePath] missingGeneratedBanners out modules = fmap concat $ mapM check generated where generated = [m | m <- modules, kind m == Generated] check m = do- let path = out </> modulePath m- exists <- doesFileExist path- if not exists- then pure []- else do- contents <- TIO.readFile path- pure [modulePath m | not (any (T.isPrefixOf "-- @generated") (T.lines contents))]+ let path = out </> modulePath m+ exists <- doesFileExist path+ if not exists+ then pure []+ else do+ contents <- TIO.readFile path+ pure [modulePath m | not (any (T.isPrefixOf "-- @generated") (T.lines contents))] writeModule :: FilePath -> ScaffoldModule -> IO (ScaffoldModule, WriteDisposition) writeModule out m = do- let path = out </> modulePath m- createDirectoryIfMissing True (takeDirectory path)- case kind m of- Generated -> do- TIO.writeFile path (moduleText m)- pure (m, Overwritten)- HoleStub -> do- exists <- doesFileExist path- if exists- then pure (m, Skipped)- else TIO.writeFile path (moduleText m) >> pure (m, Created)+ let path = out </> modulePath m+ createDirectoryIfMissing True (takeDirectory path)+ case kind m of+ Generated -> do+ TIO.writeFile path (moduleText m)+ pure (m, Overwritten)+ HoleStub -> do+ exists <- doesFileExist path+ if exists+ then pure (m, Skipped)+ else TIO.writeFile path (moduleText m) >> pure (m, Created) renderRefusals :: [Refusal] -> [Text] renderRefusals = concatMap render where render (PathCollision path origins) =- [ "error: module path collision -- refusing to scaffold; nothing was written"- , " " <> T.pack path- ]- <> [" from " <> source | source <- origins]+ [ "error: module path collision -- refusing to scaffold; nothing was written",+ " " <> T.pack path+ ]+ <> [" from " <> source | source <- origins] render (FirewallBreach breaches) =- [ "error: firewall breach -- refusing to scaffold; nothing was written"- , "firewall: BREACH (" <> tshow (length breaches) <> " forbidden token occurrence(s)):"- ]- <> [" " <> T.pack path <> ":" <> tshow line <> " contains " <> token | (path, token, line) <- breaches]+ [ "error: firewall breach -- refusing to scaffold; nothing was written",+ "firewall: BREACH (" <> tshow (length breaches) <> " forbidden token occurrence(s)):"+ ]+ <> [" " <> T.pack path <> ":" <> tshow line <> " contains " <> token | (path, token, line) <- breaches] render (LoweringRefusal refusals) =- ["error: scaffold cannot lower this spec faithfully -- refusing; nothing was written"]- <> map (" " <>) refusals+ ["error: scaffold cannot lower this spec faithfully -- refusing; nothing was written"]+ <> map (" " <>) refusals render (MissingGeneratedBanner paths) =- [ "error: refusing to overwrite " <> tshow (length paths) <> " file(s) at Generated paths that lack the '-- @generated' banner"- ]- <> map (" " <>) (map T.pack paths)- <> [" (adopted as hand code? move it, or re-run with --force-generated-overwrite)", "nothing was written"]+ [ "error: refusing to overwrite " <> tshow (length paths) <> " file(s) at Generated paths that lack the '-- @generated' banner"+ ]+ <> map (" " <>) (map T.pack paths)+ <> [" (adopted as hand code? move it, or re-run with --force-generated-overwrite)", "nothing was written"] render (ImportCycle path) =- [ "error: generated/consumer import cycle -- refusing to scaffold; nothing was written"- , " " <> T.intercalate " -> " path- , " keep bindings in a leaf module that imports only Structural.Shape.* and Keiro.Codec.Structural"- ]+ [ "error: generated/consumer import cycle -- refusing to scaffold; nothing was written",+ " " <> 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"- ]+ [ "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 =- [ "scaffold: " <> T.pack (reportSpecPath report) <> " -> " <> T.pack (reportOutDir report) <> " (module-root=" <> rootLabel <> ", layout=" <> layoutLabel <> ")"- ]- <> map moduleLine dispositions- <> [ "firewall: OK (" <> tshow generatedCount <> " generated modules scanned, 0 forbidden operators)"- , harnessLine- , dependencyLine- , "manifest: " <> T.pack (reportManifestPath report)- , "record: " <> T.pack (reportRecordPath report)- ]- <> previousSpecNote- <> constraintSection- <> newHolesSection- <> mappingDriftSection- <> staleSection+ [ "scaffold: " <> T.pack (reportSpecPath report) <> " -> " <> T.pack (reportOutDir report) <> " (module-root=" <> rootLabel <> ", layout=" <> layoutLabel <> ")"+ ]+ <> map moduleLine dispositions+ <> [ "firewall: OK (" <> tshow generatedCount <> " generated modules scanned, 0 forbidden operators)",+ harnessLine,+ dependencyLine,+ "manifest: " <> T.pack (reportManifestPath report),+ "record: " <> T.pack (reportRecordPath report)+ ]+ <> previousSpecNote+ <> constraintSection+ <> newHolesSection+ <> mappingDriftSection+ <> sourceLanguageDriftSection+ <> staleSection where ctx = reportContext report dispositions = reportDispositions report@@ -418,7 +437,7 @@ names = [moduleNameOf (modulePath m) | (m, _) <- dispositions] nameWidth = maximum (1 : map T.length names) moduleLine (m, disposition) =- " " <> kindTag (kind m) <> " " <> pad (moduleNameOf (modulePath m)) <> " " <> dispositionTag disposition+ " " <> kindTag (kind m) <> " " <> pad (moduleNameOf (modulePath m)) <> " " <> dispositionTag disposition kindTag Generated = "generated" kindTag HoleStub = "hole " dispositionTag Overwritten = "(overwritten)"@@ -428,59 +447,68 @@ 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"]- ]+ 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+ [] -> "harness: (none emitted)"+ _ -> "harness: run `cabal test <your-component>` over " <> T.unwords harnesses dependencyLine =- "dependency plan: consumer packages "- <> renderBracketed (consumerPackages (reportConsumerPlan report))- <> ", consumer modules "- <> renderBracketed (consumerModules (reportConsumerPlan report))+ "dependency plan: consumer packages "+ <> renderBracketed (consumerPackages (reportConsumerPlan report))+ <> ", consumer modules "+ <> renderBracketed (consumerModules (reportConsumerPlan report)) constraintSection = case reportConstraintPlan report of- [] -> []- constraints -> "constraint plan:" : map (" " <>) constraints+ [] -> []+ constraints -> "constraint plan:" : map (" " <>) constraints newHolesSection = case reportNewHoles report of- [] -> []- obligations ->- ["newly required holes since last scaffold: " <> tshow (length obligations)]- <> concatMap obligationLines obligations+ [] -> []+ obligations ->+ ["newly required holes since last scaffold: " <> tshow (length obligations)]+ <> concatMap obligationLines obligations obligationLines hole =- [ " " <> holeModule hole- , " " <> holeSignature hole <> " (" <> obligationKindLabel (holeKind hole) <> ")"- ]+ [ " " <> holeModule hole,+ " " <> holeSignature hole <> " (" <> obligationKindLabel (holeKind hole) <> ")"+ ] previousSpecNote = case reportPreviousSpecPath report of- Just previous- | previous /= T.pack (reportSpecPath report) ->- [ "note: the previous scaffold record used spec " <> previous- , " specs sharing context " <> contextName ctx <> " in one --out also share " <> T.pack (reportManifestPath report)- ]- _ -> []+ Just previous+ | previous /= T.pack (reportSpecPath report) ->+ [ "note: the previous scaffold record used spec " <> previous,+ " specs sharing context " <> contextName ctx <> " in one --out also share " <> T.pack (reportManifestPath report)+ ]+ _ -> [] mappingDriftSection = case reportMappingDrift report of- [] -> []- drifts ->- ["mapping drift: " <> tshow (length drifts) <> " declaration(s) changed since the previous scaffold:"]- <> concatMap driftLines drifts+ [] -> []+ 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)+ [ " " <> driftSpecName drift,+ " previous: " <> maybe "(absent)" renderMappingIdentity (driftPrevious drift),+ " current: " <> maybe "(absent)" renderMappingIdentity (driftCurrent drift)+ ]+ sourceLanguageDriftSection = case reportSourceLanguageDrift report of+ Nothing -> []+ Just drift ->+ [ "source-language drift: "+ <> sourceFormText (languageDriftPrevious drift)+ <> " -> "+ <> sourceFormText (languageDriftCurrent drift)+ <> " (generated module bytes are semantic and unaffected)" ] staleSection = case reportStale report of- [] -> []- stale ->- [ "stale: " <> tshow (length stale) <> " file(s) from a previous scaffold of context " <> contextName ctx <> " are no longer produced by this spec:"- ]- <> map staleLine stale- <> ["note: keiro-dsl never deletes files."]+ [] -> []+ stale ->+ [ "stale: " <> tshow (length stale) <> " file(s) from a previous scaffold of context " <> contextName ctx <> " are no longer produced by this spec:"+ ]+ <> 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)"+ Generated -> " generated " <> T.pack (stalePath stale) <> " (safe to delete; still on disk)"+ HoleStub -> " hole " <> T.pack (stalePath stale) <> " (hand-owned — review before deleting)" obligationKindLabel :: BindingObligationKind -> Text obligationKindLabel BindingValue = "binding"@@ -491,28 +519,43 @@ renderBracketed values = "[" <> T.intercalate ", " values <> "]" renderMappingIdentity :: MappingIdentity -> Text-renderMappingIdentity StructuralMapping{mappingPackage, mappingModule, mappingType, mappingBindingSymbol, mappingBindingVersion} =- "structural "- <> mappingPackage- <> ":"- <> mappingModule- <> "."- <> mappingType- <> " binding="- <> mappingBindingSymbol- <> " version="- <> mappingBindingVersion-renderMappingIdentity OpaqueMapping{mappingPackage, mappingModule, mappingType, mappingCodecIdentity, mappingCodecVersion} =- "opaque "- <> mappingPackage- <> ":"- <> mappingModule- <> "."- <> mappingType- <> " codec="- <> mappingCodecIdentity- <> " version="- <> mappingCodecVersion+renderMappingIdentity StructuralMapping {mappingPackage, mappingModule, mappingType, mappingBindingSymbol, mappingBindingVersion} =+ "structural "+ <> mappingPackage+ <> ":"+ <> mappingModule+ <> "."+ <> mappingType+ <> " binding="+ <> mappingBindingSymbol+ <> " version="+ <> mappingBindingVersion+renderMappingIdentity OpaqueMapping {mappingPackage, mappingModule, mappingType, mappingCodecIdentity, mappingCodecVersion} =+ "opaque "+ <> mappingPackage+ <> ":"+ <> mappingModule+ <> "."+ <> mappingType+ <> " codec="+ <> mappingCodecIdentity+ <> " version="+ <> mappingCodecVersion+renderMappingIdentity NominalMapping {mappingNominalCategory, mappingNominalRepresentation, mappingPackage, mappingModule, mappingType, mappingBindingSymbol, mappingBindingVersion} =+ "nominal-"+ <> mappingNominalCategory+ <> " "+ <> mappingPackage+ <> ":"+ <> mappingModule+ <> "."+ <> mappingType+ <> " representation="+ <> mappingNominalRepresentation+ <> " binding="+ <> mappingBindingSymbol+ <> " version="+ <> mappingBindingVersion tshow :: (Show a) => a -> Text tshow = T.pack . show
src/Keiro/Dsl/Skeleton.hs view
@@ -1,17 +1,17 @@-{- | Starter @.keiro@ skeletons for the @new \<kind\>@ subcommand. Each skeleton-is a minimal, __valid__ spec for one node kind: it parses and passes-@validateSpec@ with zero error diagnostics (a test enumerates them), so the-skeletons double as living, guaranteed-valid notation examples.--Kinds whose validator couples to other nodes (a @publisher@ needs an @emit@; an-@emit@/@intake@ needs a @contract@; a @dispatch@ needs a @workqueue@; an-@operation@ references a @workflow@) ship the whole coupled mini-spec, so the-skeleton is self-contained and checks clean on its own.--}-module Keiro.Dsl.Skeleton (- skeletonFor,+-- | Starter @.keiro@ skeletons for the @new \<kind\>@ subcommand. Each skeleton+-- is a minimal, __valid__ spec for one node kind: it parses and passes+-- @validateSpec@ with zero error diagnostics (a test enumerates them), so the+-- skeletons double as living, guaranteed-valid notation examples.+--+-- Kinds whose validator couples to other nodes (a @publisher@ needs an @emit@; an+-- @emit@/@intake@ needs a @contract@; a @dispatch@ needs a @workqueue@; an+-- @operation@ references a @workflow@) ship the whole coupled mini-spec, so the+-- skeleton is self-contained and checks clean on its own.+module Keiro.Dsl.Skeleton+ ( skeletonFor, skeletonKinds,-) where+ )+where import Data.Text (Text) import Data.Text qualified as T@@ -19,324 +19,325 @@ -- | The valid @new \<kind\>@ arguments, in help/listing order. skeletonKinds :: [Text] skeletonKinds =- [ "aggregate"- , "process"- , "router"- , "contract"- , "intake"- , "emit"- , "publisher"- , "workqueue"- , "dispatch"- , "workflow"- , "operation"- ]+ [ "aggregate",+ "process",+ "router",+ "contract",+ "intake",+ "emit",+ "publisher",+ "workqueue",+ "dispatch",+ "workflow",+ "operation"+ ] -{- | The minimal valid spec text for a node kind, or a 'Left' error naming the-valid kinds when the argument is unrecognised.--}+-- | The minimal valid spec text for a node kind, or a 'Left' error naming the+-- valid kinds when the argument is unrecognised. skeletonFor :: Text -> Either Text Text skeletonFor kind = case kind of- "aggregate" -> Right aggregateSkeleton- "process" -> Right processSkeleton- "router" -> Right routerSkeleton- "contract" -> Right contractSkeleton- "intake" -> Right intakeSkeleton- "emit" -> Right emitSkeleton- "publisher" -> Right emitSkeleton- "workqueue" -> Right workqueueSkeleton- "dispatch" -> Right workqueueSkeleton- "workflow" -> Right workflowSkeleton- "operation" -> Right workflowSkeleton- other ->- Left $- "unknown kind '" <> other <> "'. Valid kinds: " <> T.intercalate ", " skeletonKinds+ "aggregate" -> Right (versioned aggregateSkeleton)+ "process" -> Right (versioned processSkeleton)+ "router" -> Right (versioned routerSkeleton)+ "contract" -> Right (versioned contractSkeleton)+ "intake" -> Right (versioned intakeSkeleton)+ "emit" -> Right (versioned emitSkeleton)+ "publisher" -> Right (versioned emitSkeleton)+ "workqueue" -> Right (versioned workqueueSkeleton)+ "dispatch" -> Right (versioned workqueueSkeleton)+ "workflow" -> Right (versioned workflowSkeleton)+ "operation" -> Right (versioned workflowSkeleton)+ other ->+ Left $+ "unknown kind '" <> other <> "'. Valid kinds: " <> T.intercalate ", " skeletonKinds+ where+ versioned source = "language keiro-dsl 1\n" <> source aggregateSkeleton :: Text aggregateSkeleton =- T.unlines- [ "context my-service"- , ""- , "id ThingId prefix=thing"- , ""- , "aggregate Thing"- , " regs"- , " thingId ThingId = placeholder"- , " state ThingVertex = Pending"- , " states Pending Done!"- , ""- , " command DoThing { thingId attempt:Int }"- , " event ThingCompleted { thingId attempt:Int }"- , ""- , " Pending -- DoThing -->"- , " write state := Done"- , " emit ThingCompleted"- , " goto Done"- , ""- , " wire kind=ctorName fields=camelCase schemaVersion=1"- ]+ T.unlines+ [ "context my-service",+ "",+ "id ThingId prefix=thing",+ "",+ "aggregate Thing",+ " regs",+ " thingId ThingId = placeholder",+ " state ThingVertex = Pending",+ " states Pending Done!",+ "",+ " command DoThing { thingId attempt:Int }",+ " event ThingCompleted { thingId attempt:Int }",+ "",+ " Pending -- DoThing -->",+ " write state := Done",+ " emit ThingCompleted",+ " goto Done",+ "",+ " wire kind=ctorName fields=camelCase schemaVersion=1"+ ] processSkeleton :: Text processSkeleton =- T.unlines- [ "context my-service"- , ""- , "id HospitalId prefix=hosp"- , "id CommandId prefix=cmd"- , ""- , "process HospitalSurge"- , " name \"hospital-surge\""- , " input SurgeInput { hospitalId availableIcuBeds:Int redDemand:Int observedAt:Time }"- , " correlate input.hospitalId via idText"- , " saga Surge category \"hospitalSurge\""- , " target Hospital"- , " projections [ ]"- , ""- , " on SurgeInput"- , " advance NoteSurgeThreshold { hospitalId availableIcuBeds redDemand timerId=timer.id }"- , " dispatch Hospital@input.hospitalId ActivateSurge { hospitalId }"- , " on-appended AckOk ; on-duplicate AckOk ; on-failed Retry"- , " schedule surgeFollowUp"- , ""- , " dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)"- , " rejected => halt"- , " poison => halt"- , ""- , " timer surgeFollowUp"- , " id uuidv5 \"hospital-surge-timer:\" <> correlationId"- , " fireAt input.observedAt + 5m"- , " payload { kind=\"hospital-surge-follow-up\" hospitalId }"- , " fire dispatch Surge@correlationId MarkSurgeTimerFired { hospitalId timerId }"- , " fired-event-id uuidv5 \"hospital-surge-fired:\" <> correlationId"- , " on-ok Fired ; on-reject Fired ; on-ambiguous Retry ; on-error Retry ; not-mine Retry"- , " decode unknown-status => Cancelled"- , " max-attempts 5 dead-letter \"surge timer exceeded ceiling\""- , ""- , "aggregate Surge"- , " regs"- , " states Idle Fired!"- , ""- , " command NoteSurgeThreshold { hospitalId availableIcuBeds:Int redDemand:Int timerId }"- , " command MarkSurgeTimerFired { hospitalId timerId }"- , " event SurgeThresholdNoted = fields(NoteSurgeThreshold)"- , " event SurgeTimerMarked = fields(MarkSurgeTimerFired)"- , " Idle -- NoteSurgeThreshold --> emit SurgeThresholdNoted ; goto Idle"- , " Idle -- MarkSurgeTimerFired --> emit SurgeTimerMarked ; goto Fired"- , ""- , "aggregate Hospital"- , " regs"- , " states Operational Surging!"- , ""- , " command ActivateSurge { hospitalId }"- , " event SurgeActivated = fields(ActivateSurge)"- , " Operational -- ActivateSurge --> emit SurgeActivated ; goto Surging"- ]+ T.unlines+ [ "context my-service",+ "",+ "id HospitalId prefix=hosp",+ "id CommandId prefix=cmd",+ "",+ "process HospitalSurge",+ " name \"hospital-surge\"",+ " input SurgeInput { hospitalId availableIcuBeds:Int redDemand:Int observedAt:Time }",+ " correlate input.hospitalId via idText",+ " saga Surge category \"hospitalSurge\"",+ " target Hospital",+ " projections [ ]",+ "",+ " on SurgeInput",+ " advance NoteSurgeThreshold { hospitalId availableIcuBeds redDemand timerId=timer.id }",+ " dispatch Hospital@input.hospitalId ActivateSurge { hospitalId }",+ " on-appended AckOk ; on-duplicate AckOk ; on-failed Retry",+ " schedule surgeFollowUp",+ "",+ " dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)",+ " rejected => halt",+ " poison => halt",+ "",+ " timer surgeFollowUp",+ " id uuidv5 \"hospital-surge-timer:\" <> correlationId",+ " fireAt input.observedAt + 5m",+ " payload { kind=\"hospital-surge-follow-up\" hospitalId }",+ " fire dispatch Surge@correlationId MarkSurgeTimerFired { hospitalId timerId }",+ " fired-event-id uuidv5 \"hospital-surge-fired:\" <> correlationId",+ " on-ok Fired ; on-reject Fired ; on-ambiguous Retry ; on-error Retry ; not-mine Retry",+ " decode unknown-status => Cancelled",+ " max-attempts 5 dead-letter \"surge timer exceeded ceiling\"",+ "",+ "aggregate Surge",+ " regs",+ " states Idle Fired!",+ "",+ " command NoteSurgeThreshold { hospitalId availableIcuBeds:Int redDemand:Int timerId }",+ " command MarkSurgeTimerFired { hospitalId timerId }",+ " event SurgeThresholdNoted = fields(NoteSurgeThreshold)",+ " event SurgeTimerMarked = fields(MarkSurgeTimerFired)",+ " Idle -- NoteSurgeThreshold --> emit SurgeThresholdNoted ; goto Idle",+ " Idle -- MarkSurgeTimerFired --> emit SurgeTimerMarked ; goto Fired",+ "",+ "aggregate Hospital",+ " regs",+ " states Operational Surging!",+ "",+ " command ActivateSurge { hospitalId }",+ " event SurgeActivated = fields(ActivateSurge)",+ " Operational -- ActivateSurge --> emit SurgeActivated ; goto Surging"+ ] routerSkeleton :: Text routerSkeleton =- T.unlines- [ "context my-service"- , ""- , "router PagingRouter"- , " name \"paging-router\""- , " input IncidentRaised { incidentId service }"- , " key input.incidentId via idText"- , " resolve stable via hole row { responderId }"- , " target Page"- , " projections [ ]"- , " dispatch-each SendPage { incidentId=input.incidentId responderId=resolved.responderId }"- , " on-appended AckOk ; on-duplicate AckOk ; on-failed Retry"- , " dispatch-id strategy=uuidv5 from=(name, key, sourceEventId, targetStreamName, occurrence)"- , " rejected => halt"- , " poison => halt"- , ""- , "aggregate Page"- , " regs"- , " states Pending Delivered!"- , ""- , " command SendPage { incidentId responderId }"- , " event PageSent = fields(SendPage)"- , ""- , " Pending -- SendPage --> emit PageSent ; goto Delivered"- ]+ T.unlines+ [ "context my-service",+ "",+ "router PagingRouter",+ " name \"paging-router\"",+ " input IncidentRaised { incidentId service }",+ " key input.incidentId via idText",+ " resolve stable via hole row { responderId }",+ " target Page",+ " projections [ ]",+ " dispatch-each SendPage { incidentId=input.incidentId responderId=resolved.responderId }",+ " on-appended AckOk ; on-duplicate AckOk ; on-failed Retry",+ " dispatch-id strategy=uuidv5 from=(name, key, sourceEventId, targetStreamName, occurrence)",+ " rejected => halt",+ " poison => halt",+ "",+ "aggregate Page",+ " regs",+ " states Pending Delivered!",+ "",+ " command SendPage { incidentId responderId }",+ " event PageSent = fields(SendPage)",+ "",+ " Pending -- SendPage --> emit PageSent ; goto Delivered"+ ] contractSkeleton :: Text contractSkeleton =- T.unlines- [ "context my-service"- , ""- , "contract myContract {"- , " schemaVersion 1"- , " discriminator messageType"- , ""- , " topic events \"my-service.events\""- , ""- , " event ThingHappened on events {"- , " thingId: typeid \"thing\""- , " detail: text"- , " }"- , "}"- ]+ T.unlines+ [ "context my-service",+ "",+ "contract myContract {",+ " schemaVersion 1",+ " discriminator messageType",+ "",+ " topic events \"my-service.events\"",+ "",+ " event ThingHappened on events {",+ " thingId: typeid \"thing\"",+ " detail: text",+ " }",+ "}"+ ] intakeSkeleton :: Text intakeSkeleton =- T.unlines- [ "context my-service"- , ""- , "contract myContract {"- , " schemaVersion 1"- , " discriminator messageType"- , " topic events \"my-service.events\""- , " event ThingHappened on events {"- , " thingId: typeid \"thing\""- , " }"- , "}"- , ""- , "intake thingInbox {"- , " contract myContract"- , " topic events"- , " accept ThingHappened"- , ""- , " bind messageId from header \"keiro-message-id\" required cross-check body"- , ""- , " dedupe key messageId policy PreferIntegrationMessageId"- , ""- , " decode {"- , " envelope strict-required lenient-optional"- , " body strict schemaVersion == 1"- , " }"- , ""- , " disposition {"- , " processed => ackOk"- , " duplicate => ackOk"- , " inProgress => retry 5s"- , " previouslyFailed => deadLetter \"previous inbox failure\""- , " decodeFailed => deadLetter"- , " dedupeFailed => deadLetter"- , " storeFailed => retry 5s"- , " }"- , "}"- ]+ T.unlines+ [ "context my-service",+ "",+ "contract myContract {",+ " schemaVersion 1",+ " discriminator messageType",+ " topic events \"my-service.events\"",+ " event ThingHappened on events {",+ " thingId: typeid \"thing\"",+ " }",+ "}",+ "",+ "intake thingInbox {",+ " contract myContract",+ " topic events",+ " accept ThingHappened",+ "",+ " bind messageId from header \"keiro-message-id\" required cross-check body",+ "",+ " dedupe key messageId policy PreferIntegrationMessageId",+ "",+ " decode {",+ " envelope strict-required lenient-optional",+ " body strict schemaVersion == 1",+ " }",+ "",+ " disposition {",+ " processed => ackOk",+ " duplicate => ackOk",+ " inProgress => retry 5s",+ " previouslyFailed => deadLetter \"previous inbox failure\"",+ " decodeFailed => deadLetter",+ " dedupeFailed => deadLetter",+ " storeFailed => retry 5s",+ " }",+ "}"+ ] emitSkeleton :: Text emitSkeleton =- T.unlines- [ "context my-service"- , ""- , "contract myContract {"- , " schemaVersion 1"- , " discriminator messageType"- , " topic events \"my-service.events\""- , " event ThingAccepted on events {"- , " thingId: typeid \"thing\""- , " }"- , "}"- , ""- , "emit thingResponse {"- , " contract myContract"- , " topic events"- , " source \"my-service\""- , " key thingId"- , " map status {"- , " \"accepted\" => ThingAccepted"- , " _ => skip"- , " }"- , " messageId derive \"msg\" hole"- , " idempotencyKey derive hole"- , "}"- , ""- , "publisher thingPublisher {"- , " emit thingResponse"- , " ordering PerKeyHeadOfLine"- , " maxAttempts 10"- , " backoff constant 2s"- , " outboxId stable from messageId"- , "}"- ]+ T.unlines+ [ "context my-service",+ "",+ "contract myContract {",+ " schemaVersion 1",+ " discriminator messageType",+ " topic events \"my-service.events\"",+ " event ThingAccepted on events {",+ " thingId: typeid \"thing\"",+ " }",+ "}",+ "",+ "emit thingResponse {",+ " contract myContract",+ " topic events",+ " source \"my-service\"",+ " key thingId",+ " map status {",+ " \"accepted\" => ThingAccepted",+ " _ => skip",+ " }",+ " messageId derive \"msg\" hole",+ " idempotencyKey derive hole",+ "}",+ "",+ "publisher thingPublisher {",+ " emit thingResponse",+ " ordering PerKeyHeadOfLine",+ " maxAttempts 10",+ " backoff constant 2s",+ " outboxId stable from messageId",+ "}"+ ] workqueueSkeleton :: Text workqueueSkeleton =- T.unlines- [ "context my-service"- , ""- , "readmodel accepted_transfer_needs {"- , " table = \"accepted_transfer_needs\""- , " schema = \"my_service\""- , " columns {"- , " reservation_id text required"- , " hospital_id text required"- , " }"- , " version = 1"- , " shape = \"fnv1a:fec517dae7760b8a\""- , " consistency = Eventual"- , " feed = subscription"- , "}"- , ""- , "readmodel transfer_decisions {"- , " table = \"transfer_decisions\""- , " schema = \"my_service\""- , " columns {"- , " reservation_id text required"- , " }"- , " version = 1"- , " shape = \"fnv1a:d44d218822582783\""- , " consistency = Eventual"- , " feed = subscription"- , "}"- , ""- , "workqueue reservation_work {"- , " queue logical = \"my_service.reservation_work\""- , " derive physical = \"my_service_reservation_work\""- , " dlq = \"my_service_reservation_work_dlq\""- , " table = \"pgmq.q_my_service_reservation_work\""- , ""- , " payload ReservationWorkItem {"- , " reservationId -> \"reservation_id\" text required"- , " hospitalId -> \"hospital_id\" text required"- , " }"- , ""- , " retry maxRetries = 3 delay = 5s dlq = on"- , ""- , " disposition {"- , " storeFailure -> retry 5s"- , " commandRejected -> deadLetter"- , " decodeFailure -> deadLetter"- , " onCodecReject -> deadLetter"- , " }"- , "}"- , ""- , "dispatch reservation_work_dispatch {"- , " source readModel = accepted_transfer_needs key = reservationId"- , " fanout body = resolveTransferCandidates"- , " dedup key = reservationId"- , " seenIn readModel = transfer_decisions field = reservation_id"- , " seenIn queue = reservation_work field = reservation_id"- , " enqueue to = reservation_work"- , "}"- ]+ T.unlines+ [ "context my-service",+ "",+ "readmodel accepted_transfer_needs {",+ " table = \"accepted_transfer_needs\"",+ " schema = \"my_service\"",+ " columns {",+ " reservation_id text required",+ " hospital_id text required",+ " }",+ " version = 1",+ " shape = \"fnv1a:fec517dae7760b8a\"",+ " consistency = Eventual",+ " feed = subscription",+ "}",+ "",+ "readmodel transfer_decisions {",+ " table = \"transfer_decisions\"",+ " schema = \"my_service\"",+ " columns {",+ " reservation_id text required",+ " }",+ " version = 1",+ " shape = \"fnv1a:d44d218822582783\"",+ " consistency = Eventual",+ " feed = subscription",+ "}",+ "",+ "workqueue reservation_work {",+ " queue logical = \"my_service.reservation_work\"",+ " derive physical = \"my_service_reservation_work\"",+ " dlq = \"my_service_reservation_work_dlq\"",+ " table = \"pgmq.q_my_service_reservation_work\"",+ "",+ " payload ReservationWorkItem {",+ " reservationId -> \"reservation_id\" text required",+ " hospitalId -> \"hospital_id\" text required",+ " }",+ "",+ " retry maxRetries = 3 delay = 5s dlq = on",+ "",+ " disposition {",+ " storeFailure -> retry 5s",+ " commandRejected -> deadLetter",+ " decodeFailure -> deadLetter",+ " onCodecReject -> deadLetter",+ " }",+ "}",+ "",+ "dispatch reservation_work_dispatch {",+ " source readModel = accepted_transfer_needs key = reservationId",+ " fanout body = resolveTransferCandidates",+ " dedup key = reservationId",+ " seenIn readModel = transfer_decisions field = reservation_id",+ " seenIn queue = reservation_work field = reservation_id",+ " enqueue to = reservation_work",+ "}"+ ] workflowSkeleton :: Text workflowSkeleton =- T.unlines- [ "context my-service"- , ""- , "workflow HospitalTransferReservation"- , " name \"hospital-transfer-reservation\""- , " in ReservationWorkflowInput { reservationId:Id hospitalId:Id }"- , " out ReservationWorkflowSummary"- , " id from input.reservationId via idText"- , " body"- , " step create-transfer-hold -> ReservationHold"- , " await reservation-confirmation -> ReservationConfirmation"- , " step summarize-reservation -> ReservationWorkflowSummary"- , ""- , "operation SignalReservationConfirmation"- , " signal reservation-confirmation of HospitalTransferReservation"- , " key from reservationId via reservationWorkflowId"- , " value ReservationConfirmation"- , ""- , "operation RunReservationWorkflow"- , " run HospitalTransferReservation"- , " input ReservationWorkflowInput"- , " outcome -> ReservationWorkflowRun"- ]+ T.unlines+ [ "context my-service",+ "",+ "workflow HospitalTransferReservation",+ " name \"hospital-transfer-reservation\"",+ " in ReservationWorkflowInput { reservationId:Id hospitalId:Id }",+ " out ReservationWorkflowSummary",+ " id from input.reservationId via idText",+ " body",+ " step create-transfer-hold -> ReservationHold",+ " await reservation-confirmation -> ReservationConfirmation",+ " step summarize-reservation -> ReservationWorkflowSummary",+ "",+ "operation SignalReservationConfirmation",+ " signal reservation-confirmation of HospitalTransferReservation",+ " key from reservationId via reservationWorkflowId",+ " value ReservationConfirmation",+ "",+ "operation RunReservationWorkflow",+ " run HospitalTransferReservation",+ " input ReservationWorkflowInput",+ " outcome -> ReservationWorkflowRun"+ ]
src/Keiro/Dsl/TypeGraph.hs view
@@ -1,12 +1,11 @@ {-# OPTIONS_GHC -Werror=incomplete-patterns #-} -{- | Checked, resolved consumer-owned mapped types. Parser declarations keep-mandatory facts optional so diagnostics can name omissions; this module is-the phase boundary after which missing facts and unresolved references are-unrepresentable.--}-module Keiro.Dsl.TypeGraph (- QualifiedValueName (..),+-- | Checked, resolved consumer-owned mapped types. Parser declarations keep+-- mandatory facts optional so diagnostics can name omissions; this module is+-- the phase boundary after which missing facts and unresolved references are+-- unrepresentable.+module Keiro.Dsl.TypeGraph+ ( QualifiedValueName (..), CanonicalTypeId (..), BindingVersion (..), CodecIdentity (..),@@ -42,7 +41,8 @@ MappedDeclAlgebra (..), foldMappedDecl, wireFingerprint,-) where+ )+where import Data.Bifunctor (first) import Data.Bits (xor)@@ -64,128 +64,128 @@ import Numeric (showHex) newtype QualifiedValueName = QualifiedValueName {unQualifiedValueName :: Text}- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) newtype CanonicalTypeId = CanonicalTypeId {unCanonicalTypeId :: Text}- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) newtype BindingVersion = BindingVersion {unBindingVersion :: Text}- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) newtype CodecIdentity = CodecIdentity {unCodecIdentity :: Text}- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) newtype CodecVersion = CodecVersion {unCodecVersion :: Text}- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) data MappedDeclError- = MissingHaskellSource !Name- | MissingStructuralBinding !Name- | MissingStructuralBindingVersion !Name- | MissingCanonicalType !Name- | MissingFixtureCases !Name- | MissingOpaqueCodecIdentity !Name- | MissingOpaqueCodecVersion !Name- | EmptyQualifiedValueName !Text- | EmptyCanonicalTypeId !Text- | EmptyBindingVersion !Text- | EmptyCodecIdentity !Text- | EmptyCodecVersion !Text- deriving stock (Eq, Show, Generic)+ = MissingHaskellSource !Name+ | MissingStructuralBinding !Name+ | MissingStructuralBindingVersion !Name+ | MissingCanonicalType !Name+ | MissingFixtureCases !Name+ | MissingOpaqueCodecIdentity !Name+ | MissingOpaqueCodecVersion !Name+ | EmptyQualifiedValueName !Text+ | EmptyCanonicalTypeId !Text+ | EmptyBindingVersion !Text+ | EmptyCodecIdentity !Text+ | EmptyCodecVersion !Text+ deriving stock (Eq, Show, Generic) mkQualifiedValueName :: Text -> Either MappedDeclError QualifiedValueName mkQualifiedValueName value- | T.null (T.strip value) = Left (EmptyQualifiedValueName value)- | otherwise = Right (QualifiedValueName value)+ | T.null (T.strip value) = Left (EmptyQualifiedValueName value)+ | otherwise = Right (QualifiedValueName value) mkCanonicalTypeId :: Text -> Either MappedDeclError CanonicalTypeId mkCanonicalTypeId value- | T.null (T.strip value) = Left (EmptyCanonicalTypeId value)- | otherwise = Right (CanonicalTypeId value)+ | T.null (T.strip value) = Left (EmptyCanonicalTypeId value)+ | otherwise = Right (CanonicalTypeId value) mkBindingVersion :: Text -> Either MappedDeclError BindingVersion mkBindingVersion value- | T.null (T.strip value) = Left (EmptyBindingVersion value)- | otherwise = Right (BindingVersion value)+ | T.null (T.strip value) = Left (EmptyBindingVersion value)+ | otherwise = Right (BindingVersion value) mkCodecIdentity :: Text -> Either MappedDeclError CodecIdentity mkCodecIdentity value- | T.null (T.strip value) = Left (EmptyCodecIdentity value)- | otherwise = Right (CodecIdentity value)+ | T.null (T.strip value) = Left (EmptyCodecIdentity value)+ | otherwise = Right (CodecIdentity value) mkCodecVersion :: Text -> Either MappedDeclError CodecVersion mkCodecVersion value- | T.null (T.strip value) = Left (EmptyCodecVersion value)- | otherwise = Right (CodecVersion value)+ | T.null (T.strip value) = Left (EmptyCodecVersion value)+ | otherwise = Right (CodecVersion value) data StructuralDecl = StructuralDecl- { sdName :: !Name- , sdHaskell :: !HaskellSource- , sdBinding :: !QualifiedValueName- , sdBindingVersion :: !BindingVersion- , sdCanonical :: !CanonicalTypeId- , sdFixtures :: !QualifiedValueName- , sdInitial :: !(Maybe QualifiedValueName)- , sdLoc :: !Loc- }- deriving stock (Eq, Show, Generic)+ { sdName :: !Name,+ sdHaskell :: !HaskellSource,+ sdBinding :: !QualifiedValueName,+ sdBindingVersion :: !BindingVersion,+ sdCanonical :: !CanonicalTypeId,+ sdFixtures :: !QualifiedValueName,+ sdInitial :: !(Maybe QualifiedValueName),+ sdLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic) data OpaqueDecl = OpaqueDecl- { odName :: !Name- , odHaskell :: !HaskellSource- , odCodecIdentity :: !CodecIdentity- , odCodecVersion :: !CodecVersion- , odFixtures :: !QualifiedValueName- , odInitial :: !(Maybe QualifiedValueName)- , odLoc :: !Loc- }- deriving stock (Eq, Show, Generic)+ { odName :: !Name,+ odHaskell :: !HaskellSource,+ odCodecIdentity :: !CodecIdentity,+ odCodecVersion :: !CodecVersion,+ odFixtures :: !QualifiedValueName,+ odInitial :: !(Maybe QualifiedValueName),+ odLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic) data CheckedMappedDecl- = CheckedStructural !StructuralDecl !MappedShape- | CheckedOpaque !OpaqueDecl- deriving stock (Eq, Show, Generic)+ = CheckedStructural !StructuralDecl !MappedShape+ | CheckedOpaque !OpaqueDecl+ deriving stock (Eq, Show, Generic) checkMappedDecl :: MappedDecl -> Either (NonEmpty MappedDeclError) CheckedMappedDecl-checkMappedDecl MappedStructural{msName = name, msHaskell = haskell, msBinding = binding, msBindingVersion = bindingVersion, msCanonical = canonical, msFixtures = fixtures, msInitial = initial, msShape = shape, msLoc = loc} = do- checkedHaskell <- require (MissingHaskellSource name) haskell- checkedBinding <- require (MissingStructuralBinding name) binding >>= liftOne . mkQualifiedValueName- checkedBindingVersion <- require (MissingStructuralBindingVersion name) bindingVersion >>= liftOne . mkBindingVersion- checkedCanonical <- require (MissingCanonicalType name) canonical >>= liftOne . mkCanonicalTypeId- checkedFixtures <- require (MissingFixtureCases name) fixtures >>= liftOne . mkQualifiedValueName- checkedInitial <- traverse (liftOne . mkQualifiedValueName) initial- pure- ( CheckedStructural- StructuralDecl- { sdName = name- , sdHaskell = checkedHaskell- , sdBinding = checkedBinding- , sdBindingVersion = checkedBindingVersion- , sdCanonical = checkedCanonical- , sdFixtures = checkedFixtures- , sdInitial = checkedInitial- , sdLoc = loc- }- shape- )-checkMappedDecl MappedOpaque{moName = name, moHaskell = haskell, moCodecId = codecIdentity, moCodecVersion = codecVersion, moFixtures = fixtures, moInitial = initial, moLoc = loc} = do- checkedHaskell <- require (MissingHaskellSource name) haskell- checkedCodecIdentity <- require (MissingOpaqueCodecIdentity name) codecIdentity >>= liftOne . mkCodecIdentity- checkedCodecVersion <- require (MissingOpaqueCodecVersion name) codecVersion >>= liftOne . mkCodecVersion- checkedFixtures <- require (MissingFixtureCases name) fixtures >>= liftOne . mkQualifiedValueName- checkedInitial <- traverse (liftOne . mkQualifiedValueName) initial- pure- ( CheckedOpaque- OpaqueDecl- { odName = name- , odHaskell = checkedHaskell- , odCodecIdentity = checkedCodecIdentity- , odCodecVersion = checkedCodecVersion- , odFixtures = checkedFixtures- , odInitial = checkedInitial- , odLoc = loc- }- )+checkMappedDecl MappedStructural {msName = name, msHaskell = haskell, msBinding = binding, msBindingVersion = bindingVersion, msCanonical = canonical, msFixtures = fixtures, msInitial = initial, msShape = shape, msLoc = loc} = do+ checkedHaskell <- require (MissingHaskellSource name) haskell+ checkedBinding <- require (MissingStructuralBinding name) binding >>= liftOne . mkQualifiedValueName+ checkedBindingVersion <- require (MissingStructuralBindingVersion name) bindingVersion >>= liftOne . mkBindingVersion+ checkedCanonical <- require (MissingCanonicalType name) canonical >>= liftOne . mkCanonicalTypeId+ checkedFixtures <- require (MissingFixtureCases name) fixtures >>= liftOne . mkQualifiedValueName+ checkedInitial <- traverse (liftOne . mkQualifiedValueName) initial+ pure+ ( CheckedStructural+ StructuralDecl+ { sdName = name,+ sdHaskell = checkedHaskell,+ sdBinding = checkedBinding,+ sdBindingVersion = checkedBindingVersion,+ sdCanonical = checkedCanonical,+ sdFixtures = checkedFixtures,+ sdInitial = checkedInitial,+ sdLoc = loc+ }+ shape+ )+checkMappedDecl MappedOpaque {moName = name, moHaskell = haskell, moCodecId = codecIdentity, moCodecVersion = codecVersion, moFixtures = fixtures, moInitial = initial, moLoc = loc} = do+ checkedHaskell <- require (MissingHaskellSource name) haskell+ checkedCodecIdentity <- require (MissingOpaqueCodecIdentity name) codecIdentity >>= liftOne . mkCodecIdentity+ checkedCodecVersion <- require (MissingOpaqueCodecVersion name) codecVersion >>= liftOne . mkCodecVersion+ checkedFixtures <- require (MissingFixtureCases name) fixtures >>= liftOne . mkQualifiedValueName+ checkedInitial <- traverse (liftOne . mkQualifiedValueName) initial+ pure+ ( CheckedOpaque+ OpaqueDecl+ { odName = name,+ odHaskell = checkedHaskell,+ odCodecIdentity = checkedCodecIdentity,+ odCodecVersion = checkedCodecVersion,+ odFixtures = checkedFixtures,+ odInitial = checkedInitial,+ odLoc = loc+ }+ ) require :: e -> Maybe a -> Either (NonEmpty e) a require err = maybe (Left (err :| [])) Right@@ -194,120 +194,121 @@ liftOne = first (:| []) newtype MappedKey = MappedKey {unMappedKey :: Name}- deriving stock (Eq, Ord, Show, Generic)+ deriving stock (Eq, Ord, Show, Generic) data ResolvedTypeExpr- = RText- | RInt- | RBool- | RNatural- | RTime- | RJson- | ROptional !ResolvedTypeExpr- | RList !ResolvedTypeExpr- | RMap !ResolvedTypeExpr- | RRef !MappedKey- deriving stock (Eq, Show, Generic)+ = RText+ | RInt+ | RInteger+ | RBool+ | RNatural+ | RTime+ | RJson+ | ROptional !ResolvedTypeExpr+ | RList !ResolvedTypeExpr+ | RMap !ResolvedTypeExpr+ | RRef !MappedKey+ deriving stock (Eq, Show, Generic) data ResolvedWireField = ResolvedWireField- { rwfHaskell :: !Name- , rwfKey :: !Text- , rwfType :: !ResolvedTypeExpr- , rwfPresence :: !Presence- , rwfOnMissing :: !(Maybe OnMissing)- , rwfLoc :: !Loc- }- deriving stock (Eq, Show, Generic)+ { rwfHaskell :: !Name,+ rwfKey :: !Text,+ rwfType :: !ResolvedTypeExpr,+ rwfPresence :: !Presence,+ rwfOnMissing :: !(Maybe OnMissing),+ rwfLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic) data ResolvedWireArm = ResolvedWireArm- { rwaCtor :: !Name- , rwaTag :: !Text- , rwaPayload :: !(Maybe ResolvedTypeExpr)- , rwaLoc :: !Loc- }- deriving stock (Eq, Show, Generic)+ { rwaCtor :: !Name,+ rwaTag :: !Text,+ rwaPayload :: !(Maybe ResolvedTypeExpr),+ rwaLoc :: !Loc+ }+ deriving stock (Eq, Show, Generic) data ResolvedMappedShape- = RRecord !Name !UnknownFields ![ResolvedWireField]- | REnum ![WireEnum]- | RUnion !UnionEncoding ![ResolvedWireArm]- deriving stock (Eq, Show, Generic)+ = RRecord !Name !UnknownFields ![ResolvedWireField]+ | REnum ![WireEnum]+ | RUnion !UnionEncoding ![ResolvedWireArm]+ deriving stock (Eq, Show, Generic) data ResolvedMappedDecl- = ResolvedStructural !StructuralDecl !ResolvedMappedShape- | ResolvedOpaque !OpaqueDecl- deriving stock (Eq, Show, Generic)+ = ResolvedStructural !StructuralDecl !ResolvedMappedShape+ | ResolvedOpaque !OpaqueDecl+ deriving stock (Eq, Show, Generic) data TypeGraphError- = TGDeclError !Name !MappedDeclError- | TGAmbiguousName !Name ![Text]- | TGUnresolvedRef !Name !Name !Loc- | TGRecursive ![Name]- deriving stock (Eq, Show, Generic)+ = TGDeclError !Name !MappedDeclError+ | TGAmbiguousName !Name ![Text]+ | TGUnresolvedRef !Name !Name !Loc+ | TGRecursive ![Name]+ deriving stock (Eq, Show, Generic) data UseSite- = RootCommandField !Name !Name !Name !MappedKey- | RootEventField !Name !Name !Name !MappedKey- | RootRegister !Name !Name !MappedKey- deriving stock (Eq, Ord, Show, Generic)+ = RootCommandField !Name !Name !Name !MappedKey+ | RootEventField !Name !Name !Name !MappedKey+ | RootRegister !Name !Name !MappedKey+ deriving stock (Eq, Ord, Show, Generic) data PathSeg- = SegField !Name !Text- | SegArm !Name !Text- | SegElem- | SegMapValue- | SegOptional- | SegDecl !Name- deriving stock (Eq, Ord, Show, Generic)+ = SegField !Name !Text+ | SegArm !Name !Text+ | SegElem+ | SegMapValue+ | SegOptional+ | SegDecl !Name+ deriving stock (Eq, Ord, Show, Generic) data UsePath = UsePath- { upRoot :: !UseSite- , upSegments :: ![PathSeg]- }- deriving stock (Eq, Ord, Show, Generic)+ { upRoot :: !UseSite,+ upSegments :: ![PathSeg]+ }+ deriving stock (Eq, Ord, Show, Generic) data TypeGraph = TypeGraph- { tgDeclarations :: !(Map MappedKey ResolvedMappedDecl)- , tgReachability :: !(Map MappedKey (Set MappedKey))- , tgUseSites :: ![UseSite]- }- deriving stock (Eq, Show, Generic)+ { tgDeclarations :: !(Map MappedKey ResolvedMappedDecl),+ tgReachability :: !(Map MappedKey (Set MappedKey)),+ tgUseSites :: ![UseSite]+ }+ deriving stock (Eq, Show, Generic) resolveTypeGraph :: Spec -> Either (NonEmpty TypeGraphError) TypeGraph resolveTypeGraph spec = do- checked <- collectChecked (specMapped spec)- rejectMany (ambiguityErrors spec checked)- let keyByName = Map.fromList [(checkedName decl, MappedKey (checkedName decl)) | decl <- checked]- (resolveErrors, resolvedPairs) = partitionEithers (map (resolveCheckedDecl keyByName) checked)- rejectMany resolveErrors- let declarations = Map.fromList resolvedPairs- rejectMany (cycleErrors declarations)- let reachability = Map.mapWithKey (reachableFrom declarations) declarations- pure- TypeGraph- { tgDeclarations = declarations- , tgReachability = reachability- , tgUseSites = collectUseSites keyByName spec- }+ checked <- collectChecked (specMapped spec)+ rejectMany (ambiguityErrors spec checked)+ let keyByName = Map.fromList [(checkedName decl, MappedKey (checkedName decl)) | decl <- checked]+ (resolveErrors, resolvedPairs) = partitionEithers (map (resolveCheckedDecl keyByName) checked)+ rejectMany resolveErrors+ let declarations = Map.fromList resolvedPairs+ rejectMany (cycleErrors declarations)+ let reachability = Map.mapWithKey (reachableFrom declarations) declarations+ pure+ TypeGraph+ { tgDeclarations = declarations,+ tgReachability = reachability,+ tgUseSites = collectUseSites keyByName spec+ } collectChecked :: [MappedDecl] -> Either (NonEmpty TypeGraphError) [CheckedMappedDecl] collectChecked declarations =- let checked = [(rawName declaration, checkMappedDecl declaration) | declaration <- declarations]- errors =- [ TGDeclError name err- | (name, Left declarationErrors) <- checked- , err <- NE.toList declarationErrors- ]- in case NE.nonEmpty errors of- Just nonEmptyErrors -> Left nonEmptyErrors- Nothing -> Right [declaration | (_, Right declaration) <- checked]+ let checked = [(rawName declaration, checkMappedDecl declaration) | declaration <- declarations]+ errors =+ [ TGDeclError name err+ | (name, Left declarationErrors) <- checked,+ err <- NE.toList declarationErrors+ ]+ in case NE.nonEmpty errors of+ Just nonEmptyErrors -> Left nonEmptyErrors+ Nothing -> Right [declaration | (_, Right declaration) <- checked] rejectMany :: [e] -> Either (NonEmpty e) () rejectMany errors = maybe (Right ()) Left (NE.nonEmpty errors) rawName :: MappedDecl -> Name-rawName MappedStructural{msName = name} = name-rawName MappedOpaque{moName = name} = name+rawName MappedStructural {msName = name} = name+rawName MappedOpaque {moName = name} = name checkedName :: CheckedMappedDecl -> Name checkedName (CheckedStructural declaration _) = sdName declaration@@ -315,52 +316,53 @@ ambiguityErrors :: Spec -> [CheckedMappedDecl] -> [TypeGraphError] ambiguityErrors spec declarations =- [ TGAmbiguousName name origins- | (name, origins) <- Map.toList allOrigins- , length origins > 1- ]+ [ TGAmbiguousName name origins+ | (name, origins) <- Map.toList allOrigins,+ length origins > 1+ ] where builtins = ["Text", "Int", "Bool", "Natural", "Time", "UTCTime", "Json", "Optional", "List", "Map"] originPairs =- [(checkedName declaration, "mapped") | declaration <- declarations]- ++ [(idName declaration, "id") | declaration <- specIds spec]- ++ [(enumName declaration, "enum") | declaration <- specEnums spec]- ++ [(name, "built-in") | name <- builtins]+ [(checkedName declaration, "mapped") | declaration <- declarations]+ ++ [(idName declaration, "id") | declaration <- specIds spec]+ ++ [(enumName declaration, "enum") | declaration <- specEnums spec]+ ++ [(name, "built-in") | name <- builtins] allOrigins = Map.fromListWith (++) [(name, [origin]) | (name, origin) <- originPairs] resolveCheckedDecl :: Map Name MappedKey -> CheckedMappedDecl -> Either TypeGraphError (MappedKey, ResolvedMappedDecl) resolveCheckedDecl _ (CheckedOpaque declaration) =- Right (MappedKey (odName declaration), ResolvedOpaque declaration)+ Right (MappedKey (odName declaration), ResolvedOpaque declaration) resolveCheckedDecl keyByName (CheckedStructural declaration shape) = do- resolvedShape <- resolveShape keyByName (sdName declaration) shape- pure (MappedKey (sdName declaration), ResolvedStructural declaration resolvedShape)+ resolvedShape <- resolveShape keyByName (sdName declaration) shape+ pure (MappedKey (sdName declaration), ResolvedStructural declaration resolvedShape) resolveShape :: Map Name MappedKey -> Name -> MappedShape -> Either TypeGraphError ResolvedMappedShape resolveShape keyByName owner (ShapeRecord constructor unknownFields fields) =- RRecord constructor unknownFields <$> traverse resolveField fields+ RRecord constructor unknownFields <$> traverse resolveField fields where resolveField field =- ResolvedWireField- (wfHaskell field)- (wfKey field)- <$> resolveExpr keyByName owner (wireFieldLoc field) (wfType field)- <*> pure (wfPresence field)- <*> pure (wfOnMissing field)- <*> pure (wireFieldLoc field)+ ResolvedWireField+ (wfHaskell field)+ (wfKey field)+ <$> resolveExpr keyByName owner (wireFieldLoc field) (wfType field)+ <*> pure (wfPresence field)+ <*> pure (wfOnMissing field)+ <*> pure (wireFieldLoc field) resolveShape _ _ (ShapeEnum entries) = Right (REnum entries) resolveShape keyByName owner (ShapeUnion encoding arms) =- RUnion encoding <$> traverse resolveArm arms+ RUnion encoding <$> traverse resolveArm arms where resolveArm arm =- ResolvedWireArm- (waCtor arm)- (waTag arm)- <$> traverse (resolveExpr keyByName owner (waLoc arm)) (waPayload arm)- <*> pure (waLoc arm)+ ResolvedWireArm+ (waCtor arm)+ (waTag arm)+ <$> traverse (resolveExpr keyByName owner (waLoc arm)) (waPayload arm)+ <*> pure (waLoc arm) resolveExpr :: Map Name MappedKey -> Name -> Loc -> TypeExpr -> Either TypeGraphError ResolvedTypeExpr resolveExpr _ _ _ TText = Right RText resolveExpr _ _ _ TInt = Right RInt+resolveExpr _ _ _ TInteger = Right RInteger resolveExpr _ _ _ TBool = Right RBool resolveExpr _ _ _ TNatural = Right RNatural resolveExpr _ _ _ TTime = Right RTime@@ -369,143 +371,147 @@ resolveExpr names owner loc (TList value) = RList <$> resolveExpr names owner loc value resolveExpr names owner loc (TMap value) = RMap <$> resolveExpr names owner loc value resolveExpr names owner loc (TRef name) =- maybe (Left (TGUnresolvedRef owner name loc)) (Right . RRef) (Map.lookup name names)+ maybe (Left (TGUnresolvedRef owner name loc)) (Right . RRef) (Map.lookup name names) cycleErrors :: Map MappedKey ResolvedMappedDecl -> [TypeGraphError] cycleErrors declarations =- [ TGRecursive (map unMappedKey keys)- | CyclicSCC keys <- stronglyConnComp vertices- ]+ [ TGRecursive (map unMappedKey keys)+ | CyclicSCC keys <- stronglyConnComp vertices+ ] where vertices =- [ (key, key, Set.toList (directRefs declaration))- | (key, declaration) <- Map.toList declarations- ]+ [ (key, key, Set.toList (directRefs declaration))+ | (key, declaration) <- Map.toList declarations+ ] directRefs :: ResolvedMappedDecl -> Set MappedKey directRefs =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ shape -> refsInShape shape- , onOpaqueDecl = const Set.empty- }+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ shape -> refsInShape shape,+ onOpaqueDecl = const Set.empty+ } refsInShape :: ResolvedMappedShape -> Set MappedKey refsInShape =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields -> Set.unions (map (refsInExpr . rwfType) fields)- , onEnum = const Set.empty- , onUnion = \_ arms -> Set.unions (map (maybe Set.empty refsInExpr . rwaPayload) arms)- }+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields -> Set.unions (map (refsInExpr . rwfType) fields),+ onEnum = const Set.empty,+ onUnion = \_ arms -> Set.unions (map (maybe Set.empty refsInExpr . rwaPayload) arms)+ } refsInExpr :: ResolvedTypeExpr -> Set MappedKey refsInExpr =- foldTypeExpr- TypeExprAlgebra- { onText = Set.empty- , onInt = Set.empty- , onBool = Set.empty- , onNatural = Set.empty- , onTime = Set.empty- , onJson = Set.empty- , onOptional = id- , onList = id- , onMap = id- , onRef = Set.singleton- }+ foldTypeExpr+ TypeExprAlgebra+ { onText = Set.empty,+ onInt = Set.empty,+ onInteger = Set.empty,+ onBool = Set.empty,+ onNatural = Set.empty,+ onTime = Set.empty,+ onJson = Set.empty,+ onOptional = id,+ onList = id,+ onMap = id,+ onRef = Set.singleton+ } reachableFrom :: Map MappedKey ResolvedMappedDecl -> MappedKey -> ResolvedMappedDecl -> Set MappedKey reachableFrom declarations origin declaration = go Set.empty (Set.toList (directRefs declaration)) where go visited [] = Set.delete origin visited go visited (key : rest)- | key `Set.member` visited = go visited rest- | otherwise =- let next = maybe [] (Set.toList . directRefs) (Map.lookup key declarations)- in go (Set.insert key visited) (next ++ rest)+ | key `Set.member` visited = go visited rest+ | otherwise =+ let next = maybe [] (Set.toList . directRefs) (Map.lookup key declarations)+ in go (Set.insert key visited) (next ++ rest) collectUseSites :: Map Name MappedKey -> Spec -> [UseSite] collectUseSites keyByName spec = concatMap aggregateSites [aggregate | NAggregate aggregate <- specNodes spec] where aggregateSites aggregate =- [ RootCommandField (aggName aggregate) (cmdName command) (fieldName field) key- | command <- aggCommands aggregate- , field <- cmdFields command- , key <- maybeToList (fieldType field >>= (`Map.lookup` keyByName))- ]- ++ [ RootEventField (aggName aggregate) (evName event) (fieldName field) key- | event <- aggEvents aggregate- , field <- eventFields aggregate event- , key <- maybeToList (fieldType field >>= (`Map.lookup` keyByName))- ]- ++ [ RootRegister (aggName aggregate) (regName register) key- | register <- aggRegs aggregate- , key <- maybeToList (Map.lookup (regType register) keyByName)- ]+ [ RootCommandField (aggName aggregate) (cmdName command) (aggregateFieldName field) key+ | command <- aggCommands aggregate,+ field <- cmdFields command,+ key <- maybeToList (aggregateFieldType field >>= typeRefName >>= (`Map.lookup` keyByName))+ ]+ ++ [ RootEventField (aggName aggregate) (evName event) (aggregateFieldName field) key+ | event <- aggEvents aggregate,+ field <- eventFields aggregate event,+ key <- maybeToList (aggregateFieldType field >>= typeRefName >>= (`Map.lookup` keyByName))+ ]+ ++ [ RootRegister (aggName aggregate) (regName register) key+ | register <- aggRegs aggregate,+ key <- maybeToList (typeRefName (regType register) >>= (`Map.lookup` keyByName))+ ] eventFields aggregate event = case evBody event of- EventFields fields -> fields- EventFromCommand commandName ->- concat [cmdFields command | command <- aggCommands aggregate, cmdName command == commandName]+ EventFields fields -> fields+ EventFromCommand commandName ->+ concat [cmdFields command | command <- aggCommands aggregate, cmdName command == commandName] maybeToList = maybe [] pure+ typeRefName (TRef name) = Just name+ typeRefName _ = Nothing usePaths :: TypeGraph -> Name -> [UsePath] usePaths graph targetName = case Map.lookup (MappedKey targetName) (tgDeclarations graph) of- Nothing -> []- Just _ ->- [ UsePath site segments- | site <- tgUseSites graph- , segments <- sitePaths site- ]+ Nothing -> []+ Just _ ->+ [ UsePath site segments+ | site <- tgUseSites graph,+ segments <- sitePaths site+ ] where target = MappedKey targetName sitePaths site- | siteKey site == target = [[]]- | otherwise = pathsFromDecl Set.empty (siteKey site)+ | siteKey site == target = [[]]+ | otherwise = pathsFromDecl Set.empty (siteKey site) pathsFromDecl visited current- | current `Set.member` visited = []- | otherwise = case Map.lookup current (tgDeclarations graph) of- Nothing -> []- Just declaration ->- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ shape -> pathsInShape (Set.insert current visited) shape- , onOpaqueDecl = const []- }- declaration+ | current `Set.member` visited = []+ | otherwise = case Map.lookup current (tgDeclarations graph) of+ Nothing -> []+ Just declaration ->+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ shape -> pathsInShape (Set.insert current visited) shape,+ onOpaqueDecl = const []+ }+ declaration pathsInShape visited =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ fields ->- concat- [ map (SegField (rwfHaskell field) (rwfKey field) :) (pathsInExpr visited (rwfType field))- | field <- fields- ]- , onEnum = const []- , onUnion = \_ arms ->- concat- [ map (SegArm (rwaCtor arm) (rwaTag arm) :) (maybe [] (pathsInExpr visited) (rwaPayload arm))- | arm <- arms- ]- }+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ fields ->+ concat+ [ map (SegField (rwfHaskell field) (rwfKey field) :) (pathsInExpr visited (rwfType field))+ | field <- fields+ ],+ onEnum = const [],+ onUnion = \_ arms ->+ concat+ [ map (SegArm (rwaCtor arm) (rwaTag arm) :) (maybe [] (pathsInExpr visited) (rwaPayload arm))+ | arm <- arms+ ]+ } pathsInExpr visited = \case- RText -> []- RInt -> []- RBool -> []- RNatural -> []- RTime -> []- RJson -> []- ROptional value -> map (SegOptional :) (pathsInExpr visited value)- RList value -> map (SegElem :) (pathsInExpr visited value)- RMap value -> map (SegMapValue :) (pathsInExpr visited value)- RRef key- | key == target -> [[SegDecl (unMappedKey key)]]- | otherwise -> map (SegDecl (unMappedKey key) :) (pathsFromDecl visited key)+ RText -> []+ RInt -> []+ RInteger -> []+ RBool -> []+ RNatural -> []+ RTime -> []+ RJson -> []+ ROptional value -> map (SegOptional :) (pathsInExpr visited value)+ RList value -> map (SegElem :) (pathsInExpr visited value)+ RMap value -> map (SegMapValue :) (pathsInExpr visited value)+ RRef key+ | key == target -> [[SegDecl (unMappedKey key)]]+ | otherwise -> map (SegDecl (unMappedKey key) :) (pathsFromDecl visited key) siteKey :: UseSite -> MappedKey siteKey (RootCommandField _ _ _ key) = key@@ -516,15 +522,15 @@ renderUsePath (UsePath root segments) = renderRoot root <> T.concat (map renderSegment segments) where renderRoot (RootCommandField aggregate command field key) =- aggregate <> " command " <> command <> " ." <> field <> " : " <> unMappedKey key+ aggregate <> " command " <> command <> " ." <> field <> " : " <> unMappedKey key renderRoot (RootEventField aggregate event field key) =- aggregate <> " event " <> event <> " ." <> field <> " : " <> unMappedKey key+ aggregate <> " event " <> event <> " ." <> field <> " : " <> unMappedKey key renderRoot (RootRegister aggregate register key) =- aggregate <> " register " <> register <> " : " <> unMappedKey key+ aggregate <> " register " <> register <> " : " <> unMappedKey key renderSegment (SegField haskellName wireName)- | haskellName == wireName = " ." <> haskellName- | otherwise = " ." <> haskellName <> " as " <> quoted wireName+ | haskellName == wireName = " ." <> haskellName+ | otherwise = " ." <> haskellName <> " as " <> quoted wireName renderSegment (SegArm _ wireTag) = " arm " <> quoted wireTag renderSegment SegElem = " []" renderSegment SegMapValue = " {}"@@ -533,52 +539,54 @@ quoted value = T.pack (show value) data TypeExprAlgebra a = TypeExprAlgebra- { onText :: a- , onInt :: a- , onBool :: a- , onNatural :: a- , onTime :: a- , onJson :: a- , onOptional :: a -> a- , onList :: a -> a- , onMap :: a -> a- , onRef :: MappedKey -> a- }+ { onText :: a,+ onInt :: a,+ onInteger :: a,+ onBool :: a,+ onNatural :: a,+ onTime :: a,+ onJson :: a,+ onOptional :: a -> a,+ onList :: a -> a,+ onMap :: a -> a,+ onRef :: MappedKey -> a+ } foldTypeExpr :: TypeExprAlgebra a -> ResolvedTypeExpr -> a foldTypeExpr algebra = \case- RText -> onText algebra- RInt -> onInt algebra- RBool -> onBool algebra- RNatural -> onNatural algebra- RTime -> onTime algebra- RJson -> onJson algebra- ROptional value -> onOptional algebra (foldTypeExpr algebra value)- RList value -> onList algebra (foldTypeExpr algebra value)- RMap value -> onMap algebra (foldTypeExpr algebra value)- RRef key -> onRef algebra key+ RText -> onText algebra+ RInt -> onInt algebra+ RInteger -> onInteger algebra+ RBool -> onBool algebra+ RNatural -> onNatural algebra+ RTime -> onTime algebra+ RJson -> onJson algebra+ ROptional value -> onOptional algebra (foldTypeExpr algebra value)+ RList value -> onList algebra (foldTypeExpr algebra value)+ RMap value -> onMap algebra (foldTypeExpr algebra value)+ RRef key -> onRef algebra key data MappedShapeAlgebra a = MappedShapeAlgebra- { onRecord :: Name -> UnknownFields -> [ResolvedWireField] -> a- , onEnum :: [WireEnum] -> a- , onUnion :: UnionEncoding -> [ResolvedWireArm] -> a- }+ { onRecord :: Name -> UnknownFields -> [ResolvedWireField] -> a,+ onEnum :: [WireEnum] -> a,+ onUnion :: UnionEncoding -> [ResolvedWireArm] -> a+ } foldMappedShape :: MappedShapeAlgebra a -> ResolvedMappedShape -> a foldMappedShape algebra = \case- RRecord constructor unknownFields fields -> onRecord algebra constructor unknownFields fields- REnum entries -> onEnum algebra entries- RUnion encoding arms -> onUnion algebra encoding arms+ RRecord constructor unknownFields fields -> onRecord algebra constructor unknownFields fields+ REnum entries -> onEnum algebra entries+ RUnion encoding arms -> onUnion algebra encoding arms data MappedDeclAlgebra a = MappedDeclAlgebra- { onStructuralDecl :: StructuralDecl -> ResolvedMappedShape -> a- , onOpaqueDecl :: OpaqueDecl -> a- }+ { onStructuralDecl :: StructuralDecl -> ResolvedMappedShape -> a,+ onOpaqueDecl :: OpaqueDecl -> a+ } foldMappedDecl :: MappedDeclAlgebra a -> ResolvedMappedDecl -> a foldMappedDecl algebra = \case- ResolvedStructural declaration shape -> onStructuralDecl algebra declaration shape- ResolvedOpaque declaration -> onOpaqueDecl algebra declaration+ ResolvedStructural declaration shape -> onStructuralDecl algebra declaration shape+ ResolvedOpaque declaration -> onOpaqueDecl algebra declaration wireFingerprint :: TypeGraph -> Name -> Text wireFingerprint graph name = fnv1a64 (wireDecl Set.empty (MappedKey name))@@ -586,67 +594,68 @@ declarations = tgDeclarations graph wireDecl visited key- | key `Set.member` visited = "recursive"- | otherwise = case Map.lookup key declarations of- Nothing -> "missing:" <> unMappedKey key- Just declaration ->- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ shape -> wireShape (Set.insert key visited) shape- , onOpaqueDecl = \opaque ->- "opaque(" <> atom (unCodecIdentity (odCodecIdentity opaque)) <> "," <> atom (unCodecVersion (odCodecVersion opaque)) <> ")"- }- declaration+ | key `Set.member` visited = "recursive"+ | otherwise = case Map.lookup key declarations of+ Nothing -> "missing:" <> unMappedKey key+ Just declaration ->+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ shape -> wireShape (Set.insert key visited) shape,+ onOpaqueDecl = \opaque ->+ "opaque(" <> atom (unCodecIdentity (odCodecIdentity opaque)) <> "," <> atom (unCodecVersion (odCodecVersion opaque)) <> ")"+ }+ declaration wireShape visited =- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ unknownFields fields ->- "record(" <> renderUnknown unknownFields <> ";" <> T.intercalate ";" (map (wireField visited) (sortOn rwfKey fields)) <> ")"- , onEnum = \entries ->- "enum(" <> T.intercalate ";" (map (atom . weTag) (sortOn weTag entries)) <> ")"- , onUnion = \encoding arms ->- "union("- <> atom (ueTagField encoding)- <> ","- <> atom (ueContentsField encoding)- <> ","- <> renderUnknown (ueUnknownFields encoding)- <> ";"- <> T.intercalate ";" (map (wireArm visited) (sortOn rwaTag arms))- <> ")"- }+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ unknownFields fields ->+ "record(" <> renderUnknown unknownFields <> ";" <> T.intercalate ";" (map (wireField visited) (sortOn rwfKey fields)) <> ")",+ onEnum = \entries ->+ "enum(" <> T.intercalate ";" (map (atom . weTag) (sortOn weTag entries)) <> ")",+ onUnion = \encoding arms ->+ "union("+ <> atom (ueTagField encoding)+ <> ","+ <> atom (ueContentsField encoding)+ <> ","+ <> renderUnknown (ueUnknownFields encoding)+ <> ";"+ <> T.intercalate ";" (map (wireArm visited) (sortOn rwaTag arms))+ <> ")"+ } wireField visited field =- atom (rwfKey field)- <> ":"- <> wireExpr visited (rwfType field)- <> ":"- <> renderPresence (rwfPresence field)- <> ":"- <> maybe "none" (renderDefault field) (rwfOnMissing field)+ atom (rwfKey field)+ <> ":"+ <> wireExpr visited (rwfType field)+ <> ":"+ <> renderPresence (rwfPresence field)+ <> ":"+ <> maybe "none" (renderDefault field) (rwfOnMissing field) wireArm visited arm = atom (rwaTag arm) <> maybe ":unit" ((":" <>) . wireExpr visited) (rwaPayload arm) wireExpr visited = \case- RText -> "text"- RInt -> "int"- RBool -> "bool"- RNatural -> "natural"- RTime -> "time"- RJson -> "json"- ROptional value -> "optional(" <> wireExpr visited value <> ")"- RList value -> "list(" <> wireExpr visited value <> ")"- RMap value -> "map(" <> wireExpr visited value <> ")"- RRef key -> wireDecl visited key+ RText -> "text"+ RInt -> "int"+ RInteger -> "integer"+ RBool -> "bool"+ RNatural -> "natural"+ RTime -> "time"+ RJson -> "json"+ ROptional value -> "optional(" <> wireExpr visited value <> ")"+ RList value -> "list(" <> wireExpr visited value <> ")"+ RMap value -> "map(" <> wireExpr visited value <> ")"+ RRef key -> wireDecl visited key renderDefault field (OmCtor constructor) =- case rwfType field of- RRef key -> case Map.lookup key declarations of- Just (ResolvedStructural _ (REnum entries)) ->- maybe ("ctor:" <> atom constructor) ("enum:" <>) (lookup constructor [(weCtor entry, atom (weTag entry)) | entry <- entries])- _ -> "ctor:" <> atom constructor- _ -> "ctor:" <> atom constructor+ case rwfType field of+ RRef key -> case Map.lookup key declarations of+ Just (ResolvedStructural _ (REnum entries)) ->+ maybe ("ctor:" <> atom constructor) ("enum:" <>) (lookup constructor [(weCtor entry, atom (weTag entry)) | entry <- entries])+ _ -> "ctor:" <> atom constructor+ _ -> "ctor:" <> atom constructor renderDefault _ value = T.pack (show value) renderUnknown RejectUnknown = "reject"@@ -657,8 +666,8 @@ fnv1a64 :: Text -> Text fnv1a64 input =- let offsetBasis = 14695981039346656037 :: Word64- prime = 1099511628211 :: Word64- digest = T.foldl' (\hash char -> (hash `xor` fromIntegral (ord char)) * prime) offsetBasis input- hexadecimal = showHex digest ""- in T.pack (replicate (16 - length hexadecimal) '0' <> hexadecimal)+ let offsetBasis = 14695981039346656037 :: Word64+ prime = 1099511628211 :: Word64+ digest = T.foldl' (\hash char -> (hash `xor` fromIntegral (ord char)) * prime) offsetBasis input+ hexadecimal = showHex digest ""+ in T.pack (replicate (16 - length hexadecimal) '0' <> hexadecimal)
src/Keiro/Dsl/Validate.hs view
@@ -1,2242 +1,2493 @@-{- | The keiro DSL validator. A parsed 'Spec' is /valid/ only if it passes the-cross-cutting structural and hole-kind rules below. The point is to reject a-dangerous-by-omission spec — a deleted status-map, an undeclared command, a-guard atom that resolves to nothing, a wall-clock read inside a guard — /before-any Haskell is written/.--EP-1 defines the 'Diagnostic' framework and the cross-cutting rules; each later-vertical (EP-3…EP-6) appends its node-specific rules (e.g. EP-4's inbox-disposition inversions) reusing this same 'Diagnostic' type.--}-module Keiro.Dsl.Validate (- Severity (..),- DiagnosticCode (..),- Diagnostic (..),- renderDiagnostic,- validateSpec,- derivedQueueTrio,- sagaCategoryError,- nodeIdentity,-) where--import Data.Bits (xor)-import Data.Char (isControl, isSpace, ord)-import Data.List (sortOn)-import Data.List.NonEmpty qualified as NE-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Set (Set)-import Data.Set qualified as Set-import Data.Text (Text)-import Data.Text qualified as T-import Data.Word (Word64)-import Keiro.Dsl.Grammar-import Keiro.Dsl.ReadModelShape (deriveShapeHash)-import Keiro.Dsl.TypeGraph-import Numeric (showHex)--data Severity = Error | Warning- deriving stock (Eq, Show)---- | A machine-checkable code per rule, so tests match on the code, not prose.-data DiagnosticCode- = UndeclaredCommand- | UndeclaredEvent- | UndeclaredState- | UnreachableState- | TerminalHasOutgoing- | GuardAtomOutOfScope- | StatusMapNotTotal- | ClockSampled- | -- EP-2 (evolution). These codes are shared by single-spec validation and- -- the cross-spec diff path, so the enum remains the single registry of- -- evolution rules.- EvtVersionMissingUpcaster- | DuplicateUpcasterSource- | UpcasterChainGap- | DeprecatedEventReplayHazard- | EventRetirementInProgress- | DeprecatedEventStillEmitted- | WireSchemaVersionMismatch- | EvtFieldAddedWithoutBump- | EvtRemovedNotDeprecated- | -- EP-3 (process manager + durable timer).- ProcessFireAtNotInjected- | ProcessDispatchIdSupplied- | ProcessUnresolvedRef- | ProcessBenignInversion- | SagaCategoryIllegal- | -- EP-4 (integration intake / inbox disposition).- DispositionIncomplete- | DispositionDuplicateRetry- | DispositionPreviouslyFailedRetry- | DispositionDecodeUnboundedRetry- | -- EP-4 (integration coupling).- EmitSkipMissing- | EmitUnresolvedContract- | PublisherUnresolvedEmit- | IntakeUnresolvedContract- | -- EP-5 (pgmq workqueue/dispatch).- WqPhysicalDivergence- | WqStoreFailureNotRetry- | WqDecodeFailureNotDeadLetter- | WqDlqWithoutCeiling- | WqGroupKeyMissing- | WqGroupKeyWithoutFifo- | WqGroupKeyUnresolved- | WqUnloggedDurability- | WqPartitionSpecEmpty- | SnapshotIntervalInvalid- | SnapshotCodecFixtureInvalid- | DispatchEnqueueUnresolved- | -- EP-6 (workflow/operation).- AwaitSignalMismatch- | RunWorkflowUnresolved- | WorkflowPatchDuplicate- | WorkflowPatchIdInvalid- | WorkflowContinueAsNewNotTerminal- | -- Diff-only (cross-spec) decode and identity evolution rules.- EvtFieldTypeChanged- | EvtFieldRemovedSameVersion- | EvtVersionDecreased- | EnumCtorRemoved- | EnumWireSpellingChanged- | WireSpecChanged- | ContractEventRemoved- | ContractFieldChanged- | ContractDiscriminatorChanged- | ContractTopicChanged- | ContractSchemaVersionDecreased- | WqPayloadFieldChanged- | ProcessInputChanged- | WorkflowShapeChanged- | WorkflowBodyChanged- | WorkflowStableNameChanged- | WorkflowPatchRemoved- | WorkflowContinueSeedChanged- | WqOrderingChanged- | WqProvisionChanged- | WqGroupKeyChanged- | IdPrefixChanged- | DedupeIdentityChanged- | DerivedIdentityChanged- | QueueIdentityChanged- | TimerWindowChanged- | EmitMappingChanged- | DecodePostureChanged- | IntakePersistenceChanged- | ProjectionChanged- | PublisherPolicyChanged- | DispatchRetargeted- | ContractSchemaVersionBumped- | EventUndeprecated- | -- EP-104 (validator soundness).- WorkflowDuplicateLabel- | WorkflowSleepDelayUnresolved- | WorkflowIdFieldUnresolved- | RuleDomainUnresolved- | RuleNotTotal- | RuleCaseUnknownCtor- | ProcessFieldBindingUnresolved- | ProcessTimerCeilingInvalid- | OperationUnresolvedRef- | AwaitSignalValueMismatch- | WqDispositionIncomplete- | DispositionDuplicateOutcome- | TopicAffinityMismatch- | StatusMapDanglingKey- | StatusMapDuplicateKey- | WriteTargetNotRegister- | RegisterInitialOutOfScope- | DuplicateNodeName- | DuplicateEnumCtor- | DuplicateEnumWire- | DuplicateIdPrefix- | DuplicateCommandName- | DuplicateEventName- | WqDlqDivergence- | WqTableDivergence- | DispatchDedupQueueUnresolved- | DispatchDedupFieldUnresolved- | -- EP-105 (notation integrity and scaffold-safe names).- IdentHaskellKeyword- | IdentNotConstructorSafe- | VertexCtorCollision- | -- EP-107 (first-class read models).- RmShapeHashDrift- | RmStrongInlineOnly- | RmScopeWithoutStrong- | RmUnknownColumnType- | RmInlineFeedUnreferenced- | RmConsistencyConflict- | RmProjectionWithoutNode- | QueryUnresolvedReadModel- | QueryConsistencyInvalid- | DispatchReadModelUnresolved- | DispatchReadModelFieldUnknown- | -- EP-107 diff-only read-model evolution rules.- ReadModelVersionDecreased- | ReadModelShapeChangedWithoutBump- | ReadModelFeedChanged- | ReadModelConsistencyWeakened- | -- EP-108 (router and worker-policy surfaces).- RouterUnresolvedRef- | RouterKeyFieldUnknown- | RouterBindingUnscoped- | RouterCommandUnknown- | RouterReadModelUnverified- | PolicyContradiction- | PolicyDeadLetterUnused- | AmbiguousMarkedBenign- | AmbiguousFollowsRejectedPolicy- | RouterStableNameChanged- | -- Plan 143 (first-class replay-only transitions for guard evolution).- -- The first two fire in single-spec @validateSpec@; the third is the- -- diff-path guard-tightening advisory that prints the computed- -- replay-only twin.- ReplayOnlyEmitsNothing- | ReplayOnlyCommandStillLive- | AggGuardTightened- | AggFoldSurfaceChanged- | RouterDecideSurfaceChanged- | ProcessDecideSurfaceChanged- | ProcessTimerPayloadChanged- | -- MasterPlan 25 / EP-5: append-only codes for findings that were- -- formerly additive but uncoded.- DeclarationAdded- | VersionBumped- | CompatibilityStrengthened- | EnumCtorAdded- | EventRetirementAbandoned- | ContractEventAdded- | ContractTopicAdded- | WorkflowEvolutionGuardAdded- | -- MasterPlan 25 / EP-149 (consumer-owned mapped types).- MappedUnresolvedName- | MappedAmbiguousName- | MappedDuplicateFieldName- | MappedDuplicateWireKey- | MappedDuplicateArmName- | MappedDuplicateWireTag- | MappedNonInjectiveNullability- | MappedRecursiveType- | MappedUnsupportedEncoding- | MappedMissingIngredient- | MappedMissingInitialValue- | MappedInvalidHaskellName- | MappedInvalidIdentity- | MappedImportConflict- | MappedDefaultIllTyped- | MappedGuardUnsupported- | -- MasterPlan 25 / EP-149 mapped evolution codes.- MappedFieldAddedWithDefault- | MappedFieldAddedNoDefault- | MappedFieldRemoved- | MappedFieldTypeChanged- | MappedPresenceChanged- | MappedNullabilityChanged- | MappedDefaultRemoved- | MappedDefaultChanged- | MappedWireKeyChanged- | MappedUnionEncodingChanged- | MappedArmAdded- | MappedArmRemoved- | MappedArmTagChanged- | MappedEnumValueAdded- | MappedEnumValueRemoved- | MappedEnumSpellingChanged- | MappedHaskellSourceChanged- | MappedRecordConstructorChanged- | MappedBindingChanged- | MappedFixturesChanged- | MappedInitialChanged- | MappedCanonicalTypeChanged- | MappedOpaqueCodecChanged- | MappedModeCrossed- | MappedDeclAdded- | MappedDeclRemoved- | -- MasterPlan 25 / EP-152 reporting and migration-evidence codes.- CoverageOpaqueSurface- | CoverageOpaqueBoundaryAdded- | CoverageOpaqueGateExceeded- | 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.-data Diagnostic = Diagnostic- { line :: !Int- , severity :: !Severity- , code :: !DiagnosticCode- , message :: !Text- }- deriving stock (Eq, Show)--{- | Render a diagnostic in the conventional-@\<file\>:\<line\>: error[\<code\>]: \<message\>@ form.--}-renderDiagnostic :: FilePath -> Diagnostic -> Text-renderDiagnostic file d =- T.pack file- <> ":"- <> T.pack (show (line d))- <> ": "- <> sev- <> "["- <> T.pack (show (code d))- <> "]: "- <> message d- where- sev = case severity d of Error -> "error"; Warning -> "warning"--{- | Reserved wall-clock atom names. Sampling any of these inside a guard or-write breaks deterministic replay: TIME IS INJECTED, NOT SAMPLED.--}-clockAtoms :: Set Name-clockAtoms = Set.fromList ["now", "currentTime", "wallClock", "today", "utcNow"]--{- | Validate a whole spec. An empty list means valid. Diagnostics are sorted by-line for stable, readable output.--}-validateSpec :: Spec -> [Diagnostic]-validateSpec spec =- sortOn line (validateNames spec ++ validateMapped spec ++ specLevelRules spec ++ concatMap (validateNode spec) (specNodes spec))--{- | Validate consumer-owned mapped declarations without inspecting consumer-Haskell. Symbol-shaped facts are checked lexically here; GHC remains the-authority for whether the named packages, modules, values, types, and-instances actually exist with the promised types.--}-validateMapped :: Spec -> [Diagnostic]-validateMapped spec =- mappedLexicalRules spec- ++ mappedIdentityRules spec- ++ mappedConflictRules spec- ++ case resolveTypeGraph spec of- Left errors -> concatMap (typeGraphDiagnostic spec) (NE.toList errors)- Right graph -> mappedGraphRules spec graph--typeGraphDiagnostic :: Spec -> TypeGraphError -> [Diagnostic]-typeGraphDiagnostic spec = \case- TGDeclError name declarationError ->- [ mkErr (mappedLine spec name) diagnosticCode $- "mapped declaration '" <> name <> "': " <> declarationErrorMessage declarationError- ]- where- diagnosticCode = case declarationError of- MissingHaskellSource{} -> MappedMissingIngredient- MissingStructuralBinding{} -> MappedMissingIngredient- MissingStructuralBindingVersion{} -> MappedMissingIngredient- MissingCanonicalType{} -> MappedMissingIngredient- MissingFixtureCases{} -> MappedMissingIngredient- MissingOpaqueCodecIdentity{} -> MappedMissingIngredient- MissingOpaqueCodecVersion{} -> MappedMissingIngredient- EmptyQualifiedValueName{} -> MappedInvalidHaskellName- EmptyCanonicalTypeId{} -> MappedInvalidIdentity- EmptyBindingVersion{} -> MappedInvalidIdentity- EmptyCodecIdentity{} -> MappedInvalidIdentity- EmptyCodecVersion{} -> MappedInvalidIdentity- TGAmbiguousName name origins ->- [ mkErr (mappedLine spec name) MappedAmbiguousName $- "type name '" <> name <> "' is ambiguous across " <> T.intercalate ", " origins- ]- TGUnresolvedRef owner missing loc ->- [ mkErr (locLine loc) MappedUnresolvedName $- "mapped declaration '" <> owner <> "' references unresolved mapped type '" <> missing <> "'"- ]- TGRecursive names ->- [ mkErr (mappedLine spec (headOr "<mapped>" names)) MappedRecursiveType $- "recursive structural mapping is unsupported: " <> T.intercalate " -> " (names <> take 1 names)- ]--declarationErrorMessage :: MappedDeclError -> Text-declarationErrorMessage = \case- MissingHaskellSource _ -> "missing complete haskell package/module/type ingredient"- MissingStructuralBinding _ -> "missing binding ingredient; GHC will verify the named value and its type"- MissingStructuralBindingVersion _ -> "missing binding-version ingredient"- MissingCanonicalType _ -> "missing canonical-type ingredient"- MissingFixtureCases _ -> "missing fixtures ingredient; GHC will verify the named FixtureCases value"- MissingOpaqueCodecIdentity _ -> "missing opaque codec identity ingredient"- MissingOpaqueCodecVersion _ -> "missing opaque codec version ingredient"- EmptyQualifiedValueName _ -> "a binding, fixture, or initial symbol is empty; GHC will verify a syntactically valid qualified value"- EmptyCanonicalTypeId _ -> "canonical-type must be non-empty"- EmptyBindingVersion _ -> "binding-version must be non-empty"- EmptyCodecIdentity _ -> "opaque codec identity must be non-empty"- EmptyCodecVersion _ -> "opaque codec version must be non-empty"--mappedLine :: Spec -> Name -> Int-mappedLine spec name =- maybe 1 (locLine . mappedLoc) (firstMatching ((== name) . mappedName) (specMapped spec))--mappedName :: MappedDecl -> Name-mappedName MappedStructural{msName = name} = name-mappedName MappedOpaque{moName = name} = name--mappedLoc :: MappedDecl -> Loc-mappedLoc MappedStructural{msLoc = loc} = loc-mappedLoc MappedOpaque{moLoc = loc} = loc--mappedHaskell :: MappedDecl -> Maybe HaskellSource-mappedHaskell MappedStructural{msHaskell = source} = source-mappedHaskell MappedOpaque{moHaskell = source} = source--mappedCanonical :: MappedDecl -> Maybe Text-mappedCanonical MappedStructural{msCanonical = canonical} = canonical-mappedCanonical MappedOpaque{} = Nothing--mappedLexicalRules :: Spec -> [Diagnostic]-mappedLexicalRules spec = concatMap declarationRules (specMapped spec)- where- declarationRules declaration =- constructorRule "mapped declaration name" (mappedName declaration) declaration- ++ maybe [] (haskellRules declaration) (mappedHaskell declaration)- ++ qualifiedFacts declaration- ++ shapeConstructorRules declaration-- haskellRules declaration source =- [ invalid declaration $ "Haskell package '" <> hsPackage source <> "' does not follow Cabal package-name grammar"- | not (cabalPackageName (hsPackage source))- ]- ++ [ invalid declaration $ "Haskell module '" <> hsModule source <> "' must be dot-separated Upper identifiers"- | not (moduleNameSafe (hsModule source))- ]- ++ [ invalid declaration $ "Haskell type '" <> hsType source <> "' must be an Upper identifier"- | not (constructorSafe (hsType source))- ]-- qualifiedFacts MappedStructural{msBinding = binding, msFixtures = fixtures, msInitial = initial, msLoc = loc} =- concatMap (qualifiedRule loc) [("binding", binding), ("fixtures", fixtures), ("initial", initial)]- qualifiedFacts MappedOpaque{moFixtures = fixtures, moInitial = initial, moLoc = loc} =- concatMap (qualifiedRule loc) [("fixtures", fixtures), ("initial", initial)]-- qualifiedRule loc (category, value) = case value of- Just symbol- | not (T.null symbol) && not (qualifiedValueSafe symbol) ->- [ mkErr (locLine loc) MappedInvalidHaskellName $- category <> " symbol '" <> symbol <> "' must be a module path plus a lower-initial value; GHC will verify that it exists with the promised type"- ]- _ -> []-- shapeConstructorRules declaration = case declaration of- MappedStructural{msShape = ShapeRecord constructor _ fields} ->- constructorRule "record constructor" constructor declaration- ++ [ invalidAt (wireFieldLoc field) $ "record selector '" <> wfHaskell field <> "' must be a lower-initial Haskell identifier"- | field <- fields- , not (lowerIdentifierSafe (wfHaskell field))- ]- MappedStructural{msShape = ShapeEnum entries} ->- [ invalidAt (weLoc entry) $ "enum constructor '" <> weCtor entry <> "' must be an Upper identifier"- | entry <- entries- , not (constructorSafe (weCtor entry))- ]- MappedStructural{msShape = ShapeUnion _ arms} ->- [ invalidAt (waLoc arm) $ "union constructor '" <> waCtor arm <> "' must be an Upper identifier"- | arm <- arms- , not (constructorSafe (waCtor arm))- ]- MappedOpaque{} -> []-- constructorRule category value declaration =- [ invalid declaration $ category <> " '" <> value <> "' must be an Upper identifier"- | not (constructorSafe value)- ]- invalid declaration detail = invalidAt (mappedLoc declaration) detail- invalidAt loc detail =- mkErr (locLine loc) MappedInvalidHaskellName (detail <> "; this is a syntax check only, and GHC will verify the consumer declaration")--mappedIdentityRules :: Spec -> [Diagnostic]-mappedIdentityRules spec =- [ mkErr (locLine (mappedLoc declaration)) MappedInvalidIdentity $- "mapped declaration '" <> mappedName declaration <> "' has an identity/version containing an ASCII control character"- | declaration <- specMapped spec- , value <- identityValues declaration- , T.any asciiControl value- ]- where- identityValues MappedStructural{msBindingVersion = bindingVersion, msCanonical = canonical} = present [bindingVersion, canonical]- identityValues MappedOpaque{moCodecId = codecIdentity, moCodecVersion = codecVersion} = present [codecIdentity, codecVersion]- present = foldr (maybe id (:)) []--mappedConflictRules :: Spec -> [Diagnostic]-mappedConflictRules spec = sourceCollisions ++ canonicalCollisions ++ packageCollisions- where- declarations = specMapped spec- sourceFacts = [(declaration, source) | declaration <- declarations, source <- maybeToList (mappedHaskell declaration)]- sourceCollisions =- [ conflict declaration $- "Haskell target '" <> hsModule source <> "." <> hsType source <> "' is claimed by more than one mapped declaration"- | (declaration, source) <- duplicatesBy (\(_, value) -> (hsModule value, hsType value)) sourceFacts- ]- canonicalFacts = [(declaration, canonical) | declaration <- declarations, canonical <- maybeToList (mappedCanonical declaration), not (T.null canonical)]- canonicalCollisions =- [ conflict declaration $ "canonical-type '" <> canonical <> "' is claimed by more than one mapped declaration"- | (declaration, canonical) <- duplicatesBy snd canonicalFacts- ]- moduleFacts = [(declaration, hsModule source, hsPackage source) | (declaration, source) <- sourceFacts]- packageCollisions =- [ conflict declaration $- "Haskell module '" <> moduleName <> "' is declared from conflicting packages '" <> oldPackage <> "' and '" <> packageName <> "'"- | (index, (declaration, moduleName, packageName)) <- zip [0 :: Int ..] moduleFacts- , (_, oldModule, oldPackage) <- take index moduleFacts- , oldModule == moduleName- , oldPackage /= packageName- ]- conflict declaration detail = mkErr (locLine (mappedLoc declaration)) MappedImportConflict detail- maybeToList = maybe [] pure--mappedGraphRules :: Spec -> TypeGraph -> [Diagnostic]-mappedGraphRules spec graph =- concatMap declarationRules (Map.elems (tgDeclarations graph))- ++ mappedRegisterInitialRules spec graph- ++ if null (specMapped spec) then [] else mappedGuardRules spec graph- where- declarationRules =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \declaration shape ->- foldMappedShape (shapeRules declaration) shape- , onOpaqueDecl = const []- }-- shapeRules declaration =- MappedShapeAlgebra- { onRecord = \_ _ fields ->- [ mappedError (rwfLoc field) MappedDuplicateFieldName declaration $- "record selector '" <> rwfHaskell field <> "' is declared more than once"- | field <- duplicatesBy rwfHaskell fields- ]- ++ [ mappedError (rwfLoc field) MappedDuplicateWireKey declaration $- "record wire key '" <> rwfKey field <> "' is declared more than once"- | field <- duplicatesBy rwfKey fields- ]- ++ [ mappedError (rwfLoc field) MappedUnsupportedEncoding declaration "record wire keys must be non-empty"- | field <- fields- , T.null (rwfKey field)- ]- ++ concatMap (fieldRules declaration) fields- , onEnum = \entries ->- [ mappedError (weLoc entry) MappedDuplicateArmName declaration $- "enum constructor '" <> weCtor entry <> "' is declared more than once"- | entry <- duplicatesBy weCtor entries- ]- ++ [ mappedError (weLoc entry) MappedDuplicateWireTag declaration $- "enum wire spelling '" <> weTag entry <> "' is declared more than once"- | entry <- duplicatesBy weTag entries- ]- ++ [ mappedError (weLoc entry) MappedUnsupportedEncoding declaration "enum wire spellings must be non-empty"- | entry <- entries- , T.null (weTag entry)- ]- , onUnion = \encoding arms ->- [ mappedError (sdLoc declaration) MappedUnsupportedEncoding declaration "tagged-object tag and contents keys must be distinct"- | ueTagField encoding == ueContentsField encoding- ]- ++ [ mappedError (sdLoc declaration) MappedUnsupportedEncoding declaration "tagged-object tag and contents keys must be non-empty"- | T.null (ueTagField encoding) || T.null (ueContentsField encoding)- ]- ++ [ mappedError (rwaLoc arm) MappedDuplicateArmName declaration $- "union constructor '" <> rwaCtor arm <> "' is declared more than once"- | arm <- duplicatesBy rwaCtor arms- ]- ++ [ mappedError (rwaLoc arm) MappedDuplicateWireTag declaration $- "union wire tag '" <> rwaTag arm <> "' is declared more than once"- | arm <- duplicatesBy rwaTag arms- ]- ++ [ mappedError (rwaLoc arm) MappedUnsupportedEncoding declaration "union wire tags must be non-empty"- | arm <- arms- , T.null (rwaTag arm)- ]- ++ concatMap (armRules declaration) arms- }-- fieldRules declaration field =- defaultRules declaration field- ++ [ mappedError (rwfLoc field) MappedNonInjectiveNullability declaration $- "field '" <> rwfHaskell field <> "' contains Optional around a null-capable Json, Optional, or opaque mapped value"- | hasNonInjectiveOptional graph (rwfType field)- ]-- armRules declaration arm =- [ mappedError (rwaLoc arm) MappedNonInjectiveNullability declaration $- "union arm '" <> rwaCtor arm <> "' contains Optional around a null-capable Json, Optional, or opaque mapped value"- | payload <- maybeToList (rwaPayload arm)- , hasNonInjectiveOptional graph payload- ]-- defaultRules declaration field = case (rwfPresence field, rwfOnMissing field) of- (PRequired, Just _) -> [illTyped "required fields cannot declare on-missing"]- (POptional, Nothing) ->- [ mappedError (rwfLoc field) MappedMissingIngredient declaration $- "optional field '" <> rwfHaskell field <> "' is missing its on-missing policy"- ]- (POptional, Just value)- | not (defaultMatches graph (rwfType field) value) -> [illTyped "on-missing value does not match the field type or numeric bounds"]- _ -> []- where- illTyped detail =- mappedError (rwfLoc field) MappedDefaultIllTyped declaration $- "field '" <> rwfHaskell field <> "': " <> detail-- mappedError loc diagnosticCode declaration detail =- mkErr (locLine loc) diagnosticCode $- "mapped declaration '" <> sdName declaration <> "' " <> detail- maybeToList = maybe [] pure--data DefaultType- = DefaultText- | DefaultInt- | DefaultBool- | DefaultNatural- | DefaultOptional- | DefaultList- | DefaultMap- | DefaultEnum !(Set Name)- | DefaultOther--defaultMatches :: TypeGraph -> ResolvedTypeExpr -> OnMissing -> Bool-defaultMatches graph expression value = case (defaultType graph expression, value) of- (DefaultText, OmText _) -> True- (DefaultInt, OmInt integer) -> integer >= toInteger (minBound :: Int) && integer <= toInteger (maxBound :: Int)- (DefaultBool, OmBool _) -> True- (DefaultNatural, OmInt integer) -> integer >= 0- (DefaultOptional, OmNull) -> True- (DefaultList, OmEmptyList) -> True- (DefaultMap, OmEmptyMap) -> True- (DefaultEnum constructors, OmCtor constructor) -> constructor `Set.member` constructors- _ -> False--defaultType :: TypeGraph -> ResolvedTypeExpr -> DefaultType-defaultType graph =- foldTypeExpr- TypeExprAlgebra- { onText = DefaultText- , onInt = DefaultInt- , onBool = DefaultBool- , onNatural = DefaultNatural- , onTime = DefaultOther- , onJson = DefaultOther- , onOptional = const DefaultOptional- , onList = const DefaultList- , onMap = const DefaultMap- , onRef = referencedDefaultType graph- }--referencedDefaultType :: TypeGraph -> MappedKey -> DefaultType-referencedDefaultType graph key = case Map.lookup key (tgDeclarations graph) of- Nothing -> DefaultOther- Just declaration ->- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ shape ->- foldMappedShape- MappedShapeAlgebra- { onRecord = \_ _ _ -> DefaultOther- , onEnum = DefaultEnum . Set.fromList . map weCtor- , onUnion = \_ _ -> DefaultOther- }- shape- , onOpaqueDecl = const DefaultOther- }- declaration--data NullabilityFacts = NullabilityFacts- { nfTopNull :: !Bool- , nfBadOptional :: !Bool- }--hasNonInjectiveOptional :: TypeGraph -> ResolvedTypeExpr -> Bool-hasNonInjectiveOptional graph =- nfBadOptional- . foldTypeExpr- TypeExprAlgebra- { onText = nonNull- , onInt = nonNull- , onBool = nonNull- , onNatural = nonNull- , onTime = nonNull- , onJson = nullable- , onOptional = \child -> NullabilityFacts True (nfTopNull child || nfBadOptional child)- , onList = nestedNonNull- , onMap = nestedNonNull- , onRef = \key -> if mappedRefIsOpaque graph key then nullable else nonNull- }- where- nonNull = NullabilityFacts False False- nullable = NullabilityFacts True False- nestedNonNull child = NullabilityFacts False (nfBadOptional child)--mappedRefIsOpaque :: TypeGraph -> MappedKey -> Bool-mappedRefIsOpaque graph key = case Map.lookup key (tgDeclarations graph) of- Nothing -> False- Just declaration ->- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \_ _ -> False- , onOpaqueDecl = const True- }- declaration--mappedRegisterInitialRules :: Spec -> TypeGraph -> [Diagnostic]-mappedRegisterInitialRules spec graph =- concatMap aggregateRules [aggregate | NAggregate aggregate <- specNodes spec]- where- aggregateRules aggregate = concatMap registerRule (aggRegs aggregate)- registerRule register = case Map.lookup (MappedKey (regType register)) (tgDeclarations graph) of- Nothing -> []- Just declaration -> case regInitial register of- RegInitBare "initial"- | mappedInitial declaration == Nothing ->- [ mkErr (locLine (regLoc register)) MappedMissingInitialValue $- "mapped register '" <> regName register <> "' requires declaration '" <> regType register <> "' to name an explicit initial value"- ]- | otherwise -> []- _ ->- [ mkErr (locLine (regLoc register)) RegisterInitialOutOfScope $- "mapped register '" <> regName register <> "' must use the bare initial token; the declaration-owned symbol is verified by GHC"- ]- mappedInitial =- foldMappedDecl- MappedDeclAlgebra- { onStructuralDecl = \declaration _ -> sdInitial declaration- , onOpaqueDecl = odInitial- }--{- | Mapped values support whole-value writes and event copies, but guards may-only operate on Keiki's curated scalar set. Nested access has no spelling in-the grammar, so it is unrepresentable rather than silently accepted.--}-mappedGuardRules :: Spec -> TypeGraph -> [Diagnostic]-mappedGuardRules spec graph =- [ mkErr (locLine (tLoc transition)) MappedGuardUnsupported $- "guard operand '" <> operand <> "' has non-symbolic type '" <> operandType <> "'; mapped values support whole-value copy, while guards are limited to Text, Int, Bool, and Time"- | NAggregate aggregate <- specNodes spec- , transition <- aggTransitions aggregate- , guardExpression <- maybe [] pure (tGuard transition)- , operand <- dedup (exprNames guardExpression)- , operandType <- maybeToList (guardOperandType aggregate transition operand)- , not (guardTypeSupported graph operandType)- ]- where- maybeToList = maybe [] pure--guardOperandType :: Aggregate -> Transition -> Name -> Maybe Name-guardOperandType aggregate transition operand =- case [regType register | register <- aggRegs aggregate, regName register == operand] of- value : _ -> Just value- [] -> case [fieldType field | command <- aggCommands aggregate, cmdName command == tCommand transition, field <- cmdFields command, fieldName field == operand] of- value : _ -> value- [] -> Nothing--guardTypeSupported :: TypeGraph -> Name -> Bool-guardTypeSupported graph typeName =- typeName `Set.member` Set.fromList ["Text", "Int", "Bool", "Time", "UTCTime"]- && Map.notMember (MappedKey typeName) (tgDeclarations graph)--cabalPackageName :: Text -> Bool-cabalPackageName packageName =- not (null components) && all validComponent components- where- components = T.splitOn "-" packageName- validComponent component =- not (T.null component)- && T.all asciiAlphaNum component- && T.any asciiLetter component--moduleNameSafe :: Text -> Bool-moduleNameSafe moduleName =- not (null components) && all constructorSafe components- where- components = T.splitOn "." moduleName--qualifiedValueSafe :: Text -> Bool-qualifiedValueSafe qualified = case reverse (T.splitOn "." qualified) of- value : reversedModule ->- not (null reversedModule)- && lowerIdentifierSafe value- && all constructorSafe reversedModule- [] -> False--lowerIdentifierSafe :: Text -> Bool-lowerIdentifierSafe name = case T.uncons name of- Just (first, rest) -> asciiLower first && T.all asciiAlphaNumOrUnderscore rest && name `Set.notMember` haskellKeywords- Nothing -> False--asciiAlphaNum :: Char -> Bool-asciiAlphaNum c = asciiLetter c || (c >= '0' && c <= '9')--asciiLetter :: Char -> Bool-asciiLetter c = asciiUpper c || asciiLower c--asciiControl :: Char -> Bool-asciiControl c = ord c < 32 || ord c == 127--firstMatching :: (a -> Bool) -> [a] -> Maybe a-firstMatching predicate = \case- [] -> Nothing- value : rest- | predicate value -> Just value- | otherwise -> firstMatching predicate rest--headOr :: a -> [a] -> a-headOr fallback = \case- [] -> fallback- value : _ -> value--{- | Reject names that would make the scaffolder emit illegal Haskell. The-parser enforces the ASCII alphabet; this pass applies the category-specific-uppercase/lowercase and keyword rules that require AST context.--}-validateNames :: Spec -> [Diagnostic]-validateNames spec =- concat- [ concatMap idNames (specIds spec)- , concatMap enumNames (specEnums spec)- , concatMap nodeNames (specNodes spec)- ]- where- idNames declaration =- constructorName "id name" (idName declaration) (idLoc declaration)-- enumNames declaration =- constructorName "enum name" (enumName declaration) (enumLoc declaration)- ++ concatMap- (\(ctor, _) -> constructorName ("constructor of enum '" <> enumName declaration <> "'") ctor (enumLoc declaration))- (enumCtors declaration)-- nodeNames = \case- NAggregate aggregate -> aggregateNames aggregate- NProcess process -> processNames process- NRouter router -> routerNames router- NContract contract ->- pascalizedNodeName "contract" (ctrName contract) (ctrLoc contract)- ++ concatMap- (\event -> constructorName "contract event name" (ceName event) (ctrLoc contract) ++ concatMap (contractFieldName contract) (ceFields event))- (ctrEvents contract)- NIntake intake -> pascalizedNodeName "intake" (inkName intake) (inkLoc intake)- NEmit emitNode -> pascalizedNodeName "emit" (emName emitNode) (emLoc emitNode)- NPublisher publisher -> pascalizedNodeName "publisher" (pubName publisher) (pubLoc publisher)- NWorkqueue workqueue ->- pascalizedNodeName "workqueue" (wqName workqueue) (wqLoc workqueue)- ++ constructorName "workqueue payload name" (wqPayloadName workqueue) (wqLoc workqueue)- ++ concatMap (\field -> fieldNameRule "workqueue payload field" (wqfName field) (wqLoc workqueue)) (wqPayload workqueue)- NPgmqDispatch dispatch -> pascalizedNodeName "dispatch" (pdName dispatch) (pdLoc dispatch)- NReadModel readModel -> pascalizedNodeName "readmodel" (rmName readModel) (rmLoc readModel)- NWorkflow workflow -> constructorName "workflow name" (wfId workflow) (workflowNodeLoc workflow)- NOperation _ -> []-- aggregateNames aggregate =- constructorName "aggregate name" (aggName aggregate) (aggLoc aggregate)- ++ concatMap- (\register -> fieldNameRule "register name" (regName register) (regLoc register))- (aggRegs aggregate)- ++ concatMap commandNames (aggCommands aggregate)- ++ concatMap eventNames (aggEvents aggregate)- ++ maybe [] (\projection -> fieldNameRule "projection key" (projKey projection) (projLoc projection)) (aggProjection aggregate)- ++ vertexCollisions aggregate- where- commandNames command =- constructorName "command name" (cmdName command) (cmdLoc command)- ++ concatMap (\field -> fieldNameRule "command field" (fieldName field) (cmdLoc command)) (cmdFields command)- eventNames event =- constructorName "event name" (evName event) (evLoc event)- ++ case evBody event of- EventFields fields -> concatMap (\field -> fieldNameRule "event field" (fieldName field) (evLoc event)) fields- EventFromCommand _ -> []-- processNames process =- constructorName "process name" (procId process) (procLoc process)- ++ constructorName "process input name" (inName input) (procLoc process)- ++ concatMap (\field -> fieldNameRule "process input field" (fieldName field) (procLoc process)) (inFields input)- ++ concatMap (bindingName "advance field binding" (procLoc process)) (advFields (hAdvance handle))- ++ concatMap dispatchBindings (hDispatch handle)- ++ concatMap (bindingName "timer payload field binding" (tmLoc timer)) (tmPayload timer)- ++ concatMap (bindingName "timer fire field binding" (tmLoc timer)) (fireFields (tmFire timer))- where- input = procInput process- handle = procHandle process- timer = procTimer process- dispatchBindings dispatch = concatMap (bindingName "dispatch field binding" (dispLoc dispatch)) (dispFields dispatch)-- routerNames router =- constructorName "router name" (rtId router) (rtLoc router)- ++ constructorName "router input name" (inName input) (rtLoc router)- ++ concatMap (\field -> fieldNameRule "router input field" (fieldName field) (rtLoc router)) (inFields input)- ++ concatMap (\field -> fieldNameRule "router resolve-row field" field (rvLoc resolve)) (rvRow resolve)- ++ concatMap (bindingName "router dispatch field binding" (rdLoc dispatch)) (rdFields dispatch)- where- input = rtInput router- resolve = rtResolve router- dispatch = rtDispatch router-- bindingName category anchor binding = fieldNameRule category (fbName binding) anchor- contractFieldName contract field = fieldNameRule "contract field" (cfName field) (ctrLoc contract)-- constructorName category name anchor- | constructorSafe name = []- | otherwise =- [ mkErr (locLine anchor) IdentNotConstructorSafe $- category <> " '" <> name <> "' must be PascalCase: it becomes a Haskell constructor, type name, or module segment in scaffolded code"- ]-- pascalizedNodeName category name anchor- | "_" `T.isPrefixOf` name =- [ mkErr (locLine anchor) IdentNotConstructorSafe $- category <> " name '" <> name <> "' cannot begin with '_': title-casing leaves an invalid Haskell module segment"- ]- | otherwise = []-- fieldNameRule category name anchor- | name `Set.member` haskellKeywords =- [ mkErr (locLine anchor) IdentHaskellKeyword $- category <> " '" <> name <> "' is a Haskell keyword and cannot become a record field in generated code"- ]- | fieldSafe name = []- | otherwise =- [ mkErr (locLine anchor) IdentNotConstructorSafe $- category <> " '" <> name <> "' must begin with a lowercase ASCII letter or underscore to become a Haskell record field"- ]-- vertexCollisions aggregate =- [ mkErr (locLine (aggLoc aggregate)) VertexCtorCollision $- "aggregate '"- <> aggName aggregate- <> "' state '"- <> stName state- <> "' generates vertex constructor '"- <> vertex- <> "', which collides with "- <> declarationKind- <> " '"- <> vertex- <> "' in the generated Domain constructor namespace"- | state <- aggStates aggregate- , let vertex = aggName aggregate <> stName state- , declarationKind <- collisionKinds aggregate vertex- ]-- collisionKinds aggregate vertex =- ["event" | vertex `elem` map evName (aggEvents aggregate)]- ++ ["command" | vertex `elem` map cmdName (aggCommands aggregate)]- ++ ["enum constructor" | vertex `elem` [ctor | enum <- specEnums spec, (ctor, _) <- enumCtors enum]]---- Haskell 2010 reserved identifiers plus commonly enabled extension keywords.-haskellKeywords :: Set Name-haskellKeywords =- Set.fromList- [ "case"- , "class"- , "data"- , "default"- , "deriving"- , "do"- , "else"- , "foreign"- , "if"- , "import"- , "in"- , "infix"- , "infixl"- , "infixr"- , "instance"- , "let"- , "module"- , "newtype"- , "of"- , "then"- , "type"- , "where"- , "mdo"- , "rec"- , "proc"- ]--constructorSafe :: Name -> Bool-constructorSafe name = case T.uncons name of- Just (first, rest) -> asciiUpper first && T.all asciiAlphaNumOrUnderscore rest- Nothing -> False--fieldSafe :: Name -> Bool-fieldSafe name = case T.uncons name of- Just (first, rest) -> (asciiLower first || first == '_') && T.all asciiAlphaNumOrUnderscore rest- Nothing -> False--asciiUpper :: Char -> Bool-asciiUpper c = c >= 'A' && c <= 'Z'--asciiLower :: Char -> Bool-asciiLower c = c >= 'a' && c <= 'z'--asciiAlphaNumOrUnderscore :: Char -> Bool-asciiAlphaNumOrUnderscore c = asciiUpper c || asciiLower c || (c >= '0' && c <= '9') || c == '_'---- | Rules over namespaces shared by the whole specification.-specLevelRules :: Spec -> [Diagnostic]-specLevelRules spec = duplicateNodes ++ duplicateEnumMembers ++ duplicateIdPrefixes ++ ruleDiagnostics- where- duplicateNodes =- [ mkErr (locLine loc) DuplicateNodeName $- "duplicate " <> kind <> " node name '" <> name <> "'"- | node <- duplicatesBy nodeKey (specNodes spec)- , let (kind, name, loc) = nodeIdentity node- ]- nodeKey node = let (kind, name, _) = nodeIdentity node in (kind, name)- duplicateEnumMembers = concatMap enumDuplicates (specEnums spec)- enumDuplicates e =- [ mkErr (locLine (enumLoc e)) DuplicateEnumCtor $- "enum '" <> enumName e <> "' declares constructor '" <> ctor <> "' more than once"- | (ctor, _) <- duplicatesBy fst (enumCtors e)- ]- ++ [ mkErr (locLine (enumLoc e)) DuplicateEnumWire $- "enum '" <> enumName e <> "' declares wire spelling '" <> wire <> "' more than once"- | (_, wire) <- duplicatesBy snd (enumCtors e)- ]- duplicateIdPrefixes =- [ mkErr (locLine (idLoc d)) DuplicateIdPrefix $- "id '" <> idName d <> "' reuses prefix '" <> idPrefix d <> "'"- | d <- duplicatesBy idPrefix (specIds spec)- ]- ruleDiagnostics = concatMap (validateRule spec) (specRules spec)--nodeIdentity :: Node -> (Text, Name, Loc)-nodeIdentity (NAggregate a) = ("aggregate", aggName a, aggLoc a)-nodeIdentity (NProcess p) = ("process", procId p, procLoc p)-nodeIdentity (NRouter r) = ("router", rtId r, rtLoc r)-nodeIdentity (NContract c) = ("contract", ctrName c, ctrLoc c)-nodeIdentity (NIntake i) = ("intake", inkName i, inkLoc i)-nodeIdentity (NEmit e) = ("emit", emName e, emLoc e)-nodeIdentity (NPublisher p) = ("publisher", pubName p, pubLoc p)-nodeIdentity (NWorkqueue w) = ("workqueue", wqName w, wqLoc w)-nodeIdentity (NPgmqDispatch d) = ("dispatch", pdName d, pdLoc d)-nodeIdentity (NReadModel r) = ("readmodel", rmName r, rmLoc r)-nodeIdentity (NWorkflow w) = ("workflow", wfId w, workflowNodeLoc w)-nodeIdentity (NOperation o) = ("operation", opName o, opLoc o)--validateNode :: Spec -> Node -> [Diagnostic]-validateNode spec (NAggregate agg) = validateAggregate spec agg-validateNode spec (NProcess p) = validateProcess spec p-validateNode spec (NRouter router) = validateRouter spec router-validateNode _spec (NContract _) = [] -- a contract is a declaration; coupling is checked at the referrers-validateNode spec (NIntake i) = validateIntake i ++ intakeCoupling spec i-validateNode spec (NEmit e) = validateEmit spec e-validateNode spec (NPublisher p) = validatePublisher spec p-validateNode _spec (NWorkqueue w) = validateWorkqueue w-validateNode spec (NPgmqDispatch d) = validatePgmqDispatch spec d-validateNode spec (NReadModel readModel) = validateReadModel spec readModel-validateNode _spec (NWorkflow w) = validateWorkflow w-validateNode spec (NOperation o) = validateOperation spec o---- | Workflow replay keys, patch guards, rotation, and injected inputs must be unambiguous.-validateWorkflow :: WorkflowNode -> [Diagnostic]-validateWorkflow w = duplicateLabels ++ sleepFields ++ patchDuplicates ++ patchIds ++ continuePositions ++ idField- where- inputFields = map fieldName (wfInputFields w)- labelledItems = workflowLabelledItems (wfBody w)- patchItems = workflowPatchItems (wfBody w)- duplicateLabels =- [ mkErr (locLine (wfBodyLoc item)) WorkflowDuplicateLabel $- "workflow '" <> wfId w <> "' declares label '" <> label <> "' more than once; labels key deterministic replay, so a duplicate label replays the first occurrence's journaled result"- | (label, item) <- duplicatesBy fst labelledItems- ]- sleepFields =- [ mkErr (locLine loc) WorkflowSleepDelayUnresolved $- "workflow '" <> wfId w <> "' sleep '" <> label <> "' references undeclared input field '" <> delay <> "'"- | WfSleep label delay loc <- map snd labelledItems- , delay `notElem` inputFields- ]- patchDuplicates =- [ mkErr (locLine loc) WorkflowPatchDuplicate $- "workflow '" <> wfId w <> "' declares patch id '" <> patchId <> "' more than once; patch decisions journal under one stable key"- | (patchId, _, loc) <- duplicatesBy (\(patchId, _, _) -> patchId) patchItems- ]- patchIds =- [ mkErr (locLine loc) WorkflowPatchIdInvalid $- "workflow '" <> wfId w <> "' patch id '" <> patchId <> "' contains ':'; the runtime reserves that separator for the patch journal-key prefix"- | (patchId, _, loc) <- patchItems- , ":" `T.isInfixOf` patchId- ]- continuePositions =- [ mkErr (locLine loc) WorkflowContinueAsNewNotTerminal $- "workflow '" <> wfId w <> "' continueAsNew must be the last top-level body item and may not appear inside a patch"- | (isTopLevelTerminal, loc) <- workflowContinueItems (wfBody w)- , not isTopLevelTerminal- ]- idField = case wfIdField w of- Just field- | field `notElem` inputFields ->- [ mkErr (locLine (workflowNodeLoc w)) WorkflowIdFieldUnresolved $- "workflow '" <> wfId w <> "' derives its id from undeclared input field '" <> field <> "'"- ]- _ -> []--wfBodyLoc :: WfBodyItem -> Loc-wfBodyLoc (WfStep _ _ loc) = loc-wfBodyLoc (WfAwait _ _ loc) = loc-wfBodyLoc (WfSleep _ _ loc) = loc-wfBodyLoc (WfChild _ _ _ loc) = loc-wfBodyLoc (WfPatch _ _ loc) = loc-wfBodyLoc (WfContinueAsNew _ loc) = loc--workflowLabelledItems :: [WfBodyItem] -> [(Name, WfBodyItem)]-workflowLabelledItems = concatMap go- where- go item@(WfStep label _ _) = [(label, item)]- go item@(WfAwait label _ _) = [(label, item)]- go item@(WfSleep label _ _) = [(label, item)]- go item@(WfChild label _ _ _) = [(label, item)]- go (WfPatch _ items _) = workflowLabelledItems items- go WfContinueAsNew{} = []--workflowPatchItems :: [WfBodyItem] -> [(Name, [WfBodyItem], Loc)]-workflowPatchItems = concatMap go- where- go (WfPatch patchId items loc) = (patchId, items, loc) : workflowPatchItems items- go _ = []---- | Pair every rotation with whether it is the final top-level item.-workflowContinueItems :: [WfBodyItem] -> [(Bool, Loc)]-workflowContinueItems items = topLevel ++ nested- where- topLevel =- [ (index == length items - 1, loc)- | (index, WfContinueAsNew _ loc) <- zip [0 ..] items- ]- nested =- [ (False, loc)- | WfPatch _ patchBody _ <- items- , (_, loc) <- workflowContinueItems patchBody- ]---- | A top-level rule is a total, clock-free function over one declared enum.-validateRule :: Spec -> RuleDecl -> [Diagnostic]-validateRule spec rule = case [e | e <- specEnums spec, enumName e == ruleDomain rule] of- [] ->- [ mkErr rl RuleDomainUnresolved $- "rule '" <> ruleName rule <> "' has undeclared enum domain '" <> ruleDomain rule <> "'"- ]- (domain : _) -> totality domain ++ unknownCases domain ++ bodyDiagnostics- where- rl = locLine (ruleLoc rule)- caseNames = map fst (ruleCases rule)- allEnumCtors = Set.fromList [ctor | e <- specEnums spec, (ctor, _) <- enumCtors e]- totality domain =- let missing = [ctor | (ctor, _) <- enumCtors domain, ctor `notElem` caseNames]- in [ mkErr rl RuleNotTotal $- "rule '" <> ruleName rule <> "' is not total over enum '" <> enumName domain <> "'; missing cases {" <> T.intercalate ", " missing <> "}"- | not (null missing)- ]- unknownCases domain =- [ mkErr rl RuleCaseUnknownCtor $- "rule '" <> ruleName rule <> "' has case '" <> ctor <> "' which is not a constructor of enum '" <> enumName domain <> "'"- | (ctor, _) <- ruleCases rule- , ctor `notElem` map fst (enumCtors domain)- ]- bodyDiagnostics = concatMap validateBody (ruleCases rule)- validateBody (ctor, expr) =- [ mkErr rl ClockSampled $- "rule '" <> ruleName rule <> "' case '" <> ctor <> "' samples the wall clock via '" <> atom <> "'; rules must be deterministic"- | atom <- dedup (exprNames expr)- , atom `Set.member` clockAtoms- ]- ++ [ mkErr rl GuardAtomOutOfScope $- "atom '" <> atom <> "' in rule '" <> ruleName rule <> "' resolves to no enum constructor or boolean literal"- | atom <- dedup (exprNames expr)- , atom `Set.notMember` clockAtoms- , atom `Set.notMember` allEnumCtors- ]--{- | Operation rules resolve command aggregates, stream fields, projections,-read models, workflow signal labels and value types, and run targets.--}-validateOperation :: Spec -> OperationNode -> [Diagnostic]-validateOperation spec o = case opShape o of- CommandOp aggregate streamField _ projections ->- aggregateRef aggregate streamField ++ projectionRefs projections- QueryOp readModel _ _ consistency ->- resolveReadModelRef QueryUnresolvedReadModel spec (opLoc o) ("query operation '" <> opName o <> "'") readModel- ++ [ mkErr ol QueryConsistencyInvalid $- "query operation '" <> opName o <> "' has unknown consistency '" <> consistency <> "'; expected Strong, Eventual, or PositionWait"- | consistency `notElem` (["Strong", "Eventual", "PositionWait"] :: [Name])- ]- SignalOp lbl wf _ _ valueType ->- case lookupWorkflow wf of- Nothing ->- [mkErr ol AwaitSignalMismatch ("signal operation '" <> opName o <> "' targets undeclared workflow '" <> wf <> "'")]- Just w -> case [(resultType, loc) | (_, WfAwait label resultType loc) <- workflowLabelledItems (wfBody w), label == lbl] of- [] ->- [ mkErr ol AwaitSignalMismatch $- "signal '" <> lbl <> "' of " <> wf <> " has no matching 'await' (workflow declares awaits {" <> T.intercalate ", " (awaitLabels w) <> "}); the deterministic awakeable id will not match and the workflow will wait forever"- ]- ((resultType, _) : _)- | valueType == resultType -> []- | otherwise ->- [ mkErr ol AwaitSignalValueMismatch $- "signal '" <> lbl <> "' of " <> wf <> " carries value type '" <> valueType <> "' but the await expects '" <> resultType <> "'"- ]- RunOp wf _ _ ->- [ mkErr ol RunWorkflowUnresolved ("run operation '" <> opName o <> "' targets undeclared workflow '" <> wf <> "'")- | wf `notElem` map wfId workflows- ]- where- ol = locLine (opLoc o)- workflows = [w | NWorkflow w <- specNodes spec]- aggregates = [a | NAggregate a <- specNodes spec]- projectionTables = [projTable p | a <- aggregates, Just p <- [aggProjection a]]- lookupWorkflow n = case [w | w <- workflows, wfId w == n] of (w : _) -> Just w; [] -> Nothing- awaitLabels w = [l | (_, WfAwait l _ _) <- workflowLabelledItems (wfBody w)]- aggregateRef name streamField = case [a | a <- aggregates, aggName a == name] of- [] ->- [ mkErr ol OperationUnresolvedRef $- "command operation '" <> opName o <> "' targets undeclared aggregate '" <> name <> "'"- ]- (aggregate : _) ->- [ mkErr ol OperationUnresolvedRef $- "command operation '" <> opName o <> "' stream field '" <> streamField <> "' is not declared by any command of aggregate '" <> name <> "'"- | streamField `notElem` [fieldName field | command <- aggCommands aggregate, field <- cmdFields command]- ]- projectionRefs projections =- [ mkErr ol OperationUnresolvedRef $- "command operation '" <> opName o <> "' references undeclared projection table '" <> projection <> "'"- | projection <- projections- , projection `notElem` projectionTables- ]---- | Resolve a named read-model node using the caller's diagnostic code.-resolveReadModelRef :: DiagnosticCode -> Spec -> Loc -> Text -> Name -> [Diagnostic]-resolveReadModelRef diagnosticCode spec diagnosticLoc context name =- [ mkErr (locLine diagnosticLoc) diagnosticCode $- context <> " references undeclared readmodel '" <> name <> "'"- | name `notElem` [rmName readModel | NReadModel readModel <- specNodes spec]- ]---- | Validate captured identity, feed semantics, and the declared column surface.-validateReadModel :: Spec -> ReadModelNode -> [Diagnostic]-validateReadModel spec readModel =- shapeFixture ++ columnTypes ++ strongFeed ++ scopeMode ++ inlineReference- where- readModelLine = locLine (rmLoc readModel)- expectedShape = deriveShapeHash readModel- shapeFixture =- [ mkErr readModelLine RmShapeHashDrift $- "readmodel '"- <> rmName readModel- <> "': captured shape \""- <> rmShape readModel- <> "\" does not match the declared columns (expected \""- <> expectedShape- <> "\"); update the fixture AND bump version if the table shape really changed"- | rmShape readModel /= expectedShape- ]- allowedColumnTypes = Set.fromList ["text", "int", "bigint", "bool", "timestamptz", "jsonb", "numeric"]- columnTypes =- [ mkErr readModelLine RmUnknownColumnType $- "readmodel '" <> rmName readModel <> "' column '" <> rmcName columnDecl <> "' has unknown type '" <> rmcType columnDecl <> "'"- | columnDecl <- rmColumns readModel- , rmcType columnDecl `Set.notMember` allowedColumnTypes- ]- strongFeed =- [ mkErr readModelLine RmStrongInlineOnly $- "readmodel '"- <> rmName readModel- <> "': consistency = Strong with feed = inline; an inline-only model has no subscription worker to advance the cursor a Strong read waits on. Use consistency = Eventual, or feed = subscription"- | rmFeed readModel == RmInline- , rmConsistency readModel == Strong- ]- scopeMode =- [ mkErr readModelLine RmScopeWithoutStrong $- "readmodel '" <> rmName readModel <> "': scope is meaningful only with consistency = Strong"- | rmScope readModel /= Nothing- , rmConsistency readModel /= Strong- ]- inlineReference =- [ mkErr readModelLine RmInlineFeedUnreferenced $- "readmodel '" <> rmName readModel <> "' declares feed = inline but no aggregate projection references it"- | rmFeed readModel == RmInline- , rmName readModel `notElem` [projTable projection | NAggregate aggregate <- specNodes spec, Just projection <- [aggProjection aggregate]]- ]--{- | EP-5 workqueue rules: the captured physical name must match the queueRef-derivation; the disposition inversions (storeFailure transient => must retry;-decodeFailure poison => must dead-letter); and dlq=on requires a retry ceiling.--}-validateWorkqueue :: WorkqueueNode -> [Diagnostic]-validateWorkqueue w = concat [divergence, completeness, duplicateRows, inversions, retryCeiling, orderingRules, groupKeyRules, provisionRules]- where- wl = locLine (wqLoc w)- rows = wqDisposition w- (derivedPhysical, derivedDlq, derivedTable) = derivedQueueTrio (wqLogical w)- divergence =- [ mkErr wl WqPhysicalDivergence $- "workqueue '" <> wqName w <> "': captured physical \"" <> wqPhysical w <> "\" diverges from queueRef(\"" <> wqLogical w <> "\") = \"" <> derivedPhysical <> "\""- | wqPhysical w /= derivedPhysical- ]- ++ [ mkErr wl WqDlqDivergence $- "workqueue '" <> wqName w <> "': captured dlq \"" <> wqDlq w <> "\" diverges from queueRef = \"" <> derivedDlq <> "\""- | wqDlq w /= derivedDlq- ]- ++ [ mkErr wl WqTableDivergence $- "workqueue '" <> wqName w <> "': captured table \"" <> wqTable w <> "\" diverges from queueRef table = \"" <> derivedTable <> "\""- | wqTable w /= derivedTable- ]- requiredOutcomes = ["storeFailure", "commandRejected", "decodeFailure", "onCodecReject"]- completeness =- [ mkErr wl WqDispositionIncomplete $- "workqueue '" <> wqName w <> "' disposition table is missing outcome '" <> outcome <> "'"- | outcome <- requiredOutcomes- , outcome `notElem` map wqdOutcome rows- ]- duplicateRows =- [ mkErr (locLine (wqdLoc row)) DispositionDuplicateOutcome $- "workqueue '" <> wqName w <> "' repeats disposition outcome '" <> wqdOutcome row <> "'; the first row would shadow this row"- | row <- duplicatesBy wqdOutcome rows- ]- firstRow outcome = case [row | row <- rows, wqdOutcome row == outcome] of- (row : _) -> Just row- [] -> Nothing- isRetry row = case wqdAction row of IRetry _ -> True; _ -> False- isDeadLetter row = case wqdAction row of IDeadLetter _ -> True; _ -> False- inversions =- [ mkErr (locLine (wqdLoc row)) WqStoreFailureNotRetry ("workqueue '" <> wqName w <> "': 'storeFailure' is transient and MUST retry, not dead-letter")- | Just row <- [firstRow "storeFailure"]- , isDeadLetter row- ]- ++ [ mkErr (locLine (wqdLoc row)) WqDecodeFailureNotDeadLetter ("workqueue '" <> wqName w <> "': 'decodeFailure' is poison and MUST dead-letter, not retry")- | Just row <- [firstRow "decodeFailure"]- , isRetry row- ]- retryCeiling =- [ mkErr wl WqDlqWithoutCeiling ("workqueue '" <> wqName w <> "': dlq=on requires maxRetries >= 1 (an absent ceiling never dead-letters)")- | wqDlqOn w && wqMaxRetries w < 1- ]- fifo = wqOrdering w /= WqUnordered- orderingRules =- [ mkErr wl WqGroupKeyMissing $- "workqueue '" <> wqName w <> "': FIFO delivery is per group, so ordering requires a 'group key' clause that makes enqueueToGroup deterministic"- | fifo && wqGroupKey w == Nothing- ]- ++ [ mkErr wl WqGroupKeyWithoutFifo $- "workqueue '" <> wqName w <> "': a group key with unordered reads would be ignored; declare a FIFO ordering or remove the key"- | not fifo && wqGroupKey w /= Nothing- ]- groupKeyRules = case wqGroupKey w of- Nothing -> []- Just groupKey ->- case [field | field <- wqPayload w, wqfName field == gkField groupKey] of- [] ->- [ mkErr wl WqGroupKeyUnresolved $- "workqueue '" <> wqName w <> "': group key field '" <> gkField groupKey <> "' is not declared in its payload"- ]- field : _ ->- [ mkErr wl WqGroupKeyUnresolved $- "workqueue '" <> wqName w <> "': group key via raw requires a text payload field, but '" <> gkField groupKey <> "' has type '" <> wqfType field <> "'"- | gkVia groupKey == "raw" && wqfType field /= "text"- ]- ++ [ mkErr wl WqGroupKeyUnresolved $- "workqueue '" <> wqName w <> "': opaque group-key derivation '" <> gkVia groupKey <> "' requires a captured fixture"- | gkVia groupKey /= "raw" && gkFixture groupKey == Nothing- ]- provisionRules = case wqProvision w of- WqStandard -> []- WqUnlogged ->- [ Diagnostic- { line = wl- , severity = Warning- , code = WqUnloggedDurability- , message = "workqueue '" <> wqName w <> "': provision unlogged is truncated to empty on a database crash; use it only for transient, regenerable work"- }- ]- WqPartitioned interval retention ->- [ mkErr wl WqPartitionSpecEmpty $- "workqueue '" <> wqName w <> "': partition interval and retention must be non-empty; they are create-time settings and the additive reconciler will not migrate an existing queue"- | T.null interval || T.null retention- ]---- | EP-5 dispatch rule: the @enqueue to@ target must resolve to a declared workqueue.-validatePgmqDispatch :: Spec -> PgmqDispatchNode -> [Diagnostic]-validatePgmqDispatch spec d = enqueueRef ++ dedupQueueRef ++ sourceReadModelRef ++ dedupReadModelRef ++ dedupReadModelField- where- dl = locLine (pdLoc d)- workqueues = [w | NWorkqueue w <- specNodes spec]- enqueueRef =- [ mkErr dl DispatchEnqueueUnresolved ("dispatch '" <> pdName d <> "' enqueues to undeclared workqueue '" <> pdEnqueueTo d <> "'")- | pdEnqueueTo d `notElem` map wqName workqueues- ]- dedupQueueRef = case [w | w <- workqueues, wqName w == pdDedupQueue d] of- [] ->- [ mkErr dl DispatchDedupQueueUnresolved $- "dispatch '" <> pdName d <> "' checks an undeclared dedup queue '" <> pdDedupQueue d <> "'"- ]- (queue : _) ->- [ mkErr dl DispatchDedupFieldUnresolved $- "dispatch '" <> pdName d <> "' dedup field '" <> pdDedupQueueField d <> "' is not a payload wire field of queue '" <> pdDedupQueue d <> "'"- | pdDedupQueueField d `notElem` map wqfWire (wqPayload queue)- ]- sourceReadModelRef =- resolveReadModelRef DispatchReadModelUnresolved spec (pdLoc d) ("dispatch '" <> pdName d <> "' source") (pdSourceReadModel d)- dedupReadModelRef =- resolveReadModelRef DispatchReadModelUnresolved spec (pdLoc d) ("dispatch '" <> pdName d <> "' dedup") (pdDedupReadModel d)- dedupReadModelField = case [readModel | NReadModel readModel <- specNodes spec, rmName readModel == pdDedupReadModel d] of- [] -> []- (readModel : _) ->- [ mkErr dl DispatchReadModelFieldUnknown $- "dispatch '" <> pdName d <> "' dedup field '" <> pdDedupReadModelField d <> "' is not a declared column of readmodel '" <> pdDedupReadModel d <> "'"- | pdDedupReadModelField d `notElem` map rmcName (rmColumns readModel)- ]---- | The declared contracts in a spec, by name.-specContracts :: Spec -> [ContractNode]-specContracts spec = [c | NContract c <- specNodes spec]---- | EP-4 cross-node coupling: an intake's contract/topic/accepted-events resolve.-intakeCoupling :: Spec -> IntakeNode -> [Diagnostic]-intakeCoupling spec i = case lookupContract (inkContract i) of- Nothing ->- [mkErr (locLine (inkLoc i)) IntakeUnresolvedContract ("intake '" <> inkName i <> "' references undeclared contract '" <> inkContract i <> "'")]- Just c ->- [ mkErr (locLine (inkLoc i)) IntakeUnresolvedContract ("intake '" <> inkName i <> "' topic '" <> inkTopic i <> "' is not a topic of contract '" <> inkContract i <> "'")- | inkTopic i `notElem` map fst (ctrTopics c)- ]- ++ [ mkErr (locLine (inkLoc i)) IntakeUnresolvedContract ("intake '" <> inkName i <> "' accepts event '" <> ev <> "' not declared in contract '" <> inkContract i <> "'")- | ev <- inkAccept i- , ev `notElem` map ceName (ctrEvents c)- ]- ++ [ mkErr (locLine (inkLoc i)) TopicAffinityMismatch $- "intake '" <> inkName i <> "' subscribes to topic '" <> inkTopic i <> "' but accepted event '" <> ceName event <> "' is declared on topic '" <> ceTopic event <> "'"- | event <- ctrEvents c- , ceName event `elem` inkAccept i- , ceTopic event /= inkTopic i- ]- where- lookupContract n = case [c | c <- specContracts spec, ctrName c == n] of (c : _) -> Just c; [] -> Nothing--validateEmit :: Spec -> EmitNode -> [Diagnostic]-validateEmit spec e = skipRule ++ coupling- where- el = locLine (emLoc e)- skipRule =- [ mkErr el EmitSkipMissing ("emit '" <> emName e <> "' map must end with an explicit '_ => skip' catch-all (hole-kind 7 optionality)")- | not (emSkip e)- ]- coupling = case [c | c <- specContracts spec, ctrName c == emContract e] of- [] -> [mkErr el EmitUnresolvedContract ("emit '" <> emName e <> "' references undeclared contract '" <> emContract e <> "'")]- (c : _) ->- [ mkErr el EmitUnresolvedContract ("emit '" <> emName e <> "' topic '" <> emTopic e <> "' is not a topic of contract '" <> emContract e <> "'")- | emTopic e `notElem` map fst (ctrTopics c)- ]- ++ [ mkErr (locLine (emrLoc r)) EmitUnresolvedContract ("emit '" <> emName e <> "' maps to event '" <> emrEvent r <> "' not declared in contract '" <> emContract e <> "'")- | r <- emMap e- , emrEvent r `notElem` map ceName (ctrEvents c)- ]- ++ [ mkErr (locLine (emrLoc row)) TopicAffinityMismatch $- "emit '" <> emName e <> "' publishes on topic '" <> emTopic e <> "' but mapped event '" <> emrEvent row <> "' is declared on topic '" <> ceTopic event <> "'"- | row <- emMap e- , event <- ctrEvents c- , ceName event == emrEvent row- , ceTopic event /= emTopic e- ]--validatePublisher :: Spec -> PublisherNode -> [Diagnostic]-validatePublisher spec p =- [ mkErr (locLine (pubLoc p)) PublisherUnresolvedEmit ("publisher '" <> pubName p <> "' references undeclared emit '" <> pubEmit p <> "'")- | pubEmit p `notElem` [emName e | NEmit e <- specNodes spec]- ]--{- | EP-4 inbox disposition rules: the table must be complete over the seven-outcomes, and the three dangerous inversions must be stated the safe way.--}-validateIntake :: IntakeNode -> [Diagnostic]-validateIntake i = concat [completeness, duplicateRows, inversions]- where- il = locLine (inkLoc i)- rows = inkDisposition i- requiredOutcomes =- ["processed", "duplicate", "inProgress", "previouslyFailed", "decodeFailed", "dedupeFailed", "storeFailed"]- completeness =- [ mkErr il DispositionIncomplete $- "intake '" <> inkName i <> "' disposition table is missing outcome '" <> o <> "'"- | o <- requiredOutcomes- , o `notElem` map drOutcome rows- ]- duplicateRows =- [ mkErr (locLine (drLoc row)) DispositionDuplicateOutcome $- "intake '" <> inkName i <> "' repeats disposition outcome '" <> drOutcome row <> "'; the first row would shadow this row"- | row <- duplicatesBy drOutcome rows- ]- firstRow outcome = case [row | row <- rows, drOutcome row == outcome] of- (row : _) -> Just row- [] -> Nothing- isRetry row = case drAction row of IRetry _ -> True; _ -> False- inversions =- [ mkErr (locLine (drLoc row)) DispositionDuplicateRetry $- "intake '" <> inkName i <> "': a 'duplicate' redelivery must be ackOk (success), not retry"- | Just row <- [firstRow "duplicate"]- , isRetry row- ]- ++ [ mkErr (locLine (drLoc row)) DispositionPreviouslyFailedRetry $- "intake '" <> inkName i <> "': 'previouslyFailed' must dead-letter, not retry (a prior failure won't succeed on replay)"- | Just row <- [firstRow "previouslyFailed"]- , isRetry row- ]- ++ [ mkErr (locLine (drLoc row)) DispositionDecodeUnboundedRetry $- "intake '" <> inkName i <> "': 'decodeFailed' must dead-letter (terminal), not retry unboundedly"- | Just row <- [firstRow "decodeFailed"]- , isRetry row- ]---- | EP-3 rules for a process manager + its nested timer.-validateProcess :: Spec -> ProcessNode -> [Diagnostic]-validateProcess spec p =- concat [sagaCategoryRule, noWallClock, runtimeOwnedDispatchId, crossNodeCoupling, timerCeiling, policyRules, ambiguityRule, benignInversions]- where- aggregates = [a | NAggregate a <- specNodes spec]- aggNames = map aggName aggregates- projectionTables = [projTable projection | aggregate <- aggregates, Just projection <- [aggProjection aggregate]]- inputFields = map fieldName (inFields (procInput p))- timeFields = [fieldName f | f <- inFields (procInput p), fieldType f == Just "Time"]- timer = procTimer p- pl = locLine (procLoc p)-- sagaCategoryRule =- [ mkErr pl SagaCategoryIllegal $- "saga category " <> T.pack (show (sagaCategory (procSaga p))) <> " " <> reason- | Just reason <- [sagaCategoryError (sagaCategory (procSaga p))]- ]-- -- TIME IS INJECTED, NOT SAMPLED: fireAt's field must be a declared :Time- -- input field. (FireAtExpr has no clock-sampling constructor, so this is a- -- field-resolution + typed-as-Time check.)- noWallClock =- let f = faField (tmFireAt timer)- in if f `notElem` inputFields- then- [ mkErr (locLine (tmLoc timer)) ProcessFireAtNotInjected $- "timer '" <> tmName timer <> "' fireAt field '" <> f <> "' is not a field of input '" <> inName (procInput p) <> "'"- ]- else- [ mkErr (locLine (tmLoc timer)) ProcessFireAtNotInjected $- "timer '" <> tmName timer <> "' fireAt references '" <> f <> "', which is not a declared :Time field of input '" <> inName (procInput p) <> "'"- | f `notElem` timeFields- ]-- -- Dispatched (and fired) command ids are runtime-owned; no field binding may- -- supply a commandId/id.- runtimeOwnedDispatchId =- [ mkErr pl ProcessDispatchIdSupplied $- "advance command '" <> advCommand advance <> "' supplies a runtime-owned id field '" <> fbName binding <> "'; remove it"- | let advance = hAdvance (procHandle p)- , binding <- advFields advance- , fbName binding `elem` (["commandId", "id"] :: [Name])- ]- ++ [ mkErr (locLine (dispLoc d)) ProcessDispatchIdSupplied $- "dispatch to '" <> dispTarget d <> "' supplies a runtime-owned id field '" <> fbName b <> "'; remove it"- | d <- hDispatch (procHandle p)- , b <- dispFields d- , fbName b `elem` (["commandId", "id"] :: [Name])- ]- ++ [ mkErr (locLine (tmLoc timer)) ProcessDispatchIdSupplied $- "timer fire supplies a runtime-owned id field '" <> fbName b <> "'; remove it"- | b <- fireFields (tmFire timer)- , fbName b `elem` (["commandId", "id"] :: [Name])- ]-- -- Aggregate, command, field, timer, and projection references must resolve.- crossNodeCoupling =- [ mkErr pl ProcessUnresolvedRef ("saga '" <> sagaAgg (procSaga p) <> "' does not resolve to a declared aggregate")- | sagaAgg (procSaga p) `notElem` aggNames- ]- ++ [ mkErr pl ProcessUnresolvedRef ("target '" <> procTarget p <> "' does not resolve to a declared aggregate")- | procTarget p `notElem` aggNames- ]- ++ [ mkErr (locLine (tmLoc timer)) ProcessUnresolvedRef ("timer fire target '" <> fireTarget (tmFire timer) <> "' must be the saga or the target aggregate")- | fireTarget (tmFire timer) `notElem` [sagaAgg (procSaga p), procTarget p]- ]- ++ resolveCommand pl "advance" (sagaAgg (procSaga p)) (advCommand advance) (advFields advance)- ++ concatMap resolveDispatch (hDispatch (procHandle p))- ++ resolveCommand (locLine (tmLoc timer)) "timer fire" (fireTarget fire) (fireCommand fire) (fireFields fire)- ++ [ mkErr pl ProcessUnresolvedRef $- "process '" <> procId p <> "' schedules undeclared timer '" <> hSchedule (procHandle p) <> "'; declared timer is '" <> tmName timer <> "'"- | hSchedule (procHandle p) /= tmName timer- ]- ++ [ mkErr pl ProcessUnresolvedRef $- "process '" <> procId p <> "' references undeclared projection table '" <> projection <> "'"- | projection <- procProjections p- , projection `notElem` projectionTables- ]- where- advance = hAdvance (procHandle p)- fire = tmFire timer- resolveDispatch dispatch =- resolveCommand- (locLine (dispLoc dispatch))- "dispatch"- (dispTarget dispatch)- (dispCommand dispatch)- (dispFields dispatch)- resolveCommand diagnosticLine context target command bindings = case lookupAggregate target of- Nothing -> []- Just aggregate -> case [decl | decl <- aggCommands aggregate, cmdName decl == command] of- [] ->- [ mkErr diagnosticLine ProcessUnresolvedRef $- context <> " command '" <> command <> "' is not declared by aggregate '" <> target <> "'"- ]- (declaration : _) ->- [ mkErr diagnosticLine ProcessFieldBindingUnresolved $- context <> " command '" <> command <> "' binds undeclared target field '" <> fbName binding <> "'"- | binding <- bindings- , fbName binding `notElem` map fieldName (cmdFields declaration)- ]- lookupAggregate name = case [aggregate | aggregate <- aggregates, aggName aggregate == name] of- (aggregate : _) -> Just aggregate- [] -> Nothing-- timerCeiling =- [ mkErr (locLine (tmLoc timer)) ProcessTimerCeilingInvalid $- "timer '" <> tmName timer <> "' max-attempts must be at least 1"- | tmMaxAttempts timer < 1- ]-- policyRules =- policyConsistency- (procId p)- (procLoc p)- (procRejected p)- [ (dispCommand dispatch, dispLoc dispatch, dispDisposition dispatch)- | dispatch <- hDispatch (procHandle p)- ]-- ambiguityRule =- [ mkErr (locLine (tmLoc timer)) AmbiguousMarkedBenign $- "timer '" <> tmName timer <> "' maps on-ambiguous => Fired; CommandAmbiguous means multiple aggregate edges matched and is never a benign success. Use on-ambiguous Retry so the attempts ceiling dead-letters the definition bug"- | onAmbiguous (fireDisposition (tmFire timer)) == OFired- ]-- -- Surface the dangerous benign inversions the author confirmed (warnings).- benignInversions =- [ Diagnostic (locLine (tmLoc timer)) Warning ProcessBenignInversion $- "timer '" <> tmName timer <> "' maps on-reject => Fired (a CommandRejected is treated as benign success)"- | onReject (fireDisposition (tmFire timer)) == OFired- ]- ++ [ Diagnostic (locLine (dispLoc d)) Warning ProcessBenignInversion $- "dispatch to '" <> dispTarget d <> "' maps on-duplicate => AckOk (a duplicate is treated as benign success)"- | d <- hDispatch (procHandle p)- , onDuplicate (dispDisposition d) == DAckOk- ]--{- | Explain why a process saga category is illegal. The first four cases-mirror 'Keiro.Stream.category' without introducing a runtime dependency into-the toolchain library. The final @:@ case is deliberately stricter because-that prefix is reserved for the @wf:<name>@ workflow stream family.--}-sagaCategoryError :: Text -> Maybe Text-sagaCategoryError categoryName- | T.null categoryName = Just "is empty; use a non-empty camelCase category"- | categoryName == "$all" = Just "is reserved by the event store; choose a service-owned camelCase category"- | T.isInfixOf "-" categoryName = Just "contains '-' (kiroku's category/id boundary); write compound categories in camelCase, for example \"hospitalSurge\""- | Just illegal <- T.find (\character -> isSpace character || isControl character) categoryName =- Just ("contains whitespace or control character " <> T.pack (show illegal) <> "; remove it and use camelCase")- | T.isInfixOf ":" categoryName = Just "contains ':' which is reserved for the wf:<name> workflow stream family; choose a camelCase category without ':'"- | otherwise = Nothing---- | EP-108 rules for a stateless content-based router.-validateRouter :: Spec -> RouterNode -> [Diagnostic]-validateRouter spec router =- concat- [ references- , keyField- , bindingScope- , commandReference- , readModelReference- , policyRules- , duplicateNotice- ]- where- aggregates = [aggregate | NAggregate aggregate <- specNodes spec]- readModels = [readModel | NReadModel readModel <- specNodes spec]- inputFields = map fieldName (inFields (rtInput router))- resolvedFields = rvRow (rtResolve router)- dispatch = rtDispatch router- routerLine = locLine (rtLoc router)- dispatchLine = locLine (rdLoc dispatch)-- targetAggregate = case [aggregate | aggregate <- aggregates, aggName aggregate == rtTarget router] of- aggregate : _ -> Just aggregate- [] -> Nothing-- projectionTables = [projTable projection | aggregate <- aggregates, Just projection <- [aggProjection aggregate]]-- references =- [ mkErr routerLine RouterUnresolvedRef $- "router '" <> rtId router <> "' targets aggregate '" <> rtTarget router <> "' but no such aggregate is declared"- | targetAggregate == Nothing- ]- ++ [ mkErr routerLine RouterUnresolvedRef $- "router '" <> rtId router <> "' references undeclared projection table '" <> projection <> "'"- | projection <- rtProjections router- , projection `notElem` projectionTables- ]-- keyField =- [ mkErr routerLine RouterKeyFieldUnknown $- "key references 'input." <> corrField (rtKey router) <> "' but input '" <> inName (rtInput router) <> "' does not declare that field"- | corrField (rtKey router) `notElem` inputFields- ]-- bindingScope =- [ mkErr dispatchLine RouterBindingUnscoped $- "dispatch binding '" <> fbName binding <> maybe "" ("=" <>) (fbValue binding) <> "' is outside the router input and resolve-row scopes"- | binding <- rdFields dispatch- , not (bindingInScope binding)- ]- where- bindingInScope binding = case fbValue binding of- Nothing -> fbName binding `elem` inputFields- Just value- | isQuoted value -> True- | Just field <- T.stripPrefix "input." value -> field `elem` inputFields- | Just field <- T.stripPrefix "resolved." value -> field `elem` resolvedFields- | otherwise -> False- isQuoted value = T.length value >= 2 && T.head value == '"' && T.last value == '"'-- commandReference = case targetAggregate of- Nothing -> []- Just aggregate -> case [command | command <- aggCommands aggregate, cmdName command == rdCommand dispatch] of- [] ->- [ mkErr dispatchLine RouterCommandUnknown $- "dispatch command '" <> rdCommand dispatch <> "' is not declared by aggregate '" <> aggName aggregate <> "'"- ]- command : _ ->- [ mkErr dispatchLine RouterCommandUnknown $- "dispatch command '" <> rdCommand dispatch <> "' binds undeclared target field '" <> fbName binding <> "'"- | binding <- rdFields dispatch- , fbName binding `notElem` map fieldName (cmdFields command)- ]-- readModelReference = case rvSource (rtResolve router) of- ResolveHole -> []- ResolveReadModel name ->- [ mkErr (locLine (rvLoc (rtResolve router))) RouterUnresolvedRef $- "router '" <> rtId router <> "' resolve names readmodel '" <> name <> "' but no such readmodel node is declared"- | name `notElem` map rmName readModels- ]-- policyRules =- policyConsistency- (rtId router)- (rtLoc router)- (rtRejected router)- [(rdCommand dispatch, rdLoc dispatch, rdDisposition dispatch)]-- duplicateNotice =- [ Diagnostic dispatchLine Warning ProcessBenignInversion $- "router dispatch '" <> rdCommand dispatch <> "' maps on-duplicate => AckOk; Keiro.Router confirms the event id against the target stream before treating the duplicate as benign"- | onDuplicate (rdDisposition dispatch) == DAckOk- ]--{- | Reconcile per-dispatch prose with the one node-level policy the runtime-actually applies to a rejection-class failure group.--}-policyConsistency :: Name -> Loc -> PolicyChoice -> [(Name, Loc, DispatchDisposition)] -> [Diagnostic]-policyConsistency nodeName nodeLoc rejectedPolicy dispatches = contradictions ++ divergent ++ unused ++ ambiguityWarning- where- contradictions =- [ mkErr (locLine dispatchLoc) PolicyContradiction $- "dispatch '" <> command <> "' declares on-failed DeadLetter, but node '" <> nodeName <> "' does not declare rejected => deadLetter; align the dispatch story with the node-level RejectedCommandPolicy"- | (command, dispatchLoc, disposition) <- dispatches- , DDeadLetter _ <- [onFailed disposition]- , rejectedPolicy /= PolDeadLetter- ]-- divergent = case dispatches of- [] -> []- (_, _, firstDisposition) : rest ->- [ mkErr (locLine dispatchLoc) PolicyContradiction $- "dispatch '" <> command <> "' has a different on-failed action from another dispatch in node '" <> nodeName <> "'; the runtime applies one RejectedCommandPolicy to the whole failure group"- | (command, dispatchLoc, disposition) <- rest- , not (sameFailureAction (onFailed disposition) (onFailed firstDisposition))- ]-- unused =- [ Diagnostic (locLine nodeLoc) Warning PolicyDeadLetterUnused $- "node '" <> nodeName <> "' declares rejected => deadLetter but no dispatch on-failed arm says DeadLetter; the runtime policy is live, but the per-dispatch notation does not acknowledge it"- | rejectedPolicy == PolDeadLetter- , all (not . isDeadLetter . onFailed . third) dispatches- ]-- ambiguityWarning =- [ Diagnostic (locLine nodeLoc) Warning AmbiguousFollowsRejectedPolicy $- "node '" <> nodeName <> "' acknowledges rejection-class failures; CommandAmbiguous follows the same rejected policy, and a dead-letter errorClass is the durable witness of that definition bug"- | rejectedPolicy `elem` [PolDeadLetter, PolSkip]- ]-- third (_, _, value) = value- sameFailureAction DDeadLetter{} DDeadLetter{} = True- sameFailureAction left right = left == right- isDeadLetter DDeadLetter{} = True- isDeadLetter _ = False--validateAggregate :: Spec -> Aggregate -> [Diagnostic]-validateAggregate spec agg =- concat- [ duplicateMembers- , declaredRefs- , eventBodyRefs- , registerInitialScope- , reachability- , terminalNoOutgoing- , guardScope- , clockFree- , projectionSafety- , statusMapTotality- , evolutionRules- , snapshotRules- , replayOnlyRules- ]- where- states = Set.fromList (map stName (aggStates agg))- terminals = Set.fromList [stName s | s <- aggStates agg, stTerminal s]- commandFields :: Map Name [Name]- commandFields = Map.fromList [(cmdName c, map fieldName (cmdFields c)) | c <- aggCommands agg]- commandNames = Map.keysSet commandFields- eventNames = Set.fromList (map evName (aggEvents agg))- enumCtorNames = Set.fromList [c | e <- specEnums spec, (c, _) <- enumCtors e]- ruleNames = Set.fromList (map ruleName (specRules spec))- registerNames = Set.fromList (map regName (aggRegs agg))-- snapshotRules = case aggSnapshot agg of- Nothing -> []- Just snapshot ->- [ mkErr (locLine (snapLoc snapshot)) SnapshotIntervalInvalid $- "aggregate '" <> aggName agg <> "': snapshot every requires an interval of at least 1; non-positive runtime intervals silently disable snapshots"- | SnapEvery interval <- [snapPolicy snapshot]- , interval < 1- ]- ++ [ mkErr (locLine (snapLoc snapshot)) SnapshotCodecFixtureInvalid $- "aggregate '" <> aggName agg <> "': snapshot state-codec version must be at least 1 and shape-hash must be non-empty"- | snapCodecVersion snapshot < 1 || T.null (snapShapeHash snapshot)- ]-- duplicateMembers =- [ mkErr (locLine (cmdLoc c)) DuplicateCommandName $- "aggregate '" <> aggName agg <> "' declares command '" <> cmdName c <> "' more than once"- | c <- duplicatesBy cmdName (aggCommands agg)- ]- ++ [ mkErr (locLine (evLoc e)) DuplicateEventName $- "aggregate '" <> aggName agg <> "' declares event '" <> evName e <> "' more than once"- | e <- duplicatesBy evName (aggEvents agg)- ]-- eventBodyRefs =- [ mkErr (locLine (evLoc e)) UndeclaredCommand $- "event '" <> evName e <> "' copies fields from undeclared command '" <> command <> "'"- | e <- aggEvents agg- , EventFromCommand command <- [evBody e]- , command `Set.notMember` commandNames- ]-- registerInitialScope = concatMap checkRegisterInitial (aggRegs agg)- checkRegisterInitial r = case [e | e <- specEnums spec, enumName e == regType r] of- (e : _) ->- [ outOfScope r "constructor of enum" (enumName e)- | regInitialBare r `notElem` map (Just . fst) (enumCtors e)- ]- []- | regType r == aggName agg <> "Vertex" ->- [ outOfScope r "state of aggregate" (aggName agg)- | maybe True (`Set.notMember` states) (regInitialBare r)- ]- | regType r `elem` map idName (specIds spec) ->- [ outOfScope r "literal" "placeholder"- | regInitialBare r /= Just "placeholder"- ]- | otherwise -> []- outOfScope r expected domain =- mkErr (locLine (regLoc r)) RegisterInitialOutOfScope $- "register '" <> regName r <> "' initial '" <> renderRegInitial (regInitial r) <> "' is not a " <> expected <> " '" <> domain <> "'"- regInitialBare r = case regInitial r of- RegInitBare value -> Just value- RegInitText _ -> Nothing- renderRegInitial = \case- RegInitBare value -> value- RegInitText value -> value-- -- Rule 1: declared-reference for command / emit / goto / source.- declaredRefs =- concatMap transitionRefs (aggTransitions agg)- transitionRefs t =- [ mkErr (locLine (tLoc t)) UndeclaredCommand $- "transition references undeclared command '" <> tCommand t <> "'"- | not (tCommand t `Set.member` commandNames)- ]- ++ [ mkErr (locLine (tLoc t)) UndeclaredState $- "transition source '" <> tSource t <> "' is not a declared state"- | not (tSource t `Set.member` states)- ]- ++ [ mkErr (locLine (tLoc t)) UndeclaredState $- "transition goto '" <> tGoto t <> "' is not a declared state"- | not (tGoto t `Set.member` states)- ]- ++ [ mkErr (locLine (tLoc t)) UndeclaredEvent $- "emit references undeclared event '" <> ev <> "'"- | ev <- tEmits t- , not (ev `Set.member` eventNames)- ]-- -- Rule 2: reachability of every non-terminal state from the initial state- -- (the first state in the list).- reachability = case map stName (aggStates agg) of- [] -> []- (initial : _) ->- let reached = bfs (Set.singleton initial) [initial]- in [ mkErr (locLine (stLoc s)) UnreachableState $- "state '" <> stName s <> "' is not reachable from the initial state '" <> initial <> "'"- | s <- aggStates agg- , not (stTerminal s)- , not (stName s `Set.member` reached)- ]- edgesFrom src = [tGoto t | t <- aggTransitions agg, tSource t == src]- bfs seen [] = seen- bfs seen (x : xs) =- let nexts = [n | n <- edgesFrom x, not (n `Set.member` seen)]- in bfs (foldr Set.insert seen nexts) (xs ++ nexts)-- -- Rule 3: a terminal state has no outgoing transition.- terminalNoOutgoing =- [ mkErr (locLine (tLoc t)) TerminalHasOutgoing $- "terminal state '" <> tSource t <> "' has an outgoing transition"- | t <- aggTransitions agg- , tSource t `Set.member` terminals- ]-- -- Rule 4: every atom in a guard or write Expr resolves to a register, a- -- field of the transition's command, an enum constructor, a rule, or a bool.- guardScope = concatMap transitionScope (aggTransitions agg)- transitionScope t =- let inScope =- registerNames- `Set.union` Set.fromList (Map.findWithDefault [] (tCommand t) commandFields)- `Set.union` enumCtorNames- `Set.union` ruleNames- -- State names are constructors of the implicit vertex enum, so a- -- @write reservationState := Held@ references a state legitimately.- `Set.union` states- exprs = maybe [] pure (tGuard t) ++ map snd (tWrites t)- badAtoms =- [ n- | e <- exprs- , n <- exprNames e- , not (n `Set.member` clockAtoms) -- clock atoms reported separately- , not (n `Set.member` inScope)- ]- badTargets = [target | (target, _) <- tWrites t, target `Set.notMember` registerNames]- in [ mkErr (locLine (tLoc t)) WriteTargetNotRegister $- "write target '" <> target <> "' is not a register of aggregate '" <> aggName agg <> "'"- | target <- dedup badTargets- ]- ++ [ mkErr (locLine (tLoc t)) GuardAtomOutOfScope $- "atom '" <> n <> "' in transition '" <> tSource t <> " -- " <> tCommand t <> "' resolves to no register, command field, enum constructor, or rule"- | n <- dedup badAtoms- ]-- -- Rule 5 (cross-cutting): no guard or write Expr samples a wall clock.- clockFree = concatMap transitionClock (aggTransitions agg)- transitionClock t =- let exprs = maybe [] pure (tGuard t) ++ map snd (tWrites t)- sampled = [n | e <- exprs, n <- exprNames e, n `Set.member` clockAtoms]- in [ mkErr (locLine (tLoc t)) ClockSampled $- "transition '" <> tSource t <> " -- " <> tCommand t <> "' samples the wall clock via '" <> n <> "'; time must be an injected input field, not sampled"- | n <- dedup sampled- ]-- -- EP-107: a projection references a first-class read model when one exists.- -- Legacy standalone projections remain legal, but are surfaced as warnings.- projectionSafety = case aggProjection agg of- Nothing -> []- Just projection -> case [readModel | NReadModel readModel <- specNodes spec, rmName readModel == projTable projection] of- [] ->- [ mkErr (locLine (projLoc projection)) RmStrongInlineOnly $- "projection '" <> projTable projection <> "' declares consistency = Strong but has no readmodel node; a standalone projection is inline-only and has no subscription cursor"- | projConsistency projection == Just Strong- ]- ++ [ Diagnostic- { line = locLine (projLoc projection)- , severity = Warning- , code = RmProjectionWithoutNode- , message = "projection '" <> projTable projection <> "' has no readmodel node; registration, schema identity, consistency, and rebuild helpers are unavailable"- }- ]- (readModel : _) ->- [ mkErr (locLine (projLoc projection)) RmConsistencyConflict $- "projection '" <> projTable projection <> "' declares consistency " <> T.pack (show projectionConsistency) <> " but its readmodel node declares " <> T.pack (show (rmConsistency readModel))- | Just projectionConsistency <- [projConsistency projection]- , projectionConsistency /= rmConsistency readModel- ]-- -- Rule 6 (hole-kind 3, mapping): keys are exact event names, never suffixes;- -- duplicates and dangling keys are errors, and non-partial maps are total.- statusMapTotality = case aggProjection agg of- Nothing -> []- Just p ->- let evs = map evName (aggEvents agg)- pairs = maybe [] mapPairs (projStatusMap p)- keys = map fst pairs- partial = maybe False mapPartial (projStatusMap p)- uncovered = [event | event <- evs, event `notElem` keys]- dangling = [key | key <- keys, key `notElem` evs]- duplicateKeys = map fst (duplicatesBy fst pairs)- in [ mkErr (locLine (projLoc p)) StatusMapDanglingKey $- "projection '" <> projTable p <> "' status-map key '" <> key <> "' is not an event name of aggregate '" <> aggName agg <> "'"- | key <- dangling- ]- ++ [ mkErr (locLine (projLoc p)) StatusMapDuplicateKey $- "projection '" <> projTable p <> "' repeats status-map key '" <> key <> "'"- | key <- duplicateKeys- ]- ++ [ mkErr (locLine (projLoc p)) StatusMapNotTotal $- "projection '" <> projTable p <> "' status-map is not total over events {" <> T.intercalate ", " uncovered <> "}"- | not partial- , not (null evs)- , not (null uncovered)- ]-- -- EP-2 evolution rules (single-spec; the diff path adds the cross-spec ones).- evolutionRules =- versionUpcasterRule- ++ duplicateUpcasterSourceRule- ++ upcasterChainGapRule- ++ deprecatedEmitRule- ++ eventRetirementRules- ++ wireVersionRule- -- Only live transitions are the write path: a replay-only transition can- -- never fire forward, so its emits exist purely to invert stored events —- -- which is exactly where a deprecated event is allowed to remain- -- (plan 143; supersedes the guarded-but-inert retained-edge pattern).- liveEmittedNames = Set.fromList (concatMap tEmits [t | t <- aggTransitions agg, tMode t == TmLive])- replayEmittedNames = Set.fromList (concatMap tEmits [t | t <- aggTransitions agg, tMode t == TmReplayOnly])- maxEventVersion = maximum (1 : map evVersion (aggEvents agg))- upcasterSources =- Set.fromList- [ source- | event <- aggEvents agg- , Just (source, _) <- [evUpcastFrom event]- ]-- -- A non-initial event version must carry a contiguous upcaster (from v-1).- versionUpcasterRule =- [ mkErr (locLine (evLoc e)) EvtVersionMissingUpcaster $- "event '" <> evName e <> "' version " <> tInt (evVersion e) <> " has no 'upcast from v" <> tInt (evVersion e - 1) <> "' clause"- | e <- aggEvents agg- , evVersion e > 1- , maybe True ((/= evVersion e - 1) . fst) (evUpcastFrom e)- ]-- -- A generated rung dispatches by event type, so different events may- -- deliberately share a source version when they changed in one release.- -- Duplicate declarations for one event cannot survive the parser's unique- -- event-name rule, so no additional duplicate-source diagnostic is needed.- duplicateUpcasterSourceRule =- []-- -- Aggregate schema stamps are global, so every source version below the- -- current maximum needs a permanent rung regardless of which event owns it.- upcasterChainGapRule =- [ mkErr (locLine (aggLoc agg)) UpcasterChainGap $- "no event declares 'upcast from v"- <> tInt missing- <> "'; stored payloads stamped v"- <> tInt missing- <> " can never reach v"- <> tInt maxEventVersion- <> " (GapInUpcasterChain at hydration). A rung, once shipped, must exist forever — restore the upcaster for v"- <> tInt missing- <> " (re-declare it on the event whose shape changed at v"- <> tInt (missing + 1)- <> ")"- | missing <- [1 .. maxEventVersion - 1]- , missing `Set.notMember` upcasterSources- ]-- -- A deprecated event must have left the write path.- deprecatedEmitRule =- [ mkErr (locLine (evLoc e)) DeprecatedEventStillEmitted $- "deprecated event '" <> evName e <> "' is still emitted by a transition"- | e <- aggEvents agg- , evDeprecated e- , evName e `Set.member` liveEmittedNames- ]-- -- Retirement is a two-stage protocol. The pre-cutover marker keeps a live- -- emitter. The deprecated stage removes that live emitter but retains a- -- replay-only emitter until old payloads no longer need hydration.- eventRetirementRules = concatMap eventRetirementRule (aggEvents agg)- eventRetirementRule event- | evRetiring event =- [ mkErr (locLine (evLoc event)) EventRetirementInProgress $- "retiring event '" <> evName event <> "' has no live emitting transition; keep it emitting while streams are terminalized or truncated, or cut over to 'deprecated event' with a replay-only emitting transition"- | evName event `Set.notMember` liveEmittedNames- ]- ++ [ Diagnostic- { line = locLine (evLoc event)- , severity = Warning- , code = EventRetirementInProgress- , message =- "event '" <> evName event <> "' is retiring: it stays fully live and replayable. Keep its live emitting transition until every affected stream is terminal or truncated; then flip it to 'deprecated event' and retain an equivalent replay-only emitting transition for as long as old payloads may be hydrated"- }- | evName event `Set.member` liveEmittedNames- ]- | evDeprecated event =- [ Diagnostic- { line = locLine (evLoc event)- , severity = Warning- , code = DeprecatedEventReplayHazard- , message =- "deprecated event '" <> evName event <> "' stays decodable but is not replayable: no replay-only transition emits it, so hydration of a live stream containing it fails with HydrationNoInvertingEdge. Restore an equivalent replay-only emitting transition, or terminalize/truncate every affected stream before deployment"- }- | any (not . stTerminal) (aggStates agg)- , evName event `Set.notMember` replayEmittedNames- ]- ++ [ Diagnostic- { line = locLine (evLoc event)- , severity = Warning- , code = EventRetirementInProgress- , message =- "deprecated event '" <> evName event <> "' is off the live write path and remains replayable through a replay-only transition; retain that transition until every stream containing the event is terminal, truncated, or passes the replay audit"- }- | evName event `Set.member` replayEmittedNames- ]- | otherwise = []-- -- The explicit `wire schemaVersion=` (if any) must equal the max event version.- wireVersionRule = case aggWire agg of- Just w- | wireSchemaVersion w /= maxEventVersion ->- [ Diagnostic- { line = locLine (aggLoc agg)- , severity = Warning- , code = WireSchemaVersionMismatch- , message =- "wire schemaVersion=" <> tInt (wireSchemaVersion w) <> " does not match the maximum event version " <> tInt maxEventVersion- }- ]- _ -> []-- -- Plan 143: replay-only transition discipline. A replay-only transition- -- exists to invert stored events, so one that emits nothing is dead- -- weight (error); one whose (source, command) pair has no live sibling- -- means the command is fully retired at that state — legitimate, but the- -- fuller procedure is event retirement (docs/plans/139), so warn.- replayOnlyRules = concatMap replayOnlyRule (aggTransitions agg)- replayOnlyRule t- | tMode t /= TmReplayOnly = []- | otherwise =- [ mkErr (locLine (tLoc t)) ReplayOnlyEmitsNothing $- "replay-only transition '" <> tSource t <> " -- " <> tCommand t <> "' emits no event; a replay-only transition exists to invert stored events and is dead weight without an emit"- | null (tEmits t)- ]- ++ [ Diagnostic- { line = locLine (tLoc t)- , severity = Warning- , code = ReplayOnlyCommandStillLive- , message =- "replay-only transition '" <> tSource t <> " -- " <> tCommand t <> "' has no live sibling; command '" <> tCommand t <> "' is fully retired at state '" <> tSource t <> "' — if the intent is to retire its events too, follow the event-retirement procedure (docs/plans/139)"- }- | not (any (\sibling -> tMode sibling == TmLive && tSource sibling == tSource t && tCommand sibling == tCommand t) (aggTransitions agg))- ]--{- | The validator's re-derivation of the live-'Keiro.PGMQ.Runtime.queueRef' trio: physical queue, dead-letter queue, and-PGMQ backing table. Parity is pinned by the queue-runtime conformance suite.--}-derivedQueueTrio :: Text -> (Text, Text, Text)-derivedQueueTrio logical = (physical, physical <> "_dlq", "pgmq.q_" <> physical)- where- physical = physicalBase logical--physicalBase :: Text -> Text-physicalBase logical- | T.length base <= 43 && not ("_dlq" `T.isSuffixOf` base) = base- | otherwise = hashedBase logical base- where- base = sanitizeQueueName logical--sanitizeQueueName :: Text -> Text-sanitizeQueueName =- ensureLeadingLetter- . T.intercalate "_"- . filter (not . T.null)- . T.splitOn "_"- . T.map toLegal- . T.toLower- where- toLegal c- | (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' = c- | otherwise = '_'- ensureLeadingLetter value = case T.uncons value of- Nothing -> "q"- Just (c, _)- | c >= 'a' && c <= 'z' -> value- | otherwise -> T.cons 'q' value--hashedBase :: Text -> Text -> Text-hashedBase logical base = prefix <> "_" <> fnv1a64Hex logical- where- trimmedPrefix = T.dropWhileEnd (== '_') (T.take 26 base)- prefix- | T.null trimmedPrefix = "q"- | otherwise = trimmedPrefix--fnv1a64Hex :: Text -> Text-fnv1a64Hex logical = T.pack (replicate (16 - length rendered) '0' <> rendered)- where- rendered = showHex (T.foldl' step offset logical) ""- offset :: Word64- offset = 0xcbf29ce484222325- prime :: Word64- prime = 0x100000001b3- step hash character = (hash `xor` fromIntegral (ord character)) * prime--tInt :: Int -> Text-tInt = T.pack . show--mkErr :: Int -> DiagnosticCode -> Text -> Diagnostic-mkErr l c m = Diagnostic{line = l, severity = Error, code = c, message = m}--locLine :: Loc -> Int-locLine = unLoc---- | The 'AName' atom names occurring anywhere in an expression.-exprNames :: Expr -> [Name]-exprNames (EOr a b) = exprNames a ++ exprNames b-exprNames (EAnd a b) = exprNames a ++ exprNames b-exprNames (ECmp _ a b) = exprNames a ++ exprNames b-exprNames (EAtom (AName n)) = [n]-exprNames (EAtom (ABool _)) = []--dedup :: (Ord a) => [a] -> [a]-dedup = Set.toList . Set.fromList--{- | Keep each occurrence after the first for a chosen key. Diagnostics are-anchored on the shadowing declaration rather than the declaration it shadows.--}-duplicatesBy :: (Eq key) => (a -> key) -> [a] -> [a]-duplicatesBy key xs =- [ x- | (index, x) <- zip [0 :: Int ..] xs- , key x `elem` map key (take index xs)- ]+-- | The keiro DSL validator. A parsed 'Spec' is /valid/ only if it passes the+-- cross-cutting structural and hole-kind rules below. The point is to reject a+-- dangerous-by-omission spec — a deleted status-map, an undeclared command, a+-- guard atom that resolves to nothing, a wall-clock read inside a guard — /before+-- any Haskell is written/.+--+-- EP-1 defines the 'Diagnostic' framework and the cross-cutting rules; each later+-- vertical (EP-3…EP-6) appends its node-specific rules (e.g. EP-4's inbox+-- disposition inversions) reusing this same 'Diagnostic' type.+module Keiro.Dsl.Validate+ ( Severity (..),+ DiagnosticCode (..),+ Diagnostic (..),+ renderDiagnostic,+ validateSpec,+ derivedQueueTrio,+ sagaCategoryError,+ nodeIdentity,+ )+where++import Data.Bits (xor)+import Data.Char (isControl, isSpace, ord)+import Data.List (sortOn)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Word (Word64)+import Keiro.Dsl.AggregateType+import Keiro.Dsl.Expression+import Keiro.Dsl.Grammar+import Keiro.Dsl.NominalType qualified as Nominal+import Keiro.Dsl.ReadModelShape (deriveShapeHash)+import Keiro.Dsl.TypeGraph+import Numeric (showHex)++data Severity = Error | Warning+ deriving stock (Eq, Show)++-- | A machine-checkable code per rule, so tests match on the code, not prose.+data DiagnosticCode+ = UndeclaredCommand+ | UndeclaredEvent+ | UndeclaredState+ | UnreachableState+ | TerminalHasOutgoing+ | GuardAtomOutOfScope+ | StatusMapNotTotal+ | ClockSampled+ | -- EP-2 (evolution). These codes are shared by single-spec validation and+ -- the cross-spec diff path, so the enum remains the single registry of+ -- evolution rules.+ EvtVersionMissingUpcaster+ | DuplicateUpcasterSource+ | UpcasterChainGap+ | DeprecatedEventReplayHazard+ | EventRetirementInProgress+ | DeprecatedEventStillEmitted+ | WireSchemaVersionMismatch+ | EvtFieldAddedWithoutBump+ | EvtRemovedNotDeprecated+ | -- EP-3 (process manager + durable timer).+ ProcessFireAtNotInjected+ | ProcessDispatchIdSupplied+ | ProcessUnresolvedRef+ | ProcessBenignInversion+ | SagaCategoryIllegal+ | -- EP-4 (integration intake / inbox disposition).+ DispositionIncomplete+ | DispositionDuplicateRetry+ | DispositionPreviouslyFailedRetry+ | DispositionDecodeUnboundedRetry+ | -- EP-4 (integration coupling).+ EmitSkipMissing+ | EmitUnresolvedContract+ | PublisherUnresolvedEmit+ | IntakeUnresolvedContract+ | -- EP-5 (pgmq workqueue/dispatch).+ WqPhysicalDivergence+ | WqStoreFailureNotRetry+ | WqDecodeFailureNotDeadLetter+ | WqDlqWithoutCeiling+ | WqGroupKeyMissing+ | WqGroupKeyWithoutFifo+ | WqGroupKeyUnresolved+ | WqUnloggedDurability+ | WqPartitionSpecEmpty+ | SnapshotIntervalInvalid+ | SnapshotCodecFixtureInvalid+ | DispatchEnqueueUnresolved+ | -- EP-6 (workflow/operation).+ AwaitSignalMismatch+ | RunWorkflowUnresolved+ | WorkflowPatchDuplicate+ | WorkflowPatchIdInvalid+ | WorkflowContinueAsNewNotTerminal+ | -- Diff-only (cross-spec) decode and identity evolution rules.+ EvtFieldTypeChanged+ | EvtFieldRemovedSameVersion+ | EvtVersionDecreased+ | EnumCtorRemoved+ | EnumWireSpellingChanged+ | WireSpecChanged+ | ContractEventRemoved+ | ContractFieldChanged+ | ContractDiscriminatorChanged+ | ContractTopicChanged+ | ContractSchemaVersionDecreased+ | WqPayloadFieldChanged+ | ProcessInputChanged+ | WorkflowShapeChanged+ | WorkflowBodyChanged+ | WorkflowStableNameChanged+ | WorkflowPatchRemoved+ | WorkflowContinueSeedChanged+ | WqOrderingChanged+ | WqProvisionChanged+ | WqGroupKeyChanged+ | IdPrefixChanged+ | DedupeIdentityChanged+ | DerivedIdentityChanged+ | QueueIdentityChanged+ | TimerWindowChanged+ | EmitMappingChanged+ | DecodePostureChanged+ | IntakePersistenceChanged+ | ProjectionChanged+ | PublisherPolicyChanged+ | DispatchRetargeted+ | ContractSchemaVersionBumped+ | EventUndeprecated+ | -- EP-104 (validator soundness).+ WorkflowDuplicateLabel+ | WorkflowSleepDelayUnresolved+ | WorkflowIdFieldUnresolved+ | RuleDomainUnresolved+ | RuleNotTotal+ | RuleCaseUnknownCtor+ | ProcessFieldBindingUnresolved+ | ProcessTimerCeilingInvalid+ | OperationUnresolvedRef+ | AwaitSignalValueMismatch+ | WqDispositionIncomplete+ | DispositionDuplicateOutcome+ | TopicAffinityMismatch+ | StatusMapDanglingKey+ | StatusMapDuplicateKey+ | WriteTargetNotRegister+ | RegisterInitialOutOfScope+ | DuplicateNodeName+ | DuplicateEnumCtor+ | DuplicateEnumWire+ | DuplicateIdPrefix+ | DuplicateCommandName+ | DuplicateEventName+ | WqDlqDivergence+ | WqTableDivergence+ | DispatchDedupQueueUnresolved+ | DispatchDedupFieldUnresolved+ | -- EP-105 (notation integrity and scaffold-safe names).+ IdentHaskellKeyword+ | IdentNotConstructorSafe+ | VertexCtorCollision+ | -- EP-107 (first-class read models).+ RmShapeHashDrift+ | RmStrongInlineOnly+ | RmScopeWithoutStrong+ | RmUnknownColumnType+ | RmInlineFeedUnreferenced+ | RmConsistencyConflict+ | RmProjectionWithoutNode+ | QueryUnresolvedReadModel+ | QueryConsistencyInvalid+ | DispatchReadModelUnresolved+ | DispatchReadModelFieldUnknown+ | -- EP-107 diff-only read-model evolution rules.+ ReadModelVersionDecreased+ | ReadModelShapeChangedWithoutBump+ | ReadModelFeedChanged+ | ReadModelConsistencyWeakened+ | -- EP-108 (router and worker-policy surfaces).+ RouterUnresolvedRef+ | RouterKeyFieldUnknown+ | RouterBindingUnscoped+ | RouterCommandUnknown+ | RouterReadModelUnverified+ | PolicyContradiction+ | PolicyDeadLetterUnused+ | AmbiguousMarkedBenign+ | AmbiguousFollowsRejectedPolicy+ | RouterStableNameChanged+ | -- Plan 143 (first-class replay-only transitions for guard evolution).+ -- The first two fire in single-spec @validateSpec@; the third is the+ -- diff-path guard-tightening advisory that prints the computed+ -- replay-only twin.+ ReplayOnlyEmitsNothing+ | ReplayOnlyCommandStillLive+ | AggGuardTightened+ | AggFoldSurfaceChanged+ | RouterDecideSurfaceChanged+ | ProcessDecideSurfaceChanged+ | ProcessTimerPayloadChanged+ | -- MasterPlan 25 / EP-5: append-only codes for findings that were+ -- formerly additive but uncoded.+ DeclarationAdded+ | VersionBumped+ | CompatibilityStrengthened+ | EnumCtorAdded+ | EventRetirementAbandoned+ | ContractEventAdded+ | ContractTopicAdded+ | WorkflowEvolutionGuardAdded+ | -- MasterPlan 25 / EP-149 (consumer-owned mapped types).+ MappedUnresolvedName+ | MappedAmbiguousName+ | MappedDuplicateFieldName+ | MappedDuplicateWireKey+ | MappedDuplicateArmName+ | MappedDuplicateWireTag+ | MappedNonInjectiveNullability+ | MappedRecursiveType+ | MappedUnsupportedEncoding+ | MappedMissingIngredient+ | MappedMissingInitialValue+ | MappedInvalidHaskellName+ | MappedInvalidIdentity+ | MappedImportConflict+ | MappedDefaultIllTyped+ | MappedGuardUnsupported+ | -- MasterPlan 25 / EP-149 mapped evolution codes.+ MappedFieldAddedWithDefault+ | MappedFieldAddedNoDefault+ | MappedFieldRemoved+ | MappedFieldTypeChanged+ | MappedPresenceChanged+ | MappedNullabilityChanged+ | MappedDefaultRemoved+ | MappedDefaultChanged+ | MappedWireKeyChanged+ | MappedUnionEncodingChanged+ | MappedArmAdded+ | MappedArmRemoved+ | MappedArmTagChanged+ | MappedEnumValueAdded+ | MappedEnumValueRemoved+ | MappedEnumSpellingChanged+ | MappedHaskellSourceChanged+ | MappedRecordConstructorChanged+ | MappedBindingChanged+ | MappedFixturesChanged+ | MappedInitialChanged+ | MappedCanonicalTypeChanged+ | MappedOpaqueCodecChanged+ | MappedModeCrossed+ | MappedDeclAdded+ | MappedDeclRemoved+ | -- MasterPlan 25 / EP-152 reporting and migration-evidence codes.+ CoverageOpaqueSurface+ | CoverageOpaqueBoundaryAdded+ | CoverageOpaqueGateExceeded+ | 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+ | -- EP-157: canonical aggregate type resolution and capabilities.+ AggregateTypeUnknown+ | AggregateTypeUnsupportedAtUse+ | AggregateRegisterInitialInvalid+ | AggregateGuardTypeMismatch+ | AggregateGuardCapabilityUnsupported+ | AggregateExpressionRootUnknown+ | AggregateExpressionRootAmbiguous+ | AggregateExpressionPathInvalid+ | AggregateExpressionPathUnsupported+ | AggregateExpressionLiteralNeedsType+ | AggregateExpressionLiteralInvalid+ | AggregateExpressionOperandTypeMismatch+ | AggregateExpressionOperatorUnsupported+ | AggregateExpressionBooleanRequired+ | AggregateExpressionGuardBoolRequired+ | AggregateExpressionWriteTargetUnknown+ | AggregateExpressionWriteTypeMismatch+ | AggregateTransitionOwnershipConflict+ | CollectionExpressionUnsupported+ | -- EP-160: append-only source-language composition and diff facts.+ WorkspaceLanguageVersionMismatch+ | SourceLanguageDeclarationChanged+ | -- EP-158: checked consumer-owned nominal IDs, enums, and scalars.+ NominalMissingIngredient+ | NominalInvalidHaskellSource+ | NominalInvalidQualifiedName+ | NominalInvalidIdentity+ | NominalInvalidIdPrefix+ | NominalUnsupportedRepresentation+ | NominalEmptyEnumRepresentation+ | NominalMissingInitialValue+ | NominalNameCollision+ | NominalBindingChanged+ | NominalFixturesChanged+ | NominalCanonicalTypeChanged+ | NominalInitialChanged+ | NominalRepresentationChanged+ | NominalIdDecoderTightened+ deriving stock (Eq, Show)++-- | A line-numbered, structured diagnostic.+data Diagnostic = Diagnostic+ { line :: !Int,+ severity :: !Severity,+ code :: !DiagnosticCode,+ message :: !Text+ }+ deriving stock (Eq, Show)++-- | Render a diagnostic in the conventional+-- @\<file\>:\<line\>: error[\<code\>]: \<message\>@ form.+renderDiagnostic :: FilePath -> Diagnostic -> Text+renderDiagnostic file d =+ T.pack file+ <> ":"+ <> T.pack (show (line d))+ <> ": "+ <> sev+ <> "["+ <> T.pack (show (code d))+ <> "]: "+ <> message d+ where+ sev = case severity d of Error -> "error"; Warning -> "warning"++-- | Reserved wall-clock atom names. Sampling any of these inside a guard or+-- write breaks deterministic replay: TIME IS INJECTED, NOT SAMPLED.+clockAtoms :: Set Name+clockAtoms = Set.fromList ["now", "currentTime", "wallClock", "today", "utcNow"]++-- | Validate a whole spec. An empty list means valid. Diagnostics are sorted by+-- line for stable, readable output.+validateSpec :: Spec -> [Diagnostic]+validateSpec spec =+ sortOn line (validateNames spec ++ validateMapped spec ++ validateNominal spec ++ validateAggregateTypes spec ++ specLevelRules spec ++ concatMap (validateNode spec) (specNodes spec))++validateNominal :: Spec -> [Diagnostic]+validateNominal spec = case Nominal.resolveNominalTypes spec of+ Right _ -> []+ Left errors -> map nominalTypeDiagnostic (NE.toList errors)++nominalTypeDiagnostic :: Nominal.NominalTypeError -> Diagnostic+nominalTypeDiagnostic nominalError = case nominalError of+ Nominal.NominalMissingIngredient name loc ingredient ->+ problem loc NominalMissingIngredient $ "nominal declaration '" <> name <> "' is missing required " <> ingredient <> " provenance"+ Nominal.NominalInvalidHaskellSource name loc ingredient ->+ problem loc NominalInvalidHaskellSource $ "nominal declaration '" <> name <> "' has an invalid Haskell " <> ingredient <> " name"+ Nominal.NominalInvalidQualifiedValue name loc ingredient value ->+ problem loc NominalInvalidQualifiedName $+ "nominal declaration '" <> name <> "' has invalid " <> ingredient <> " symbol '" <> value <> "'; expected a module path plus a lower-initial value"+ Nominal.NominalInvalidIdentity name loc ingredient value ->+ problem loc NominalInvalidIdentity $ "nominal declaration '" <> name <> "' has invalid " <> ingredient <> " '" <> value <> "'"+ Nominal.NominalInvalidIdPrefix name loc prefix detail ->+ problem loc NominalInvalidIdPrefix $ "bound id '" <> name <> "' has invalid TypeID prefix '" <> prefix <> "': " <> detail+ Nominal.NominalUnsupportedScalar name loc representation ->+ problem loc NominalUnsupportedRepresentation $+ "nominal scalar '" <> name <> "' uses unsupported representation '" <> representation <> "'; supported representations are Text, Int, Natural, Bool, and Time"+ Nominal.NominalEmptyEnum name loc ->+ problem loc NominalEmptyEnumRepresentation $ "enum '" <> name <> "' must declare at least one closed representation constructor"+ Nominal.NominalMissingRegisterInitial name loc registerName ->+ problem loc NominalMissingInitialValue $+ "consumer-owned nominal type '" <> name <> "' is used by register '" <> registerName <> "' and must name an initial symbol"+ Nominal.NominalDeclarationCollision name loc categories ->+ problem loc NominalNameCollision $ "declaration name '" <> name <> "' collides across " <> T.intercalate ", " categories+ where+ problem loc diagnosticCode detail = mkErr (locLine loc) diagnosticCode (detail <> "; GHC and conformance validate consumer function bodies")++-- | Resolve every direct aggregate type once at the earliest semantic gate.+validateAggregateTypes :: Spec -> [Diagnostic]+validateAggregateTypes spec = case Nominal.resolveNominalTypes spec of+ Left _ -> []+ Right _ -> concatMap aggregateRules aggregates+ where+ symbols = aggregateSymbols spec+ aggregates = [aggregate | NAggregate aggregate <- specNodes spec]++ aggregateRules aggregate =+ concatMap commandRules (aggCommands aggregate)+ ++ concatMap eventRules (aggEvents aggregate)+ ++ concatMap registerRules (aggRegs aggregate)+ ++ concatMap (transitionRules aggregate) (aggTransitions aggregate)+ where+ commandRules command = concatMap (fieldRule aggregate CommandFieldUse) (cmdFields command)+ eventRules event = case evBody event of+ EventFields fields -> concatMap (fieldRule aggregate EventFieldUse) fields+ EventFromCommand _ -> []+ registerRules register = case resolveAggregateType symbols (regLoc register) RegisterUse (regType register) of+ Left typeError -> [aggregateTypeDiagnostic typeError]+ Right AggregateMapped {} -> []+ Right resolved -> case resolveRegisterInitial symbols (regLoc register) resolved (regInitial register) of+ Left initialError -> [aggregateTypeDiagnostic initialError]+ Right _ -> []++ fieldRule aggregate useSite field =+ either (pure . aggregateTypeDiagnostic) (const []) (inferAggregateFieldType symbols aggregate useSite field)++ transitionRules aggregate transition = case tImplementation transition of+ LegacyHoleImplementation ->+ concatMap (comparisonRule aggregate transition) (maybe [] comparisons (tGuard transition))+ GeneratedImplementation ->+ let environment = expressionEnvironment spec aggregate transition+ in maybe [] (expressionDiagnostics . resolveGuardExpr environment) (tGuard transition)+ ++ concatMap (expressionDiagnostics . uncurry (resolveWriteExpr environment)) (tWrites transition)+ HoleImplementation ->+ [ mkErr (locLine (tLoc transition)) AggregateTransitionOwnershipConflict $+ "transition '"+ <> tSource transition+ <> " -- "+ <> tCommand transition+ <> "' selects implementation hole and therefore cannot also declare guard or write clauses"+ | tGuard transition /= Nothing || not (null (tWrites transition))+ ]++ expressionDiagnostics = either (map expressionDiagnostic . NE.toList) (const [])++ expressionDiagnostic diagnostic =+ mkErr+ (locLine (expressionDiagnosticLoc diagnostic))+ (expressionCode (expressionDiagnosticCode diagnostic))+ (expressionDiagnosticMessage diagnostic)++ expressionCode = \case+ ScalarRootUnknown -> AggregateExpressionRootUnknown+ ScalarRootAmbiguous -> AggregateExpressionRootAmbiguous+ ScalarPathInvalid -> AggregateExpressionPathInvalid+ ScalarPathUnsupported -> AggregateExpressionPathUnsupported+ ScalarLiteralNeedsType -> AggregateExpressionLiteralNeedsType+ ScalarLiteralInvalid -> AggregateExpressionLiteralInvalid+ ScalarOperandTypeMismatch -> AggregateExpressionOperandTypeMismatch+ ScalarOperatorUnsupported -> AggregateExpressionOperatorUnsupported+ ScalarBooleanOperandRequired -> AggregateExpressionBooleanRequired+ ScalarGuardBoolRequired -> AggregateExpressionGuardBoolRequired+ ScalarWriteTargetUnknown -> AggregateExpressionWriteTargetUnknown+ ScalarWriteTypeMismatch -> AggregateExpressionWriteTypeMismatch++ comparisonRule aggregate transition (operator, left, right) =+ case (expressionType aggregate transition left, expressionType aggregate transition right) of+ (Right leftType, Right rightType)+ | leftType /= rightType ->+ [ mkErr (locLine (tLoc transition)) AggregateGuardTypeMismatch $+ "comparison operands have different aggregate types '"+ <> aggregateCanonicalName leftType+ <> "' and '"+ <> aggregateCanonicalName rightType+ <> "'"+ ]+ | aggregateCapability useSite leftType == Unsupported ->+ [ mkErr (locLine (tLoc transition)) AggregateGuardCapabilityUnsupported $+ renderAggregateUseSite useSite+ <> " is unsupported for aggregate type '"+ <> aggregateCanonicalName leftType+ <> "'"+ ]+ | otherwise -> []+ _ -> []+ where+ useSite = case operator of+ OpEq -> EqualityGuardUse+ OpNeq -> EqualityGuardUse+ OpLt -> OrderingGuardUse+ OpLe -> OrderingGuardUse+ OpGt -> OrderingGuardUse+ OpGe -> OrderingGuardUse++ expressionType aggregate transition expression = case expression of+ EAtom (ABool _) -> pure AggregateBool+ EAtom (AName name) -> atomType aggregate transition name+ EOr {} -> pure AggregateBool+ EAnd {} -> pure AggregateBool+ ECmp {} -> pure AggregateBool+ EAdd _ left _ -> expressionType aggregate transition left+ ESubtract _ left _ -> expressionType aggregate transition left+ EMultiply _ left _ -> expressionType aggregate transition left+ EPath loc _ path -> case path of+ name : _ -> atomType aggregate transition name+ [] -> Left (AggregateTypeError loc EqualityGuardUse (UnknownAggregateType "<empty-path>"))+ ELiteral _ literal -> case literal of+ LiteralBool {} -> pure AggregateBool+ LiteralText {} -> pure AggregateText+ LiteralIntegral {} -> Left (AggregateTypeError (exprLoc expression) EqualityGuardUse (UnknownAggregateType "<contextual-integral-literal>"))+ LiteralQualified typeName _ -> resolveAggregateType symbols (exprLoc expression) EqualityGuardUse (TRef typeName)+ LiteralId typeName _ -> resolveAggregateType symbols (exprLoc expression) EqualityGuardUse (TRef typeName)++ atomType aggregate transition name = case [register | register <- aggRegs aggregate, regName register == name] of+ register : _ -> resolveAggregateType symbols (regLoc register) RegisterUse (regType register)+ [] -> case [field | command <- aggCommands aggregate, cmdName command == tCommand transition, field <- cmdFields command, aggregateFieldName field == name] of+ field : _ -> inferAggregateFieldType symbols aggregate CommandFieldUse field+ [] -> case [enumName declaration | declaration <- specEnums spec, name `elem` map fst (enumCtors declaration)] of+ enumType : _ -> resolveAggregateType symbols (tLoc transition) CommandFieldUse (TRef enumType)+ []+ | name `elem` map stName (aggStates aggregate) -> pure (AggregateVertex (aggName aggregate <> "Vertex"))+ | Just rule <- firstMatching ((== name) . ruleName) (specRules spec) ->+ resolveAggregateType symbols (ruleLoc rule) EqualityGuardUse (nameTypeExpr (ruleCodomain rule))+ | otherwise -> Left (AggregateTypeError (tLoc transition) EqualityGuardUse (UnknownAggregateType name))++ nameTypeExpr name = case name of+ "Text" -> TText+ "Int" -> TInt+ "Bool" -> TBool+ "Natural" -> TNatural+ "Time" -> TTime+ "UTCTime" -> TTime+ "Json" -> TJson+ _ -> TRef name++ comparisons expression = case expression of+ EOr left right -> comparisons left <> comparisons right+ EAnd left right -> comparisons left <> comparisons right+ ECmp operator left right -> (operator, left, right) : comparisons left <> comparisons right+ EAdd _ left right -> comparisons left <> comparisons right+ ESubtract _ left right -> comparisons left <> comparisons right+ EMultiply _ left right -> comparisons left <> comparisons right+ EPath {} -> []+ ELiteral {} -> []+ EAtom {} -> []++aggregateTypeDiagnostic :: AggregateTypeError -> Diagnostic+aggregateTypeDiagnostic aggregateError =+ mkErr (locLine (aggregateTypeErrorLoc aggregateError)) diagnosticCode diagnosticMessage+ where+ diagnosticCode = case aggregateTypeErrorReason aggregateError of+ UnknownAggregateType {} -> AggregateTypeUnknown+ UnsupportedAggregateShape {} -> AggregateTypeUnsupportedAtUse+ UnsupportedAggregateCapability {} -> case aggregateTypeErrorUseSite aggregateError of+ EqualityGuardUse -> AggregateGuardCapabilityUnsupported+ OrderingGuardUse -> AggregateGuardCapabilityUnsupported+ _ -> AggregateTypeUnsupportedAtUse+ InvalidRegisterInitial {} -> AggregateRegisterInitialInvalid+ diagnosticMessage = case aggregateTypeErrorReason aggregateError of+ UnknownAggregateType name ->+ "unknown aggregate type '" <> name <> "' at " <> renderAggregateUseSite (aggregateTypeErrorUseSite aggregateError)+ UnsupportedAggregateShape expression ->+ "direct aggregate type '"+ <> typeExprCanonicalName expression+ <> "' is unsupported at "+ <> renderAggregateUseSite (aggregateTypeErrorUseSite aggregateError)+ <> "; use a mapped structural declaration for Json or container shapes"+ UnsupportedAggregateCapability resolved ->+ renderAggregateUseSite (aggregateTypeErrorUseSite aggregateError)+ <> " is unsupported for aggregate type '"+ <> aggregateCanonicalName resolved+ <> "'"+ InvalidRegisterInitial resolved detail ->+ "invalid " <> aggregateCanonicalName resolved <> " register initial: " <> detail++renderAggregateUseSite :: AggregateUseSite -> Text+renderAggregateUseSite useSite = case useSite of+ CommandFieldUse -> "command field"+ EventFieldUse -> "event field"+ RegisterUse -> "register"+ EqualityGuardUse -> "equality guard"+ OrderingGuardUse -> "ordering guard"+ WholeValueWriteUse -> "whole-value write"+ CodecUse -> "JSON codec"+ SnapshotUse -> "snapshot"+ HarnessSampleUse -> "harness sample"+ HaskellLoweringUse -> "Haskell lowering"++-- | Validate consumer-owned mapped declarations without inspecting consumer+-- Haskell. Symbol-shaped facts are checked lexically here; GHC remains the+-- authority for whether the named packages, modules, values, types, and+-- instances actually exist with the promised types.+validateMapped :: Spec -> [Diagnostic]+validateMapped spec =+ mappedLexicalRules spec+ ++ mappedIdentityRules spec+ ++ mappedConflictRules spec+ ++ case resolveTypeGraph spec of+ Left errors -> concatMap (typeGraphDiagnostic spec) (NE.toList errors)+ Right graph -> mappedGraphRules spec graph++typeGraphDiagnostic :: Spec -> TypeGraphError -> [Diagnostic]+typeGraphDiagnostic spec = \case+ TGDeclError name declarationError ->+ [ mkErr (mappedLine spec name) diagnosticCode $+ "mapped declaration '" <> name <> "': " <> declarationErrorMessage declarationError+ ]+ where+ diagnosticCode = case declarationError of+ MissingHaskellSource {} -> MappedMissingIngredient+ MissingStructuralBinding {} -> MappedMissingIngredient+ MissingStructuralBindingVersion {} -> MappedMissingIngredient+ MissingCanonicalType {} -> MappedMissingIngredient+ MissingFixtureCases {} -> MappedMissingIngredient+ MissingOpaqueCodecIdentity {} -> MappedMissingIngredient+ MissingOpaqueCodecVersion {} -> MappedMissingIngredient+ EmptyQualifiedValueName {} -> MappedInvalidHaskellName+ EmptyCanonicalTypeId {} -> MappedInvalidIdentity+ EmptyBindingVersion {} -> MappedInvalidIdentity+ EmptyCodecIdentity {} -> MappedInvalidIdentity+ EmptyCodecVersion {} -> MappedInvalidIdentity+ TGAmbiguousName name origins ->+ [ mkErr (mappedLine spec name) MappedAmbiguousName $+ "type name '" <> name <> "' is ambiguous across " <> T.intercalate ", " origins+ ]+ TGUnresolvedRef owner missing loc ->+ [ mkErr (locLine loc) MappedUnresolvedName $+ "mapped declaration '" <> owner <> "' references unresolved mapped type '" <> missing <> "'"+ ]+ TGRecursive names ->+ [ mkErr (mappedLine spec (headOr "<mapped>" names)) MappedRecursiveType $+ "recursive structural mapping is unsupported: " <> T.intercalate " -> " (names <> take 1 names)+ ]++declarationErrorMessage :: MappedDeclError -> Text+declarationErrorMessage = \case+ MissingHaskellSource _ -> "missing complete haskell package/module/type ingredient"+ MissingStructuralBinding _ -> "missing binding ingredient; GHC will verify the named value and its type"+ MissingStructuralBindingVersion _ -> "missing binding-version ingredient"+ MissingCanonicalType _ -> "missing canonical-type ingredient"+ MissingFixtureCases _ -> "missing fixtures ingredient; GHC will verify the named FixtureCases value"+ MissingOpaqueCodecIdentity _ -> "missing opaque codec identity ingredient"+ MissingOpaqueCodecVersion _ -> "missing opaque codec version ingredient"+ EmptyQualifiedValueName _ -> "a binding, fixture, or initial symbol is empty; GHC will verify a syntactically valid qualified value"+ EmptyCanonicalTypeId _ -> "canonical-type must be non-empty"+ EmptyBindingVersion _ -> "binding-version must be non-empty"+ EmptyCodecIdentity _ -> "opaque codec identity must be non-empty"+ EmptyCodecVersion _ -> "opaque codec version must be non-empty"++mappedLine :: Spec -> Name -> Int+mappedLine spec name =+ maybe 1 (locLine . mappedLoc) (firstMatching ((== name) . mappedName) (specMapped spec))++mappedName :: MappedDecl -> Name+mappedName MappedStructural {msName = name} = name+mappedName MappedOpaque {moName = name} = name++mappedLoc :: MappedDecl -> Loc+mappedLoc MappedStructural {msLoc = loc} = loc+mappedLoc MappedOpaque {moLoc = loc} = loc++mappedHaskell :: MappedDecl -> Maybe HaskellSource+mappedHaskell MappedStructural {msHaskell = source} = source+mappedHaskell MappedOpaque {moHaskell = source} = source++mappedCanonical :: MappedDecl -> Maybe Text+mappedCanonical MappedStructural {msCanonical = canonical} = canonical+mappedCanonical MappedOpaque {} = Nothing++mappedLexicalRules :: Spec -> [Diagnostic]+mappedLexicalRules spec = concatMap declarationRules (specMapped spec)+ where+ declarationRules declaration =+ constructorRule "mapped declaration name" (mappedName declaration) declaration+ ++ maybe [] (haskellRules declaration) (mappedHaskell declaration)+ ++ qualifiedFacts declaration+ ++ shapeConstructorRules declaration++ haskellRules declaration source =+ [ invalid declaration $ "Haskell package '" <> hsPackage source <> "' does not follow Cabal package-name grammar"+ | not (cabalPackageName (hsPackage source))+ ]+ ++ [ invalid declaration $ "Haskell module '" <> hsModule source <> "' must be dot-separated Upper identifiers"+ | not (moduleNameSafe (hsModule source))+ ]+ ++ [ invalid declaration $ "Haskell type '" <> hsType source <> "' must be an Upper identifier"+ | not (constructorSafe (hsType source))+ ]++ qualifiedFacts MappedStructural {msBinding = binding, msFixtures = fixtures, msInitial = initial, msLoc = loc} =+ concatMap (qualifiedRule loc) [("binding", binding), ("fixtures", fixtures), ("initial", initial)]+ qualifiedFacts MappedOpaque {moFixtures = fixtures, moInitial = initial, moLoc = loc} =+ concatMap (qualifiedRule loc) [("fixtures", fixtures), ("initial", initial)]++ qualifiedRule loc (category, value) = case value of+ Just symbol+ | not (T.null symbol) && not (qualifiedValueSafe symbol) ->+ [ mkErr (locLine loc) MappedInvalidHaskellName $+ category <> " symbol '" <> symbol <> "' must be a module path plus a lower-initial value; GHC will verify that it exists with the promised type"+ ]+ _ -> []++ shapeConstructorRules declaration = case declaration of+ MappedStructural {msShape = ShapeRecord constructor _ fields} ->+ constructorRule "record constructor" constructor declaration+ ++ [ invalidAt (wireFieldLoc field) $ "record selector '" <> wfHaskell field <> "' must be a lower-initial Haskell identifier"+ | field <- fields,+ not (lowerIdentifierSafe (wfHaskell field))+ ]+ MappedStructural {msShape = ShapeEnum entries} ->+ [ invalidAt (weLoc entry) $ "enum constructor '" <> weCtor entry <> "' must be an Upper identifier"+ | entry <- entries,+ not (constructorSafe (weCtor entry))+ ]+ MappedStructural {msShape = ShapeUnion _ arms} ->+ [ invalidAt (waLoc arm) $ "union constructor '" <> waCtor arm <> "' must be an Upper identifier"+ | arm <- arms,+ not (constructorSafe (waCtor arm))+ ]+ MappedOpaque {} -> []++ constructorRule category value declaration =+ [ invalid declaration $ category <> " '" <> value <> "' must be an Upper identifier"+ | not (constructorSafe value)+ ]+ invalid declaration detail = invalidAt (mappedLoc declaration) detail+ invalidAt loc detail =+ mkErr (locLine loc) MappedInvalidHaskellName (detail <> "; this is a syntax check only, and GHC will verify the consumer declaration")++mappedIdentityRules :: Spec -> [Diagnostic]+mappedIdentityRules spec =+ [ mkErr (locLine (mappedLoc declaration)) MappedInvalidIdentity $+ "mapped declaration '" <> mappedName declaration <> "' has an identity/version containing an ASCII control character"+ | declaration <- specMapped spec,+ value <- identityValues declaration,+ T.any asciiControl value+ ]+ where+ identityValues MappedStructural {msBindingVersion = bindingVersion, msCanonical = canonical} = present [bindingVersion, canonical]+ identityValues MappedOpaque {moCodecId = codecIdentity, moCodecVersion = codecVersion} = present [codecIdentity, codecVersion]+ present = foldr (maybe id (:)) []++mappedConflictRules :: Spec -> [Diagnostic]+mappedConflictRules spec = sourceCollisions ++ canonicalCollisions ++ packageCollisions+ where+ declarations = specMapped spec+ sourceFacts = [(declaration, source) | declaration <- declarations, source <- maybeToList (mappedHaskell declaration)]+ sourceCollisions =+ [ conflict declaration $+ "Haskell target '" <> hsModule source <> "." <> hsType source <> "' is claimed by more than one mapped declaration"+ | (declaration, source) <- duplicatesBy (\(_, value) -> (hsModule value, hsType value)) sourceFacts+ ]+ canonicalFacts = [(declaration, canonical) | declaration <- declarations, canonical <- maybeToList (mappedCanonical declaration), not (T.null canonical)]+ canonicalCollisions =+ [ conflict declaration $ "canonical-type '" <> canonical <> "' is claimed by more than one mapped declaration"+ | (declaration, canonical) <- duplicatesBy snd canonicalFacts+ ]+ moduleFacts = [(declaration, hsModule source, hsPackage source) | (declaration, source) <- sourceFacts]+ packageCollisions =+ [ conflict declaration $+ "Haskell module '" <> moduleName <> "' is declared from conflicting packages '" <> oldPackage <> "' and '" <> packageName <> "'"+ | (index, (declaration, moduleName, packageName)) <- zip [0 :: Int ..] moduleFacts,+ (_, oldModule, oldPackage) <- take index moduleFacts,+ oldModule == moduleName,+ oldPackage /= packageName+ ]+ conflict declaration detail = mkErr (locLine (mappedLoc declaration)) MappedImportConflict detail+ maybeToList = maybe [] pure++mappedGraphRules :: Spec -> TypeGraph -> [Diagnostic]+mappedGraphRules spec graph =+ concatMap declarationRules (Map.elems (tgDeclarations graph))+ ++ mappedRegisterInitialRules spec graph+ ++ if null (specMapped spec) then [] else mappedGuardRules spec graph+ where+ declarationRules =+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \declaration shape ->+ foldMappedShape (shapeRules declaration) shape,+ onOpaqueDecl = const []+ }++ shapeRules declaration =+ MappedShapeAlgebra+ { onRecord = \_ _ fields ->+ [ mappedError (rwfLoc field) MappedDuplicateFieldName declaration $+ "record selector '" <> rwfHaskell field <> "' is declared more than once"+ | field <- duplicatesBy rwfHaskell fields+ ]+ ++ [ mappedError (rwfLoc field) MappedDuplicateWireKey declaration $+ "record wire key '" <> rwfKey field <> "' is declared more than once"+ | field <- duplicatesBy rwfKey fields+ ]+ ++ [ mappedError (rwfLoc field) MappedUnsupportedEncoding declaration "record wire keys must be non-empty"+ | field <- fields,+ T.null (rwfKey field)+ ]+ ++ concatMap (fieldRules declaration) fields,+ onEnum = \entries ->+ [ mappedError (weLoc entry) MappedDuplicateArmName declaration $+ "enum constructor '" <> weCtor entry <> "' is declared more than once"+ | entry <- duplicatesBy weCtor entries+ ]+ ++ [ mappedError (weLoc entry) MappedDuplicateWireTag declaration $+ "enum wire spelling '" <> weTag entry <> "' is declared more than once"+ | entry <- duplicatesBy weTag entries+ ]+ ++ [ mappedError (weLoc entry) MappedUnsupportedEncoding declaration "enum wire spellings must be non-empty"+ | entry <- entries,+ T.null (weTag entry)+ ],+ onUnion = \encoding arms ->+ [ mappedError (sdLoc declaration) MappedUnsupportedEncoding declaration "tagged-object tag and contents keys must be distinct"+ | ueTagField encoding == ueContentsField encoding+ ]+ ++ [ mappedError (sdLoc declaration) MappedUnsupportedEncoding declaration "tagged-object tag and contents keys must be non-empty"+ | T.null (ueTagField encoding) || T.null (ueContentsField encoding)+ ]+ ++ [ mappedError (rwaLoc arm) MappedDuplicateArmName declaration $+ "union constructor '" <> rwaCtor arm <> "' is declared more than once"+ | arm <- duplicatesBy rwaCtor arms+ ]+ ++ [ mappedError (rwaLoc arm) MappedDuplicateWireTag declaration $+ "union wire tag '" <> rwaTag arm <> "' is declared more than once"+ | arm <- duplicatesBy rwaTag arms+ ]+ ++ [ mappedError (rwaLoc arm) MappedUnsupportedEncoding declaration "union wire tags must be non-empty"+ | arm <- arms,+ T.null (rwaTag arm)+ ]+ ++ concatMap (armRules declaration) arms+ }++ fieldRules declaration field =+ defaultRules declaration field+ ++ [ mappedError (rwfLoc field) MappedNonInjectiveNullability declaration $+ "field '" <> rwfHaskell field <> "' contains Optional around a null-capable Json, Optional, or opaque mapped value"+ | hasNonInjectiveOptional graph (rwfType field)+ ]++ armRules declaration arm =+ [ mappedError (rwaLoc arm) MappedNonInjectiveNullability declaration $+ "union arm '" <> rwaCtor arm <> "' contains Optional around a null-capable Json, Optional, or opaque mapped value"+ | payload <- maybeToList (rwaPayload arm),+ hasNonInjectiveOptional graph payload+ ]++ defaultRules declaration field = case (rwfPresence field, rwfOnMissing field) of+ (PRequired, Just _) -> [illTyped "required fields cannot declare on-missing"]+ (POptional, Nothing) ->+ [ mappedError (rwfLoc field) MappedMissingIngredient declaration $+ "optional field '" <> rwfHaskell field <> "' is missing its on-missing policy"+ ]+ (POptional, Just value)+ | not (defaultMatches graph (rwfType field) value) -> [illTyped "on-missing value does not match the field type or numeric bounds"]+ _ -> []+ where+ illTyped detail =+ mappedError (rwfLoc field) MappedDefaultIllTyped declaration $+ "field '" <> rwfHaskell field <> "': " <> detail++ mappedError loc diagnosticCode declaration detail =+ mkErr (locLine loc) diagnosticCode $+ "mapped declaration '" <> sdName declaration <> "' " <> detail+ maybeToList = maybe [] pure++data DefaultType+ = DefaultText+ | DefaultInt+ | DefaultBool+ | DefaultNatural+ | DefaultOptional+ | DefaultList+ | DefaultMap+ | DefaultEnum !(Set Name)+ | DefaultOther++defaultMatches :: TypeGraph -> ResolvedTypeExpr -> OnMissing -> Bool+defaultMatches graph expression value = case (defaultType graph expression, value) of+ (DefaultText, OmText _) -> True+ (DefaultInt, OmInt integer) -> integer >= toInteger (minBound :: Int) && integer <= toInteger (maxBound :: Int)+ (DefaultBool, OmBool _) -> True+ (DefaultNatural, OmInt integer) -> integer >= 0+ (DefaultOptional, OmNull) -> True+ (DefaultList, OmEmptyList) -> True+ (DefaultMap, OmEmptyMap) -> True+ (DefaultEnum constructors, OmCtor constructor) -> constructor `Set.member` constructors+ _ -> False++defaultType :: TypeGraph -> ResolvedTypeExpr -> DefaultType+defaultType graph =+ foldTypeExpr+ TypeExprAlgebra+ { onText = DefaultText,+ onInt = DefaultInt,+ onInteger = DefaultInt,+ onBool = DefaultBool,+ onNatural = DefaultNatural,+ onTime = DefaultOther,+ onJson = DefaultOther,+ onOptional = const DefaultOptional,+ onList = const DefaultList,+ onMap = const DefaultMap,+ onRef = referencedDefaultType graph+ }++referencedDefaultType :: TypeGraph -> MappedKey -> DefaultType+referencedDefaultType graph key = case Map.lookup key (tgDeclarations graph) of+ Nothing -> DefaultOther+ Just declaration ->+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ shape ->+ foldMappedShape+ MappedShapeAlgebra+ { onRecord = \_ _ _ -> DefaultOther,+ onEnum = DefaultEnum . Set.fromList . map weCtor,+ onUnion = \_ _ -> DefaultOther+ }+ shape,+ onOpaqueDecl = const DefaultOther+ }+ declaration++data NullabilityFacts = NullabilityFacts+ { nfTopNull :: !Bool,+ nfBadOptional :: !Bool+ }++hasNonInjectiveOptional :: TypeGraph -> ResolvedTypeExpr -> Bool+hasNonInjectiveOptional graph =+ nfBadOptional+ . foldTypeExpr+ TypeExprAlgebra+ { onText = nonNull,+ onInt = nonNull,+ onInteger = nonNull,+ onBool = nonNull,+ onNatural = nonNull,+ onTime = nonNull,+ onJson = nullable,+ onOptional = \child -> NullabilityFacts True (nfTopNull child || nfBadOptional child),+ onList = nestedNonNull,+ onMap = nestedNonNull,+ onRef = \key -> if mappedRefIsOpaque graph key then nullable else nonNull+ }+ where+ nonNull = NullabilityFacts False False+ nullable = NullabilityFacts True False+ nestedNonNull child = NullabilityFacts False (nfBadOptional child)++mappedRefIsOpaque :: TypeGraph -> MappedKey -> Bool+mappedRefIsOpaque graph key = case Map.lookup key (tgDeclarations graph) of+ Nothing -> False+ Just declaration ->+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \_ _ -> False,+ onOpaqueDecl = const True+ }+ declaration++mappedRegisterInitialRules :: Spec -> TypeGraph -> [Diagnostic]+mappedRegisterInitialRules spec graph =+ concatMap aggregateRules [aggregate | NAggregate aggregate <- specNodes spec]+ where+ aggregateRules aggregate = concatMap registerRule (aggRegs aggregate)+ registerRule register = case regType register of+ TRef typeName -> case Map.lookup (MappedKey typeName) (tgDeclarations graph) of+ Nothing -> []+ Just declaration -> case regInitial register of+ RegInitBare "initial"+ | mappedInitial declaration == Nothing ->+ [ mkErr (locLine (regLoc register)) MappedMissingInitialValue $+ "mapped register '" <> regName register <> "' requires declaration '" <> typeName <> "' to name an explicit initial value"+ ]+ | otherwise -> []+ _ ->+ [ mkErr (locLine (regLoc register)) RegisterInitialOutOfScope $+ "mapped register '" <> regName register <> "' must use the bare initial token; the declaration-owned symbol is verified by GHC"+ ]+ _ -> []+ mappedInitial =+ foldMappedDecl+ MappedDeclAlgebra+ { onStructuralDecl = \declaration _ -> sdInitial declaration,+ onOpaqueDecl = odInitial+ }++-- | Mapped values support whole-value writes and event copies, but guards may+-- only operate on Keiki's curated scalar set. Nested access has no spelling in+-- the grammar, so it is unrepresentable rather than silently accepted.+mappedGuardRules :: Spec -> TypeGraph -> [Diagnostic]+mappedGuardRules _spec _graph = []++cabalPackageName :: Text -> Bool+cabalPackageName packageName =+ not (null components) && all validComponent components+ where+ components = T.splitOn "-" packageName+ validComponent component =+ not (T.null component)+ && T.all asciiAlphaNum component+ && T.any asciiLetter component++moduleNameSafe :: Text -> Bool+moduleNameSafe moduleName =+ not (null components) && all constructorSafe components+ where+ components = T.splitOn "." moduleName++qualifiedValueSafe :: Text -> Bool+qualifiedValueSafe qualified = case reverse (T.splitOn "." qualified) of+ value : reversedModule ->+ not (null reversedModule)+ && lowerIdentifierSafe value+ && all constructorSafe reversedModule+ [] -> False++lowerIdentifierSafe :: Text -> Bool+lowerIdentifierSafe name = case T.uncons name of+ Just (first, rest) -> asciiLower first && T.all asciiAlphaNumOrUnderscore rest && name `Set.notMember` haskellKeywords+ Nothing -> False++asciiAlphaNum :: Char -> Bool+asciiAlphaNum c = asciiLetter c || (c >= '0' && c <= '9')++asciiLetter :: Char -> Bool+asciiLetter c = asciiUpper c || asciiLower c++asciiControl :: Char -> Bool+asciiControl c = ord c < 32 || ord c == 127++firstMatching :: (a -> Bool) -> [a] -> Maybe a+firstMatching predicate = \case+ [] -> Nothing+ value : rest+ | predicate value -> Just value+ | otherwise -> firstMatching predicate rest++headOr :: a -> [a] -> a+headOr fallback = \case+ [] -> fallback+ value : _ -> value++-- | Reject names that would make the scaffolder emit illegal Haskell. The+-- parser enforces the ASCII alphabet; this pass applies the category-specific+-- uppercase/lowercase and keyword rules that require AST context.+validateNames :: Spec -> [Diagnostic]+validateNames spec =+ concat+ [ concatMap idNames (specIds spec),+ concatMap enumNames (specEnums spec),+ concatMap nominalNames (specNominalScalars spec),+ concatMap nodeNames (specNodes spec)+ ]+ where+ idNames declaration =+ constructorName "id name" (idName declaration) (idLoc declaration)++ enumNames declaration =+ constructorName "enum name" (enumName declaration) (enumLoc declaration)+ ++ concatMap+ (\(ctor, _) -> constructorName ("constructor of enum '" <> enumName declaration <> "'") ctor (enumLoc declaration))+ (enumCtors declaration)++ nominalNames declaration =+ constructorName "nominal scalar name" (nominalScalarName declaration) (nominalScalarLoc declaration)++ nodeNames = \case+ NAggregate aggregate -> aggregateNames aggregate+ NProcess process -> processNames process+ NRouter router -> routerNames router+ NContract contract ->+ pascalizedNodeName "contract" (ctrName contract) (ctrLoc contract)+ ++ concatMap+ (\event -> constructorName "contract event name" (ceName event) (ctrLoc contract) ++ concatMap (contractFieldName contract) (ceFields event))+ (ctrEvents contract)+ NIntake intake -> pascalizedNodeName "intake" (inkName intake) (inkLoc intake)+ NEmit emitNode -> pascalizedNodeName "emit" (emName emitNode) (emLoc emitNode)+ NPublisher publisher -> pascalizedNodeName "publisher" (pubName publisher) (pubLoc publisher)+ NWorkqueue workqueue ->+ pascalizedNodeName "workqueue" (wqName workqueue) (wqLoc workqueue)+ ++ constructorName "workqueue payload name" (wqPayloadName workqueue) (wqLoc workqueue)+ ++ concatMap (\field -> fieldNameRule "workqueue payload field" (wqfName field) (wqLoc workqueue)) (wqPayload workqueue)+ NPgmqDispatch dispatch -> pascalizedNodeName "dispatch" (pdName dispatch) (pdLoc dispatch)+ NReadModel readModel -> pascalizedNodeName "readmodel" (rmName readModel) (rmLoc readModel)+ NWorkflow workflow -> constructorName "workflow name" (wfId workflow) (workflowNodeLoc workflow)+ NOperation _ -> []++ aggregateNames aggregate =+ constructorName "aggregate name" (aggName aggregate) (aggLoc aggregate)+ ++ concatMap+ (\register -> fieldNameRule "register name" (regName register) (regLoc register))+ (aggRegs aggregate)+ ++ concatMap commandNames (aggCommands aggregate)+ ++ concatMap eventNames (aggEvents aggregate)+ ++ maybe [] (\projection -> fieldNameRule "projection key" (projKey projection) (projLoc projection)) (aggProjection aggregate)+ ++ vertexCollisions aggregate+ where+ commandNames command =+ constructorName "command name" (cmdName command) (cmdLoc command)+ ++ concatMap (\field -> fieldNameRule "command field" (aggregateFieldName field) (aggregateFieldLoc field)) (cmdFields command)+ eventNames event =+ constructorName "event name" (evName event) (evLoc event)+ ++ case evBody event of+ EventFields fields -> concatMap (\field -> fieldNameRule "event field" (aggregateFieldName field) (aggregateFieldLoc field)) fields+ EventFromCommand _ -> []++ processNames process =+ constructorName "process name" (procId process) (procLoc process)+ ++ constructorName "process input name" (inName input) (procLoc process)+ ++ concatMap (\field -> fieldNameRule "process input field" (fieldName field) (procLoc process)) (inFields input)+ ++ concatMap (bindingName "advance field binding" (procLoc process)) (advFields (hAdvance handle))+ ++ concatMap dispatchBindings (hDispatch handle)+ ++ concatMap (bindingName "timer payload field binding" (tmLoc timer)) (tmPayload timer)+ ++ concatMap (bindingName "timer fire field binding" (tmLoc timer)) (fireFields (tmFire timer))+ where+ input = procInput process+ handle = procHandle process+ timer = procTimer process+ dispatchBindings dispatch = concatMap (bindingName "dispatch field binding" (dispLoc dispatch)) (dispFields dispatch)++ routerNames router =+ constructorName "router name" (rtId router) (rtLoc router)+ ++ constructorName "router input name" (inName input) (rtLoc router)+ ++ concatMap (\field -> fieldNameRule "router input field" (fieldName field) (rtLoc router)) (inFields input)+ ++ concatMap (\field -> fieldNameRule "router resolve-row field" field (rvLoc resolve)) (rvRow resolve)+ ++ concatMap (bindingName "router dispatch field binding" (rdLoc dispatch)) (rdFields dispatch)+ where+ input = rtInput router+ resolve = rtResolve router+ dispatch = rtDispatch router++ bindingName category anchor binding = fieldNameRule category (fbName binding) anchor+ contractFieldName contract field = fieldNameRule "contract field" (cfName field) (ctrLoc contract)++ constructorName category name anchor+ | constructorSafe name = []+ | otherwise =+ [ mkErr (locLine anchor) IdentNotConstructorSafe $+ category <> " '" <> name <> "' must be PascalCase: it becomes a Haskell constructor, type name, or module segment in scaffolded code"+ ]++ pascalizedNodeName category name anchor+ | "_" `T.isPrefixOf` name =+ [ mkErr (locLine anchor) IdentNotConstructorSafe $+ category <> " name '" <> name <> "' cannot begin with '_': title-casing leaves an invalid Haskell module segment"+ ]+ | otherwise = []++ fieldNameRule category name anchor+ | name `Set.member` haskellKeywords =+ [ mkErr (locLine anchor) IdentHaskellKeyword $+ category <> " '" <> name <> "' is a Haskell keyword and cannot become a record field in generated code"+ ]+ | fieldSafe name = []+ | otherwise =+ [ mkErr (locLine anchor) IdentNotConstructorSafe $+ category <> " '" <> name <> "' must begin with a lowercase ASCII letter or underscore to become a Haskell record field"+ ]++ vertexCollisions aggregate =+ [ mkErr (locLine (aggLoc aggregate)) VertexCtorCollision $+ "aggregate '"+ <> aggName aggregate+ <> "' state '"+ <> stName state+ <> "' generates vertex constructor '"+ <> vertex+ <> "', which collides with "+ <> declarationKind+ <> " '"+ <> vertex+ <> "' in the generated Domain constructor namespace"+ | state <- aggStates aggregate,+ let vertex = aggName aggregate <> stName state,+ declarationKind <- collisionKinds aggregate vertex+ ]++ collisionKinds aggregate vertex =+ ["event" | vertex `elem` map evName (aggEvents aggregate)]+ ++ ["command" | vertex `elem` map cmdName (aggCommands aggregate)]+ ++ ["enum constructor" | vertex `elem` [ctor | enum <- specEnums spec, (ctor, _) <- enumCtors enum]]++-- Haskell 2010 reserved identifiers plus commonly enabled extension keywords.+haskellKeywords :: Set Name+haskellKeywords =+ Set.fromList+ [ "case",+ "class",+ "data",+ "default",+ "deriving",+ "do",+ "else",+ "foreign",+ "if",+ "import",+ "in",+ "infix",+ "infixl",+ "infixr",+ "instance",+ "let",+ "module",+ "newtype",+ "of",+ "then",+ "type",+ "where",+ "mdo",+ "rec",+ "proc"+ ]++constructorSafe :: Name -> Bool+constructorSafe name = case T.uncons name of+ Just (first, rest) -> asciiUpper first && T.all asciiAlphaNumOrUnderscore rest+ Nothing -> False++fieldSafe :: Name -> Bool+fieldSafe name = case T.uncons name of+ Just (first, rest) -> (asciiLower first || first == '_') && T.all asciiAlphaNumOrUnderscore rest+ Nothing -> False++asciiUpper :: Char -> Bool+asciiUpper c = c >= 'A' && c <= 'Z'++asciiLower :: Char -> Bool+asciiLower c = c >= 'a' && c <= 'z'++asciiAlphaNumOrUnderscore :: Char -> Bool+asciiAlphaNumOrUnderscore c = asciiUpper c || asciiLower c || (c >= '0' && c <= '9') || c == '_'++-- | Rules over namespaces shared by the whole specification.+specLevelRules :: Spec -> [Diagnostic]+specLevelRules spec = duplicateNodes ++ duplicateEnumMembers ++ duplicateIdPrefixes ++ ruleDiagnostics+ where+ duplicateNodes =+ [ mkErr (locLine loc) DuplicateNodeName $+ "duplicate " <> kind <> " node name '" <> name <> "'"+ | node <- duplicatesBy nodeKey (specNodes spec),+ let (kind, name, loc) = nodeIdentity node+ ]+ nodeKey node = let (kind, name, _) = nodeIdentity node in (kind, name)+ duplicateEnumMembers = concatMap enumDuplicates (specEnums spec)+ enumDuplicates e =+ [ mkErr (locLine (enumLoc e)) DuplicateEnumCtor $+ "enum '" <> enumName e <> "' declares constructor '" <> ctor <> "' more than once"+ | (ctor, _) <- duplicatesBy fst (enumCtors e)+ ]+ ++ [ mkErr (locLine (enumLoc e)) DuplicateEnumWire $+ "enum '" <> enumName e <> "' declares wire spelling '" <> wire <> "' more than once"+ | (_, wire) <- duplicatesBy snd (enumCtors e)+ ]+ duplicateIdPrefixes =+ [ mkErr (locLine (idLoc d)) DuplicateIdPrefix $+ "id '" <> idName d <> "' reuses prefix '" <> idPrefix d <> "'"+ | d <- duplicatesBy idPrefix (specIds spec)+ ]+ ruleDiagnostics = concatMap (validateRule spec) (specRules spec)++nodeIdentity :: Node -> (Text, Name, Loc)+nodeIdentity (NAggregate a) = ("aggregate", aggName a, aggLoc a)+nodeIdentity (NProcess p) = ("process", procId p, procLoc p)+nodeIdentity (NRouter r) = ("router", rtId r, rtLoc r)+nodeIdentity (NContract c) = ("contract", ctrName c, ctrLoc c)+nodeIdentity (NIntake i) = ("intake", inkName i, inkLoc i)+nodeIdentity (NEmit e) = ("emit", emName e, emLoc e)+nodeIdentity (NPublisher p) = ("publisher", pubName p, pubLoc p)+nodeIdentity (NWorkqueue w) = ("workqueue", wqName w, wqLoc w)+nodeIdentity (NPgmqDispatch d) = ("dispatch", pdName d, pdLoc d)+nodeIdentity (NReadModel r) = ("readmodel", rmName r, rmLoc r)+nodeIdentity (NWorkflow w) = ("workflow", wfId w, workflowNodeLoc w)+nodeIdentity (NOperation o) = ("operation", opName o, opLoc o)++validateNode :: Spec -> Node -> [Diagnostic]+validateNode spec (NAggregate agg) = validateAggregate spec agg+validateNode spec (NProcess p) = validateProcess spec p+validateNode spec (NRouter router) = validateRouter spec router+validateNode _spec (NContract _) = [] -- a contract is a declaration; coupling is checked at the referrers+validateNode spec (NIntake i) = validateIntake i ++ intakeCoupling spec i+validateNode spec (NEmit e) = validateEmit spec e+validateNode spec (NPublisher p) = validatePublisher spec p+validateNode _spec (NWorkqueue w) = validateWorkqueue w+validateNode spec (NPgmqDispatch d) = validatePgmqDispatch spec d+validateNode spec (NReadModel readModel) = validateReadModel spec readModel+validateNode _spec (NWorkflow w) = validateWorkflow w+validateNode spec (NOperation o) = validateOperation spec o++-- | Workflow replay keys, patch guards, rotation, and injected inputs must be unambiguous.+validateWorkflow :: WorkflowNode -> [Diagnostic]+validateWorkflow w = duplicateLabels ++ sleepFields ++ patchDuplicates ++ patchIds ++ continuePositions ++ idField+ where+ inputFields = map fieldName (wfInputFields w)+ labelledItems = workflowLabelledItems (wfBody w)+ patchItems = workflowPatchItems (wfBody w)+ duplicateLabels =+ [ mkErr (locLine (wfBodyLoc item)) WorkflowDuplicateLabel $+ "workflow '" <> wfId w <> "' declares label '" <> label <> "' more than once; labels key deterministic replay, so a duplicate label replays the first occurrence's journaled result"+ | (label, item) <- duplicatesBy fst labelledItems+ ]+ sleepFields =+ [ mkErr (locLine loc) WorkflowSleepDelayUnresolved $+ "workflow '" <> wfId w <> "' sleep '" <> label <> "' references undeclared input field '" <> delay <> "'"+ | WfSleep label delay loc <- map snd labelledItems,+ delay `notElem` inputFields+ ]+ patchDuplicates =+ [ mkErr (locLine loc) WorkflowPatchDuplicate $+ "workflow '" <> wfId w <> "' declares patch id '" <> patchId <> "' more than once; patch decisions journal under one stable key"+ | (patchId, _, loc) <- duplicatesBy (\(patchId, _, _) -> patchId) patchItems+ ]+ patchIds =+ [ mkErr (locLine loc) WorkflowPatchIdInvalid $+ "workflow '" <> wfId w <> "' patch id '" <> patchId <> "' contains ':'; the runtime reserves that separator for the patch journal-key prefix"+ | (patchId, _, loc) <- patchItems,+ ":" `T.isInfixOf` patchId+ ]+ continuePositions =+ [ mkErr (locLine loc) WorkflowContinueAsNewNotTerminal $+ "workflow '" <> wfId w <> "' continueAsNew must be the last top-level body item and may not appear inside a patch"+ | (isTopLevelTerminal, loc) <- workflowContinueItems (wfBody w),+ not isTopLevelTerminal+ ]+ idField = case wfIdField w of+ Just field+ | field `notElem` inputFields ->+ [ mkErr (locLine (workflowNodeLoc w)) WorkflowIdFieldUnresolved $+ "workflow '" <> wfId w <> "' derives its id from undeclared input field '" <> field <> "'"+ ]+ _ -> []++wfBodyLoc :: WfBodyItem -> Loc+wfBodyLoc (WfStep _ _ loc) = loc+wfBodyLoc (WfAwait _ _ loc) = loc+wfBodyLoc (WfSleep _ _ loc) = loc+wfBodyLoc (WfChild _ _ _ loc) = loc+wfBodyLoc (WfPatch _ _ loc) = loc+wfBodyLoc (WfContinueAsNew _ loc) = loc++workflowLabelledItems :: [WfBodyItem] -> [(Name, WfBodyItem)]+workflowLabelledItems = concatMap go+ where+ go item@(WfStep label _ _) = [(label, item)]+ go item@(WfAwait label _ _) = [(label, item)]+ go item@(WfSleep label _ _) = [(label, item)]+ go item@(WfChild label _ _ _) = [(label, item)]+ go (WfPatch _ items _) = workflowLabelledItems items+ go WfContinueAsNew {} = []++workflowPatchItems :: [WfBodyItem] -> [(Name, [WfBodyItem], Loc)]+workflowPatchItems = concatMap go+ where+ go (WfPatch patchId items loc) = (patchId, items, loc) : workflowPatchItems items+ go _ = []++-- | Pair every rotation with whether it is the final top-level item.+workflowContinueItems :: [WfBodyItem] -> [(Bool, Loc)]+workflowContinueItems items = topLevel ++ nested+ where+ topLevel =+ [ (index == length items - 1, loc)+ | (index, WfContinueAsNew _ loc) <- zip [0 ..] items+ ]+ nested =+ [ (False, loc)+ | WfPatch _ patchBody _ <- items,+ (_, loc) <- workflowContinueItems patchBody+ ]++-- | A top-level rule is a total, clock-free function over one declared enum.+validateRule :: Spec -> RuleDecl -> [Diagnostic]+validateRule spec rule = case [e | e <- specEnums spec, enumName e == ruleDomain rule] of+ [] ->+ [ mkErr rl RuleDomainUnresolved $+ "rule '" <> ruleName rule <> "' has undeclared enum domain '" <> ruleDomain rule <> "'"+ ]+ (domain : _) -> totality domain ++ unknownCases domain ++ bodyDiagnostics+ where+ rl = locLine (ruleLoc rule)+ caseNames = map fst (ruleCases rule)+ allEnumCtors = Set.fromList [ctor | e <- specEnums spec, (ctor, _) <- enumCtors e]+ totality domain =+ let missing = [ctor | (ctor, _) <- enumCtors domain, ctor `notElem` caseNames]+ in [ mkErr rl RuleNotTotal $+ "rule '" <> ruleName rule <> "' is not total over enum '" <> enumName domain <> "'; missing cases {" <> T.intercalate ", " missing <> "}"+ | not (null missing)+ ]+ unknownCases domain =+ [ mkErr rl RuleCaseUnknownCtor $+ "rule '" <> ruleName rule <> "' has case '" <> ctor <> "' which is not a constructor of enum '" <> enumName domain <> "'"+ | (ctor, _) <- ruleCases rule,+ ctor `notElem` map fst (enumCtors domain)+ ]+ bodyDiagnostics = concatMap validateBody (ruleCases rule)+ validateBody (ctor, expr) =+ [ mkErr rl ClockSampled $+ "rule '" <> ruleName rule <> "' case '" <> ctor <> "' samples the wall clock via '" <> atom <> "'; rules must be deterministic"+ | atom <- dedup (exprNames expr),+ atom `Set.member` clockAtoms+ ]+ ++ [ mkErr rl GuardAtomOutOfScope $+ "atom '" <> atom <> "' in rule '" <> ruleName rule <> "' resolves to no enum constructor or boolean literal"+ | atom <- dedup (exprNames expr),+ atom `Set.notMember` clockAtoms,+ atom `Set.notMember` allEnumCtors+ ]++-- | Operation rules resolve command aggregates, stream fields, projections,+-- read models, workflow signal labels and value types, and run targets.+validateOperation :: Spec -> OperationNode -> [Diagnostic]+validateOperation spec o = case opShape o of+ CommandOp aggregate streamField _ projections ->+ aggregateRef aggregate streamField ++ projectionRefs projections+ QueryOp readModel _ _ consistency ->+ resolveReadModelRef QueryUnresolvedReadModel spec (opLoc o) ("query operation '" <> opName o <> "'") readModel+ ++ [ mkErr ol QueryConsistencyInvalid $+ "query operation '" <> opName o <> "' has unknown consistency '" <> consistency <> "'; expected Strong, Eventual, or PositionWait"+ | consistency `notElem` (["Strong", "Eventual", "PositionWait"] :: [Name])+ ]+ SignalOp lbl wf _ _ valueType ->+ case lookupWorkflow wf of+ Nothing ->+ [mkErr ol AwaitSignalMismatch ("signal operation '" <> opName o <> "' targets undeclared workflow '" <> wf <> "'")]+ Just w -> case [(resultType, loc) | (_, WfAwait label resultType loc) <- workflowLabelledItems (wfBody w), label == lbl] of+ [] ->+ [ mkErr ol AwaitSignalMismatch $+ "signal '" <> lbl <> "' of " <> wf <> " has no matching 'await' (workflow declares awaits {" <> T.intercalate ", " (awaitLabels w) <> "}); the deterministic awakeable id will not match and the workflow will wait forever"+ ]+ ((resultType, _) : _)+ | valueType == resultType -> []+ | otherwise ->+ [ mkErr ol AwaitSignalValueMismatch $+ "signal '" <> lbl <> "' of " <> wf <> " carries value type '" <> valueType <> "' but the await expects '" <> resultType <> "'"+ ]+ RunOp wf _ _ ->+ [ mkErr ol RunWorkflowUnresolved ("run operation '" <> opName o <> "' targets undeclared workflow '" <> wf <> "'")+ | wf `notElem` map wfId workflows+ ]+ where+ ol = locLine (opLoc o)+ workflows = [w | NWorkflow w <- specNodes spec]+ aggregates = [a | NAggregate a <- specNodes spec]+ projectionTables = [projTable p | a <- aggregates, Just p <- [aggProjection a]]+ lookupWorkflow n = case [w | w <- workflows, wfId w == n] of (w : _) -> Just w; [] -> Nothing+ awaitLabels w = [l | (_, WfAwait l _ _) <- workflowLabelledItems (wfBody w)]+ aggregateRef name streamField = case [a | a <- aggregates, aggName a == name] of+ [] ->+ [ mkErr ol OperationUnresolvedRef $+ "command operation '" <> opName o <> "' targets undeclared aggregate '" <> name <> "'"+ ]+ (aggregate : _) ->+ [ mkErr ol OperationUnresolvedRef $+ "command operation '" <> opName o <> "' stream field '" <> streamField <> "' is not declared by any command of aggregate '" <> name <> "'"+ | streamField `notElem` [aggregateFieldName field | command <- aggCommands aggregate, field <- cmdFields command]+ ]+ projectionRefs projections =+ [ mkErr ol OperationUnresolvedRef $+ "command operation '" <> opName o <> "' references undeclared projection table '" <> projection <> "'"+ | projection <- projections,+ projection `notElem` projectionTables+ ]++-- | Resolve a named read-model node using the caller's diagnostic code.+resolveReadModelRef :: DiagnosticCode -> Spec -> Loc -> Text -> Name -> [Diagnostic]+resolveReadModelRef diagnosticCode spec diagnosticLoc context name =+ [ mkErr (locLine diagnosticLoc) diagnosticCode $+ context <> " references undeclared readmodel '" <> name <> "'"+ | name `notElem` [rmName readModel | NReadModel readModel <- specNodes spec]+ ]++-- | Validate captured identity, feed semantics, and the declared column surface.+validateReadModel :: Spec -> ReadModelNode -> [Diagnostic]+validateReadModel spec readModel =+ shapeFixture ++ columnTypes ++ strongFeed ++ scopeMode ++ inlineReference+ where+ readModelLine = locLine (rmLoc readModel)+ expectedShape = deriveShapeHash readModel+ shapeFixture =+ [ mkErr readModelLine RmShapeHashDrift $+ "readmodel '"+ <> rmName readModel+ <> "': captured shape \""+ <> rmShape readModel+ <> "\" does not match the declared columns (expected \""+ <> expectedShape+ <> "\"); update the fixture AND bump version if the table shape really changed"+ | rmShape readModel /= expectedShape+ ]+ allowedColumnTypes = Set.fromList ["text", "int", "bigint", "bool", "timestamptz", "jsonb", "numeric"]+ columnTypes =+ [ mkErr readModelLine RmUnknownColumnType $+ "readmodel '" <> rmName readModel <> "' column '" <> rmcName columnDecl <> "' has unknown type '" <> rmcType columnDecl <> "'"+ | columnDecl <- rmColumns readModel,+ rmcType columnDecl `Set.notMember` allowedColumnTypes+ ]+ strongFeed =+ [ mkErr readModelLine RmStrongInlineOnly $+ "readmodel '"+ <> rmName readModel+ <> "': consistency = Strong with feed = inline; an inline-only model has no subscription worker to advance the cursor a Strong read waits on. Use consistency = Eventual, or feed = subscription"+ | rmFeed readModel == RmInline,+ rmConsistency readModel == Strong+ ]+ scopeMode =+ [ mkErr readModelLine RmScopeWithoutStrong $+ "readmodel '" <> rmName readModel <> "': scope is meaningful only with consistency = Strong"+ | rmScope readModel /= Nothing,+ rmConsistency readModel /= Strong+ ]+ inlineReference =+ [ mkErr readModelLine RmInlineFeedUnreferenced $+ "readmodel '" <> rmName readModel <> "' declares feed = inline but no aggregate projection references it"+ | rmFeed readModel == RmInline,+ rmName readModel `notElem` [projTable projection | NAggregate aggregate <- specNodes spec, Just projection <- [aggProjection aggregate]]+ ]++-- | EP-5 workqueue rules: the captured physical name must match the queueRef+-- derivation; the disposition inversions (storeFailure transient => must retry;+-- decodeFailure poison => must dead-letter); and dlq=on requires a retry ceiling.+validateWorkqueue :: WorkqueueNode -> [Diagnostic]+validateWorkqueue w = concat [divergence, completeness, duplicateRows, inversions, retryCeiling, orderingRules, groupKeyRules, provisionRules]+ where+ wl = locLine (wqLoc w)+ rows = wqDisposition w+ (derivedPhysical, derivedDlq, derivedTable) = derivedQueueTrio (wqLogical w)+ divergence =+ [ mkErr wl WqPhysicalDivergence $+ "workqueue '" <> wqName w <> "': captured physical \"" <> wqPhysical w <> "\" diverges from queueRef(\"" <> wqLogical w <> "\") = \"" <> derivedPhysical <> "\""+ | wqPhysical w /= derivedPhysical+ ]+ ++ [ mkErr wl WqDlqDivergence $+ "workqueue '" <> wqName w <> "': captured dlq \"" <> wqDlq w <> "\" diverges from queueRef = \"" <> derivedDlq <> "\""+ | wqDlq w /= derivedDlq+ ]+ ++ [ mkErr wl WqTableDivergence $+ "workqueue '" <> wqName w <> "': captured table \"" <> wqTable w <> "\" diverges from queueRef table = \"" <> derivedTable <> "\""+ | wqTable w /= derivedTable+ ]+ requiredOutcomes = ["storeFailure", "commandRejected", "decodeFailure", "onCodecReject"]+ completeness =+ [ mkErr wl WqDispositionIncomplete $+ "workqueue '" <> wqName w <> "' disposition table is missing outcome '" <> outcome <> "'"+ | outcome <- requiredOutcomes,+ outcome `notElem` map wqdOutcome rows+ ]+ duplicateRows =+ [ mkErr (locLine (wqdLoc row)) DispositionDuplicateOutcome $+ "workqueue '" <> wqName w <> "' repeats disposition outcome '" <> wqdOutcome row <> "'; the first row would shadow this row"+ | row <- duplicatesBy wqdOutcome rows+ ]+ firstRow outcome = case [row | row <- rows, wqdOutcome row == outcome] of+ (row : _) -> Just row+ [] -> Nothing+ isRetry row = case wqdAction row of IRetry _ -> True; _ -> False+ isDeadLetter row = case wqdAction row of IDeadLetter _ -> True; _ -> False+ inversions =+ [ mkErr (locLine (wqdLoc row)) WqStoreFailureNotRetry ("workqueue '" <> wqName w <> "': 'storeFailure' is transient and MUST retry, not dead-letter")+ | Just row <- [firstRow "storeFailure"],+ isDeadLetter row+ ]+ ++ [ mkErr (locLine (wqdLoc row)) WqDecodeFailureNotDeadLetter ("workqueue '" <> wqName w <> "': 'decodeFailure' is poison and MUST dead-letter, not retry")+ | Just row <- [firstRow "decodeFailure"],+ isRetry row+ ]+ retryCeiling =+ [ mkErr wl WqDlqWithoutCeiling ("workqueue '" <> wqName w <> "': dlq=on requires maxRetries >= 1 (an absent ceiling never dead-letters)")+ | wqDlqOn w && wqMaxRetries w < 1+ ]+ fifo = wqOrdering w /= WqUnordered+ orderingRules =+ [ mkErr wl WqGroupKeyMissing $+ "workqueue '" <> wqName w <> "': FIFO delivery is per group, so ordering requires a 'group key' clause that makes enqueueToGroup deterministic"+ | fifo && wqGroupKey w == Nothing+ ]+ ++ [ mkErr wl WqGroupKeyWithoutFifo $+ "workqueue '" <> wqName w <> "': a group key with unordered reads would be ignored; declare a FIFO ordering or remove the key"+ | not fifo && wqGroupKey w /= Nothing+ ]+ groupKeyRules = case wqGroupKey w of+ Nothing -> []+ Just groupKey ->+ case [field | field <- wqPayload w, wqfName field == gkField groupKey] of+ [] ->+ [ mkErr wl WqGroupKeyUnresolved $+ "workqueue '" <> wqName w <> "': group key field '" <> gkField groupKey <> "' is not declared in its payload"+ ]+ field : _ ->+ [ mkErr wl WqGroupKeyUnresolved $+ "workqueue '" <> wqName w <> "': group key via raw requires a text payload field, but '" <> gkField groupKey <> "' has type '" <> wqfType field <> "'"+ | gkVia groupKey == "raw" && wqfType field /= "text"+ ]+ ++ [ mkErr wl WqGroupKeyUnresolved $+ "workqueue '" <> wqName w <> "': opaque group-key derivation '" <> gkVia groupKey <> "' requires a captured fixture"+ | gkVia groupKey /= "raw" && gkFixture groupKey == Nothing+ ]+ provisionRules = case wqProvision w of+ WqStandard -> []+ WqUnlogged ->+ [ Diagnostic+ { line = wl,+ severity = Warning,+ code = WqUnloggedDurability,+ message = "workqueue '" <> wqName w <> "': provision unlogged is truncated to empty on a database crash; use it only for transient, regenerable work"+ }+ ]+ WqPartitioned interval retention ->+ [ mkErr wl WqPartitionSpecEmpty $+ "workqueue '" <> wqName w <> "': partition interval and retention must be non-empty; they are create-time settings and the additive reconciler will not migrate an existing queue"+ | T.null interval || T.null retention+ ]++-- | EP-5 dispatch rule: the @enqueue to@ target must resolve to a declared workqueue.+validatePgmqDispatch :: Spec -> PgmqDispatchNode -> [Diagnostic]+validatePgmqDispatch spec d = enqueueRef ++ dedupQueueRef ++ sourceReadModelRef ++ dedupReadModelRef ++ dedupReadModelField+ where+ dl = locLine (pdLoc d)+ workqueues = [w | NWorkqueue w <- specNodes spec]+ enqueueRef =+ [ mkErr dl DispatchEnqueueUnresolved ("dispatch '" <> pdName d <> "' enqueues to undeclared workqueue '" <> pdEnqueueTo d <> "'")+ | pdEnqueueTo d `notElem` map wqName workqueues+ ]+ dedupQueueRef = case [w | w <- workqueues, wqName w == pdDedupQueue d] of+ [] ->+ [ mkErr dl DispatchDedupQueueUnresolved $+ "dispatch '" <> pdName d <> "' checks an undeclared dedup queue '" <> pdDedupQueue d <> "'"+ ]+ (queue : _) ->+ [ mkErr dl DispatchDedupFieldUnresolved $+ "dispatch '" <> pdName d <> "' dedup field '" <> pdDedupQueueField d <> "' is not a payload wire field of queue '" <> pdDedupQueue d <> "'"+ | pdDedupQueueField d `notElem` map wqfWire (wqPayload queue)+ ]+ sourceReadModelRef =+ resolveReadModelRef DispatchReadModelUnresolved spec (pdLoc d) ("dispatch '" <> pdName d <> "' source") (pdSourceReadModel d)+ dedupReadModelRef =+ resolveReadModelRef DispatchReadModelUnresolved spec (pdLoc d) ("dispatch '" <> pdName d <> "' dedup") (pdDedupReadModel d)+ dedupReadModelField = case [readModel | NReadModel readModel <- specNodes spec, rmName readModel == pdDedupReadModel d] of+ [] -> []+ (readModel : _) ->+ [ mkErr dl DispatchReadModelFieldUnknown $+ "dispatch '" <> pdName d <> "' dedup field '" <> pdDedupReadModelField d <> "' is not a declared column of readmodel '" <> pdDedupReadModel d <> "'"+ | pdDedupReadModelField d `notElem` map rmcName (rmColumns readModel)+ ]++-- | The declared contracts in a spec, by name.+specContracts :: Spec -> [ContractNode]+specContracts spec = [c | NContract c <- specNodes spec]++-- | EP-4 cross-node coupling: an intake's contract/topic/accepted-events resolve.+intakeCoupling :: Spec -> IntakeNode -> [Diagnostic]+intakeCoupling spec i = case lookupContract (inkContract i) of+ Nothing ->+ [mkErr (locLine (inkLoc i)) IntakeUnresolvedContract ("intake '" <> inkName i <> "' references undeclared contract '" <> inkContract i <> "'")]+ Just c ->+ [ mkErr (locLine (inkLoc i)) IntakeUnresolvedContract ("intake '" <> inkName i <> "' topic '" <> inkTopic i <> "' is not a topic of contract '" <> inkContract i <> "'")+ | inkTopic i `notElem` map fst (ctrTopics c)+ ]+ ++ [ mkErr (locLine (inkLoc i)) IntakeUnresolvedContract ("intake '" <> inkName i <> "' accepts event '" <> ev <> "' not declared in contract '" <> inkContract i <> "'")+ | ev <- inkAccept i,+ ev `notElem` map ceName (ctrEvents c)+ ]+ ++ [ mkErr (locLine (inkLoc i)) TopicAffinityMismatch $+ "intake '" <> inkName i <> "' subscribes to topic '" <> inkTopic i <> "' but accepted event '" <> ceName event <> "' is declared on topic '" <> ceTopic event <> "'"+ | event <- ctrEvents c,+ ceName event `elem` inkAccept i,+ ceTopic event /= inkTopic i+ ]+ where+ lookupContract n = case [c | c <- specContracts spec, ctrName c == n] of (c : _) -> Just c; [] -> Nothing++validateEmit :: Spec -> EmitNode -> [Diagnostic]+validateEmit spec e = skipRule ++ coupling+ where+ el = locLine (emLoc e)+ skipRule =+ [ mkErr el EmitSkipMissing ("emit '" <> emName e <> "' map must end with an explicit '_ => skip' catch-all (hole-kind 7 optionality)")+ | not (emSkip e)+ ]+ coupling = case [c | c <- specContracts spec, ctrName c == emContract e] of+ [] -> [mkErr el EmitUnresolvedContract ("emit '" <> emName e <> "' references undeclared contract '" <> emContract e <> "'")]+ (c : _) ->+ [ mkErr el EmitUnresolvedContract ("emit '" <> emName e <> "' topic '" <> emTopic e <> "' is not a topic of contract '" <> emContract e <> "'")+ | emTopic e `notElem` map fst (ctrTopics c)+ ]+ ++ [ mkErr (locLine (emrLoc r)) EmitUnresolvedContract ("emit '" <> emName e <> "' maps to event '" <> emrEvent r <> "' not declared in contract '" <> emContract e <> "'")+ | r <- emMap e,+ emrEvent r `notElem` map ceName (ctrEvents c)+ ]+ ++ [ mkErr (locLine (emrLoc row)) TopicAffinityMismatch $+ "emit '" <> emName e <> "' publishes on topic '" <> emTopic e <> "' but mapped event '" <> emrEvent row <> "' is declared on topic '" <> ceTopic event <> "'"+ | row <- emMap e,+ event <- ctrEvents c,+ ceName event == emrEvent row,+ ceTopic event /= emTopic e+ ]++validatePublisher :: Spec -> PublisherNode -> [Diagnostic]+validatePublisher spec p =+ [ mkErr (locLine (pubLoc p)) PublisherUnresolvedEmit ("publisher '" <> pubName p <> "' references undeclared emit '" <> pubEmit p <> "'")+ | pubEmit p `notElem` [emName e | NEmit e <- specNodes spec]+ ]++-- | EP-4 inbox disposition rules: the table must be complete over the seven+-- outcomes, and the three dangerous inversions must be stated the safe way.+validateIntake :: IntakeNode -> [Diagnostic]+validateIntake i = concat [completeness, duplicateRows, inversions]+ where+ il = locLine (inkLoc i)+ rows = inkDisposition i+ requiredOutcomes =+ ["processed", "duplicate", "inProgress", "previouslyFailed", "decodeFailed", "dedupeFailed", "storeFailed"]+ completeness =+ [ mkErr il DispositionIncomplete $+ "intake '" <> inkName i <> "' disposition table is missing outcome '" <> o <> "'"+ | o <- requiredOutcomes,+ o `notElem` map drOutcome rows+ ]+ duplicateRows =+ [ mkErr (locLine (drLoc row)) DispositionDuplicateOutcome $+ "intake '" <> inkName i <> "' repeats disposition outcome '" <> drOutcome row <> "'; the first row would shadow this row"+ | row <- duplicatesBy drOutcome rows+ ]+ firstRow outcome = case [row | row <- rows, drOutcome row == outcome] of+ (row : _) -> Just row+ [] -> Nothing+ isRetry row = case drAction row of IRetry _ -> True; _ -> False+ inversions =+ [ mkErr (locLine (drLoc row)) DispositionDuplicateRetry $+ "intake '" <> inkName i <> "': a 'duplicate' redelivery must be ackOk (success), not retry"+ | Just row <- [firstRow "duplicate"],+ isRetry row+ ]+ ++ [ mkErr (locLine (drLoc row)) DispositionPreviouslyFailedRetry $+ "intake '" <> inkName i <> "': 'previouslyFailed' must dead-letter, not retry (a prior failure won't succeed on replay)"+ | Just row <- [firstRow "previouslyFailed"],+ isRetry row+ ]+ ++ [ mkErr (locLine (drLoc row)) DispositionDecodeUnboundedRetry $+ "intake '" <> inkName i <> "': 'decodeFailed' must dead-letter (terminal), not retry unboundedly"+ | Just row <- [firstRow "decodeFailed"],+ isRetry row+ ]++-- | EP-3 rules for a process manager + its nested timer.+validateProcess :: Spec -> ProcessNode -> [Diagnostic]+validateProcess spec p =+ concat [sagaCategoryRule, noWallClock, runtimeOwnedDispatchId, crossNodeCoupling, timerCeiling, policyRules, ambiguityRule, benignInversions]+ where+ aggregates = [a | NAggregate a <- specNodes spec]+ aggNames = map aggName aggregates+ projectionTables = [projTable projection | aggregate <- aggregates, Just projection <- [aggProjection aggregate]]+ inputFields = map fieldName (inFields (procInput p))+ timeFields = [fieldName f | f <- inFields (procInput p), fieldType f == Just "Time"]+ timer = procTimer p+ pl = locLine (procLoc p)++ sagaCategoryRule =+ [ mkErr pl SagaCategoryIllegal $+ "saga category " <> T.pack (show (sagaCategory (procSaga p))) <> " " <> reason+ | Just reason <- [sagaCategoryError (sagaCategory (procSaga p))]+ ]++ -- TIME IS INJECTED, NOT SAMPLED: fireAt's field must be a declared :Time+ -- input field. (FireAtExpr has no clock-sampling constructor, so this is a+ -- field-resolution + typed-as-Time check.)+ noWallClock =+ let f = faField (tmFireAt timer)+ in if f `notElem` inputFields+ then+ [ mkErr (locLine (tmLoc timer)) ProcessFireAtNotInjected $+ "timer '" <> tmName timer <> "' fireAt field '" <> f <> "' is not a field of input '" <> inName (procInput p) <> "'"+ ]+ else+ [ mkErr (locLine (tmLoc timer)) ProcessFireAtNotInjected $+ "timer '" <> tmName timer <> "' fireAt references '" <> f <> "', which is not a declared :Time field of input '" <> inName (procInput p) <> "'"+ | f `notElem` timeFields+ ]++ -- Dispatched (and fired) command ids are runtime-owned; no field binding may+ -- supply a commandId/id.+ runtimeOwnedDispatchId =+ [ mkErr pl ProcessDispatchIdSupplied $+ "advance command '" <> advCommand advance <> "' supplies a runtime-owned id field '" <> fbName binding <> "'; remove it"+ | let advance = hAdvance (procHandle p),+ binding <- advFields advance,+ fbName binding `elem` (["commandId", "id"] :: [Name])+ ]+ ++ [ mkErr (locLine (dispLoc d)) ProcessDispatchIdSupplied $+ "dispatch to '" <> dispTarget d <> "' supplies a runtime-owned id field '" <> fbName b <> "'; remove it"+ | d <- hDispatch (procHandle p),+ b <- dispFields d,+ fbName b `elem` (["commandId", "id"] :: [Name])+ ]+ ++ [ mkErr (locLine (tmLoc timer)) ProcessDispatchIdSupplied $+ "timer fire supplies a runtime-owned id field '" <> fbName b <> "'; remove it"+ | b <- fireFields (tmFire timer),+ fbName b `elem` (["commandId", "id"] :: [Name])+ ]++ -- Aggregate, command, field, timer, and projection references must resolve.+ crossNodeCoupling =+ [ mkErr pl ProcessUnresolvedRef ("saga '" <> sagaAgg (procSaga p) <> "' does not resolve to a declared aggregate")+ | sagaAgg (procSaga p) `notElem` aggNames+ ]+ ++ [ mkErr pl ProcessUnresolvedRef ("target '" <> procTarget p <> "' does not resolve to a declared aggregate")+ | procTarget p `notElem` aggNames+ ]+ ++ [ mkErr (locLine (tmLoc timer)) ProcessUnresolvedRef ("timer fire target '" <> fireTarget (tmFire timer) <> "' must be the saga or the target aggregate")+ | fireTarget (tmFire timer) `notElem` [sagaAgg (procSaga p), procTarget p]+ ]+ ++ resolveCommand pl "advance" (sagaAgg (procSaga p)) (advCommand advance) (advFields advance)+ ++ concatMap resolveDispatch (hDispatch (procHandle p))+ ++ resolveCommand (locLine (tmLoc timer)) "timer fire" (fireTarget fire) (fireCommand fire) (fireFields fire)+ ++ [ mkErr pl ProcessUnresolvedRef $+ "process '" <> procId p <> "' schedules undeclared timer '" <> hSchedule (procHandle p) <> "'; declared timer is '" <> tmName timer <> "'"+ | hSchedule (procHandle p) /= tmName timer+ ]+ ++ [ mkErr pl ProcessUnresolvedRef $+ "process '" <> procId p <> "' references undeclared projection table '" <> projection <> "'"+ | projection <- procProjections p,+ projection `notElem` projectionTables+ ]+ where+ advance = hAdvance (procHandle p)+ fire = tmFire timer+ resolveDispatch dispatch =+ resolveCommand+ (locLine (dispLoc dispatch))+ "dispatch"+ (dispTarget dispatch)+ (dispCommand dispatch)+ (dispFields dispatch)+ resolveCommand diagnosticLine context target command bindings = case lookupAggregate target of+ Nothing -> []+ Just aggregate -> case [decl | decl <- aggCommands aggregate, cmdName decl == command] of+ [] ->+ [ mkErr diagnosticLine ProcessUnresolvedRef $+ context <> " command '" <> command <> "' is not declared by aggregate '" <> target <> "'"+ ]+ (declaration : _) ->+ [ mkErr diagnosticLine ProcessFieldBindingUnresolved $+ context <> " command '" <> command <> "' binds undeclared target field '" <> fbName binding <> "'"+ | binding <- bindings,+ fbName binding `notElem` map aggregateFieldName (cmdFields declaration)+ ]+ lookupAggregate name = case [aggregate | aggregate <- aggregates, aggName aggregate == name] of+ (aggregate : _) -> Just aggregate+ [] -> Nothing++ timerCeiling =+ [ mkErr (locLine (tmLoc timer)) ProcessTimerCeilingInvalid $+ "timer '" <> tmName timer <> "' max-attempts must be at least 1"+ | tmMaxAttempts timer < 1+ ]++ policyRules =+ policyConsistency+ (procId p)+ (procLoc p)+ (procRejected p)+ [ (dispCommand dispatch, dispLoc dispatch, dispDisposition dispatch)+ | dispatch <- hDispatch (procHandle p)+ ]++ ambiguityRule =+ [ mkErr (locLine (tmLoc timer)) AmbiguousMarkedBenign $+ "timer '" <> tmName timer <> "' maps on-ambiguous => Fired; CommandAmbiguous means multiple aggregate edges matched and is never a benign success. Use on-ambiguous Retry so the attempts ceiling dead-letters the definition bug"+ | onAmbiguous (fireDisposition (tmFire timer)) == OFired+ ]++ -- Surface the dangerous benign inversions the author confirmed (warnings).+ benignInversions =+ [ Diagnostic (locLine (tmLoc timer)) Warning ProcessBenignInversion $+ "timer '" <> tmName timer <> "' maps on-reject => Fired (a CommandRejected is treated as benign success)"+ | onReject (fireDisposition (tmFire timer)) == OFired+ ]+ ++ [ Diagnostic (locLine (dispLoc d)) Warning ProcessBenignInversion $+ "dispatch to '" <> dispTarget d <> "' maps on-duplicate => AckOk (a duplicate is treated as benign success)"+ | d <- hDispatch (procHandle p),+ onDuplicate (dispDisposition d) == DAckOk+ ]++-- | Explain why a process saga category is illegal. The first four cases+-- mirror 'Keiro.Stream.category' without introducing a runtime dependency into+-- the toolchain library. The final @:@ case is deliberately stricter because+-- that prefix is reserved for the @wf:<name>@ workflow stream family.+sagaCategoryError :: Text -> Maybe Text+sagaCategoryError categoryName+ | T.null categoryName = Just "is empty; use a non-empty camelCase category"+ | categoryName == "$all" = Just "is reserved by the event store; choose a service-owned camelCase category"+ | T.isInfixOf "-" categoryName = Just "contains '-' (kiroku's category/id boundary); write compound categories in camelCase, for example \"hospitalSurge\""+ | Just illegal <- T.find (\character -> isSpace character || isControl character) categoryName =+ Just ("contains whitespace or control character " <> T.pack (show illegal) <> "; remove it and use camelCase")+ | T.isInfixOf ":" categoryName = Just "contains ':' which is reserved for the wf:<name> workflow stream family; choose a camelCase category without ':'"+ | otherwise = Nothing++-- | EP-108 rules for a stateless content-based router.+validateRouter :: Spec -> RouterNode -> [Diagnostic]+validateRouter spec router =+ concat+ [ references,+ keyField,+ bindingScope,+ commandReference,+ readModelReference,+ policyRules,+ duplicateNotice+ ]+ where+ aggregates = [aggregate | NAggregate aggregate <- specNodes spec]+ readModels = [readModel | NReadModel readModel <- specNodes spec]+ inputFields = map fieldName (inFields (rtInput router))+ resolvedFields = rvRow (rtResolve router)+ dispatch = rtDispatch router+ routerLine = locLine (rtLoc router)+ dispatchLine = locLine (rdLoc dispatch)++ targetAggregate = case [aggregate | aggregate <- aggregates, aggName aggregate == rtTarget router] of+ aggregate : _ -> Just aggregate+ [] -> Nothing++ projectionTables = [projTable projection | aggregate <- aggregates, Just projection <- [aggProjection aggregate]]++ references =+ [ mkErr routerLine RouterUnresolvedRef $+ "router '" <> rtId router <> "' targets aggregate '" <> rtTarget router <> "' but no such aggregate is declared"+ | targetAggregate == Nothing+ ]+ ++ [ mkErr routerLine RouterUnresolvedRef $+ "router '" <> rtId router <> "' references undeclared projection table '" <> projection <> "'"+ | projection <- rtProjections router,+ projection `notElem` projectionTables+ ]++ keyField =+ [ mkErr routerLine RouterKeyFieldUnknown $+ "key references 'input." <> corrField (rtKey router) <> "' but input '" <> inName (rtInput router) <> "' does not declare that field"+ | corrField (rtKey router) `notElem` inputFields+ ]++ bindingScope =+ [ mkErr dispatchLine RouterBindingUnscoped $+ "dispatch binding '" <> fbName binding <> maybe "" ("=" <>) (fbValue binding) <> "' is outside the router input and resolve-row scopes"+ | binding <- rdFields dispatch,+ not (bindingInScope binding)+ ]+ where+ bindingInScope binding = case fbValue binding of+ Nothing -> fbName binding `elem` inputFields+ Just value+ | isQuoted value -> True+ | Just field <- T.stripPrefix "input." value -> field `elem` inputFields+ | Just field <- T.stripPrefix "resolved." value -> field `elem` resolvedFields+ | otherwise -> False+ isQuoted value = T.length value >= 2 && T.head value == '"' && T.last value == '"'++ commandReference = case targetAggregate of+ Nothing -> []+ Just aggregate -> case [command | command <- aggCommands aggregate, cmdName command == rdCommand dispatch] of+ [] ->+ [ mkErr dispatchLine RouterCommandUnknown $+ "dispatch command '" <> rdCommand dispatch <> "' is not declared by aggregate '" <> aggName aggregate <> "'"+ ]+ command : _ ->+ [ mkErr dispatchLine RouterCommandUnknown $+ "dispatch command '" <> rdCommand dispatch <> "' binds undeclared target field '" <> fbName binding <> "'"+ | binding <- rdFields dispatch,+ fbName binding `notElem` map aggregateFieldName (cmdFields command)+ ]++ readModelReference = case rvSource (rtResolve router) of+ ResolveHole -> []+ ResolveReadModel name ->+ [ mkErr (locLine (rvLoc (rtResolve router))) RouterUnresolvedRef $+ "router '" <> rtId router <> "' resolve names readmodel '" <> name <> "' but no such readmodel node is declared"+ | name `notElem` map rmName readModels+ ]++ policyRules =+ policyConsistency+ (rtId router)+ (rtLoc router)+ (rtRejected router)+ [(rdCommand dispatch, rdLoc dispatch, rdDisposition dispatch)]++ duplicateNotice =+ [ Diagnostic dispatchLine Warning ProcessBenignInversion $+ "router dispatch '" <> rdCommand dispatch <> "' maps on-duplicate => AckOk; Keiro.Router confirms the event id against the target stream before treating the duplicate as benign"+ | onDuplicate (rdDisposition dispatch) == DAckOk+ ]++-- | Reconcile per-dispatch prose with the one node-level policy the runtime+-- actually applies to a rejection-class failure group.+policyConsistency :: Name -> Loc -> PolicyChoice -> [(Name, Loc, DispatchDisposition)] -> [Diagnostic]+policyConsistency nodeName nodeLoc rejectedPolicy dispatches = contradictions ++ divergent ++ unused ++ ambiguityWarning+ where+ contradictions =+ [ mkErr (locLine dispatchLoc) PolicyContradiction $+ "dispatch '" <> command <> "' declares on-failed DeadLetter, but node '" <> nodeName <> "' does not declare rejected => deadLetter; align the dispatch story with the node-level RejectedCommandPolicy"+ | (command, dispatchLoc, disposition) <- dispatches,+ DDeadLetter _ <- [onFailed disposition],+ rejectedPolicy /= PolDeadLetter+ ]++ divergent = case dispatches of+ [] -> []+ (_, _, firstDisposition) : rest ->+ [ mkErr (locLine dispatchLoc) PolicyContradiction $+ "dispatch '" <> command <> "' has a different on-failed action from another dispatch in node '" <> nodeName <> "'; the runtime applies one RejectedCommandPolicy to the whole failure group"+ | (command, dispatchLoc, disposition) <- rest,+ not (sameFailureAction (onFailed disposition) (onFailed firstDisposition))+ ]++ unused =+ [ Diagnostic (locLine nodeLoc) Warning PolicyDeadLetterUnused $+ "node '" <> nodeName <> "' declares rejected => deadLetter but no dispatch on-failed arm says DeadLetter; the runtime policy is live, but the per-dispatch notation does not acknowledge it"+ | rejectedPolicy == PolDeadLetter,+ all (not . isDeadLetter . onFailed . third) dispatches+ ]++ ambiguityWarning =+ [ Diagnostic (locLine nodeLoc) Warning AmbiguousFollowsRejectedPolicy $+ "node '" <> nodeName <> "' acknowledges rejection-class failures; CommandAmbiguous follows the same rejected policy, and a dead-letter errorClass is the durable witness of that definition bug"+ | rejectedPolicy `elem` [PolDeadLetter, PolSkip]+ ]++ third (_, _, value) = value+ sameFailureAction DDeadLetter {} DDeadLetter {} = True+ sameFailureAction left right = left == right+ isDeadLetter DDeadLetter {} = True+ isDeadLetter _ = False++validateAggregate :: Spec -> Aggregate -> [Diagnostic]+validateAggregate spec agg =+ concat+ [ duplicateMembers,+ declaredRefs,+ eventBodyRefs,+ registerInitialScope,+ reachability,+ terminalNoOutgoing,+ guardScope,+ clockFree,+ projectionSafety,+ statusMapTotality,+ evolutionRules,+ snapshotRules,+ replayOnlyRules+ ]+ where+ states = Set.fromList (map stName (aggStates agg))+ terminals = Set.fromList [stName s | s <- aggStates agg, stTerminal s]+ commandFields :: Map Name [Name]+ commandFields = Map.fromList [(cmdName c, map aggregateFieldName (cmdFields c)) | c <- aggCommands agg]+ commandNames = Map.keysSet commandFields+ eventNames = Set.fromList (map evName (aggEvents agg))+ enumCtorNames = Set.fromList [c | e <- specEnums spec, (c, _) <- enumCtors e]+ ruleNames = Set.fromList (map ruleName (specRules spec))+ registerNames = Set.fromList (map regName (aggRegs agg))++ snapshotRules = case aggSnapshot agg of+ Nothing -> []+ Just snapshot ->+ [ mkErr (locLine (snapLoc snapshot)) SnapshotIntervalInvalid $+ "aggregate '" <> aggName agg <> "': snapshot every requires an interval of at least 1; non-positive runtime intervals silently disable snapshots"+ | SnapEvery interval <- [snapPolicy snapshot],+ interval < 1+ ]+ ++ [ mkErr (locLine (snapLoc snapshot)) SnapshotCodecFixtureInvalid $+ "aggregate '" <> aggName agg <> "': snapshot state-codec version must be at least 1 and shape-hash must be non-empty"+ | snapCodecVersion snapshot < 1 || T.null (snapShapeHash snapshot)+ ]++ duplicateMembers =+ [ mkErr (locLine (cmdLoc c)) DuplicateCommandName $+ "aggregate '" <> aggName agg <> "' declares command '" <> cmdName c <> "' more than once"+ | c <- duplicatesBy cmdName (aggCommands agg)+ ]+ ++ [ mkErr (locLine (evLoc e)) DuplicateEventName $+ "aggregate '" <> aggName agg <> "' declares event '" <> evName e <> "' more than once"+ | e <- duplicatesBy evName (aggEvents agg)+ ]++ eventBodyRefs =+ [ mkErr (locLine (evLoc e)) UndeclaredCommand $+ "event '" <> evName e <> "' copies fields from undeclared command '" <> command <> "'"+ | e <- aggEvents agg,+ EventFromCommand command <- [evBody e],+ command `Set.notMember` commandNames+ ]++ registerInitialScope = concatMap checkRegisterInitial (aggRegs agg)+ checkRegisterInitial r = case [e | e <- specEnums spec, TRef (enumName e) == regType r] of+ (e : _) -> case enumBinding e of+ Just _ ->+ [ outOfScope r "declaration-owned symbol selected by" "initial"+ | regInitialBare r /= Just "initial"+ ]+ Nothing ->+ [ outOfScope r "constructor of enum" (enumName e)+ | regInitialBare r `notElem` map (Just . fst) (enumCtors e)+ ]+ []+ | regType r == TRef (aggName agg <> "Vertex") ->+ [ outOfScope r "state of aggregate" (aggName agg)+ | maybe True (`Set.notMember` states) (regInitialBare r)+ ]+ | Just identifier <- firstMatching (\declaration -> regType r == TRef (idName declaration)) (specIds spec) -> case idBinding identifier of+ Just _ ->+ [ outOfScope r "declaration-owned symbol selected by" "initial"+ | regInitialBare r /= Just "initial"+ ]+ Nothing ->+ [ outOfScope r "literal" "placeholder"+ | regInitialBare r /= Just "placeholder"+ ]+ | otherwise -> []+ outOfScope r expected domain =+ mkErr (locLine (regLoc r)) RegisterInitialOutOfScope $+ "register '" <> regName r <> "' initial '" <> renderRegInitial (regInitial r) <> "' is not a " <> expected <> " '" <> domain <> "'"+ regInitialBare r = case regInitial r of+ RegInitBare value -> Just value+ RegInitText _ -> Nothing+ renderRegInitial = \case+ RegInitBare value -> value+ RegInitText value -> value++ -- Rule 1: declared-reference for command / emit / goto / source.+ declaredRefs =+ concatMap transitionRefs (aggTransitions agg)+ transitionRefs t =+ [ mkErr (locLine (tLoc t)) UndeclaredCommand $+ "transition references undeclared command '" <> tCommand t <> "'"+ | not (tCommand t `Set.member` commandNames)+ ]+ ++ [ mkErr (locLine (tLoc t)) UndeclaredState $+ "transition source '" <> tSource t <> "' is not a declared state"+ | not (tSource t `Set.member` states)+ ]+ ++ [ mkErr (locLine (tLoc t)) UndeclaredState $+ "transition goto '" <> tGoto t <> "' is not a declared state"+ | not (tGoto t `Set.member` states)+ ]+ ++ [ mkErr (locLine (tLoc t)) UndeclaredEvent $+ "emit references undeclared event '" <> ev <> "'"+ | ev <- tEmits t,+ not (ev `Set.member` eventNames)+ ]++ -- Rule 2: reachability of every non-terminal state from the initial state+ -- (the first state in the list).+ reachability = case map stName (aggStates agg) of+ [] -> []+ (initial : _) ->+ let reached = bfs (Set.singleton initial) [initial]+ in [ mkErr (locLine (stLoc s)) UnreachableState $+ "state '" <> stName s <> "' is not reachable from the initial state '" <> initial <> "'"+ | s <- aggStates agg,+ not (stTerminal s),+ not (stName s `Set.member` reached)+ ]+ edgesFrom src = [tGoto t | t <- aggTransitions agg, tSource t == src]+ bfs seen [] = seen+ bfs seen (x : xs) =+ let nexts = [n | n <- edgesFrom x, not (n `Set.member` seen)]+ in bfs (foldr Set.insert seen nexts) (xs ++ nexts)++ -- Rule 3: a terminal state has no outgoing transition.+ terminalNoOutgoing =+ [ mkErr (locLine (tLoc t)) TerminalHasOutgoing $+ "terminal state '" <> tSource t <> "' has an outgoing transition"+ | t <- aggTransitions agg,+ tSource t `Set.member` terminals+ ]++ -- Rule 4: every atom in a guard or write Expr resolves to a register, a+ -- field of the transition's command, an enum constructor, a rule, or a bool.+ guardScope = concatMap transitionScope (aggTransitions agg)+ transitionScope t =+ let inScope =+ registerNames+ `Set.union` Set.fromList (Map.findWithDefault [] (tCommand t) commandFields)+ `Set.union` enumCtorNames+ `Set.union` ruleNames+ -- State names are constructors of the implicit vertex enum, so a+ -- @write reservationState := Held@ references a state legitimately.+ `Set.union` states+ exprs = maybe [] pure (tGuard t) ++ map snd (tWrites t)+ badAtoms =+ [ n+ | e <- exprs,+ n <- exprNames e,+ not (n `Set.member` clockAtoms), -- clock atoms reported separately+ not (n `Set.member` inScope)+ ]+ badTargets = [target | (target, _) <- tWrites t, target `Set.notMember` registerNames]+ in [ mkErr (locLine (tLoc t)) WriteTargetNotRegister $+ "write target '" <> target <> "' is not a register of aggregate '" <> aggName agg <> "'"+ | target <- dedup badTargets+ ]+ ++ [ mkErr (locLine (tLoc t)) GuardAtomOutOfScope $+ "atom '" <> n <> "' in transition '" <> tSource t <> " -- " <> tCommand t <> "' resolves to no register, command field, enum constructor, or rule"+ | n <- dedup badAtoms+ ]++ -- Rule 5 (cross-cutting): no guard or write Expr samples a wall clock.+ clockFree = concatMap transitionClock (aggTransitions agg)+ transitionClock t =+ let exprs = maybe [] pure (tGuard t) ++ map snd (tWrites t)+ sampled = [n | e <- exprs, n <- exprNames e, n `Set.member` clockAtoms]+ in [ mkErr (locLine (tLoc t)) ClockSampled $+ "transition '" <> tSource t <> " -- " <> tCommand t <> "' samples the wall clock via '" <> n <> "'; time must be an injected input field, not sampled"+ | n <- dedup sampled+ ]++ -- EP-107: a projection references a first-class read model when one exists.+ -- Legacy standalone projections remain legal, but are surfaced as warnings.+ projectionSafety = case aggProjection agg of+ Nothing -> []+ Just projection -> case [readModel | NReadModel readModel <- specNodes spec, rmName readModel == projTable projection] of+ [] ->+ [ mkErr (locLine (projLoc projection)) RmStrongInlineOnly $+ "projection '" <> projTable projection <> "' declares consistency = Strong but has no readmodel node; a standalone projection is inline-only and has no subscription cursor"+ | projConsistency projection == Just Strong+ ]+ ++ [ Diagnostic+ { line = locLine (projLoc projection),+ severity = Warning,+ code = RmProjectionWithoutNode,+ message = "projection '" <> projTable projection <> "' has no readmodel node; registration, schema identity, consistency, and rebuild helpers are unavailable"+ }+ ]+ (readModel : _) ->+ [ mkErr (locLine (projLoc projection)) RmConsistencyConflict $+ "projection '" <> projTable projection <> "' declares consistency " <> T.pack (show projectionConsistency) <> " but its readmodel node declares " <> T.pack (show (rmConsistency readModel))+ | Just projectionConsistency <- [projConsistency projection],+ projectionConsistency /= rmConsistency readModel+ ]++ -- Rule 6 (hole-kind 3, mapping): keys are exact event names, never suffixes;+ -- duplicates and dangling keys are errors, and non-partial maps are total.+ statusMapTotality = case aggProjection agg of+ Nothing -> []+ Just p ->+ let evs = map evName (aggEvents agg)+ pairs = maybe [] mapPairs (projStatusMap p)+ keys = map fst pairs+ partial = maybe False mapPartial (projStatusMap p)+ uncovered = [event | event <- evs, event `notElem` keys]+ dangling = [key | key <- keys, key `notElem` evs]+ duplicateKeys = map fst (duplicatesBy fst pairs)+ in [ mkErr (locLine (projLoc p)) StatusMapDanglingKey $+ "projection '" <> projTable p <> "' status-map key '" <> key <> "' is not an event name of aggregate '" <> aggName agg <> "'"+ | key <- dangling+ ]+ ++ [ mkErr (locLine (projLoc p)) StatusMapDuplicateKey $+ "projection '" <> projTable p <> "' repeats status-map key '" <> key <> "'"+ | key <- duplicateKeys+ ]+ ++ [ mkErr (locLine (projLoc p)) StatusMapNotTotal $+ "projection '" <> projTable p <> "' status-map is not total over events {" <> T.intercalate ", " uncovered <> "}"+ | not partial,+ not (null evs),+ not (null uncovered)+ ]++ -- EP-2 evolution rules (single-spec; the diff path adds the cross-spec ones).+ evolutionRules =+ versionUpcasterRule+ ++ duplicateUpcasterSourceRule+ ++ upcasterChainGapRule+ ++ deprecatedEmitRule+ ++ eventRetirementRules+ ++ wireVersionRule+ -- Only live transitions are the write path: a replay-only transition can+ -- never fire forward, so its emits exist purely to invert stored events —+ -- which is exactly where a deprecated event is allowed to remain+ -- (plan 143; supersedes the guarded-but-inert retained-edge pattern).+ liveEmittedNames = Set.fromList (concatMap tEmits [t | t <- aggTransitions agg, tMode t == TmLive])+ replayEmittedNames = Set.fromList (concatMap tEmits [t | t <- aggTransitions agg, tMode t == TmReplayOnly])+ maxEventVersion = maximum (1 : map evVersion (aggEvents agg))+ upcasterSources =+ Set.fromList+ [ source+ | event <- aggEvents agg,+ Just (source, _) <- [evUpcastFrom event]+ ]++ -- A non-initial event version must carry a contiguous upcaster (from v-1).+ versionUpcasterRule =+ [ mkErr (locLine (evLoc e)) EvtVersionMissingUpcaster $+ "event '" <> evName e <> "' version " <> tInt (evVersion e) <> " has no 'upcast from v" <> tInt (evVersion e - 1) <> "' clause"+ | e <- aggEvents agg,+ evVersion e > 1,+ maybe True ((/= evVersion e - 1) . fst) (evUpcastFrom e)+ ]++ -- A generated rung dispatches by event type, so different events may+ -- deliberately share a source version when they changed in one release.+ -- Duplicate declarations for one event cannot survive the parser's unique+ -- event-name rule, so no additional duplicate-source diagnostic is needed.+ duplicateUpcasterSourceRule =+ []++ -- Aggregate schema stamps are global, so every source version below the+ -- current maximum needs a permanent rung regardless of which event owns it.+ upcasterChainGapRule =+ [ mkErr (locLine (aggLoc agg)) UpcasterChainGap $+ "no event declares 'upcast from v"+ <> tInt missing+ <> "'; stored payloads stamped v"+ <> tInt missing+ <> " can never reach v"+ <> tInt maxEventVersion+ <> " (GapInUpcasterChain at hydration). A rung, once shipped, must exist forever — restore the upcaster for v"+ <> tInt missing+ <> " (re-declare it on the event whose shape changed at v"+ <> tInt (missing + 1)+ <> ")"+ | missing <- [1 .. maxEventVersion - 1],+ missing `Set.notMember` upcasterSources+ ]++ -- A deprecated event must have left the write path.+ deprecatedEmitRule =+ [ mkErr (locLine (evLoc e)) DeprecatedEventStillEmitted $+ "deprecated event '" <> evName e <> "' is still emitted by a transition"+ | e <- aggEvents agg,+ evDeprecated e,+ evName e `Set.member` liveEmittedNames+ ]++ -- Retirement is a two-stage protocol. The pre-cutover marker keeps a live+ -- emitter. The deprecated stage removes that live emitter but retains a+ -- replay-only emitter until old payloads no longer need hydration.+ eventRetirementRules = concatMap eventRetirementRule (aggEvents agg)+ eventRetirementRule event+ | evRetiring event =+ [ mkErr (locLine (evLoc event)) EventRetirementInProgress $+ "retiring event '" <> evName event <> "' has no live emitting transition; keep it emitting while streams are terminalized or truncated, or cut over to 'deprecated event' with a replay-only emitting transition"+ | evName event `Set.notMember` liveEmittedNames+ ]+ ++ [ Diagnostic+ { line = locLine (evLoc event),+ severity = Warning,+ code = EventRetirementInProgress,+ message =+ "event '" <> evName event <> "' is retiring: it stays fully live and replayable. Keep its live emitting transition until every affected stream is terminal or truncated; then flip it to 'deprecated event' and retain an equivalent replay-only emitting transition for as long as old payloads may be hydrated"+ }+ | evName event `Set.member` liveEmittedNames+ ]+ | evDeprecated event =+ [ Diagnostic+ { line = locLine (evLoc event),+ severity = Warning,+ code = DeprecatedEventReplayHazard,+ message =+ "deprecated event '" <> evName event <> "' stays decodable but is not replayable: no replay-only transition emits it, so hydration of a live stream containing it fails with HydrationNoInvertingEdge. Restore an equivalent replay-only emitting transition, or terminalize/truncate every affected stream before deployment"+ }+ | any (not . stTerminal) (aggStates agg),+ evName event `Set.notMember` replayEmittedNames+ ]+ ++ [ Diagnostic+ { line = locLine (evLoc event),+ severity = Warning,+ code = EventRetirementInProgress,+ message =+ "deprecated event '" <> evName event <> "' is off the live write path and remains replayable through a replay-only transition; retain that transition until every stream containing the event is terminal, truncated, or passes the replay audit"+ }+ | evName event `Set.member` replayEmittedNames+ ]+ | otherwise = []++ -- The explicit `wire schemaVersion=` (if any) must equal the max event version.+ wireVersionRule = case aggWire agg of+ Just w+ | wireSchemaVersion w /= maxEventVersion ->+ [ Diagnostic+ { line = locLine (aggLoc agg),+ severity = Warning,+ code = WireSchemaVersionMismatch,+ message =+ "wire schemaVersion=" <> tInt (wireSchemaVersion w) <> " does not match the maximum event version " <> tInt maxEventVersion+ }+ ]+ _ -> []++ -- Plan 143: replay-only transition discipline. A replay-only transition+ -- exists to invert stored events, so one that emits nothing is dead+ -- weight (error); one whose (source, command) pair has no live sibling+ -- means the command is fully retired at that state — legitimate, but the+ -- fuller procedure is event retirement (docs/plans/139), so warn.+ replayOnlyRules = concatMap replayOnlyRule (aggTransitions agg)+ replayOnlyRule t+ | tMode t /= TmReplayOnly = []+ | otherwise =+ [ mkErr (locLine (tLoc t)) ReplayOnlyEmitsNothing $+ "replay-only transition '" <> tSource t <> " -- " <> tCommand t <> "' emits no event; a replay-only transition exists to invert stored events and is dead weight without an emit"+ | null (tEmits t)+ ]+ ++ [ Diagnostic+ { line = locLine (tLoc t),+ severity = Warning,+ code = ReplayOnlyCommandStillLive,+ message =+ "replay-only transition '" <> tSource t <> " -- " <> tCommand t <> "' has no live sibling; command '" <> tCommand t <> "' is fully retired at state '" <> tSource t <> "' — if the intent is to retire its events too, follow the event-retirement procedure (docs/plans/139)"+ }+ | not (any (\sibling -> tMode sibling == TmLive && tSource sibling == tSource t && tCommand sibling == tCommand t) (aggTransitions agg))+ ]++-- | The validator's re-derivation of the live+-- 'Keiro.PGMQ.Runtime.queueRef' trio: physical queue, dead-letter queue, and+-- PGMQ backing table. Parity is pinned by the queue-runtime conformance suite.+derivedQueueTrio :: Text -> (Text, Text, Text)+derivedQueueTrio logical = (physical, physical <> "_dlq", "pgmq.q_" <> physical)+ where+ physical = physicalBase logical++physicalBase :: Text -> Text+physicalBase logical+ | T.length base <= 43 && not ("_dlq" `T.isSuffixOf` base) = base+ | otherwise = hashedBase logical base+ where+ base = sanitizeQueueName logical++sanitizeQueueName :: Text -> Text+sanitizeQueueName =+ ensureLeadingLetter+ . T.intercalate "_"+ . filter (not . T.null)+ . T.splitOn "_"+ . T.map toLegal+ . T.toLower+ where+ toLegal c+ | (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' = c+ | otherwise = '_'+ ensureLeadingLetter value = case T.uncons value of+ Nothing -> "q"+ Just (c, _)+ | c >= 'a' && c <= 'z' -> value+ | otherwise -> T.cons 'q' value++hashedBase :: Text -> Text -> Text+hashedBase logical base = prefix <> "_" <> fnv1a64Hex logical+ where+ trimmedPrefix = T.dropWhileEnd (== '_') (T.take 26 base)+ prefix+ | T.null trimmedPrefix = "q"+ | otherwise = trimmedPrefix++fnv1a64Hex :: Text -> Text+fnv1a64Hex logical = T.pack (replicate (16 - length rendered) '0' <> rendered)+ where+ rendered = showHex (T.foldl' step offset logical) ""+ offset :: Word64+ offset = 0xcbf29ce484222325+ prime :: Word64+ prime = 0x100000001b3+ step hash character = (hash `xor` fromIntegral (ord character)) * prime++tInt :: Int -> Text+tInt = T.pack . show++mkErr :: Int -> DiagnosticCode -> Text -> Diagnostic+mkErr l c m = Diagnostic {line = l, severity = Error, code = c, message = m}++locLine :: Loc -> Int+locLine = unLoc++-- | The 'AName' atom names occurring anywhere in an expression.+exprNames :: Expr -> [Name]+exprNames (EOr a b) = exprNames a ++ exprNames b+exprNames (EAnd a b) = exprNames a ++ exprNames b+exprNames (ECmp _ a b) = exprNames a ++ exprNames b+exprNames (EAdd _ a b) = exprNames a ++ exprNames b+exprNames (ESubtract _ a b) = exprNames a ++ exprNames b+exprNames (EMultiply _ a b) = exprNames a ++ exprNames b+exprNames (EPath _ _ (name : _)) = [name]+exprNames (EPath _ _ []) = []+exprNames ELiteral {} = []+exprNames (EAtom (AName n)) = [n]+exprNames (EAtom (ABool _)) = []++dedup :: (Ord a) => [a] -> [a]+dedup = Set.toList . Set.fromList++-- | Keep each occurrence after the first for a chosen key. Diagnostics are+-- anchored on the shadowing declaration rather than the declaration it shadows.+duplicatesBy :: (Eq key) => (a -> key) -> [a] -> [a]+duplicatesBy key xs =+ [ x+ | (index, x) <- zip [0 :: Int ..] xs,+ key x `elem` map key (take index xs)+ ]
src/Keiro/Dsl/Workspace.hs view
@@ -1,1300 +1,1430 @@ {-# 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+-- | 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,+ oneMemberParsedWorkspace,+ 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.LanguageVersion (ParsedSource (..), SourceLanguage (..), SourceLanguageDiagnostic, effectiveLanguageVersion, languageVersionText)+import Keiro.Dsl.Parser (ParseError, ParseFailure (..), parseSource, renderParseFailure)+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+ { -- | The stable workspace identity, e.g. @demo-project@.+ wmfService :: !Text,+ wmfServiceLoc :: !Loc,+ -- | The optional @module@ clause: the workspace's module-root authority.+ wmfModuleRoot :: !(Maybe Text),+ -- | Meaningful only when 'wmfModuleRoot' is 'Just'.+ wmfModuleRootLoc :: !Loc,+ -- | The optional @layout@ clause: the workspace's placement authority.+ wmfLayout :: !(Maybe Placement),+ -- | Meaningful only when 'wmfLayout' is 'Just'.+ wmfLayoutLoc :: !Loc,+ -- | At least one member, in canonical order.+ wmfMembers :: !(NonEmpty WorkspaceMemberRef)+ }+ deriving stock (Eq, Show)++-- | One @spec \<path\>@ line: the normalized manifest-relative member path.+data WorkspaceMemberRef = WorkspaceMemberRef+ { -- | Normalized: forward slashes, no @./@ segments, relative, ends in @.keiro@.+ wmrPath :: !FilePath,+ 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,+ wdSourceLanguageCause :: !(Maybe SourceLanguageDiagnostic),+ 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+ { -- | Normalized, manifest-relative.+ wmPath :: !FilePath,+ -- | Exactly as parsed: line numbers are the member's own.+ wmSpec :: !Spec,+ -- | The member's declared-versus-legacy source contract.+ wmSourceLanguage :: !SourceLanguage,+ -- | Added to this member's lines to place them in the merged spec.+ wmLineBase :: !Int,+ -- | Source lines in the member file.+ wmLineCount :: !Int+ }+ 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+ { -- | The stable workspace identity (the manifest's @service@ name).+ wsService :: !Text,+ wsManifestPath :: !FilePath,+ -- | The members' unanimous @context@.+ wsContext :: !Name,+ wsModuleRoot :: !(Maybe Text),+ wsLayout :: !(Maybe Placement),+ -- | Canonical order.+ wsMembers :: ![WorkspaceMember],+ 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 = oneMemberParsedWorkspace path (ParsedSource LegacyUnversioned spec)++-- | Preserve provenance when adapting one parsed source to workspace consumers.+oneMemberParsedWorkspace :: FilePath -> ParsedSource -> WorkspaceSpec+oneMemberParsedWorkspace path parsedSource =+ WorkspaceSpec+ { wsService = T.pack (takeBaseName path),+ wsManifestPath = path,+ wsContext = specContext spec,+ wsModuleRoot = specModuleRoot spec,+ wsLayout = specLayout spec,+ wsMembers =+ [ WorkspaceMember+ { wmPath = relative,+ wmSpec = spec,+ wmSourceLanguage = parsedSourceLanguage parsedSource,+ wmLineBase = 0,+ wmLineCount = maximum (0 : collectLocs spec)+ }+ ],+ wsMergedSpec = spec,+ wsLineMap = LineMap [(0, maxBound, relative)],+ wsOwnership = ownershipOf [(relative, spec)]+ }+ where+ spec = parsedSpec parsedSource+ 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,+ wdSourceLanguageCause = Nothing,+ 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, ParsedSource)] ->+ 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, parsedSource)) | (path, text, parsedSource) <- supplied])+ | ref <- NE.toList (wmfMembers manifest)+ ]+ unsupplied =+ [ WorkspaceDiagnostic+ { wdLocations = pure (manifestLocation (wmrLoc ref) ""),+ wdSeverity = Error,+ wdCode = WorkspaceMemberUnreadable,+ wdSourceLanguageCause = Nothing,+ wdMessage = "workspace member '" <> T.pack (wmrPath ref) <> "' was not supplied to the composer"+ }+ | (ref, Nothing) <- ordered+ ]+ entries =+ [ (ref, text, parsedSourceLanguage parsedSource, parsedSpec parsedSource)+ | (ref, Just (text, parsedSource)) <- ordered+ ]++ refusals =+ languageRefusals+ <> contextRefusals+ <> moduleRefusals+ <> layoutRefusals+ <> declarationRefusals+ <> nodeRefusals+ <> collisionRefusals++ --------------------------------------------------------------------------+ -- Effective source language and context+ --------------------------------------------------------------------------+ effectiveVersions = nub [effectiveLanguageVersion sourceLanguage | (_, _, sourceLanguage, _) <- entries]+ languageRefusals+ | length effectiveVersions <= 1 = []+ | otherwise =+ [ WorkspaceDiagnostic+ { wdLocations =+ NE.fromList+ [ memberLocation+ ref+ (sourceLanguageLine text sourceLanguage)+ ("member selects effective language version " <> languageVersionText (effectiveLanguageVersion sourceLanguage))+ | (ref, text, sourceLanguage, _) <- entries+ ],+ wdSeverity = Error,+ wdCode = WorkspaceLanguageVersionMismatch,+ wdSourceLanguageCause = Nothing,+ wdMessage =+ "workspace members select different effective language versions ("+ <> T.intercalate ", " (map languageVersionText effectiveVersions)+ <> "); one semantic graph cannot combine different language contracts"+ }+ ]+ sourceLanguageLine text LegacyUnversioned = clauseLine "context" text+ sourceLanguageLine _ DeclaredLanguage {languageVersionLoc = Loc lineNumber} = Just lineNumber++ 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,+ wdSourceLanguageCause = Nothing,+ 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,+ wdSourceLanguageCause = Nothing,+ 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,+ wdSourceLanguageCause = Nothing,+ 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,+ wdSourceLanguageCause = Nothing,+ 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,+ wdSourceLanguageCause = Nothing,+ 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,+ wmSourceLanguage = sourceLanguage,+ wmLineBase = base,+ wmLineCount = memberLines+ }+ | ((ref, _, sourceLanguage, 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,+ specNominalScalars = concatMap specNominalScalars 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,+ wdSourceLanguageCause = Nothing,+ 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]+ <> [("nominal", nominalScalarName d, nominalScalarLoc d) | d <- specNominalScalars 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) Nothing)+ Right text -> case parseSource (workspaceDisplayPath manifestPath (WorkspaceMemberFile (wmrPath ref))) text of+ Left parseFailure ->+ Left+ ( memberFailure+ ref+ WorkspaceMemberParseFailed+ ("workspace member '" <> T.pack (wmrPath ref) <> "' failed to parse:\n" <> renderParseFailure parseFailure)+ (case parseFailure of SourceLanguageFailure diagnostic -> Just diagnostic; BodyGrammarFailure {} -> Nothing)+ )+ Right parsedSource -> Right (wmrPath ref, text, parsedSource)+ memberFailure ref failureCode note sourceLanguageCause =+ WorkspaceDiagnostic+ { wdLocations = pure (WorkspaceLocation WorkspaceManifestFile (max 1 (unLoc (wmrLoc ref))) ""),+ wdSeverity = Error,+ wdCode = failureCode,+ wdSourceLanguageCause = sourceLanguageCause,+ 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 AggregateField++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 ExprRoot++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 NominalBindingDecl++instance HasLocs NominalScalarDecl++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 ScalarLiteral++instance HasLocs Spec++instance HasLocs StateDecl++instance HasLocs TimerNode++instance HasLocs Transition++instance HasLocs TransitionImplementation++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
@@ -1,37 +1,36 @@-{- | 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 (..),+-- | 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,@@ -39,7 +38,8 @@ renderMigrationReport, markLegacyRecordSuperseded, outputTreeFiles,-) where+ )+where import Data.List (sort) import Data.Maybe (isNothing)@@ -56,213 +56,209 @@ -- | Why a file could be claimed. See the module header. data ClaimEvidence = ClaimedFromRecord | ClaimedFromBanner- deriving stock (Eq, Show)+ 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)+ { cfPath :: !FilePath,+ cfEvidence :: !ClaimEvidence,+ -- | The legacy record's file name, for @record@ evidence.+ cfSource :: !(Maybe Text),+ -- | The legacy record's @spec:@ field, for @record@ evidence.+ cfSpec :: !(Maybe Text)+ }+ 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.--}+-- | 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).+ { mrService :: !Text,+ -- | The legacy record consulted, as @(file name, its @spec:@ field)@.+ mrLegacyRecord :: !(Maybe (FilePath, Text)),+ mrClaimed :: ![ClaimedFile],+ mrLikelyStale :: ![StaleModule],+ mrUnclaimed :: ![FilePath]+ }+ deriving stock (Eq, Show) -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.--}+-- | 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+ 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+ 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)+ 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+ -- 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+ 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]+ -- 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+ 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- ]+ [ 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.--}+-- | 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+ 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 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")+ else TIO.writeFile path (ensureNewline contents <> marker <> "\n") where ensureNewline contents- | T.null contents || T.isSuffixOf "\n" contents = contents- | otherwise = contents <> "\n"+ | 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 ""+ 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+ 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]+ 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+ 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))+ 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@.--}+-- | 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))- ]+ [ "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 <> ")"]+ 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- ]+ [] -> [" 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+ [] -> []+ 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)"+ 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]+ [] -> []+ 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
@@ -1,19 +1,19 @@-{- | 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 (..),+-- | 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+ )+where import Control.Applicative ((<|>)) import Data.Char (isSpace)@@ -21,73 +21,92 @@ 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.Diff (Change (..), ChangeKind (..), advisoryAt, consumerBuildContext, diffSpecs, sourceLanguageChange) import Keiro.Dsl.DiffReport (OwnedSite (..), WorkspaceChange (..), WorkspaceDiffReport, WorkspaceMeta (..), renderFinding, workspaceDiffReport) import Keiro.Dsl.Grammar (Loc (..), Name, Placement (..))+import Keiro.Dsl.LanguageVersion (SourceLanguage (..)) import Keiro.Dsl.Validate (DiagnosticCode (..))-import Keiro.Dsl.Workspace (OwnershipIndex (..), WorkspaceSpec (..))+import Keiro.Dsl.Workspace (OwnershipIndex (..), WorkspaceMember (..), 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+ memberLanguageChanges 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- ]- }+ 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 +memberLanguageChanges :: WorkspaceSpec -> WorkspaceSpec -> [WorkspaceChange]+memberLanguageChanges old new =+ [ WorkspaceChange+ { wcChange = change,+ wcDeclarationSite = Just (OwnedSite path (sourceLine (wmSourceLanguage newMember))),+ wcUseSites = []+ }+ | (path, newMember) <- Map.toAscList newByPath,+ Just oldMember <- [Map.lookup path oldByPath],+ change <- sourceLanguageChange (wsService new) (T.pack path) (wmSourceLanguage oldMember) (wmSourceLanguage newMember)+ ]+ where+ oldByPath = Map.fromList [(wmPath member, member) | member <- wsMembers old]+ newByPath = Map.fromList [(wmPath member, member) | member <- wsMembers new]+ sourceLine LegacyUnversioned = 1+ sourceLine DeclaredLanguage {languageVersionLoc = Loc lineNumber} = lineNumber+ -- | Preserve the existing headline/vector bytes and append indented citations. renderWorkspaceFinding :: WorkspaceChange -> Text renderWorkspaceFinding workspaceChange =- T.intercalate "\n" (renderFinding (wcChange workspaceChange) : declarationLine <> useLines)+ T.intercalate "\n" (renderFinding (wcChange workspaceChange) : declarationLine <> useLines) where declarationLine = case wcDeclarationSite workspaceChange of- Nothing -> []- Just site -> [" declared: " <> renderOwnedSite site]+ Nothing -> []+ Just site -> [" declared: " <> renderOwnedSite site] useLines =- [ " use-site: " <> path <> " (" <> renderOwnedSite site <> ")"- | (path, Just site) <- wcUseSites workspaceChange- ]+ [ " 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)+ (_, (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+ | "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+ 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))@@ -99,56 +118,56 @@ 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- ]+ [ 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- ]+ 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 = []- }- ]+ | 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"
src/Keiro/Dsl/WorkspaceRecord.hs view
@@ -1,52 +1,52 @@-{- | 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 (..),+-- | 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 (..),+ WorkspaceSourceLanguageRow (..), AdoptedRow (..), renderWorkspaceRecord, parseWorkspaceRecord,@@ -54,212 +54,255 @@ workspaceManifestFileName, workspaceMigrationReportFileName, supersededByLine,-) where+ )+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.List (nub, sort) 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.LanguageVersion (SourceLanguage (..), declaredLanguageVersionMaybe, effectiveLanguageVersion, sourceFormText) 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).--}+-- | 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)+ { 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]]+ 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)- }+ 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).--}+-- | One member's source-language provenance in a workspace record.+data WorkspaceSourceLanguageRow = WorkspaceSourceLanguageRow+ { wrslPath :: !FilePath,+ wrslSourceLanguage :: !SourceLanguage+ }+ deriving stock (Eq, Show)++instance ToJSON WorkspaceSourceLanguageRow where+ toJSON row =+ object+ [ "path" .= T.pack (wrslPath row),+ "sourceForm" .= sourceFormText sourceLanguage,+ "declaredLanguageVersion" .= declaredLanguageVersionMaybe sourceLanguage,+ "effectiveLanguageVersion" .= effectiveLanguageVersion sourceLanguage+ ]+ where+ sourceLanguage = wrslSourceLanguage row++instance FromJSON WorkspaceSourceLanguageRow where+ parseJSON value@(Aeson.Object fields) = do+ path <- fields .: "path"+ sourceLanguage <- parseJSON value+ pure+ WorkspaceSourceLanguageRow+ { wrslPath = T.unpack (path :: Text),+ wrslSourceLanguage = sourceLanguage+ }+ parseJSON _ = fail "WorkspaceSourceLanguageRow must be an object"++-- | 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)+ { adPath :: !FilePath,+ adEvidence :: !Text,+ -- | The legacy record's file name, when the evidence is @record@.+ adSource :: !(Maybe Text),+ -- | The legacy record's @spec:@ field, when available.+ adSpec :: !(Maybe Text)+ }+ 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]]+ 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- }+ 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)+ { -- | The manifest's @service@ name: the workspace's durable identity.+ wrService :: !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.+ wrManifest :: !Text,+ wrContext :: !Text,+ wrModuleRoot :: !Text,+ wrLayout :: !Text,+ -- | Canonically ordered manifest-relative member paths.+ wrMembers :: ![FilePath],+ wrSourceLanguages :: ![WorkspaceSourceLanguageRow],+ 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]+ 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]+ <> ["source-language " <> encodeRow row | row <- wrSourceLanguages record]+ <> ["module " <> encodeRow row | row <- wrModules record]+ <> [mappingRowPrefix 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.--}+-- | 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+ 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]]+ sourceLanguages <- parseSourceLanguages members rows+ modules <- traverse (decodeRow "module ") (rowsWith "module " rows)+ checkedModules <- traverse checkedModule modules+ ordinaryMappings <- traverse (decodeRow "mapping ") (rowsWith "mapping " rows)+ nominalMappings <- traverse (decodeRow "nominal-mapping ") (rowsWith "nominal-mapping " rows)+ let mappings = ordinaryMappings <> nominalMappings+ 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,+ wrSourceLanguages = sourceLanguages,+ 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+ [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)+ 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}+ 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}+ path <- safePath (T.pack (adPath row))+ pure row {adPath = path}+ parseSourceLanguages members rows = case rowsWith "source-language " rows of+ [] -> Just [WorkspaceSourceLanguageRow path LegacyUnversioned | path <- members]+ sourceRows -> do+ decoded <- traverse (decodeRow "source-language ") sourceRows+ checked <- traverse checkedSourceLanguage decoded+ if hasDuplicates (map wrslPath checked) || sort (map wrslPath checked) /= sort members+ then Nothing+ else Just checked+ checkedSourceLanguage row = do+ path <- safePath (T.pack (wrslPath row))+ pure row {wrslPath = path} safePath raw =- let path = T.unpack raw- in if null path || isAbsolute path || ".." `elem` splitDirectories path- then Nothing- else Just path+ 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- )+ ( 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.--}+-- | @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" @@ -267,15 +310,17 @@ 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.--}+-- | @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.--}+-- | 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)++mappingRowPrefix :: MappingIdentity -> Text+mappingRowPrefix NominalMapping {} = "nominal-mapping "+mappingRowPrefix _ = "mapping "
src/Keiro/Dsl/WorkspaceScaffold.hs view
@@ -1,36 +1,35 @@-{- | 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+-- | 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,@@ -42,10 +41,12 @@ -- * Execution OwnershipMove (..),+ WorkspaceSourceLanguageDrift (..), WorkspaceScaffoldReport (..), executeWorkspaceScaffold, renderWorkspaceScaffoldReport,-) where+ )+where import Data.List (nub, sortOn) import Data.Map.Strict qualified as Map@@ -56,11 +57,12 @@ import Keiro.Dsl.Goldens (GoldenPayload) import Keiro.Dsl.Grammar import Keiro.Dsl.Harness (harnessForWithGoldens, harnessProcess, harnessReadModel, harnessRouter, harnessWorkflow)+import Keiro.Dsl.LanguageVersion (SourceLanguage, sourceFormText) import Keiro.Dsl.Manifest (moduleNameOf, renderManifest) import Keiro.Dsl.MappedConsumer (ConsumerPlan (..), consumerPlan) import Keiro.Dsl.Scaffold-import Keiro.Dsl.ScaffoldRun (- MappingDrift (..),+import Keiro.Dsl.ScaffoldRun+ ( MappingDrift (..), Refusal (..), StaleModule (..), WriteDisposition (..),@@ -72,7 +74,7 @@ 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)@@ -84,107 +86,102 @@ -- 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.--}+-- | 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)+ = 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.--}+-- | 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)+ { wpWorkspace :: !WorkspaceSpec,+ wpContext :: !Context,+ -- | 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.+ wpGoldenRoot :: !FilePath,+ 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.--}+-- | 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+ [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+ 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.--}+-- | 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- ]+ [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 _ -> []+ 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)+ 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@@ -192,8 +189,8 @@ -- 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+ [owner] | length owners == length names -> MemberOwned owner+ _ -> ContextLevel where owners = [owner | name <- names, Just (owner, _) <- [declarationOwner ownership "mapped" name]] @@ -204,63 +201,61 @@ -- 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}+ | 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.--}+-- | 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)]+ 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 _ -> [])+ 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+ (_, '/' : rest) -> rest+ _ -> relative firstJustM [] = pure Nothing firstJustM (path : rest) = do- exists <- doesFileExist path- if exists then pure (Just path) else firstJustM rest+ 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.--}+-- | 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]- ]+ [ 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" @@ -268,124 +263,131 @@ -- 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.--}+-- | 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)+ { omPath :: !FilePath,+ omPrevious :: !(Maybe FilePath),+ omCurrent :: !(Maybe FilePath)+ }+ deriving stock (Eq, Show) +data WorkspaceSourceLanguageDrift = WorkspaceSourceLanguageDrift+ { wsldPath :: !FilePath,+ wsldPrevious :: !SourceLanguage,+ wsldCurrent :: !SourceLanguage+ }+ 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.+ { wsrManifestPath :: !FilePath,+ wsrOutDir :: !FilePath,+ wsrService :: !Text,+ wsrContext :: !Context,+ wsrMembers :: ![FilePath],+ wsrDispositions :: ![(ScaffoldModule, ModuleProvenance, WriteDisposition)],+ wsrBuildManifestPath :: !FilePath,+ wsrRecordPath :: !FilePath,+ -- | The manifest file name the previous workspace record was written from,+ -- when it differs from this run's.+ wsrPreviousManifest :: !(Maybe Text),+ wsrStale :: ![StaleModule],+ wsrOwnershipMoves :: ![OwnershipMove],+ wsrConsumerPlan :: !ConsumerPlan,+ wsrConstraintPlan :: ![Text],+ wsrMappingDrift :: ![MappingDrift],+ wsrSourceLanguageDrift :: ![WorkspaceSourceLanguageDrift],+ wsrNewHoles :: ![BindingHole],+ -- | Present only on the run that adopted pre-workspace scaffold output.+ wsrMigration :: !(Maybe MigrationReport)+ }+ deriving stock (Eq, Show) - * A Generated module whose bytes already match is reported 'Unchanged' and- not rewritten, which is what makes idempotence observable rather than- merely claimed.--}+-- | 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- }+ 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+ languageDrift = workspaceSourceLanguageDrift workspace 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,+ wsrSourceLanguageDrift = languageDrift,+ wsrNewHoles = newHoles,+ wsrMigration = migration+ } where workspace = wpWorkspace plan merged = wsMergedSpec workspace@@ -398,123 +400,140 @@ readWorkspaceRecord :: FilePath -> IO (Maybe WorkspaceRecord) readWorkspaceRecord path = do- exists <- doesFileExist path- if exists then parseWorkspaceRecord <$> TIO.readFile path else pure Nothing+ 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.--}+-- | 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- }+ WorkspaceRecord+ { wrService = wsService workspace,+ wrManifest = T.pack (takeFileName (wsManifestPath workspace)),+ wrContext = wsContext workspace,+ wrModuleRoot = moduleRoot ctx,+ wrLayout = layoutLabel ctx,+ wrMembers = map wmPath (wsMembers workspace),+ wrSourceLanguages =+ [ WorkspaceSourceLanguageRow (wmPath member) (wmSourceLanguage member)+ | member <- 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 +workspaceSourceLanguageDrift :: WorkspaceSpec -> Maybe WorkspaceRecord -> [WorkspaceSourceLanguageDrift]+workspaceSourceLanguageDrift workspace previous =+ [ WorkspaceSourceLanguageDrift path oldLanguage newLanguage+ | member <- wsMembers workspace,+ let path = wmPath member+ newLanguage = wmSourceLanguage member,+ Just oldLanguage <- [Map.lookup path previousByPath],+ oldLanguage /= newLanguage+ ]+ where+ previousByPath =+ Map.fromList+ [ (wrslPath row, wrslSourceLanguage row)+ | row <- maybe [] wrSourceLanguages previous+ ]+ 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.--}+-- | 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- ]+ [ 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.--}+-- | 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)+ 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+ 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)+ 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.--}+-- | 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+ [ "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+ <> sourceLanguageDriftSection+ <> ownershipSection+ <> staleSection where ctx = wsrContext report dispositions = wsrDispositions report@@ -522,14 +541,14 @@ 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 (kind m)+ <> " "+ <> pad (moduleNameOf (modulePath m))+ <> " "+ <> dispositionTag disposition+ <> " "+ <> ownerTag provenance kindTag Generated = "generated" kindTag HoleStub = "hole " dispositionTag Overwritten = "(overwritten)"@@ -541,68 +560,80 @@ 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"]- ]+ 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+ [] -> "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))+ "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 -> []+ 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+ [] -> []+ constraints -> "constraint plan:" : map (" " <>) constraints newHolesSection = case wsrNewHoles report of- [] -> []- obligations ->- ["newly required holes since last scaffold: " <> tshow (length obligations)]- <> concatMap obligationLines obligations+ [] -> []+ obligations ->+ ["newly required holes since last scaffold: " <> tshow (length obligations)]+ <> concatMap obligationLines obligations obligationLines hole =- [ " " <> holeModule hole- , " " <> holeSignature hole <> " (" <> obligationKindLabel (holeKind 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+ [] -> []+ 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)- ]+ [ " " <> driftSpecName drift,+ " previous: " <> maybe "(absent)" renderMappingIdentity (driftPrevious drift),+ " current: " <> maybe "(absent)" renderMappingIdentity (driftCurrent drift)+ ]+ sourceLanguageDriftSection = case wsrSourceLanguageDrift report of+ [] -> []+ drifts ->+ ["source-language drift: " <> tshow (length drifts) <> " member(s) changed provenance (generated module bytes are semantic and unaffected):"]+ <> [ " "+ <> T.pack (wsldPath drift)+ <> " "+ <> sourceFormText (wsldPrevious drift)+ <> " -> "+ <> sourceFormText (wsldCurrent drift)+ | drift <- drifts+ ] 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- ]+ [] -> []+ 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."]+ [] -> []+ 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)"+ 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 <> "]"
test/Main.hs view
@@ -1,5598 +1,6350 @@-{-# LANGUAGE ImportQualifiedPost #-}--{- | Test driver for keiro-dsl. EP-1 milestone 1 tests: the @parse . pretty@-round-trip property over generated specs, and a unit test pinning the shape-of the canonical Reservation fixture.--}-module Main (main) where--import Control.Exception (bracket)-import Control.Monad (filterM, forM, forM_, unless)-import Data.Aeson (Value, object, (.=))-import Data.Aeson qualified as Aeson-import Data.Aeson.KeyMap qualified as KeyMap-import Data.Either (isLeft, isRight)-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-specOf path = do- input <- readTestText path- case parseSpec path input of- Left err -> expectationFailure (T.unpack err) >> error "unreachable"- Right spec -> pure spec---- | Parse a fixture and scaffold every aggregate in it.-scaffoldFixture :: FilePath -> IO [ScaffoldModule]-scaffoldFixture path = do- input <- readTestText path- case parseSpec path input of- Left err -> expectationFailure (T.unpack err) >> pure []- Right spec ->- pure $- concat- [ scaffoldAggregate (ctx spec) spec agg <> harnessFor (ctx spec) spec agg- | NAggregate agg <- specNodes spec- ]- where- ctx spec = defaultContext (specContext spec)--scaffoldProcessFixture :: FilePath -> IO [ScaffoldModule]-scaffoldProcessFixture path = do- input <- readTestText path- case parseSpec path input of- Left err -> expectationFailure (T.unpack err) >> pure []- Right spec ->- pure $ concat [scaffoldProcess (ctx spec) p | NProcess p <- specNodes spec]- where- ctx spec = defaultContext (specContext spec)--{- | Assert a freshly-scaffolded Generated module matches its committed copy-under test/conformance/ (whitespace-normalized). The committed copies are the-ones the keiro-dsl-conformance suite compiles, so this pins the live scaffolder-to known-compiling output.--}-assertMatchesCommitted :: ScaffoldModule -> IO ()-assertMatchesCommitted m = do- let committedPath = "test/conformance/" <> modulePath m- committed <- readTestText committedPath- normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText m)--normalizeGenerated :: T.Text -> (T.Text, [T.Text])-normalizeGenerated text =- let (imports, body) = partition isImport (T.lines text)- in (normalizeBody body, sort (map normalizeImport imports))- where- -- Compare the deterministic body exactly as before and imports as a sorted,- -- whitespace-normalized list. Sorting tolerates formatter reordering while- -- additions, removals, and renamed imports now fail the pin.- normalizeBody =- T.replace " , )" " )"- . T.unwords- . T.words- . T.replace "}" " } "- . T.replace "{" " { "- . T.replace "]" " ] "- . T.replace "[" " [ "- . T.replace "," " , "- . T.unlines- normalizeImport line =- let reordered = case T.words line of- "import" : "qualified" : moduleName : rest -> T.unwords ("import" : moduleName : "qualified" : rest)- wordsInImport -> T.unwords wordsInImport- (prefix, explicit) = T.breakOn " (" reordered- in if T.null explicit- then prefix- else- let members =- sort- . map (T.unwords . T.words)- . T.splitOn ","- . T.dropEnd 1- $ T.drop 2 explicit- in prefix <> " (" <> T.intercalate "," members <> ")"- isImport line = case T.words line of- "import" : _ -> True- _ -> False--{- | Locate and read a test fixture or committed conformance source regardless-of whether the suite was launched from the package directory or repo root.--}-readTestText :: FilePath -> IO T.Text-readTestText path = resolveTestPath path >>= TIO.readFile---- | Locate a repo file regardless of the test process's current directory.-resolveTestPath :: FilePath -> IO FilePath-resolveTestPath rel = do- override <- lookupEnv "KEIRO_DSL_TEST_ROOT"- let candidates = [rel, "keiro-dsl" </> rel] <> maybe [] (\root -> [root </> rel]) override- existing <- filterM doesFileExist candidates- case existing of- path : _ -> pure path- [] ->- fail $- "unable to locate keiro-dsl test file "- <> show rel- <> "; tried "- <> show candidates--leftContains :: T.Text -> Either T.Text a -> Bool-leftContains needle = \case- Left err -> needle `T.isInfixOf` err- Right _ -> False--parseInlineSpec :: FilePath -> T.Text -> IO Spec-parseInlineSpec sourceName src = case parseSpec sourceName src of- Left err -> expectationFailure (T.unpack err) >> error "unreachable"- Right spec -> pure spec--shouldResolveTypeGraph :: Spec -> IO TypeGraph-shouldResolveTypeGraph spec = case resolveTypeGraph spec of- Left errors -> expectationFailure ("type graph failed: " <> show errors) >> error "unreachable"- Right graph -> pure graph--shouldResolveCoverage :: FilePath -> Spec -> IO Coverage.CoverageReport-shouldResolveCoverage path spec = case Coverage.coverageReport path spec of- Left errors -> expectationFailure ("coverage graph failed: " <> show errors) >> error "unreachable"- Right report -> pure report--withoutVendorGeometry :: Spec -> Spec-withoutVendorGeometry spec =- spec- { specMapped = filter (not . isVendorGeometry) (specMapped spec)- , specNodes = map stripNode (specNodes spec)- }- where- isVendorGeometry MappedOpaque{moName = "VendorGeometry"} = True- isVendorGeometry _ = False- stripNode (NAggregate aggregate) =- NAggregate- aggregate- { aggRegs = filter ((/= "VendorGeometry") . regType) (aggRegs aggregate)- , aggCommands = map stripCommand (aggCommands aggregate)- , aggEvents = map stripEvent (aggEvents aggregate)- }- stripNode node = node- stripCommand command = command{cmdFields = filter ((/= Just "VendorGeometry") . fieldType) (cmdFields command)}- stripEvent event = event{evBody = case evBody event of EventFields fields -> EventFields (filter ((/= Just "VendorGeometry") . fieldType) fields); body -> body}--withMetadataJson :: Spec -> Spec-withMetadataJson spec = spec{specMapped = map updateDeclaration (specMapped spec)}- where- updateDeclaration declaration@MappedStructural{msName = "ArtifactMetadata", msShape = ShapeRecord constructor unknownFields fields} =- declaration- { msShape =- ShapeRecord- constructor- unknownFields- [if wfHaskell field == "note" then field{wfType = TJson} else field | field <- fields]- }- updateDeclaration declaration = declaration--expressionTags :: TypeExprAlgebra [T.Text]-expressionTags =- TypeExprAlgebra- { onText = ["text"]- , onInt = ["int"]- , onBool = ["bool"]- , onNatural = ["natural"]- , onTime = ["time"]- , onJson = ["json"]- , onOptional = ("optional" :)- , onList = ("list" :)- , onMap = ("map" :)- , onRef = \key -> ["ref:" <> unMappedKey key]- }--hasTypeGraphError :: (TypeGraphError -> Bool) -> Either (NonEmpty TypeGraphError) TypeGraph -> Bool-hasTypeGraphError predicate = \case- Left errors -> any predicate errors- Right _ -> False--isRecursive :: TypeGraphError -> Bool-isRecursive TGRecursive{} = True-isRecursive _ = False--isUnresolved :: TypeGraphError -> Bool-isUnresolved TGUnresolvedRef{} = True-isUnresolved _ = False--mappedSpec :: [MappedDecl] -> Spec-mappedSpec declarations = Spec "mapped-test" Nothing Nothing [] [] [] declarations []--completeStructural :: Name -> MappedShape -> MappedDecl-completeStructural name shape =- MappedStructural- { msName = name- , msHaskell = Just (HaskellSource "mapped-test" "Example.Mapped" name)- , msBinding = Just ("Example.Mapped." <> T.toLower name <> "Binding")- , msBindingVersion = Just "1"- , msCanonical = Just ("example.mapped." <> name)- , msFixtures = Just ("Example.Mapped." <> T.toLower name <> "Cases")- , msInitial = Nothing- , msShape = shape- , msLoc = noLoc- }--recordShape :: [TypeExpr] -> MappedShape-recordShape types =- ShapeRecord- "MappedRecord"- RejectUnknown- [ WireField- { wfHaskell = "field" <> T.pack (show index)- , wfKey = "field" <> T.pack (show index)- , wfType = fieldType- , wfPresence = PRequired- , wfOnMissing = Nothing- , wfLoc = noLoc- }- | (index, fieldType) <- zip [(1 :: Int) ..] types- ]--mapArtifactField :: (WireField -> WireField) -> Spec -> Spec-mapArtifactField = mapArtifactNamedField "key"--mapArtifactNamedField :: Name -> (WireField -> WireField) -> Spec -> Spec-mapArtifactNamedField target transform spec = spec{specMapped = map updateDeclaration (specMapped spec)}- where- updateDeclaration declaration@MappedStructural{msName = "ArtifactInfo", msShape = ShapeRecord constructor unknownFields fields} =- declaration- { msShape =- ShapeRecord- constructor- unknownFields- [if wfHaskell field == target then transform field else field | field <- fields]- }- updateDeclaration declaration = declaration--mapMappedStructural :: Name -> (MappedDecl -> MappedDecl) -> Spec -> Spec-mapMappedStructural target transform spec =- spec- { specMapped =- [ case declaration of- MappedStructural{msName = name}- | name == target -> transform declaration- _ -> declaration- | declaration <- specMapped spec- ]- }--renameRecordConstructor :: MappedShape -> MappedShape-renameRecordConstructor (ShapeRecord _ unknownFields fields) = ShapeRecord "ArtifactInfoV2" unknownFields fields-renameRecordConstructor shape = shape--renameMappedRecordConstructor :: MappedDecl -> MappedDecl-renameMappedRecordConstructor declaration@MappedStructural{msShape = shape} =- declaration{msShape = renameRecordConstructor shape}-renameMappedRecordConstructor declaration = declaration--changeMappedCanonical :: MappedDecl -> MappedDecl-changeMappedCanonical declaration@MappedStructural{} =- declaration{msCanonical = Just "example.artifact.ArtifactInfo.v2"}-changeMappedCanonical declaration = declaration--data MappedMutation = MappedMutation- { mmCandidate :: !Spec- , mmCode :: !DiagnosticCode- , mmExpectedSubjects :: !(Set.Set T.Text)- }- deriving stock (Show)--mappedWireMutations :: Spec -> [MappedMutation]-mappedWireMutations spec = case resolveTypeGraph spec of- Left _ -> []- Right graph -> concatMap (uncurry (declarationMutations graph)) (zip [0 :: Int ..] (specMapped spec))- where- declarationMutations graph declarationIndex declaration = case declaration of- MappedStructural{msName = declarationName, msShape = shape} -> case shape of- ShapeRecord _ _ fields ->- concat- [ [ mutation- graph- declarationName- MappedWireKeyChanged- (fieldSubject field{wfKey = wfKey field <> "__mutated"})- (mutateRecordField declarationIndex fieldIndex (\value -> value{wfKey = wfKey value <> "__mutated"}) spec)- , mutation- graph- declarationName- MappedPresenceChanged- (fieldSubject field)- (mutateRecordField declarationIndex fieldIndex (\value -> value{wfPresence = flipPresence (wfPresence value)}) spec)- ]- <> [ mutation- graph- declarationName- defaultCode- (fieldSubject field)- (mutateRecordField declarationIndex fieldIndex (\value -> value{wfOnMissing = changedDefault}) spec)- | oldDefault <- maybeToListTest (wfOnMissing field)- , let (changedDefault, defaultCode) = mutateDefault oldDefault- ]- | (fieldIndex, field) <- zip [0 :: Int ..] fields- ]- ShapeEnum entries ->- [ mutation- graph- declarationName- MappedEnumSpellingChanged- (enumSubject entry{weTag = weTag entry <> "__mutated"})- (mutateEnumEntry declarationIndex entryIndex (\value -> value{weTag = weTag value <> "__mutated"}) spec)- | (entryIndex, entry) <- zip [0 :: Int ..] entries- ]- ShapeUnion _ arms ->- [ mutation- graph- declarationName- MappedArmTagChanged- (armSubject arm{waTag = waTag arm <> "__mutated"})- (mutateUnionArm declarationIndex armIndex (\value -> value{waTag = waTag value <> "__mutated"}) spec)- | (armIndex, arm) <- zip [0 :: Int ..] arms- ]- MappedOpaque{moName = declarationName, moCodecVersion = version} ->- [ mutation- graph- declarationName- MappedOpaqueCodecChanged- "codec"- ( updateMappedAt- declarationIndex- ( \case- value@MappedOpaque{} -> value{moCodecVersion = fmap (<> "__mutated") version}- value -> value- )- spec- )- ]-- mutation graph declarationName diagnosticCode leaf candidate =- MappedMutation- { mmCandidate = candidate- , mmCode = diagnosticCode- , mmExpectedSubjects =- Set.fromList- [ renderUsePath path <> " " <> leaf- | path <- usePaths graph declarationName- ]- }--fieldSubject :: WireField -> T.Text-fieldSubject field = ".field " <> wfHaskell field <> "[\"" <> wfKey field <> "\"]"--enumSubject :: WireEnum -> T.Text-enumSubject entry = ".enum " <> weCtor entry <> "[\"" <> weTag entry <> "\"]"--armSubject :: WireArm -> T.Text-armSubject arm = ".arm " <> waCtor arm <> "[\"" <> waTag arm <> "\"]"--flipPresence :: Presence -> Presence-flipPresence PRequired = POptional-flipPresence POptional = PRequired--mutateDefault :: OnMissing -> (Maybe OnMissing, DiagnosticCode)-mutateDefault = \case- OmNull -> (Nothing, MappedDefaultRemoved)- OmText value -> (Just (OmText (value <> "__mutated")), MappedDefaultChanged)- OmInt value -> (Just (OmInt (value + 1)), MappedDefaultChanged)- OmBool value -> (Just (OmBool (not value)), MappedDefaultChanged)- OmEmptyList -> (Nothing, MappedDefaultRemoved)- OmEmptyMap -> (Nothing, MappedDefaultRemoved)- OmCtor constructor -> (Just (OmCtor (constructor <> "Mutated")), MappedDefaultChanged)--mutateRecordField :: Int -> Int -> (WireField -> WireField) -> Spec -> Spec-mutateRecordField declarationIndex fieldIndex transform =- updateMappedAt declarationIndex $ \case- declaration@MappedStructural{msShape = ShapeRecord constructor unknownFields fields} ->- declaration{msShape = ShapeRecord constructor unknownFields (updateAt fieldIndex transform fields)}- declaration -> declaration--mutateEnumEntry :: Int -> Int -> (WireEnum -> WireEnum) -> Spec -> Spec-mutateEnumEntry declarationIndex entryIndex transform =- updateMappedAt declarationIndex $ \case- declaration@MappedStructural{msShape = ShapeEnum entries} ->- declaration{msShape = ShapeEnum (updateAt entryIndex transform entries)}- declaration -> declaration--mutateUnionArm :: Int -> Int -> (WireArm -> WireArm) -> Spec -> Spec-mutateUnionArm declarationIndex armIndex transform =- updateMappedAt declarationIndex $ \case- declaration@MappedStructural{msShape = ShapeUnion encoding arms} ->- declaration{msShape = ShapeUnion encoding (updateAt armIndex transform arms)}- declaration -> declaration--updateMappedAt :: Int -> (MappedDecl -> MappedDecl) -> Spec -> Spec-updateMappedAt declarationIndex transform spec =- spec{specMapped = updateAt declarationIndex transform (specMapped spec)}--updateAt :: Int -> (a -> a) -> [a] -> [a]-updateAt target transform values =- [if index == target then transform value else value | (index, value) <- zip [0 :: Int ..] values]--maybeToListTest :: Maybe a -> [a]-maybeToListTest = maybe [] pure--isAdditiveChange :: Change -> Bool-isAdditiveChange Additive{} = True-isAdditiveChange Advisory{} = False-isAdditiveChange Breaking{} = False--mappedIngredientMutations :: Spec -> [(Spec, DiagnosticCode)]-mappedIngredientMutations spec =- [ (mapMappedStructural "ArtifactInfo" clearStructuralHaskell spec, MappedMissingIngredient)- , (mapMappedStructural "ArtifactInfo" clearStructuralBinding spec, MappedMissingIngredient)- , (mapMappedStructural "ArtifactInfo" clearStructuralBindingVersion spec, MappedMissingIngredient)- , (mapMappedStructural "ArtifactInfo" clearStructuralCanonical spec, MappedMissingIngredient)- , (mapMappedStructural "ArtifactInfo" clearStructuralFixtures spec, MappedMissingIngredient)- , (mapMappedStructural "ArtifactInfo" clearStructuralInitial spec, MappedMissingInitialValue)- , (mapMappedDeclaration "VendorGeometry" clearOpaqueHaskell spec, MappedMissingIngredient)- , (mapMappedDeclaration "VendorGeometry" clearOpaqueCodec spec, MappedMissingIngredient)- , (mapMappedDeclaration "VendorGeometry" clearOpaqueCodecVersion spec, MappedMissingIngredient)- , (mapMappedDeclaration "VendorGeometry" clearOpaqueFixtures spec, MappedMissingIngredient)- ]- where- clearStructuralHaskell declaration@MappedStructural{} = declaration{msHaskell = Nothing}- clearStructuralHaskell declaration = declaration- clearStructuralBinding declaration@MappedStructural{} = declaration{msBinding = Nothing}- clearStructuralBinding declaration = declaration- clearStructuralBindingVersion declaration@MappedStructural{} = declaration{msBindingVersion = Nothing}- clearStructuralBindingVersion declaration = declaration- clearStructuralCanonical declaration@MappedStructural{} = declaration{msCanonical = Nothing}- clearStructuralCanonical declaration = declaration- clearStructuralFixtures declaration@MappedStructural{} = declaration{msFixtures = Nothing}- clearStructuralFixtures declaration = declaration- clearStructuralInitial declaration@MappedStructural{} = declaration{msInitial = Nothing}- clearStructuralInitial declaration = declaration- clearOpaqueHaskell declaration@MappedOpaque{} = declaration{moHaskell = Nothing}- clearOpaqueHaskell declaration = declaration- clearOpaqueCodec declaration@MappedOpaque{} = declaration{moCodecId = Nothing}- clearOpaqueCodec declaration = declaration- clearOpaqueCodecVersion declaration@MappedOpaque{} = declaration{moCodecVersion = Nothing}- clearOpaqueCodecVersion declaration = declaration- clearOpaqueFixtures declaration@MappedOpaque{} = declaration{moFixtures = Nothing}- clearOpaqueFixtures declaration = declaration--mapMappedDeclaration :: Name -> (MappedDecl -> MappedDecl) -> Spec -> Spec-mapMappedDeclaration target transform spec =- spec- { specMapped =- [ if mappedDeclarationName declaration == target then transform declaration else declaration- | declaration <- specMapped spec- ]- }--mappedDeclarationName :: MappedDecl -> Name-mappedDeclarationName MappedStructural{msName = name} = name-mappedDeclarationName MappedOpaque{moName = name} = name--statusMapSpec :: T.Text -> T.Text-statusMapSpec marker =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states Open"- , ""- , " event Created { }"- , " event Changed { }"- , ""- , " projection things consistency=Eventual key=thingId"- , " status-map" <> marker <> " { Created=>held }"- ]--parseErrorOf :: FilePath -> T.Text -> IO T.Text-parseErrorOf sourceName src = case parseSpec sourceName src of- Left err -> pure err- Right _ -> expectationFailure ("expected parse failure for " <> sourceName) >> error "unreachable"--duplicateGotoSpec :: T.Text-duplicateGotoSpec =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states A B C"- , ""- , " command Go { }"- , " A -- Go -->"- , " goto B"- , " goto C"- ]--missingGotoSpec :: T.Text-missingGotoSpec =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states A B"- , ""- , " command Go { }"- , " A -- Go -->"- , " emit Changed"- ]--duplicateWireSpec :: T.Text-duplicateWireSpec =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states Open"- , ""- , " wire kind=ctorName fields=camelCase schemaVersion=1"- , " wire kind=typeName fields=snakeCase schemaVersion=2"- ]--duplicateProjectionSpec :: T.Text-duplicateProjectionSpec =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states Open"- , ""- , " projection first consistency=Strong key=thingId"- , " status-map partial { }"- , " projection second consistency=Eventual key=thingId"- ]--projectionWithoutConsistencySpec :: T.Text-projectionWithoutConsistencySpec =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states Open"- , ""- , " projection things key=thingId"- ]--malformedRegisterSpec :: T.Text-malformedRegisterSpec =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " status Status"- , " states Open"- ]--misplacedDispatchIdSpec :: T.Text-misplacedDispatchIdSpec =- T.replace- " schedule timer\n\n dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)\n"- " dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)\n schedule timer\n"- (renderSpec (Spec "svc" Nothing Nothing [] [] [] [] [NProcess (processWithLiteral "literal")]))--lineNumberContaining :: T.Text -> T.Text -> Int-lineNumberContaining needle = go 1 . T.lines- where- go current = \case- [] -> current- lineText : rest- | needle `T.isInfixOf` lineText -> current- | otherwise -> go (current + 1) rest--decimalOverflow :: T.Text-decimalOverflow = "18446744073709551617"--decimalOverflowSpecs :: [(String, T.Text)]-decimalOverflowSpecs =- [ ("event-version", eventVersionDecimalSpec decimalOverflow)- , ("wire-schema", wireDecimalSpec decimalOverflow)- , ("contract-schema", contractDecimalSpec decimalOverflow)- , ("decode-schema", decodeDecimalSpec decimalOverflow)- , ("publisher-attempts", publisherDecimalSpec decimalOverflow)- , ("workqueue-retries", workqueueDecimalSpec decimalOverflow)- , ("timer-attempts", timerDecimalSpec decimalOverflow)- ]--eventVersionDecimalSpec :: T.Text -> T.Text-eventVersionDecimalSpec value =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states Open"- , ""- , " event Changed v" <> value <> " { }"- ]--wireDecimalSpec :: T.Text -> T.Text-wireDecimalSpec value =- T.unlines- [ "context svc"- , ""- , "aggregate Thing"- , " regs"- , " states Open"- , ""- , " wire kind=ctorName fields=camelCase schemaVersion=" <> value- ]--contractDecimalSpec :: T.Text -> T.Text-contractDecimalSpec value =- T.unlines- [ "context svc"- , ""- , "contract Contract {"- , " schemaVersion " <> value- , " discriminator kind"- , "}"- ]--decodeDecimalSpec :: T.Text -> T.Text-decodeDecimalSpec value =- T.unlines- [ "context svc"- , ""- , "intake Inbox {"- , " contract Contract"- , " topic events"- , " accept Event"- , " dedupe key messageId policy PreferIntegrationMessageId"- , " decode { envelope strict-required lenient-optional body strict schemaVersion == " <> value <> " }"- , " disposition { }"- , "}"- ]--publisherDecimalSpec :: T.Text -> T.Text-publisherDecimalSpec value =- T.unlines- [ "context svc"- , ""- , "publisher Publisher {"- , " emit Emit"- , " ordering PerKeyHeadOfLine"- , " maxAttempts " <> value- , " backoff constant 2s"- , " outboxId stable from messageId"- , "}"- ]--workqueueDecimalSpec :: T.Text -> T.Text-workqueueDecimalSpec value =- T.unlines- [ "context svc"- , ""- , "workqueue Queue {"- , " queue logical = \"queue\""- , " derive physical = \"queue\""- , " dlq = \"queue_dlq\""- , " table = \"pgmq.q_queue\""- , " payload Job { }"- , " retry maxRetries = " <> value <> " delay = 5s dlq = on"- , " disposition { }"- , "}"- ]--timerDecimalSpec :: T.Text -> T.Text-timerDecimalSpec value =- T.replace- "max-attempts 5"- ("max-attempts " <> value)- (renderSpec (Spec "svc" Nothing Nothing [] [] [] [] [NProcess (processWithLiteral "literal")]))--identifierHygieneSpec :: T.Text-identifierHygieneSpec =- T.unlines- [ "context svc"- , ""- , "aggregate thing"- , " regs"- , " states Open"- , ""- , " command DoIt { data }"- ]--vertexCollisionSpec :: T.Text-vertexCollisionSpec =- T.unlines- [ "context svc"- , ""- , "aggregate Reservation"- , " regs"- , " states Created"- , ""- , " event ReservationCreated { }"- ]--underscoreNodeSpec :: T.Text-underscoreNodeSpec =- T.unlines- [ "context svc"- , ""- , "contract _contract {"- , " schemaVersion 1"- , " discriminator kind"- , "}"- ]--unicodeIdentifierSpec :: T.Text-unicodeIdentifierSpec =- T.unlines- [ "context svc"- , ""- , "aggregate Résumé"- , " regs"- , " states Open"- ]--emptyStatesSpec :: Spec-emptyStatesSpec =- Spec- "svc"- Nothing- Nothing- []- []- []- []- [NAggregate (Aggregate "Thing" [] [] [] [] [] Nothing Nothing Nothing noLoc)]--crossFamilyBoundarySpec :: T.Text-crossFamilyBoundarySpec =- T.unlines- [ "context svc"- , ""- , "aggregate First"- , " regs"- , " states A B"- , " command Go { }"- , " A -- Go -->"- , " emit Changed"- , " goto B"- , ""- , "emit Output {"- , " contract Contract"- , " topic events"- , " source \"source\""- , " key thingId"- , " map status { _ => skip }"- , " messageId derive hole"- , " idempotencyKey derive hole"- , "}"- , ""- , "aggregate Second"- , " regs"- , " states"- , ""- , "dispatch QueueDispatch {"- , " source readModel = source key = thingId"- , " fanout body = resolveFanout"- , " dedup key = thingId"- , " seenIn readModel = seen field = thingId"- , " seenIn queue = workQueue field = thingId"- , " enqueue to = workQueue"- , "}"- ]------------------------------------------------------------------------------------- Generators (bounded; restricted to valid, non-reserved identifiers)-----------------------------------------------------------------------------------{- | Text that exercises every supported escape plus notation punctuation that-used to be able to split one emit-map row into several rows.--}-genAdversarialText :: Gen T.Text-genAdversarialText =- T.concat- <$> resize- 20- (listOf (elements ["a", "Z", "\"", "\\", "\n", "\t", "\r", "=>", "#", "{", "}", " "]))--{- | One spec carrying the same adversarial value through three distinct-printer paths: a contract topic, an emit-map value, and a quote-wrapped-field-binding literal.--}-escapedSpec :: T.Text -> Spec-escapedSpec value =- Spec- "escape"- Nothing- Nothing- []- []- []- []- [ NContract- ContractNode- { ctrName = "Contract"- , ctrSchemaVersion = 1- , ctrDiscriminator = "kind"- , ctrTopics = [("events", value)]- , ctrEvents = []- , ctrLoc = noLoc- }- , NEmit- EmitNode- { emName = "Emit"- , emContract = "Contract"- , emTopic = "events"- , emSource = "source"- , emKey = "key"- , emDiscriminant = "status"- , emMap = [EmitMapRow value "Event" noLoc]- , emSkip = True- , emMessageId = DeriveSpec Nothing- , emIdempotencyKey = DeriveSpec Nothing- , emLoc = noLoc- }- , NProcess (processWithLiteral value)- ]--processWithLiteral :: T.Text -> ProcessNode-processWithLiteral value =- ProcessNode- { procId = "Process"- , procName = "process"- , procInput = InputDecl "Input" []- , procCorrelate = CorrelateDecl "key" "idText"- , procSaga = SagaRef "Saga" "saga"- , procTarget = "Target"- , procProjections = []- , procHandle =- HandleNode- { hOn = "Input"- , hAdvance = AdvanceNode "Advance" [FieldBinding "literal" (Just ("\"" <> value <> "\""))]- , hDispatch = []- , hSchedule = "timer"- }- , procRejected = PolHalt- , procPoison = PolHalt- , procTimer =- TimerNode- { tmName = "timer"- , tmId = IdExpr UuidV5Id "timer:"- , tmFireAt = FireAtExpr "observedAt" "5m"- , tmPayload = []- , tmFire =- FireNode- { fireTarget = "Target"- , fireKey = "correlationId"- , fireCommand = "Fire"- , fireFields = []- , fireFiredEventId = IdExpr UuidV5Id "fired:"- , fireDisposition = FireDisposition OFired OFired ORetry ORetry ORetry- }- , tmDecodeUnknown = "Cancelled"- , tmMaxAttempts = 5- , tmDeadLetter = "exhausted"- , tmLoc = noLoc- }- , procLoc = noLoc- }--genName :: Gen Name-genName =- frequency- [- ( 3- , do- base <- elements ["Aa", "Bb", "Cc", "Dd", "St", "Cmd", "Ev", "Reg", "Fld", "Foo", "Bar", "Qux"]- n <- choose (0, 9 :: Int)- pure (T.pack (base <> show n))- )- , (1, elements ["data1", "typeA", "whereX", "gotoX", "guardY", "emitZ", "_lead"])- ]--genWire :: Gen T.Text-genWire = do- base <- elements ["red", "blue", "green", "ctorName", "camelCase", "rsv", "hosp", "held", "partial-divert", "1st"]- n <- choose (0, 9 :: Int)- pure (T.pack (base <> show n))--genWireWord :: Gen T.Text-genWireWord = genWire--smallList :: Gen a -> Gen [a]-smallList g = choose (0, 3 :: Int) >>= \n -> vectorOf n g--nonEmptyList :: Gen a -> Gen [a]-nonEmptyList g = choose (1, 3 :: Int) >>= \n -> vectorOf n g--genMaybe :: Gen a -> Gen (Maybe a)-genMaybe g = oneof [pure Nothing, Just <$> g]--genCmp :: Gen CmpOp-genCmp = elements [OpEq, OpNeq, OpLt, OpLe, OpGt, OpGe]--genAtom :: Gen Expr-genAtom = EAtom <$> oneof [AName <$> genName, ABool <$> arbitrary]--genExpr :: Gen Expr-genExpr = go (3 :: Int)- where- go 0 = genAtom- go d =- oneof- [ genAtom- , EOr <$> go (d - 1) <*> go (d - 1)- , EAnd <$> go (d - 1) <*> go (d - 1)- , ECmp <$> genCmp <*> go (d - 1) <*> go (d - 1)- ]--genField :: Gen Field-genField = Field <$> genName <*> oneof [pure Nothing, Just <$> genName]--genReg :: Gen RegDecl-genReg = RegDecl <$> genName <*> genName <*> genRegInitial <*> pure noLoc--genRegInitial :: Gen RegInitial-genRegInitial = oneof [RegInitBare <$> genName, RegInitText <$> genAdversarialText]--genState :: Gen StateDecl-genState = StateDecl <$> genName <*> arbitrary <*> pure noLoc--genCommand :: Gen Command-genCommand = Command <$> genName <*> smallList genField <*> pure noLoc--genEvent :: Gen Event-genEvent = do- name <- genName- eventBody <- body- version <- choose (1, 3)- upcast <- genMaybe ((,) <$> choose (0, 3) <*> pure Hole)- (retiring, deprecated) <- elements [(False, False), (True, False), (False, True)]- pure- Event- { evName = name- , evBody = eventBody- , evVersion = version- , evUpcastFrom = upcast- , evRetiring = retiring- , evDeprecated = deprecated- , evLoc = noLoc- }- where- body = oneof [EventFromCommand <$> genName, EventFields <$> smallList genField]--genTransition :: Gen Transition-genTransition =- Transition- <$> genName- <*> genName- <*> genMaybe genExpr- <*> smallList ((,) <$> genName <*> genExpr)- <*> smallList genName- <*> genName- <*> elements [TmLive, TmReplayOnly]- <*> pure noLoc--genWireSpec :: Gen WireSpec-genWireSpec = WireSpec <$> genWire <*> genWire <*> (getNonNegative <$> arbitrary)--genProjection :: Gen ProjectionSpec-genProjection =- ProjectionSpec- <$> genName- <*> genMaybe (elements [Strong, Eventual])- <*> genName- <*> genMaybe (Mapping <$> smallList ((,) <$> genName <*> genWire) <*> arbitrary)- <*> pure noLoc--genAggregate :: Gen Aggregate-genAggregate =- Aggregate- <$> genName- <*> smallList genReg- <*> smallList genState- <*> smallList genCommand- <*> smallList genEvent- <*> smallList genTransition- <*> genMaybe genWireSpec- <*> genMaybe genProjection- <*> genMaybe (SnapshotSpec <$> oneof [SnapEvery <$> choose (0, 5), pure SnapOnTerminal] <*> choose (0, 5) <*> genAdversarialText <*> pure noLoc)- <*> pure noLoc--genDottedRef :: Gen T.Text-genDottedRef = elements ["input.id", "input.hospitalId", "timer.id", "correlationId", "payload.messageId"]--genWindow :: Gen T.Text-genWindow = elements ["0s", "5s", "2m", "1h"]--genFieldBinding :: Gen FieldBinding-genFieldBinding =- FieldBinding- <$> genName- <*> oneof- [ pure Nothing- , Just <$> genDottedRef- , Just . (\raw -> "\"" <> raw <> "\"") <$> genAdversarialText- ]--genDispatchDisposition :: Gen DispatchDisposition-genDispatchDisposition = DispatchDisposition <$> genDisp <*> genDisp <*> genDisp- where- genDisp = oneof [pure DAckOk, pure DRetry, DDeadLetter <$> genAdversarialText]--genDispatchNode :: Gen DispatchNode-genDispatchNode =- DispatchNode- <$> genName- <*> genDottedRef- <*> genName- <*> smallList genFieldBinding- <*> genDispatchDisposition- <*> pure noLoc--genFireDisposition :: Gen FireDisposition-genFireDisposition =- FireDisposition- <$> elements [OFired, ORetry]- <*> elements [OFired, ORetry]- <*> elements [OFired, ORetry]- <*> elements [OFired, ORetry]- <*> elements [OFired, ORetry]--genIdExpr :: Gen IdExpr-genIdExpr = IdExpr UuidV5Id <$> genAdversarialText--genFireNode :: Gen FireNode-genFireNode =- FireNode- <$> genName- <*> genDottedRef- <*> genName- <*> smallList genFieldBinding- <*> genIdExpr- <*> genFireDisposition--genTimerNode :: Gen TimerNode-genTimerNode =- TimerNode- <$> genName- <*> genIdExpr- <*> (FireAtExpr <$> genName <*> genWindow)- <*> smallList genFieldBinding- <*> genFireNode- <*> genName- <*> choose (0, 5)- <*> genAdversarialText- <*> pure noLoc--genProcess :: Gen ProcessNode-genProcess =- ProcessNode- <$> genName- <*> genAdversarialText- <*> (InputDecl <$> genName <*> smallList genField)- <*> (CorrelateDecl <$> genName <*> genName)- <*> (SagaRef <$> genName <*> genAdversarialText)- <*> genName- <*> smallList genName- <*> (HandleNode <$> genName <*> (AdvanceNode <$> genName <*> smallList genFieldBinding) <*> smallList genDispatchNode <*> genName)- <*> elements [PolHalt, PolDeadLetter, PolSkip]- <*> elements [PolHalt, PolDeadLetter, PolSkip]- <*> genTimerNode- <*> pure noLoc--genResolveSource :: Gen ResolveSource-genResolveSource = oneof [ResolveReadModel <$> genName, pure ResolveHole]--genRouter :: Gen RouterNode-genRouter =- RouterNode- <$> genName- <*> genAdversarialText- <*> (InputDecl <$> genName <*> smallList genField)- <*> (CorrelateDecl <$> genName <*> genName)- <*> (ResolveDecl <$> genResolveSource <*> smallList genName <*> pure noLoc)- <*> genName- <*> smallList genName- <*> (RouterDispatchNode <$> genName <*> smallList genFieldBinding <*> genDispatchDisposition <*> pure noLoc)- <*> elements [PolHalt, PolDeadLetter, PolSkip]- <*> elements [PolHalt, PolDeadLetter, PolSkip]- <*> pure noLoc--genContractField :: Gen ContractField-genContractField = ContractField <$> genName <*> oneof [CTypeId <$> genAdversarialText, pure CText, pure CInt]--genContractEvent :: Gen ContractEvent-genContractEvent = ContractEvent <$> genName <*> genName <*> smallList genContractField--genContract :: Gen ContractNode-genContract =- ContractNode- <$> genName- <*> choose (0, 5)- <*> genName- <*> smallList ((,) <$> genName <*> genAdversarialText)- <*> smallList genContractEvent- <*> pure noLoc--genWireSource :: Gen WireSource-genWireSource = oneof [SrcHeader <$> genAdversarialText, pure SrcBody, pure SrcKafkaKey, pure SrcKafkaCursor]--genInboxAction :: Gen InboxAction-genInboxAction = oneof [pure IAckOk, IRetry <$> genWindow, IDeadLetter <$> genMaybe genAdversarialText]--genDispositionRow :: Gen DispositionRow-genDispositionRow = DispositionRow <$> genName <*> genInboxAction <*> pure noLoc--genDecodeSpec :: Gen DecodeSpec-genDecodeSpec =- DecodeSpec- <$> ((\first second -> first <> " " <> second) <$> genWireWord <*> genWireWord)- <*> arbitrary- <*> choose (0, 5)--genIntake :: Gen IntakeNode-genIntake =- IntakeNode- <$> genName- <*> genName- <*> genName- <*> nonEmptyList genName- <*> smallList (BindRow <$> genName <*> genWireSource <*> arbitrary <*> arbitrary)- <*> genName- <*> genName- <*> elements [InkPersistFull, InkPersistDedupeOnly]- <*> genDecodeSpec- <*> smallList genDispositionRow- <*> pure noLoc--genDeriveSpec :: Gen DeriveSpec-genDeriveSpec = DeriveSpec <$> genMaybe genAdversarialText--genEmit :: Gen EmitNode-genEmit =- EmitNode- <$> genName- <*> genName- <*> genName- <*> genAdversarialText- <*> genName- <*> genName- <*> smallList (EmitMapRow <$> genAdversarialText <*> genName <*> pure noLoc)- <*> arbitrary- <*> genDeriveSpec- <*> genDeriveSpec- <*> pure noLoc--genPublisher :: Gen PublisherNode-genPublisher =- PublisherNode- <$> genName- <*> genName- <*> genName- <*> choose (0, 5)- <*> (BackoffSpec <$> genName <*> genWindow <*> genMaybe genWindow <*> genMaybe (elements ["1.0", "2.0", "3"]))- <*> genName- <*> pure noLoc--genWqField :: Gen WqField-genWqField = WqField <$> genName <*> genAdversarialText <*> genName <*> arbitrary--genWqDispRow :: Gen WqDispRow-genWqDispRow = WqDispRow <$> genName <*> genInboxAction <*> pure noLoc--genWorkqueue :: Gen WorkqueueNode-genWorkqueue =- WorkqueueNode- <$> genName- <*> genAdversarialText- <*> genAdversarialText- <*> genAdversarialText- <*> genAdversarialText- <*> elements [WqUnordered, WqFifoThroughput, WqFifoRoundRobin]- <*> genMaybe (WqGroupKey <$> genName <*> genName <*> genMaybe genAdversarialText)- <*> oneof [pure WqStandard, pure WqUnlogged, WqPartitioned <$> genAdversarialText <*> genAdversarialText]- <*> genName- <*> smallList genWqField- <*> choose (0, 5)- <*> genWindow- <*> arbitrary- <*> smallList genWqDispRow- <*> pure noLoc--genReadModel :: Gen ReadModelNode-genReadModel =- ReadModelNode- <$> genName- <*> genAdversarialText- <*> genAdversarialText- <*> smallList (RmColumn <$> genWireWord <*> genName <*> arbitrary)- <*> choose (0, 5)- <*> genAdversarialText- <*> elements [Strong, Eventual]- <*> genMaybe (oneof [pure RmEntireLog, RmCategory <$> genAdversarialText])- <*> elements [RmInline, RmSubscription]- <*> genMaybe genAdversarialText- <*> pure noLoc--genPgmqDispatch :: Gen PgmqDispatchNode-genPgmqDispatch =- PgmqDispatchNode- <$> genName- <*> genName- <*> genName- <*> genName- <*> genName- <*> genName- <*> genName- <*> genName- <*> genName- <*> genName- <*> pure noLoc--genWfBodyItem :: Gen WfBodyItem-genWfBodyItem = sized go- where- go size =- oneof $- [ WfStep <$> genWireWord <*> genName <*> pure noLoc- , WfAwait <$> genWireWord <*> genName <*> pure noLoc- , WfSleep <$> genWireWord <*> genName <*> pure noLoc- , WfChild <$> genWireWord <*> genName <*> genName <*> pure noLoc- , WfContinueAsNew <$> genName <*> pure noLoc- ]- ++ [ WfPatch <$> genWireWord <*> resize (size `div` 2) (smallList genWfBodyItem) <*> pure noLoc- | size > 0- ]--genWorkflow :: Gen WorkflowNode-genWorkflow =- WorkflowNode- <$> genName- <*> genAdversarialText- <*> genName- <*> smallList genField- <*> genName- <*> genMaybe genName- <*> genName- <*> smallList genWfBodyItem- <*> pure noLoc--genOperationShape :: Gen OperationShape-genOperationShape =- oneof- [ CommandOp <$> genName <*> genName <*> genName <*> smallList genName- , QueryOp <$> genName <*> genName <*> ((\parts -> T.unwords parts) <$> nonEmptyList genName) <*> genName- , SignalOp <$> genWireWord <*> genName <*> genName <*> genName <*> genName- , RunOp <$> genName <*> genName <*> genName- ]--genOperation :: Gen OperationNode-genOperation = OperationNode <$> genName <*> genOperationShape <*> pure noLoc--allNodeTags :: [String]-allNodeTags = ["aggregate", "process", "router", "contract", "intake", "emit", "publisher", "workqueue", "pgmq-dispatch", "readmodel", "workflow", "operation"]--nodeTag :: Node -> String-nodeTag = \case- NAggregate _ -> "aggregate"- NProcess _ -> "process"- NRouter _ -> "router"- NContract _ -> "contract"- NIntake _ -> "intake"- NEmit _ -> "emit"- NPublisher _ -> "publisher"- NWorkqueue _ -> "workqueue"- NPgmqDispatch _ -> "pgmq-dispatch"- NReadModel _ -> "readmodel"- NWorkflow _ -> "workflow"- NOperation _ -> "operation"--genId :: Gen IdDecl-genId = IdDecl <$> genName <*> genWire <*> pure noLoc--genEnum :: Gen EnumDecl-genEnum = EnumDecl <$> genName <*> smallList ((,) <$> genName <*> genWire) <*> pure noLoc--genRule :: Gen RuleDecl-genRule =- RuleDecl- <$> genName- <*> genName- <*> genName- <*> nonEmptyList ((,) <$> genName <*> genExpr)- <*> pure noLoc--genMappedDecls :: Gen [MappedDecl]-genMappedDecls = do- count <- choose (0, 4 :: Int)- let names = take count ["MappedA", "MappedB", "MappedC", "MappedD"]- traverse (genMappedDecl names) names--genMappedDecl :: [Name] -> Name -> Gen MappedDecl-genMappedDecl names name =- oneof- [ MappedStructural name- <$> genMaybe genHaskellSource- <*> genMaybe genAdversarialText- <*> genMaybe genAdversarialText- <*> genMaybe genAdversarialText- <*> genMaybe genAdversarialText- <*> genMaybe genAdversarialText- <*> genMappedShape names- <*> pure noLoc- , MappedOpaque name- <$> genMaybe genHaskellSource- <*> genMaybe genAdversarialText- <*> genMaybe genAdversarialText- <*> genMaybe genAdversarialText- <*> genMaybe genAdversarialText- <*> pure noLoc- ]--genHaskellSource :: Gen HaskellSource-genHaskellSource =- HaskellSource- <$> genWire- <*> genModuleRoot- <*> genName--genMappedShape :: [Name] -> Gen MappedShape-genMappedShape names =- oneof- [ ShapeRecord- <$> genName- <*> elements [RejectUnknown, IgnoreUnknown]- <*> smallList (genWireField names)- , ShapeEnum <$> smallList (WireEnum <$> genName <*> genAdversarialText <*> pure noLoc)- , ShapeUnion- <$> (TaggedObject <$> genAdversarialText <*> genAdversarialText <*> elements [RejectUnknown, IgnoreUnknown])- <*> smallList (WireArm <$> genName <*> genAdversarialText <*> genMaybe (genTypeExpr names) <*> pure noLoc)- ]--genWireField :: [Name] -> Gen WireField-genWireField names =- WireField- <$> genName- <*> genAdversarialText- <*> genTypeExpr names- <*> elements [PRequired, POptional]- <*> genMaybe genOnMissing- <*> pure noLoc--genTypeExpr :: [Name] -> Gen TypeExpr-genTypeExpr names = sized (go . min 3)- where- go 0 = base- go depth =- frequency- [ (4, base)- , (1, TOptional <$> go (depth - 1))- , (1, TList <$> go (depth - 1))- , (1, TMap <$> go (depth - 1))- ]- base = elements ([TText, TInt, TBool, TNatural, TTime, TJson] ++ map TRef names)--genOnMissing :: Gen OnMissing-genOnMissing =- oneof- [ pure OmNull- , OmText <$> genAdversarialText- , OmInt <$> choose (-10, 10)- , OmBool <$> arbitrary- , pure OmEmptyList- , pure OmEmptyMap- , OmCtor <$> genName- ]--genSpec :: Gen Spec-genSpec = do- contextName <- genWire- moduleRoot <- genMaybe genModuleRoot- layout <- genMaybe (elements [GeneratedPrefix, CollocatedLeaf])- ids <- smallList genId- enums <- smallList genEnum- rules <- smallList genRule- mapped <- genMappedDecls- nodes <- smallList genNode- pure (Spec contextName moduleRoot layout ids enums rules mapped nodes)- where- genNode =- oneof- [ NAggregate <$> genAggregate- , NProcess <$> genProcess- , NRouter <$> genRouter- , NContract <$> genContract- , NIntake <$> genIntake- , NEmit <$> genEmit- , NPublisher <$> genPublisher- , NWorkqueue <$> genWorkqueue- , NPgmqDispatch <$> genPgmqDispatch- , NReadModel <$> genReadModel- , NWorkflow <$> genWorkflow- , NOperation <$> genOperation- ]---- | A dotted PascalCase module prefix, e.g. @Acme@ or @Acme.Services@.-genModuleRoot :: Gen T.Text-genModuleRoot = do- n <- choose (1, 3 :: Int)- segs <- vectorOf n (elements ["Acme", "Services", "Hospital", "Domain", "Core"])- pure (T.intercalate "." segs)+-- | Test driver for keiro-dsl. EP-1 milestone 1 tests: the @parse . pretty@+-- round-trip property over generated specs, and a unit test pinning the shape+-- of the canonical Reservation fixture.+module Main (main) where++import Control.Exception (bracket)+import Control.Monad (filterM, forM, forM_, unless)+import Data.Aeson (Value, object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Either (isLeft, isRight)+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.AggregateType+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, diffSources, 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.Expression+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.LanguageVersion+import Keiro.Dsl.Manifest (manifestDependencies, moduleNameOf, renderManifest)+import Keiro.Dsl.MappedConsumer (ConsumerPlan (..), MappingIdentity (..), consumerPlan)+import Keiro.Dsl.NominalType hiding (NominalInvalidHaskellSource, NominalInvalidIdPrefix, NominalInvalidIdentity, NominalMissingIngredient)+import Keiro.Dsl.Parser (parseSource, parseSpec)+import Keiro.Dsl.PrettyPrint (renderSource, 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, renderRecord)+import Keiro.Dsl.ScaffoldRun (MappingDrift (..), Refusal (..), ScaffoldReport (..), SourceLanguageDrift (..), StaleModule (..), WriteDisposition (..), executeScaffold, executeScaffoldWithLanguage, 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, takeExtension, (</>))+import System.IO (hClose, openTempFile)+import System.Process (readProcessWithExitCode)+import Test.Hspec hiding (Spec)+import Test.QuickCheck++main :: IO ()+main = hspec $ do+ describe "source language version" $ do+ let legacy = "context hospital-capacity\n"+ declared = "# leading comment\n\nlanguage keiro-dsl 1\ncontext hospital-capacity\n"+ failureCode source = case parseSource "source.keiro" source of+ Left (SourceLanguageFailure diagnostic) -> Just (sourceLanguageErrorCode diagnostic)+ _ -> Nothing+ parseRight name source = case parseSource name source of+ Left failure -> expectationFailure (T.unpack (renderParseFailure failure)) >> fail "unreachable"+ Right value -> pure value+ declaredVersionOf DeclaredLanguage {declaredLanguageVersion = version} = Just version+ declaredVersionOf LegacyUnversioned = Nothing+ orderedSubstrings needles haystack = go (map T.pack needles) (T.pack haystack)+ where+ go [] _ = True+ go (needle : rest) remaining =+ let (_, suffix) = T.breakOn needle remaining+ in not (T.null suffix) && go rest (T.drop (T.length needle) suffix)++ it "selects declared v1 after comments while preserving semantic equality" $ do+ legacySource <- parseRight "legacy.keiro" legacy+ declaredSource <- parseRight "declared.keiro" declared+ parsedSpec legacySource `shouldBe` parsedSpec declaredSource+ parsedSourceLanguage legacySource `shouldBe` LegacyUnversioned+ declaredVersionOf (parsedSourceLanguage declaredSource) `shouldBe` languageVersion 1+ effectiveLanguageVersion (parsedSourceLanguage legacySource)+ `shouldBe` effectiveLanguageVersion (parsedSourceLanguage declaredSource)++ it "retains explicit declarations in source rendering and leaves legacy unversioned" $ do+ legacySource <- parseRight "legacy.keiro" legacy+ declaredSource <- parseRight "declared.keiro" declared+ renderSource legacySource `shouldBe` "context hospital-capacity\n"+ renderSource declaredSource `shouldBe` "language keiro-dsl 1\ncontext hospital-capacity\n"+ parseSpec "declared.keiro" declared `shouldBe` Right (parsedSpec declaredSource)++ it "classifies invalid, unsupported, duplicate, and misplaced preambles" $ do+ failureCode "language keiro-dsl 0\ncontext hospital-capacity\n" `shouldBe` Just InvalidLanguageVersion+ failureCode "language keiro-dsl nope\ncontext hospital-capacity\n" `shouldBe` Just InvalidLanguageVersion+ failureCode "language keiro-dsl -1\ncontext hospital-capacity\n" `shouldBe` Just InvalidLanguageVersion+ failureCode "language keiro-dsl 3\ncontext hospital-capacity\n" `shouldBe` Just UnsupportedLanguageVersion+ failureCode "language keiro-dsl 1\nlanguage keiro-dsl 1\ncontext hospital-capacity\n" `shouldBe` Just DuplicateLanguagePreamble+ failureCode "context hospital-capacity\nlanguage keiro-dsl 1\n" `shouldBe` Just MisplacedLanguagePreamble++ it "rejects a future version before parsing an invalid v1 body" $+ case parseSource "future.keiro" "language keiro-dsl 3\nthis is not a v2 body\n" of+ Left failure@(SourceLanguageFailure diagnostic) -> do+ sourceLanguageErrorCode diagnostic `shouldBe` UnsupportedLanguageVersion+ renderParseFailure failure `shouldSatisfy` T.isInfixOf "supported versions: 1, 2"+ renderParseFailure failure `shouldNotSatisfy` T.isInfixOf "expecting `context`"+ other -> expectationFailure ("expected source-language failure, got " <> show other)++ it "accepts and canonically round-trips nominal declarations only in v2" $ do+ let nominalSource =+ T.unlines+ [ "language keiro-dsl 2",+ "context orders",+ "id OrderId prefix=ord using {",+ " haskell package=orders-domain module=Orders.Id type=OrderId",+ " binding = \"Orders.KeiroBindings.orderIdBinding\"",+ " binding-version = \"1\"",+ " canonical-type = \"orders.OrderId.v1\"",+ " fixtures = \"Orders.KeiroBindings.orderIdFixtures\"",+ "}",+ "enum OrderStatus { Draft=draft Submitted=submitted } using {",+ " haskell package=orders-domain module=Orders.Order type=OrderStatus",+ " binding = \"Orders.KeiroBindings.orderStatusBinding\"",+ " binding-version = \"1\"",+ " canonical-type = \"orders.OrderStatus.v1\"",+ " fixtures = \"Orders.KeiroBindings.orderStatusFixtures\"",+ "}",+ "mapped nominal AccountNumber : Text {",+ " haskell package=orders-domain module=Orders.Account type=AccountNumber",+ " binding = \"Orders.KeiroBindings.accountNumberBinding\"",+ " binding-version = \"1\"",+ " canonical-type = \"orders.AccountNumber.v1\"",+ " fixtures = \"Orders.KeiroBindings.accountNumberFixtures\"",+ " initial = \"Orders.KeiroBindings.initialAccountNumber\"",+ "}"+ ]+ parsed <- parseRight "nominal.keiro" nominalSource+ length (specIds (parsedSpec parsed)) `shouldBe` 1+ length (specEnums (parsedSpec parsed)) `shouldBe` 1+ length (specNominalScalars (parsedSpec parsed)) `shouldBe` 1+ parseSource "nominal-round-trip.keiro" (renderSource parsed) `shouldBe` Right parsed++ it "reports successor nominal syntax as one language-version diagnostic under v1 and legacy" $ do+ let body = "context orders\nmapped nominal AccountNumber : Text {}\n"+ failureCode ("language keiro-dsl 1\n" <> body) `shouldBe` Just LanguageFeatureRequiresVersion+ failureCode body `shouldBe` Just LanguageFeatureRequiresVersion++ it "reports a declaration-only rewrite without semantic, generated, fold, or replay impact" $ do+ fixture <- readTestText "test/fixtures/reservation.keiro"+ let legacyFixture = T.unlines (drop 1 (T.lines fixture))+ legacySource <- parseRight "legacy.keiro" legacyFixture+ declaredSource <- parseRight "declared.keiro" fixture+ let oldSpec = parsedSpec legacySource+ newSpec = parsedSpec declaredSource+ changes = diffSources legacySource declaredSource+ vectors = [ckVector kind | change <- changes, let kind = workspaceChangeKind change]+ map changeCode changes `shouldBe` [SourceLanguageDeclarationChanged]+ diffSpecs oldSpec newSpec `shouldBe` []+ vectors `shouldSatisfy` all (\compatibility -> all ((== VCompatible) . (`verdictFor` compatibility)) [minBound .. maxBound])+ case changes of+ [change] ->+ remediationFor (ckContext (workspaceChangeKind change)) SourceLanguageDeclarationChanged+ `shouldBe` (RemedyNoSemanticAction :| [])+ _ -> expectationFailure "expected one source-language change"+ let generatedSurface spec =+ [ (modulePath scaffoldModule, moduleText scaffoldModule, kind scaffoldModule)+ | scaffoldModule <- scaffoldModules (defaultContext (specContext spec)) spec+ ]+ generatedSurface oldSpec `shouldBe` generatedSurface newSpec+ [aggregateFoldFingerprint oldSpec aggregate | NAggregate aggregate <- specNodes oldSpec]+ `shouldBe` [aggregateFoldFingerprint newSpec aggregate | NAggregate aggregate <- specNodes newSpec]+ ReplayImpact.replayImpact oldSpec newSpec `shouldBe` ReplayNeutral++ it "exposes stable JSON inspection for a source and canonically ordered workspace members" $ do+ (sourceCode, sourceOut, sourceErr) <- runKeiroDsl ["inspect", "test/fixtures/reservation.keiro", "--format=json"]+ sourceCode `shouldBe` ExitSuccess+ sourceErr `shouldBe` ""+ sourceOut `shouldContain` "\"schema\":\"keiro-dsl/source-inspection/1\""+ sourceOut `shouldContain` "\"kind\":\"source\""+ sourceOut `shouldContain` "\"sourceForm\":\"declared\""+ sourceOut `shouldContain` "\"declaredLanguageVersion\":1"+ sourceOut `shouldContain` "\"effectiveLanguageVersion\":1"+ (workspaceCode, workspaceOut, workspaceErr) <- runKeiroDsl ["inspect", canonicalWorkspacePath, "--format=json"]+ workspaceCode `shouldBe` ExitSuccess+ workspaceErr `shouldBe` ""+ workspaceOut `shouldContain` "\"kind\":\"workspace\""+ workspaceOut `shouldContain` "\"service\":\"demo-project\""+ workspaceOut `shouldSatisfy` orderedSubstrings ["domain/project-artifact.keiro", "domain/project.keiro", "domain/shared.keiro"]++ it "keeps only the named source-version compatibility fixtures outside canonical v1" $ do+ fixtureTree <- treeSnapshot "test/fixtures"+ let withoutCanonicalV1 =+ sort+ [ path+ | (path, contents) <- fixtureTree,+ takeExtension path == ".keiro",+ "language keiro-dsl 1" `notElem` T.lines contents+ ]+ withoutCanonicalV1+ `shouldBe` sort+ [ "language-duplicate.keiro",+ "aggregate-collection-expressions-v2-rejects.keiro",+ "aggregate-scalar-expressions-v2.keiro",+ "language-future.keiro",+ "language-legacy.keiro",+ "language-malformed.keiro",+ "language-misplaced.keiro",+ "language-zero.keiro",+ "nominal-bad-qualified.keiro",+ "nominal-invalid-prefix.keiro",+ "nominal-missing-facts.keiro",+ "nominal-missing-initial.keiro",+ "nominal-name-collision.keiro",+ "nominal-scalars.keiro",+ "nominal-unsupported-representation.keiro"+ ]++ it "checks v1, rejects a future contract once, and inspects legacy explicitly" $ do+ (v1Code, v1Out, v1Err) <- runKeiroDsl ["check", "test/fixtures/language-v1.keiro"]+ v1Code `shouldBe` ExitSuccess+ v1Out `shouldBe` "OK\n"+ v1Err `shouldBe` ""+ (futureCode, _, futureErr) <- runKeiroDsl ["check", "test/fixtures/language-future.keiro"]+ futureCode `shouldBe` ExitFailure 1+ T.count "UnsupportedLanguageVersion" (T.pack futureErr) `shouldBe` 1+ futureErr `shouldContain` "supported versions: 1, 2"+ futureErr `shouldNotContain` "expecting `context`"+ (legacyCode, legacyOut, legacyErr) <- runKeiroDsl ["inspect", "test/fixtures/language-legacy.keiro", "--format=json"]+ legacyCode `shouldBe` ExitSuccess+ legacyErr `shouldBe` ""+ legacyOut `shouldContain` "\"sourceForm\":\"legacy-unversioned\""+ legacyOut `shouldContain` "\"declaredLanguageVersion\":null"+ legacyOut `shouldContain` "\"effectiveLanguageVersion\":1"++ it "preserves a workspace member's source-selection code beneath outer attribution" $ do+ let manifest = "service demo\nspec domain/future.keiro\n"+ futureSource = "language keiro-dsl 3\nthis body must not parse\n"+ source = memoryContentSource (Map.fromList [("service.keiro-workspace", manifest), ("domain/future.keiro", futureSource)])+ loaded <- loadWorkspace source "service.keiro-workspace"+ case loaded of+ Left (WorkspaceRefused (diagnostic :| [])) -> do+ wdCode diagnostic `shouldBe` WorkspaceMemberParseFailed+ sourceLanguageErrorCode <$> wdSourceLanguageCause diagnostic+ `shouldBe` Just UnsupportedLanguageVersion+ renderWorkspaceDiagnostic "service.keiro-workspace" diagnostic+ `shouldSatisfy` T.isInfixOf "UnsupportedLanguageVersion"+ other -> expectationFailure ("expected one attributed source-language refusal, got " <> show other)++ it "attributes a workspace provenance-only diff to the changed member" $ do+ workspace <- shouldComposeWorkspace canonicalWorkspacePath+ case wsMembers workspace of+ firstMember : remaining -> do+ let changedMember = firstMember {wmSourceLanguage = LegacyUnversioned}+ changedWorkspace = workspace {wsMembers = changedMember : remaining}+ changes = diffWorkspaces workspace changedWorkspace+ map (changeCode . wcChange) changes `shouldBe` [SourceLanguageDeclarationChanged]+ map (fmap osFile . wcDeclarationSite) changes `shouldBe` [Just (wmPath firstMember)]+ map wcChange changes `shouldSatisfy` all (not . gatedBreaking (gateWith [minBound .. maxBound]))+ _ -> expectationFailure "canonical workspace had no member"++ describe "scalar expressions" $ do+ it "parses, validates, and round-trips the authoritative v2 scalar fixture" $ do+ source <- readTestText "test/fixtures/aggregate-scalar-expressions-v2.keiro"+ parsed <- case parseSource "aggregate-scalar-expressions-v2.keiro" source of+ Left failure -> expectationFailure (T.unpack (renderParseFailure failure)) >> fail "unreachable"+ Right value -> pure value+ validateSpec (parsedSpec parsed) `shouldBe` []+ parseSource "round-trip.keiro" (renderSource parsed) `shouldBe` Right parsed+ case [aggregate | NAggregate aggregate <- specNodes (parsedSpec parsed)] of+ [aggregate] -> case aggTransitions aggregate of+ transition : holeTransition : [] -> do+ tImplementation transition `shouldBe` GeneratedImplementation+ tImplementation holeTransition `shouldBe` HoleImplementation+ let environment = expressionEnvironment (parsedSpec parsed) aggregate transition+ case lookup "reserved" (tWrites transition) >>= either (const Nothing) Just . resolveWriteExpr environment "reserved" of+ Just resolved -> do+ typedScalarType resolved `shouldBe` AggregateNatural+ show (typedScalarNode resolved) `shouldContain` "TotalNaturalArithmetic"+ Nothing -> expectationFailure "reserved write did not resolve"+ let modules = scaffoldAggregate (defaultContext (specContext (parsedSpec parsed))) (parsedSpec parsed) aggregate+ expressions = generatedTextEndingIn "Expressions.hs" modules+ transducer = generatedTextEndingIn "Transducer.hs" modules+ holes = holeTextEndingIn "Holes.hs" modules+ map modulePath modules `shouldSatisfy` any (T.isSuffixOf "Expressions.hs" . T.pack)+ map modulePath modules `shouldSatisfy` any (T.isSuffixOf "Transducer.hs" . T.pack)+ expressions `shouldSatisfy` T.isInfixOf "K.tsub (d.requested) (B.reg @\"capacity\")"+ transducer `shouldSatisfy` T.isInfixOf "B.requireGuard (Expressions.transition1OpenAdjustGuard d)"+ transducer `shouldSatisfy` T.isInfixOf "B.slot @\"balance\" =: Expressions.transition1OpenAdjustWriteBalance d"+ transducer `shouldSatisfy` T.isInfixOf "scalarAccountPredicateVerifications"+ transducer `shouldSatisfy` T.isInfixOf "S.verifyPredicate predicate"+ holes `shouldSatisfy` T.isInfixOf "transition1OpenAdjustOutput1Adjusted"+ holes `shouldSatisfy` T.isInfixOf "transition2ReviewedCloseHoleFoldVersion"+ holes `shouldSatisfy` (not . T.isInfixOf "scalarAccountTransducer")+ firewallBreaches modules `shouldBe` []+ _ -> expectationFailure "expected one generated and one Hole scalar transition"+ _ -> expectationFailure "expected one scalar aggregate"++ it "rejects Int arithmetic and mixed numeric operands before scaffolding" $ do+ let source =+ T.unlines+ [ "language keiro-dsl 2",+ "context scalar-errors",+ "aggregate Counter",+ " regs",+ " machine Int = 0",+ " exact Integer = 0",+ " states Open Closed!",+ " command Add { machine:Int exact:Integer }",+ " event Added = fields(Add)",+ " Open -- Add -->",+ " guard cmd.machine + 1 >= 0 && cmd.exact == cmd.machine",+ " emit Added",+ " goto Closed"+ ]+ spec <- parseInlineSpec "<scalar-errors>" source+ errorCodes spec `shouldContain` [AggregateExpressionOperatorUnsupported, AggregateExpressionOperandTypeMismatch]++ it "rejects machine-Int arithmetic at both platform bounds" $ do+ let source =+ T.unlines+ [ "language keiro-dsl 2",+ "context scalar-int-bounds",+ "aggregate Counter",+ " regs",+ " machine Int = 0",+ " states Open Closed!",+ " command Set { machine:Int }",+ " event SetEvent = fields(Set)",+ " Open -- Set -->",+ " guard cmd.machine + 1 >= " <> T.pack (show (minBound :: Int)),+ " && cmd.machine - 1 <= " <> T.pack (show (maxBound :: Int)),+ " emit SetEvent",+ " goto Closed"+ ]+ spec <- parseInlineSpec "<scalar-int-bounds>" source+ length [() | diagnostic <- validateSpec spec, code diagnostic == AggregateExpressionOperatorUnsupported]+ `shouldBe` 2++ it "rejects predicate-valued Bool writes that Keiki cannot represent as scalar terms" $ do+ let source =+ T.unlines+ [ "language keiro-dsl 2",+ "context scalar-bool-write",+ "aggregate Flag",+ " regs",+ " active Bool = False",+ " states Open Closed!",+ " command Set { active:Bool }",+ " event SetEvent = fields(Set)",+ " Open -- Set -->",+ " write active := cmd.active == true",+ " emit SetEvent",+ " goto Closed"+ ]+ spec <- parseInlineSpec "<scalar-bool-write>" source+ errorCodes spec `shouldContain` [AggregateExpressionOperatorUnsupported]++ it "requires explicit roots for a same-named register and command field" $ do+ let source =+ T.unlines+ [ "language keiro-dsl 2",+ "context scalar-ambiguity",+ "aggregate Counter",+ " regs",+ " amount Integer = 0",+ " states Open Closed!",+ " command Set { amount:Integer }",+ " event SetEvent = fields(Set)",+ " Open -- Set -->",+ " guard amount == 0",+ " emit SetEvent",+ " goto Closed"+ ]+ spec <- parseInlineSpec "<scalar-ambiguity>" source+ errorCodes spec `shouldContain` [AggregateExpressionRootAmbiguous]++ it "enforces exclusive Hole ownership and preserves its canonical spelling" $ do+ let source =+ T.unlines+ [ "language keiro-dsl 2",+ "context scalar-hole",+ "aggregate Counter",+ " regs",+ " amount Integer = 0",+ " states Open Closed!",+ " command Set { amount:Integer }",+ " event SetEvent = fields(Set)",+ " Open -- Set -->",+ " implementation hole",+ " guard cmd.amount >= 0",+ " emit SetEvent",+ " goto Closed"+ ]+ parsed <- case parseSource "<scalar-hole>" source of+ Left failure -> expectationFailure (T.unpack (renderParseFailure failure)) >> fail "unreachable"+ Right value -> pure value+ errorCodes (parsedSpec parsed) `shouldContain` [AggregateTransitionOwnershipConflict]+ renderSource parsed `shouldSatisfy` T.isInfixOf "implementation hole"++ it "generates a stable per-transition Hole boundary and fold token" $ do+ let source =+ T.unlines+ [ "language keiro-dsl 2",+ "context scalar-hole",+ "aggregate Counter",+ " regs",+ " amount Integer = 0",+ " states Open Closed!",+ " command Set { amount:Integer }",+ " event SetEvent = fields(Set)",+ " Open -- Set -->",+ " implementation hole",+ " emit SetEvent",+ " goto Closed"+ ]+ spec <- parseInlineSpec "<scalar-hole-valid>" source+ aggregate <- case [value | NAggregate value <- specNodes spec] of+ [value] -> pure value+ _ -> expectationFailure "expected one Hole aggregate" >> fail "unreachable"+ let modules = scaffoldAggregate (defaultContext (specContext spec)) spec aggregate+ transducer = generatedTextEndingIn "Transducer.hs" modules+ holes = holeTextEndingIn "Holes.hs" modules+ errorCodes spec `shouldBe` []+ transducer `shouldSatisfy` T.isInfixOf "Holes.transition1OpenSetHole d"+ transducer `shouldSatisfy` T.isInfixOf "foldToken Holes.transition1OpenSetHoleFoldVersion"+ holes `shouldSatisfy` T.isInfixOf "transition1OpenSetHole _d = B.requireGuard K.PTop"+ holes `shouldSatisfy` T.isInfixOf "transition1OpenSetHoleFoldVersion = FoldVersion"+ holes `shouldSatisfy` (not . T.isInfixOf "counterTransducer")++ it "pins v1 and collection rejection at their stable boundaries" $ do+ v1Source <- readTestText "test/fixtures/aggregate-scalar-expressions-v1-rejects.keiro"+ case parseSource "v1.keiro" v1Source of+ Left (SourceLanguageFailure diagnostic) -> sourceLanguageErrorCode diagnostic `shouldBe` LanguageFeatureRequiresVersion+ other -> expectationFailure ("expected v1 source-language refusal, got " <> show other)+ collectionSource <- readTestText "test/fixtures/aggregate-collection-expressions-v2-rejects.keiro"+ case parseSource "collections.keiro" collectionSource of+ Left failure -> renderParseFailure failure `shouldSatisfy` T.isInfixOf "CollectionExpressionUnsupported"+ Right _ -> expectationFailure "collection syntax unexpectedly parsed"++ it "keeps arithmetic operands intact when complementing a scalar comparison" $ do+ let left = EAdd noLoc (EPath noLoc CommandRoot ["balance"]) (ELiteral noLoc (LiteralIntegral 2))+ right = ESubtract noLoc (EPath noLoc RegisterRoot ["balance"]) (ELiteral noLoc (LiteralIntegral 3))+ predicate = ECmp OpLt left right+ complementExpr predicate `shouldBe` ECmp OpGe left right+ complementExpr (complementExpr predicate) `shouldBe` predicate++ it "keeps the committed scalar-expression conformance tree fresh" $ do+ spec <- specOf "test/fixtures/aggregate-scalar-expressions-v2.keiro"+ let modules = scaffoldModules (defaultContext (specContext spec)) spec+ forM_ [generatedModule | generatedModule <- modules, kind generatedModule == Generated] $ \generatedModule -> do+ committed <- readTestText ("test/conformance-scalar-expressions/" <> modulePath generatedModule)+ normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText generatedModule)++ describe "nominal consumer types" $ do+ it "resolves every category through one checked registry and explains exact obligations" $ do+ spec <- specOf "test/fixtures/nominal-scalars.keiro"+ errorCodes spec `shouldBe` []+ registry <- case resolveNominalTypes spec of+ Left errors -> expectationFailure (show errors) >> fail "unreachable"+ Right value -> pure value+ Map.keys (nominalTypes registry)+ `shouldBe` ["AccountNumber", "FeatureFlag", "ObservedAt", "OrderId", "OrderStatus", "RiskScore", "SequenceNumber"]+ obligations <- either (\errors -> expectationFailure (show errors) >> pure []) pure (bindingObligations spec)+ length obligations `shouldBe` 21+ map obligationCategory obligations `shouldSatisfy` all (`elem` ["nominal-id", "nominal-enum", "nominal-scalar"])+ let signatures = map obligationSignature obligations+ forM_+ [ "orderIdBinding :: NominalBinding NominalConformance.Domain.OrderId (KindID \"ord\")",+ "orderStatusBinding :: NominalBinding NominalConformance.Domain.OrderStatus Generated.NominalScalars.Nominal.Shape.OrderStatus.OrderStatusRepresentation",+ "accountNumberBinding :: NominalBinding NominalConformance.Domain.AccountNumber Text",+ "orderIdFixtures :: NominalFixtureCases NominalConformance.Domain.OrderId",+ "initialAccountNumber :: NominalConformance.Domain.AccountNumber"+ ]+ (`shouldSatisfy` (`elem` signatures))+ map obligationCanonicalType obligations `shouldSatisfy` all (/= Nothing)+ let rendered = renderBindingObligations (specContext spec) obligations+ rendered `shouldSatisfy` T.isInfixOf "nominal-id type OrderId"+ rendered `shouldSatisfy` T.isInfixOf "canonical-type: \"nominal.OrderId.v1\""+ case obligations of+ firstObligation : _ ->+ (Aeson.eitherDecode (Aeson.encode firstObligation) :: Either String BindingObligation)+ `shouldBe` Right firstObligation+ [] -> expectationFailure "expected nominal binding obligations"++ it "allocates distinct stable diagnostics for incomplete or incompatible nominal declarations" $ do+ missing <- errorCodesOf "test/fixtures/nominal-missing-facts.keiro"+ missing `shouldBe` replicate 5 NominalMissingIngredient+ errorCodesOf "test/fixtures/nominal-bad-qualified.keiro" `shouldReturn` [NominalInvalidQualifiedName]+ errorCodesOf "test/fixtures/nominal-invalid-prefix.keiro" `shouldReturn` [NominalInvalidIdPrefix]+ errorCodesOf "test/fixtures/nominal-unsupported-representation.keiro" `shouldReturn` [NominalUnsupportedRepresentation]+ errorCodesOf "test/fixtures/nominal-missing-initial.keiro" `shouldReturn` [NominalMissingInitialValue]+ errorCodesOf "test/fixtures/nominal-name-collision.keiro" `shouldReturn` [NominalNameCollision, NominalNameCollision]++ it "keeps v1 rejection at the source-language boundary" $ do+ source <- readTestText "test/fixtures/nominal-v1.keiro"+ case parseSource "nominal-v1.keiro" source of+ Left (SourceLanguageFailure diagnostic) -> sourceLanguageErrorCode diagnostic `shouldBe` LanguageFeatureRequiresVersion+ other -> expectationFailure ("expected source-language refusal, got " <> show other)++ it "scaffolds consumer types, checked codecs, enum representation, projections, and deterministic manifests" $ do+ spec <- specOf "test/fixtures/nominal-scalars.keiro"+ let ctx = defaultContext (specContext spec)+ modules = scaffoldModules ctx spec+ moduleAt path = case [value | value <- modules, modulePath value == path] of+ [value] -> pure value+ values -> expectationFailure ("expected one module at " <> path <> ", got " <> show (map modulePath values)) >> fail "unreachable"+ domainModule <- moduleAt "Generated/NominalScalars/NominalLedger/Domain.hs"+ codecModule <- moduleAt "Generated/NominalScalars/NominalLedger/Codec.hs"+ enumModule <- moduleAt "Generated/NominalScalars/Nominal/Shape/OrderStatus.hs"+ projectionModule <- moduleAt "Generated/NominalScalars/NominalProjections.hs"+ bindingModule <- moduleAt "NominalConformance/Bindings.hs"+ moduleText domainModule `shouldSatisfy` T.isInfixOf "NominalConformance.Domain.OrderId"+ moduleText domainModule `shouldSatisfy` (not . T.isInfixOf "newtype OrderId")+ moduleText domainModule `shouldSatisfy` (not . T.isInfixOf "data OrderStatus =")+ moduleText codecModule `shouldSatisfy` T.isInfixOf "KindID.parseText @\"ord\""+ moduleText codecModule `shouldSatisfy` T.isInfixOf "KindID.toText (nominalToRepresentation"+ moduleText codecModule `shouldSatisfy` T.isInfixOf "nominalFromRepresentation"+ forM_ ["coerce", "unsafe", "read ", "error "] $ \forbidden ->+ moduleText codecModule `shouldSatisfy` (not . T.isInfixOf forbidden)+ moduleText enumModule `shouldSatisfy` T.isInfixOf "data OrderStatusRepresentation = Draft | Submitted"+ moduleText enumModule `shouldSatisfy` (not . T.isInfixOf "NominalConformance")+ moduleText projectionModule `shouldSatisfy` T.isInfixOf "type FieldOwner AccountNumberNominalProjection = NominalConformance.Domain.AccountNumber"+ moduleText projectionModule `shouldSatisfy` T.isInfixOf "projectFieldValue _ = nominalToRepresentation NominalConformance.Bindings.accountNumberBinding"+ kind bindingModule `shouldBe` HoleStub+ moduleText bindingModule `shouldSatisfy` T.isInfixOf "orderIdBinding :: NominalBinding NominalConformance.Domain.OrderId (KindID \"ord\")"+ firewallBreaches modules `shouldBe` []+ scaffoldModules ctx spec `shouldBe` modules+ manifestDependencies spec `shouldContain` ["mmzk-typeid", "nominal-conformance"]++ it "persists nominal provenance in a separate forward-compatible row kind" $ do+ spec <- specOf "test/fixtures/nominal-scalars.keiro"+ workspace <- shouldComposeWorkspace canonicalWorkspacePath+ let plan = consumerPlan spec+ record =+ ScaffoldRecord+ { recSpecPath = "nominal-scalars.keiro",+ recModuleRoot = "",+ recLayout = "prefixed",+ recSourceLanguage = LegacyUnversioned,+ recFiles = [],+ recMappings = consumerMappings plan,+ recBindingObligations = []+ }+ encoded = renderRecord record+ workspaceRecord =+ (sampleWorkspaceRecord workspace)+ { wrMappings = consumerMappings plan+ }+ workspaceEncoded = renderWorkspaceRecord workspaceRecord+ consumerPackages plan `shouldBe` ["nominal-conformance"]+ length [() | NominalMapping {} <- consumerMappings plan] `shouldBe` 7+ T.count "nominal-mapping " encoded `shouldBe` 7+ T.count "\nmapping " encoded `shouldBe` 0+ parseRecord encoded `shouldBe` Just record+ T.count "nominal-mapping " workspaceEncoded `shouldBe` 7+ T.count "\nmapping " workspaceEncoded `shouldBe` 0+ parseWorkspaceRecord workspaceEncoded `shouldBe` Just workspaceRecord++ it "reports bound-ID decoder tightening and makes binding provenance replay-visible" $ do+ current <- specOf "test/fixtures/nominal-scalars.keiro"+ let unbound = current {specIds = [declaration {idBinding = Nothing} | declaration <- specIds current]}+ adoption = diffSpecs unbound current+ decoderFindings = [kindOfChange change | change <- adoption, changeCode change == NominalIdDecoderTightened]+ map ckSubject decoderFindings `shouldContain` ["NominalLedger event NominalsRecorded .orderId"]+ decoderFindings `shouldSatisfy` all ((== VAdvisory) . verdictFor PrivateHistoryRead . ckVector)+ let bumped =+ current+ { specIds =+ [ declaration+ { idBinding = fmap (\binding -> binding {nominalBindingVersion = Just "2"}) (idBinding declaration)+ }+ | declaration <- specIds current+ ]+ }+ bindingChanges = diffSpecs current bumped+ map changeCode bindingChanges `shouldContain` [NominalBindingChanged]+ ReplayImpact.replayImpact current bumped `shouldSatisfy` \case+ ReplayImpact.ReplayAffected impacts ->+ maybe False (\impact -> Set.member "NominalsRecorded" (ReplayImpact.eventTypes impact) && includeSnapshotStreams impact) (Map.lookup "NominalLedger" impacts)+ ReplayImpact.ReplayNeutral -> False+ case [aggregate | NAggregate aggregate <- specNodes current] of+ aggregate : _ -> aggregateFoldSurface current aggregate `shouldNotBe` aggregateFoldSurface bumped aggregate+ [] -> expectationFailure "expected nominal aggregate"++ 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", AggregateGuardCapabilityUnsupported)+ ]+ forM_ cases $ \(fixture, expected) ->+ errorCodesOf ("test/fixtures/" <> fixture) `shouldReturn` [expected]+ it "keeps Time and Natural in Keiki's curated comparison set" $ do+ errorCodesOf "test/fixtures/mapped-guard-time.keiro" `shouldReturn` []+ errorCodesOf "test/fixtures/mapped-guard-natural.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 "aggregate type capabilities" $ do+ it "enumerates the policy for every resolved type and use site" $ do+ let resolvedTypes =+ [ AggregateText,+ AggregateInt,+ AggregateBool,+ AggregateTime,+ AggregateNatural,+ AggregateNominal (ResolvedNominalType "EntityId" (IdRepresentation "ent") GeneratedNominal noLoc),+ AggregateNominal (ResolvedNominalType "Status" (EnumRepresentation (("Active", "active") :| [])) GeneratedNominal noLoc),+ AggregateNominal (ResolvedNominalType "Amount" (ScalarRepresentation NominalInt) (consumerNominalFor "Amount") noLoc),+ AggregateNominal (ResolvedNominalType "Label" (ScalarRepresentation NominalText) (consumerNominalFor "Label") noLoc),+ AggregateVertex "EntityVertex",+ AggregateMapped (MappedKey "ConsumerValue")+ ]+ useSites = [minBound .. maxBound]+ expected useSite resolvedType = case useSite of+ OrderingGuardUse -> case resolvedType of+ AggregateInt -> SolverVisible+ AggregateTime -> SolverVisible+ AggregateNatural -> SolverVisible+ AggregateNominal nominal -> case resolvedNominalRepresentation nominal of+ ScalarRepresentation NominalInt -> SolverVisible+ ScalarRepresentation NominalNatural -> SolverVisible+ ScalarRepresentation NominalTime -> SolverVisible+ _ -> Unsupported+ _ -> Unsupported+ EqualityGuardUse -> case resolvedType of+ AggregateMapped {} -> Unsupported+ AggregateNominal nominal -> case resolvedNominalRepresentation nominal of+ ScalarRepresentation {} -> SolverVisible+ _ -> OpaqueOnly+ AggregateVertex {} -> OpaqueOnly+ _ -> SolverVisible+ _ -> case resolvedType of+ AggregateNominal nominal -> case resolvedNominalRepresentation nominal of+ ScalarRepresentation {} -> SolverVisible+ _ -> OpaqueOnly+ AggregateVertex {} -> OpaqueOnly+ AggregateMapped {} -> OpaqueOnly+ _ -> SolverVisible+ actual =+ [ (useSite, resolvedType, aggregateCapability useSite resolvedType)+ | useSite <- useSites,+ resolvedType <- resolvedTypes+ ]+ wanted =+ [ (useSite, resolvedType, expected useSite resolvedType)+ | useSite <- useSites,+ resolvedType <- resolvedTypes+ ]+ actual `shouldBe` wanted+ it "lowers direct Time and Natural through every generated aggregate boundary" $ do+ spec <- specOf "test/fixtures/aggregate-scalars.keiro"+ errorCodes spec `shouldBe` []+ let aggregate = onlyAggregate spec+ generated =+ [ moduleText generatedModule+ | generatedModule <- scaffoldAggregate (defaultContext (specContext spec)) spec aggregate,+ Keiro.Dsl.Scaffold.kind generatedModule == Generated+ ]+ domain = generatedTextEndingIn "Domain.hs" (scaffoldAggregate (defaultContext (specContext spec)) spec aggregate)+ domain `shouldSatisfy` T.isInfixOf "observedAt :: !UTCTime"+ domain `shouldSatisfy` T.isInfixOf "revision :: !Natural"+ domain `shouldSatisfy` T.isInfixOf "UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)"+ domain `shouldSatisfy` T.isInfixOf "import Data.Time.Calendar (fromGregorian)"+ domain `shouldSatisfy` T.isInfixOf "import Data.Time.Clock (UTCTime(..), picosecondsToDiffTime)"+ domain `shouldSatisfy` T.isInfixOf "import Numeric.Natural (Natural)"+ manifestDependencies spec `shouldContain` ["time"]+ manifestDependencies spec `shouldNotContain` ["keiki-codec-json"]+ generated `shouldSatisfy` all (not . T.isInfixOf "error")+ generated `shouldSatisfy` all (not . T.isInfixOf "getCurrentTime")+ generated `shouldSatisfy` all (not . T.isInfixOf "iso8601ParseM")+ it "canonicalizes Time and UTCTime across pretty, diff, and fold identity" $ do+ source <- readTestText "test/fixtures/aggregate-scalars.keiro"+ canonical <- parseInlineSpec "<time>" source+ alias <- parseInlineSpec "<utctime>" (T.replace ":Time" ":UTCTime" (T.replace " Time =" " UTCTime =" source))+ renderSpec alias `shouldBe` renderSpec canonical+ diffSpecs canonical alias `shouldBe` []+ aggregateFoldFingerprint canonical (onlyAggregate canonical)+ `shouldBe` aggregateFoldFingerprint alias (onlyAggregate alias)+ aggregateFoldSurface canonical (onlyAggregate canonical)+ `shouldBe` aggregateFoldSurface alias (onlyAggregate alias)+ it "keeps the committed scalar conformance generated tree fresh" $ do+ spec <- specOf "test/fixtures/aggregate-scalars.keiro"+ let modules = scaffoldModules (defaultContext (specContext spec)) spec+ forM_ [generatedModule | generatedModule <- modules, Keiro.Dsl.Scaffold.kind generatedModule == Generated] $ \generatedModule -> do+ committed <- readTestText ("test/conformance-aggregate-scalars/" <> modulePath generatedModule)+ normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText generatedModule)+ it "never sends a clean scalar aggregate to a type scaffold refusal" $+ property $+ forAll (elements scalarRegisterCases) $ \(typeName, initialValue) ->+ case parseSpec "<clean-scalar>" (cleanScalarAggregateSpec typeName initialValue) of+ Left parseError -> counterexample (T.unpack parseError) False+ Right spec ->+ let diagnostics = [diagnostic | diagnostic <- validateSpec spec, severity diagnostic == Error]+ modules = scaffoldModules (defaultContext (specContext spec)) spec+ in counterexample+ (show diagnostics <> "\n" <> show (scaffoldRefusals spec))+ ( null diagnostics+ && null (scaffoldRefusals spec)+ && all (not . T.null . moduleText) modules+ )++ describe "aggregate scalar diagnostics" $ do+ it "reports unsupported shapes, invalid initials, and mismatched guards at stable lines" $ do+ diagnostics <- diagnosticsOf "test/fixtures/aggregate-scalars-unsupported.keiro"+ [(code diagnostic, line diagnostic) | diagnostic <- diagnostics, severity diagnostic == Error]+ `shouldBe` [ (AggregateRegisterInitialInvalid, 6),+ (AggregateRegisterInitialInvalid, 7),+ (AggregateTypeUnsupportedAtUse, 10),+ (AggregateGuardTypeMismatch, 13)+ ]+ map message diagnostics `shouldSatisfy` any (T.isInfixOf "non-negative integral literals")+ map message diagnostics `shouldSatisfy` any (T.isInfixOf "ISO-8601 UTC timestamps")+ map message diagnostics `shouldSatisfy` any (T.isInfixOf "mapped structural declaration")+ it "rejects aggregate arithmetic at the operator" $ do+ source <- readTestText "test/fixtures/aggregate-scalars-arithmetic.keiro"+ err <- parseErrorOf "test/fixtures/aggregate-scalars-arithmetic.keiro" source+ err `shouldSatisfy` T.isInfixOf "aggregate arithmetic operator '+' is unsupported"+ err `shouldSatisfy` T.isInfixOf "aggregate-scalars-arithmetic.keiro:12:39:"+ it "covers unknown, container, fractional, out-of-range, and ordering failures" $ do+ diagnostics <- diagnosticsOf "test/fixtures/aggregate-scalars-invalid-capabilities.keiro"+ [(code diagnostic, line diagnostic) | diagnostic <- diagnostics, severity diagnostic == Error]+ `shouldBe` [ (AggregateRegisterInitialInvalid, 6),+ (AggregateRegisterInitialInvalid, 7),+ (AggregateTypeUnknown, 10),+ (AggregateTypeUnsupportedAtUse, 10),+ (AggregateTypeUnsupportedAtUse, 10),+ (AggregateTypeUnsupportedAtUse, 10),+ (AggregateGuardCapabilityUnsupported, 13)+ ]+ it "keeps one-member workspace diagnostics identical to the single file" $ do+ direct <- diagnosticsOf "test/fixtures/aggregate-scalars-unsupported.keiro"+ composed <- shouldComposeWorkspace "test/fixtures/aggregate-scalars-workspace/service.keiro-workspace"+ let directErrors =+ [(code diagnostic, line diagnostic, message diagnostic) | diagnostic <- direct, severity diagnostic == Error]+ workspaceErrors =+ [ (wdCode diagnostic, wlLine (NE.head (wdLocations diagnostic)), wdMessage diagnostic)+ | diagnostic <- checkWorkspace composed,+ wdSeverity diagnostic == Error+ ]+ workspaceErrors `shouldBe` directErrors++ 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" Nothing 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` [18]+ 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 "exempts only the authoritative generated expression/transducer module paths" $ do+ let expressions = syntheticGenerated "Gen/Aggregate/Expressions.hs" "import Keiki.Core qualified as K\nx = K.lit 1"+ transducer = syntheticGenerated "Gen/Aggregate/Transducer.hs" "import Keiki.Builder qualified as B\nx = B.slot"+ ordinary = syntheticGenerated "Gen/Aggregate/Projection.hs" "import Keiki.Builder qualified as B"+ firewallBreaches [expressions, transducer] `shouldBe` []+ firewallBreaches [ordinary] `shouldBe` [("Gen/Aggregate/Projection.hs", "import:Keiki.Builder", 1)]+ 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))+ let expected =+ ScaffoldRecord+ { recSpecPath = "counter.keiro",+ recModuleRoot = "",+ recLayout = "prefixed",+ recSourceLanguage = LegacyUnversioned,+ recFiles = [(kind m, modulePath m) | (m, _) <- reportDispositions report],+ recMappings = [],+ recBindingObligations = []+ }+ sourceRows = filter ("source-language " `T.isPrefixOf`) (T.lines contents)+ withoutSourceRows = T.unlines (filter (not . T.isPrefixOf "source-language ") (T.lines contents))+ parseRecord contents `shouldBe` Just expected+ parseRecord withoutSourceRows `shouldBe` Just expected+ case sourceRows of+ [sourceRow] -> do+ parseRecord (T.replace sourceRow (sourceRow <> "\n" <> sourceRow) contents) `shouldBe` Nothing+ parseRecord (T.replace sourceRow "source-language {malformed}" contents) `shouldBe` Nothing+ _ -> expectationFailure "expected exactly one source-language row"+ parseRecord (T.replace "spec: " "future-field: retained\nspec: " contents) `shouldBe` parseRecord contents+ parseRecord (T.replace "record v1" "record v2" contents) `shouldBe` Nothing+ it "records declared provenance and reports a header-only scaffold drift" $+ withTempDirectory "keiro-dsl-language-drift" $ \out -> do+ spec <- parseInlineSpec "<language-drift>" loweringAggregateSpec+ let ctx = defaultContext (specContext spec)+ modules <- case planScaffold ctx spec of+ Left refusals -> expectationFailure (show refusals) >> fail "unreachable"+ Right planned -> pure planned+ _ <- executePlannedScaffold out "counter.keiro" ctx spec+ case languageVersion 1 of+ Nothing -> expectationFailure "version 1 was not constructible"+ Just version -> do+ let declared = DeclaredLanguage version noLoc+ result <- executeScaffoldWithLanguage out False "counter.keiro" declared ctx spec modules+ case result of+ Left refusals -> expectationFailure (show refusals)+ Right report -> do+ reportSourceLanguageDrift report+ `shouldBe` Just (SourceLanguageDrift LegacyUnversioned declared)+ contents <- TIO.readFile (reportRecordPath report)+ recSourceLanguage <$> parseRecord contents `shouldBe` Just declared++ 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:Json" 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 description source expected =+ it description $ 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:4: 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:4: 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:4"+ err `shouldContain` "workspace-dup-decl/domain/shared.keiro:4"+ 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:13: 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:4"+ 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 "treats absent source-language rows as legacy and rejects partial, duplicate, or malformed rows" $ do+ workspace <- shouldComposeWorkspace canonicalWorkspacePath+ let record = sampleWorkspaceRecord workspace+ rendered = renderWorkspaceRecord record+ sourceRows = filter ("source-language " `T.isPrefixOf`) (T.lines rendered)+ withoutSourceRows = T.unlines (filter (not . T.isPrefixOf "source-language ") (T.lines rendered))+ legacyRows = [WorkspaceSourceLanguageRow path LegacyUnversioned | path <- wrMembers record]+ parseWorkspaceRecord withoutSourceRows+ `shouldBe` Just record {wrSourceLanguages = legacyRows}+ case sourceRows of+ firstRow : secondRow : _ -> do+ parseWorkspaceRecord (T.unlines (filter (/= secondRow) (T.lines rendered))) `shouldBe` Nothing+ parseWorkspaceRecord (T.replace firstRow (firstRow <> "\n" <> firstRow) rendered) `shouldBe` Nothing+ parseWorkspaceRecord (T.replace firstRow "source-language {malformed}" rendered) `shouldBe` Nothing+ _ -> expectationFailure "expected multiple workspace source-language rows"+ 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+ [] -> ""++holeTextEndingIn :: T.Text -> [ScaffoldModule] -> T.Text+holeTextEndingIn suffix modules = case [moduleText m | m <- modules, kind m == HoleStub, 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"+ ]++scalarRegisterCases :: [(T.Text, T.Text)]+scalarRegisterCases =+ [ ("Text", "\"sample\""),+ ("Int", "0"),+ ("Bool", "False"),+ ("Time", "\"2026-01-02T03:04:05.123456789012Z\""),+ ("Natural", "0")+ ]++cleanScalarAggregateSpec :: T.Text -> T.Text -> T.Text+cleanScalarAggregateSpec typeName initialValue =+ T.unlines+ [ "context clean-scalar",+ "",+ "aggregate Scalar",+ " regs",+ " value " <> typeName <> " = " <> initialValue,+ " states Empty Done!",+ " command Set { value:" <> typeName <> " }",+ " event SetDone { value:" <> typeName <> " }",+ " Empty -- Set --> write value := value ; emit SetDone ; 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 == TRef "CounterVertex" then TRef "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",+ tImplementation = LegacyHoleImplementation,+ 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),+ wrSourceLanguages =+ [ WorkspaceSourceLanguageRow (wmPath member) (wmSourceLanguage member)+ | member <- 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+specOf path = do+ input <- readTestText path+ case parseSpec path input of+ Left err -> expectationFailure (T.unpack err) >> error "unreachable"+ Right spec -> pure spec++-- | Parse a fixture and scaffold every aggregate in it.+scaffoldFixture :: FilePath -> IO [ScaffoldModule]+scaffoldFixture path = do+ input <- readTestText path+ case parseSpec path input of+ Left err -> expectationFailure (T.unpack err) >> pure []+ Right spec ->+ pure $+ concat+ [ scaffoldAggregate (ctx spec) spec agg <> harnessFor (ctx spec) spec agg+ | NAggregate agg <- specNodes spec+ ]+ where+ ctx spec = defaultContext (specContext spec)++scaffoldProcessFixture :: FilePath -> IO [ScaffoldModule]+scaffoldProcessFixture path = do+ input <- readTestText path+ case parseSpec path input of+ Left err -> expectationFailure (T.unpack err) >> pure []+ Right spec ->+ pure $ concat [scaffoldProcess (ctx spec) p | NProcess p <- specNodes spec]+ where+ ctx spec = defaultContext (specContext spec)++-- | Assert a freshly-scaffolded Generated module matches its committed copy+-- under test/conformance/ (whitespace-normalized). The committed copies are the+-- ones the keiro-dsl-conformance suite compiles, so this pins the live scaffolder+-- to known-compiling output.+assertMatchesCommitted :: ScaffoldModule -> IO ()+assertMatchesCommitted m = do+ let committedPath = "test/conformance/" <> modulePath m+ committed <- readTestText committedPath+ normalizeGenerated committed `shouldBe` normalizeGenerated (moduleText m)++normalizeGenerated :: T.Text -> (T.Text, [T.Text])+normalizeGenerated text =+ let (imports, body) = partition isImport (T.lines text)+ in (normalizeBody body, sort (map normalizeImport imports))+ where+ -- Compare the deterministic body exactly as before and imports as a sorted,+ -- whitespace-normalized list. Sorting tolerates formatter reordering while+ -- additions, removals, and renamed imports now fail the pin.+ normalizeBody =+ T.replace " , )" " )"+ . T.unwords+ . T.words+ . T.replace "}" " } "+ . T.replace "{" " { "+ . T.replace "]" " ] "+ . T.replace "[" " [ "+ . T.replace "," " , "+ . T.unlines+ normalizeImport line =+ let reordered = case T.words line of+ "import" : "qualified" : moduleName : rest -> T.unwords ("import" : moduleName : "qualified" : rest)+ wordsInImport -> T.unwords wordsInImport+ (prefix, explicit) = T.breakOn " (" reordered+ in if T.null explicit+ then prefix+ else+ let members =+ sort+ . map (T.replace " (" "(" . T.unwords . T.words)+ . T.splitOn ","+ . T.dropEnd 1+ $ T.drop 2 explicit+ in prefix <> " (" <> T.intercalate "," members <> ")"+ isImport line = case T.words line of+ "import" : _ -> True+ _ -> False++-- | Locate and read a test fixture or committed conformance source regardless+-- of whether the suite was launched from the package directory or repo root.+readTestText :: FilePath -> IO T.Text+readTestText path = resolveTestPath path >>= TIO.readFile++-- | Locate a repo file regardless of the test process's current directory.+resolveTestPath :: FilePath -> IO FilePath+resolveTestPath rel = do+ override <- lookupEnv "KEIRO_DSL_TEST_ROOT"+ let candidates = [rel, "keiro-dsl" </> rel] <> maybe [] (\root -> [root </> rel]) override+ existing <- filterM doesFileExist candidates+ case existing of+ path : _ -> pure path+ [] ->+ fail $+ "unable to locate keiro-dsl test file "+ <> show rel+ <> "; tried "+ <> show candidates++leftContains :: T.Text -> Either T.Text a -> Bool+leftContains needle = \case+ Left err -> needle `T.isInfixOf` err+ Right _ -> False++parseInlineSpec :: FilePath -> T.Text -> IO Spec+parseInlineSpec sourceName src = case parseSpec sourceName src of+ Left err -> expectationFailure (T.unpack err) >> error "unreachable"+ Right spec -> pure spec++shouldResolveTypeGraph :: Spec -> IO TypeGraph+shouldResolveTypeGraph spec = case resolveTypeGraph spec of+ Left errors -> expectationFailure ("type graph failed: " <> show errors) >> error "unreachable"+ Right graph -> pure graph++shouldResolveCoverage :: FilePath -> Spec -> IO Coverage.CoverageReport+shouldResolveCoverage path spec = case Coverage.coverageReport path spec of+ Left errors -> expectationFailure ("coverage graph failed: " <> show errors) >> error "unreachable"+ Right report -> pure report++withoutVendorGeometry :: Spec -> Spec+withoutVendorGeometry spec =+ spec+ { specMapped = filter (not . isVendorGeometry) (specMapped spec),+ specNodes = map stripNode (specNodes spec)+ }+ where+ isVendorGeometry MappedOpaque {moName = "VendorGeometry"} = True+ isVendorGeometry _ = False+ stripNode (NAggregate aggregate) =+ NAggregate+ aggregate+ { aggRegs = filter ((/= TRef "VendorGeometry") . regType) (aggRegs aggregate),+ aggCommands = map stripCommand (aggCommands aggregate),+ aggEvents = map stripEvent (aggEvents aggregate)+ }+ stripNode node = node+ stripCommand command = command {cmdFields = filter ((/= Just (TRef "VendorGeometry")) . aggregateFieldType) (cmdFields command)}+ stripEvent event = event {evBody = case evBody event of EventFields fields -> EventFields (filter ((/= Just (TRef "VendorGeometry")) . aggregateFieldType) fields); body -> body}++withMetadataJson :: Spec -> Spec+withMetadataJson spec = spec {specMapped = map updateDeclaration (specMapped spec)}+ where+ updateDeclaration declaration@MappedStructural {msName = "ArtifactMetadata", msShape = ShapeRecord constructor unknownFields fields} =+ declaration+ { msShape =+ ShapeRecord+ constructor+ unknownFields+ [if wfHaskell field == "note" then field {wfType = TJson} else field | field <- fields]+ }+ updateDeclaration declaration = declaration++expressionTags :: TypeExprAlgebra [T.Text]+expressionTags =+ TypeExprAlgebra+ { onText = ["text"],+ onInt = ["int"],+ onInteger = ["integer"],+ onBool = ["bool"],+ onNatural = ["natural"],+ onTime = ["time"],+ onJson = ["json"],+ onOptional = ("optional" :),+ onList = ("list" :),+ onMap = ("map" :),+ onRef = \key -> ["ref:" <> unMappedKey key]+ }++hasTypeGraphError :: (TypeGraphError -> Bool) -> Either (NonEmpty TypeGraphError) TypeGraph -> Bool+hasTypeGraphError predicate = \case+ Left errors -> any predicate errors+ Right _ -> False++isRecursive :: TypeGraphError -> Bool+isRecursive TGRecursive {} = True+isRecursive _ = False++isUnresolved :: TypeGraphError -> Bool+isUnresolved TGUnresolvedRef {} = True+isUnresolved _ = False++mappedSpec :: [MappedDecl] -> Spec+mappedSpec declarations = Spec "mapped-test" Nothing Nothing [] [] [] [] declarations []++completeStructural :: Name -> MappedShape -> MappedDecl+completeStructural name shape =+ MappedStructural+ { msName = name,+ msHaskell = Just (HaskellSource "mapped-test" "Example.Mapped" name),+ msBinding = Just ("Example.Mapped." <> T.toLower name <> "Binding"),+ msBindingVersion = Just "1",+ msCanonical = Just ("example.mapped." <> name),+ msFixtures = Just ("Example.Mapped." <> T.toLower name <> "Cases"),+ msInitial = Nothing,+ msShape = shape,+ msLoc = noLoc+ }++recordShape :: [TypeExpr] -> MappedShape+recordShape types =+ ShapeRecord+ "MappedRecord"+ RejectUnknown+ [ WireField+ { wfHaskell = "field" <> T.pack (show index),+ wfKey = "field" <> T.pack (show index),+ wfType = fieldType,+ wfPresence = PRequired,+ wfOnMissing = Nothing,+ wfLoc = noLoc+ }+ | (index, fieldType) <- zip [(1 :: Int) ..] types+ ]++mapArtifactField :: (WireField -> WireField) -> Spec -> Spec+mapArtifactField = mapArtifactNamedField "key"++mapArtifactNamedField :: Name -> (WireField -> WireField) -> Spec -> Spec+mapArtifactNamedField target transform spec = spec {specMapped = map updateDeclaration (specMapped spec)}+ where+ updateDeclaration declaration@MappedStructural {msName = "ArtifactInfo", msShape = ShapeRecord constructor unknownFields fields} =+ declaration+ { msShape =+ ShapeRecord+ constructor+ unknownFields+ [if wfHaskell field == target then transform field else field | field <- fields]+ }+ updateDeclaration declaration = declaration++mapMappedStructural :: Name -> (MappedDecl -> MappedDecl) -> Spec -> Spec+mapMappedStructural target transform spec =+ spec+ { specMapped =+ [ case declaration of+ MappedStructural {msName = name}+ | name == target -> transform declaration+ _ -> declaration+ | declaration <- specMapped spec+ ]+ }++renameRecordConstructor :: MappedShape -> MappedShape+renameRecordConstructor (ShapeRecord _ unknownFields fields) = ShapeRecord "ArtifactInfoV2" unknownFields fields+renameRecordConstructor shape = shape++renameMappedRecordConstructor :: MappedDecl -> MappedDecl+renameMappedRecordConstructor declaration@MappedStructural {msShape = shape} =+ declaration {msShape = renameRecordConstructor shape}+renameMappedRecordConstructor declaration = declaration++changeMappedCanonical :: MappedDecl -> MappedDecl+changeMappedCanonical declaration@MappedStructural {} =+ declaration {msCanonical = Just "example.artifact.ArtifactInfo.v2"}+changeMappedCanonical declaration = declaration++data MappedMutation = MappedMutation+ { mmCandidate :: !Spec,+ mmCode :: !DiagnosticCode,+ mmExpectedSubjects :: !(Set.Set T.Text)+ }+ deriving stock (Show)++mappedWireMutations :: Spec -> [MappedMutation]+mappedWireMutations spec = case resolveTypeGraph spec of+ Left _ -> []+ Right graph -> concatMap (uncurry (declarationMutations graph)) (zip [0 :: Int ..] (specMapped spec))+ where+ declarationMutations graph declarationIndex declaration = case declaration of+ MappedStructural {msName = declarationName, msShape = shape} -> case shape of+ ShapeRecord _ _ fields ->+ concat+ [ [ mutation+ graph+ declarationName+ MappedWireKeyChanged+ (fieldSubject field {wfKey = wfKey field <> "__mutated"})+ (mutateRecordField declarationIndex fieldIndex (\value -> value {wfKey = wfKey value <> "__mutated"}) spec),+ mutation+ graph+ declarationName+ MappedPresenceChanged+ (fieldSubject field)+ (mutateRecordField declarationIndex fieldIndex (\value -> value {wfPresence = flipPresence (wfPresence value)}) spec)+ ]+ <> [ mutation+ graph+ declarationName+ defaultCode+ (fieldSubject field)+ (mutateRecordField declarationIndex fieldIndex (\value -> value {wfOnMissing = changedDefault}) spec)+ | oldDefault <- maybeToListTest (wfOnMissing field),+ let (changedDefault, defaultCode) = mutateDefault oldDefault+ ]+ | (fieldIndex, field) <- zip [0 :: Int ..] fields+ ]+ ShapeEnum entries ->+ [ mutation+ graph+ declarationName+ MappedEnumSpellingChanged+ (enumSubject entry {weTag = weTag entry <> "__mutated"})+ (mutateEnumEntry declarationIndex entryIndex (\value -> value {weTag = weTag value <> "__mutated"}) spec)+ | (entryIndex, entry) <- zip [0 :: Int ..] entries+ ]+ ShapeUnion _ arms ->+ [ mutation+ graph+ declarationName+ MappedArmTagChanged+ (armSubject arm {waTag = waTag arm <> "__mutated"})+ (mutateUnionArm declarationIndex armIndex (\value -> value {waTag = waTag value <> "__mutated"}) spec)+ | (armIndex, arm) <- zip [0 :: Int ..] arms+ ]+ MappedOpaque {moName = declarationName, moCodecVersion = version} ->+ [ mutation+ graph+ declarationName+ MappedOpaqueCodecChanged+ "codec"+ ( updateMappedAt+ declarationIndex+ ( \case+ value@MappedOpaque {} -> value {moCodecVersion = fmap (<> "__mutated") version}+ value -> value+ )+ spec+ )+ ]++ mutation graph declarationName diagnosticCode leaf candidate =+ MappedMutation+ { mmCandidate = candidate,+ mmCode = diagnosticCode,+ mmExpectedSubjects =+ Set.fromList+ [ renderUsePath path <> " " <> leaf+ | path <- usePaths graph declarationName+ ]+ }++fieldSubject :: WireField -> T.Text+fieldSubject field = ".field " <> wfHaskell field <> "[\"" <> wfKey field <> "\"]"++enumSubject :: WireEnum -> T.Text+enumSubject entry = ".enum " <> weCtor entry <> "[\"" <> weTag entry <> "\"]"++armSubject :: WireArm -> T.Text+armSubject arm = ".arm " <> waCtor arm <> "[\"" <> waTag arm <> "\"]"++flipPresence :: Presence -> Presence+flipPresence PRequired = POptional+flipPresence POptional = PRequired++mutateDefault :: OnMissing -> (Maybe OnMissing, DiagnosticCode)+mutateDefault = \case+ OmNull -> (Nothing, MappedDefaultRemoved)+ OmText value -> (Just (OmText (value <> "__mutated")), MappedDefaultChanged)+ OmInt value -> (Just (OmInt (value + 1)), MappedDefaultChanged)+ OmBool value -> (Just (OmBool (not value)), MappedDefaultChanged)+ OmEmptyList -> (Nothing, MappedDefaultRemoved)+ OmEmptyMap -> (Nothing, MappedDefaultRemoved)+ OmCtor constructor -> (Just (OmCtor (constructor <> "Mutated")), MappedDefaultChanged)++mutateRecordField :: Int -> Int -> (WireField -> WireField) -> Spec -> Spec+mutateRecordField declarationIndex fieldIndex transform =+ updateMappedAt declarationIndex $ \case+ declaration@MappedStructural {msShape = ShapeRecord constructor unknownFields fields} ->+ declaration {msShape = ShapeRecord constructor unknownFields (updateAt fieldIndex transform fields)}+ declaration -> declaration++mutateEnumEntry :: Int -> Int -> (WireEnum -> WireEnum) -> Spec -> Spec+mutateEnumEntry declarationIndex entryIndex transform =+ updateMappedAt declarationIndex $ \case+ declaration@MappedStructural {msShape = ShapeEnum entries} ->+ declaration {msShape = ShapeEnum (updateAt entryIndex transform entries)}+ declaration -> declaration++mutateUnionArm :: Int -> Int -> (WireArm -> WireArm) -> Spec -> Spec+mutateUnionArm declarationIndex armIndex transform =+ updateMappedAt declarationIndex $ \case+ declaration@MappedStructural {msShape = ShapeUnion encoding arms} ->+ declaration {msShape = ShapeUnion encoding (updateAt armIndex transform arms)}+ declaration -> declaration++updateMappedAt :: Int -> (MappedDecl -> MappedDecl) -> Spec -> Spec+updateMappedAt declarationIndex transform spec =+ spec {specMapped = updateAt declarationIndex transform (specMapped spec)}++updateAt :: Int -> (a -> a) -> [a] -> [a]+updateAt target transform values =+ [if index == target then transform value else value | (index, value) <- zip [0 :: Int ..] values]++maybeToListTest :: Maybe a -> [a]+maybeToListTest = maybe [] pure++isAdditiveChange :: Change -> Bool+isAdditiveChange Additive {} = True+isAdditiveChange Advisory {} = False+isAdditiveChange Breaking {} = False++mappedIngredientMutations :: Spec -> [(Spec, DiagnosticCode)]+mappedIngredientMutations spec =+ [ (mapMappedStructural "ArtifactInfo" clearStructuralHaskell spec, MappedMissingIngredient),+ (mapMappedStructural "ArtifactInfo" clearStructuralBinding spec, MappedMissingIngredient),+ (mapMappedStructural "ArtifactInfo" clearStructuralBindingVersion spec, MappedMissingIngredient),+ (mapMappedStructural "ArtifactInfo" clearStructuralCanonical spec, MappedMissingIngredient),+ (mapMappedStructural "ArtifactInfo" clearStructuralFixtures spec, MappedMissingIngredient),+ (mapMappedStructural "ArtifactInfo" clearStructuralInitial spec, MappedMissingInitialValue),+ (mapMappedDeclaration "VendorGeometry" clearOpaqueHaskell spec, MappedMissingIngredient),+ (mapMappedDeclaration "VendorGeometry" clearOpaqueCodec spec, MappedMissingIngredient),+ (mapMappedDeclaration "VendorGeometry" clearOpaqueCodecVersion spec, MappedMissingIngredient),+ (mapMappedDeclaration "VendorGeometry" clearOpaqueFixtures spec, MappedMissingIngredient)+ ]+ where+ clearStructuralHaskell declaration@MappedStructural {} = declaration {msHaskell = Nothing}+ clearStructuralHaskell declaration = declaration+ clearStructuralBinding declaration@MappedStructural {} = declaration {msBinding = Nothing}+ clearStructuralBinding declaration = declaration+ clearStructuralBindingVersion declaration@MappedStructural {} = declaration {msBindingVersion = Nothing}+ clearStructuralBindingVersion declaration = declaration+ clearStructuralCanonical declaration@MappedStructural {} = declaration {msCanonical = Nothing}+ clearStructuralCanonical declaration = declaration+ clearStructuralFixtures declaration@MappedStructural {} = declaration {msFixtures = Nothing}+ clearStructuralFixtures declaration = declaration+ clearStructuralInitial declaration@MappedStructural {} = declaration {msInitial = Nothing}+ clearStructuralInitial declaration = declaration+ clearOpaqueHaskell declaration@MappedOpaque {} = declaration {moHaskell = Nothing}+ clearOpaqueHaskell declaration = declaration+ clearOpaqueCodec declaration@MappedOpaque {} = declaration {moCodecId = Nothing}+ clearOpaqueCodec declaration = declaration+ clearOpaqueCodecVersion declaration@MappedOpaque {} = declaration {moCodecVersion = Nothing}+ clearOpaqueCodecVersion declaration = declaration+ clearOpaqueFixtures declaration@MappedOpaque {} = declaration {moFixtures = Nothing}+ clearOpaqueFixtures declaration = declaration++mapMappedDeclaration :: Name -> (MappedDecl -> MappedDecl) -> Spec -> Spec+mapMappedDeclaration target transform spec =+ spec+ { specMapped =+ [ if mappedDeclarationName declaration == target then transform declaration else declaration+ | declaration <- specMapped spec+ ]+ }++mappedDeclarationName :: MappedDecl -> Name+mappedDeclarationName MappedStructural {msName = name} = name+mappedDeclarationName MappedOpaque {moName = name} = name++statusMapSpec :: T.Text -> T.Text+statusMapSpec marker =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states Open",+ "",+ " event Created { }",+ " event Changed { }",+ "",+ " projection things consistency=Eventual key=thingId",+ " status-map" <> marker <> " { Created=>held }"+ ]++parseErrorOf :: FilePath -> T.Text -> IO T.Text+parseErrorOf sourceName src = case parseSpec sourceName src of+ Left err -> pure err+ Right _ -> expectationFailure ("expected parse failure for " <> sourceName) >> error "unreachable"++duplicateGotoSpec :: T.Text+duplicateGotoSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states A B C",+ "",+ " command Go { }",+ " A -- Go -->",+ " goto B",+ " goto C"+ ]++missingGotoSpec :: T.Text+missingGotoSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states A B",+ "",+ " command Go { }",+ " A -- Go -->",+ " emit Changed"+ ]++duplicateWireSpec :: T.Text+duplicateWireSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states Open",+ "",+ " wire kind=ctorName fields=camelCase schemaVersion=1",+ " wire kind=typeName fields=snakeCase schemaVersion=2"+ ]++duplicateProjectionSpec :: T.Text+duplicateProjectionSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states Open",+ "",+ " projection first consistency=Strong key=thingId",+ " status-map partial { }",+ " projection second consistency=Eventual key=thingId"+ ]++projectionWithoutConsistencySpec :: T.Text+projectionWithoutConsistencySpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states Open",+ "",+ " projection things key=thingId"+ ]++malformedRegisterSpec :: T.Text+malformedRegisterSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " status Status",+ " states Open"+ ]++misplacedDispatchIdSpec :: T.Text+misplacedDispatchIdSpec =+ T.replace+ " schedule timer\n\n dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)\n"+ " dispatch-id strategy=uuidv5 from=(name, correlationId, sourceEventId, emitIndex)\n schedule timer\n"+ (renderSpec (Spec "svc" Nothing Nothing [] [] [] [] [] [NProcess (processWithLiteral "literal")]))++lineNumberContaining :: T.Text -> T.Text -> Int+lineNumberContaining needle = go 1 . T.lines+ where+ go current = \case+ [] -> current+ lineText : rest+ | needle `T.isInfixOf` lineText -> current+ | otherwise -> go (current + 1) rest++decimalOverflow :: T.Text+decimalOverflow = "18446744073709551617"++decimalOverflowSpecs :: [(String, T.Text)]+decimalOverflowSpecs =+ [ ("event-version", eventVersionDecimalSpec decimalOverflow),+ ("wire-schema", wireDecimalSpec decimalOverflow),+ ("contract-schema", contractDecimalSpec decimalOverflow),+ ("decode-schema", decodeDecimalSpec decimalOverflow),+ ("publisher-attempts", publisherDecimalSpec decimalOverflow),+ ("workqueue-retries", workqueueDecimalSpec decimalOverflow),+ ("timer-attempts", timerDecimalSpec decimalOverflow)+ ]++eventVersionDecimalSpec :: T.Text -> T.Text+eventVersionDecimalSpec value =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states Open",+ "",+ " event Changed v" <> value <> " { }"+ ]++wireDecimalSpec :: T.Text -> T.Text+wireDecimalSpec value =+ T.unlines+ [ "context svc",+ "",+ "aggregate Thing",+ " regs",+ " states Open",+ "",+ " wire kind=ctorName fields=camelCase schemaVersion=" <> value+ ]++contractDecimalSpec :: T.Text -> T.Text+contractDecimalSpec value =+ T.unlines+ [ "context svc",+ "",+ "contract Contract {",+ " schemaVersion " <> value,+ " discriminator kind",+ "}"+ ]++decodeDecimalSpec :: T.Text -> T.Text+decodeDecimalSpec value =+ T.unlines+ [ "context svc",+ "",+ "intake Inbox {",+ " contract Contract",+ " topic events",+ " accept Event",+ " dedupe key messageId policy PreferIntegrationMessageId",+ " decode { envelope strict-required lenient-optional body strict schemaVersion == " <> value <> " }",+ " disposition { }",+ "}"+ ]++publisherDecimalSpec :: T.Text -> T.Text+publisherDecimalSpec value =+ T.unlines+ [ "context svc",+ "",+ "publisher Publisher {",+ " emit Emit",+ " ordering PerKeyHeadOfLine",+ " maxAttempts " <> value,+ " backoff constant 2s",+ " outboxId stable from messageId",+ "}"+ ]++workqueueDecimalSpec :: T.Text -> T.Text+workqueueDecimalSpec value =+ T.unlines+ [ "context svc",+ "",+ "workqueue Queue {",+ " queue logical = \"queue\"",+ " derive physical = \"queue\"",+ " dlq = \"queue_dlq\"",+ " table = \"pgmq.q_queue\"",+ " payload Job { }",+ " retry maxRetries = " <> value <> " delay = 5s dlq = on",+ " disposition { }",+ "}"+ ]++timerDecimalSpec :: T.Text -> T.Text+timerDecimalSpec value =+ T.replace+ "max-attempts 5"+ ("max-attempts " <> value)+ (renderSpec (Spec "svc" Nothing Nothing [] [] [] [] [] [NProcess (processWithLiteral "literal")]))++identifierHygieneSpec :: T.Text+identifierHygieneSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate thing",+ " regs",+ " states Open",+ "",+ " command DoIt { data }"+ ]++vertexCollisionSpec :: T.Text+vertexCollisionSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Reservation",+ " regs",+ " states Created",+ "",+ " event ReservationCreated { }"+ ]++underscoreNodeSpec :: T.Text+underscoreNodeSpec =+ T.unlines+ [ "context svc",+ "",+ "contract _contract {",+ " schemaVersion 1",+ " discriminator kind",+ "}"+ ]++unicodeIdentifierSpec :: T.Text+unicodeIdentifierSpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate Résumé",+ " regs",+ " states Open"+ ]++emptyStatesSpec :: Spec+emptyStatesSpec =+ Spec+ "svc"+ Nothing+ Nothing+ []+ []+ []+ []+ []+ [NAggregate (Aggregate "Thing" [] [] [] [] [] Nothing Nothing Nothing noLoc)]++crossFamilyBoundarySpec :: T.Text+crossFamilyBoundarySpec =+ T.unlines+ [ "context svc",+ "",+ "aggregate First",+ " regs",+ " states A B",+ " command Go { }",+ " A -- Go -->",+ " emit Changed",+ " goto B",+ "",+ "emit Output {",+ " contract Contract",+ " topic events",+ " source \"source\"",+ " key thingId",+ " map status { _ => skip }",+ " messageId derive hole",+ " idempotencyKey derive hole",+ "}",+ "",+ "aggregate Second",+ " regs",+ " states",+ "",+ "dispatch QueueDispatch {",+ " source readModel = source key = thingId",+ " fanout body = resolveFanout",+ " dedup key = thingId",+ " seenIn readModel = seen field = thingId",+ " seenIn queue = workQueue field = thingId",+ " enqueue to = workQueue",+ "}"+ ]++--------------------------------------------------------------------------------+-- Generators (bounded; restricted to valid, non-reserved identifiers)+--------------------------------------------------------------------------------++-- | Text that exercises every supported escape plus notation punctuation that+-- used to be able to split one emit-map row into several rows.+genAdversarialText :: Gen T.Text+genAdversarialText =+ T.concat+ <$> resize+ 20+ (listOf (elements ["a", "Z", "\"", "\\", "\n", "\t", "\r", "=>", "#", "{", "}", " "]))++-- | One spec carrying the same adversarial value through three distinct+-- printer paths: a contract topic, an emit-map value, and a quote-wrapped+-- field-binding literal.+escapedSpec :: T.Text -> Spec+escapedSpec value =+ Spec+ "escape"+ Nothing+ Nothing+ []+ []+ []+ []+ []+ [ NContract+ ContractNode+ { ctrName = "Contract",+ ctrSchemaVersion = 1,+ ctrDiscriminator = "kind",+ ctrTopics = [("events", value)],+ ctrEvents = [],+ ctrLoc = noLoc+ },+ NEmit+ EmitNode+ { emName = "Emit",+ emContract = "Contract",+ emTopic = "events",+ emSource = "source",+ emKey = "key",+ emDiscriminant = "status",+ emMap = [EmitMapRow value "Event" noLoc],+ emSkip = True,+ emMessageId = DeriveSpec Nothing,+ emIdempotencyKey = DeriveSpec Nothing,+ emLoc = noLoc+ },+ NProcess (processWithLiteral value)+ ]++processWithLiteral :: T.Text -> ProcessNode+processWithLiteral value =+ ProcessNode+ { procId = "Process",+ procName = "process",+ procInput = InputDecl "Input" [],+ procCorrelate = CorrelateDecl "key" "idText",+ procSaga = SagaRef "Saga" "saga",+ procTarget = "Target",+ procProjections = [],+ procHandle =+ HandleNode+ { hOn = "Input",+ hAdvance = AdvanceNode "Advance" [FieldBinding "literal" (Just ("\"" <> value <> "\""))],+ hDispatch = [],+ hSchedule = "timer"+ },+ procRejected = PolHalt,+ procPoison = PolHalt,+ procTimer =+ TimerNode+ { tmName = "timer",+ tmId = IdExpr UuidV5Id "timer:",+ tmFireAt = FireAtExpr "observedAt" "5m",+ tmPayload = [],+ tmFire =+ FireNode+ { fireTarget = "Target",+ fireKey = "correlationId",+ fireCommand = "Fire",+ fireFields = [],+ fireFiredEventId = IdExpr UuidV5Id "fired:",+ fireDisposition = FireDisposition OFired OFired ORetry ORetry ORetry+ },+ tmDecodeUnknown = "Cancelled",+ tmMaxAttempts = 5,+ tmDeadLetter = "exhausted",+ tmLoc = noLoc+ },+ procLoc = noLoc+ }++genName :: Gen Name+genName =+ frequency+ [ ( 3,+ do+ base <- elements ["Aa", "Bb", "Cc", "Dd", "St", "Cmd", "Ev", "Reg", "Fld", "Foo", "Bar", "Qux"]+ n <- choose (0, 9 :: Int)+ pure (T.pack (base <> show n))+ ),+ (1, elements ["data1", "typeA", "whereX", "gotoX", "guardY", "emitZ", "_lead"])+ ]++genWire :: Gen T.Text+genWire = do+ base <- elements ["red", "blue", "green", "ctorName", "camelCase", "rsv", "hosp", "held", "partial-divert", "1st"]+ n <- choose (0, 9 :: Int)+ pure (T.pack (base <> show n))++genWireWord :: Gen T.Text+genWireWord = genWire++smallList :: Gen a -> Gen [a]+smallList g = choose (0, 3 :: Int) >>= \n -> vectorOf n g++nonEmptyList :: Gen a -> Gen [a]+nonEmptyList g = choose (1, 3 :: Int) >>= \n -> vectorOf n g++genMaybe :: Gen a -> Gen (Maybe a)+genMaybe g = oneof [pure Nothing, Just <$> g]++genCmp :: Gen CmpOp+genCmp = elements [OpEq, OpNeq, OpLt, OpLe, OpGt, OpGe]++genAtom :: Gen Expr+genAtom = EAtom <$> oneof [AName <$> genName, ABool <$> arbitrary]++genExpr :: Gen Expr+genExpr = go (3 :: Int)+ where+ go 0 = genAtom+ go d =+ oneof+ [ genAtom,+ EOr <$> go (d - 1) <*> go (d - 1),+ EAnd <$> go (d - 1) <*> go (d - 1),+ ECmp <$> genCmp <*> go (d - 1) <*> go (d - 1)+ ]++genField :: Gen Field+genField = Field <$> genName <*> oneof [pure Nothing, Just <$> genName]++genAggregateField :: Gen AggregateField+genAggregateField = AggregateField <$> genName <*> genMaybe (genTypeExpr []) <*> pure noLoc++genReg :: Gen RegDecl+genReg = RegDecl <$> genName <*> genTypeExpr [] <*> genRegInitial <*> pure noLoc++genRegInitial :: Gen RegInitial+genRegInitial = oneof [RegInitBare <$> genName, RegInitText <$> genAdversarialText]++genState :: Gen StateDecl+genState = StateDecl <$> genName <*> arbitrary <*> pure noLoc++genCommand :: Gen Command+genCommand = Command <$> genName <*> smallList genAggregateField <*> pure noLoc++genEvent :: Gen Event+genEvent = do+ name <- genName+ eventBody <- body+ version <- choose (1, 3)+ upcast <- genMaybe ((,) <$> choose (0, 3) <*> pure Hole)+ (retiring, deprecated) <- elements [(False, False), (True, False), (False, True)]+ pure+ Event+ { evName = name,+ evBody = eventBody,+ evVersion = version,+ evUpcastFrom = upcast,+ evRetiring = retiring,+ evDeprecated = deprecated,+ evLoc = noLoc+ }+ where+ body = oneof [EventFromCommand <$> genName, EventFields <$> smallList genAggregateField]++genTransition :: Gen Transition+genTransition =+ Transition+ <$> genName+ <*> genName+ <*> pure LegacyHoleImplementation+ <*> genMaybe genExpr+ <*> smallList ((,) <$> genName <*> genExpr)+ <*> smallList genName+ <*> genName+ <*> elements [TmLive, TmReplayOnly]+ <*> pure noLoc++genWireSpec :: Gen WireSpec+genWireSpec = WireSpec <$> genWire <*> genWire <*> (getNonNegative <$> arbitrary)++genProjection :: Gen ProjectionSpec+genProjection =+ ProjectionSpec+ <$> genName+ <*> genMaybe (elements [Strong, Eventual])+ <*> genName+ <*> genMaybe (Mapping <$> smallList ((,) <$> genName <*> genWire) <*> arbitrary)+ <*> pure noLoc++genAggregate :: Gen Aggregate+genAggregate =+ Aggregate+ <$> genName+ <*> smallList genReg+ <*> smallList genState+ <*> smallList genCommand+ <*> smallList genEvent+ <*> smallList genTransition+ <*> genMaybe genWireSpec+ <*> genMaybe genProjection+ <*> genMaybe (SnapshotSpec <$> oneof [SnapEvery <$> choose (0, 5), pure SnapOnTerminal] <*> choose (0, 5) <*> genAdversarialText <*> pure noLoc)+ <*> pure noLoc++genDottedRef :: Gen T.Text+genDottedRef = elements ["input.id", "input.hospitalId", "timer.id", "correlationId", "payload.messageId"]++genWindow :: Gen T.Text+genWindow = elements ["0s", "5s", "2m", "1h"]++genFieldBinding :: Gen FieldBinding+genFieldBinding =+ FieldBinding+ <$> genName+ <*> oneof+ [ pure Nothing,+ Just <$> genDottedRef,+ Just . (\raw -> "\"" <> raw <> "\"") <$> genAdversarialText+ ]++genDispatchDisposition :: Gen DispatchDisposition+genDispatchDisposition = DispatchDisposition <$> genDisp <*> genDisp <*> genDisp+ where+ genDisp = oneof [pure DAckOk, pure DRetry, DDeadLetter <$> genAdversarialText]++genDispatchNode :: Gen DispatchNode+genDispatchNode =+ DispatchNode+ <$> genName+ <*> genDottedRef+ <*> genName+ <*> smallList genFieldBinding+ <*> genDispatchDisposition+ <*> pure noLoc++genFireDisposition :: Gen FireDisposition+genFireDisposition =+ FireDisposition+ <$> elements [OFired, ORetry]+ <*> elements [OFired, ORetry]+ <*> elements [OFired, ORetry]+ <*> elements [OFired, ORetry]+ <*> elements [OFired, ORetry]++genIdExpr :: Gen IdExpr+genIdExpr = IdExpr UuidV5Id <$> genAdversarialText++genFireNode :: Gen FireNode+genFireNode =+ FireNode+ <$> genName+ <*> genDottedRef+ <*> genName+ <*> smallList genFieldBinding+ <*> genIdExpr+ <*> genFireDisposition++genTimerNode :: Gen TimerNode+genTimerNode =+ TimerNode+ <$> genName+ <*> genIdExpr+ <*> (FireAtExpr <$> genName <*> genWindow)+ <*> smallList genFieldBinding+ <*> genFireNode+ <*> genName+ <*> choose (0, 5)+ <*> genAdversarialText+ <*> pure noLoc++genProcess :: Gen ProcessNode+genProcess =+ ProcessNode+ <$> genName+ <*> genAdversarialText+ <*> (InputDecl <$> genName <*> smallList genField)+ <*> (CorrelateDecl <$> genName <*> genName)+ <*> (SagaRef <$> genName <*> genAdversarialText)+ <*> genName+ <*> smallList genName+ <*> (HandleNode <$> genName <*> (AdvanceNode <$> genName <*> smallList genFieldBinding) <*> smallList genDispatchNode <*> genName)+ <*> elements [PolHalt, PolDeadLetter, PolSkip]+ <*> elements [PolHalt, PolDeadLetter, PolSkip]+ <*> genTimerNode+ <*> pure noLoc++genResolveSource :: Gen ResolveSource+genResolveSource = oneof [ResolveReadModel <$> genName, pure ResolveHole]++genRouter :: Gen RouterNode+genRouter =+ RouterNode+ <$> genName+ <*> genAdversarialText+ <*> (InputDecl <$> genName <*> smallList genField)+ <*> (CorrelateDecl <$> genName <*> genName)+ <*> (ResolveDecl <$> genResolveSource <*> smallList genName <*> pure noLoc)+ <*> genName+ <*> smallList genName+ <*> (RouterDispatchNode <$> genName <*> smallList genFieldBinding <*> genDispatchDisposition <*> pure noLoc)+ <*> elements [PolHalt, PolDeadLetter, PolSkip]+ <*> elements [PolHalt, PolDeadLetter, PolSkip]+ <*> pure noLoc++genContractField :: Gen ContractField+genContractField = ContractField <$> genName <*> oneof [CTypeId <$> genAdversarialText, pure CText, pure CInt]++genContractEvent :: Gen ContractEvent+genContractEvent = ContractEvent <$> genName <*> genName <*> smallList genContractField++genContract :: Gen ContractNode+genContract =+ ContractNode+ <$> genName+ <*> choose (0, 5)+ <*> genName+ <*> smallList ((,) <$> genName <*> genAdversarialText)+ <*> smallList genContractEvent+ <*> pure noLoc++genWireSource :: Gen WireSource+genWireSource = oneof [SrcHeader <$> genAdversarialText, pure SrcBody, pure SrcKafkaKey, pure SrcKafkaCursor]++genInboxAction :: Gen InboxAction+genInboxAction = oneof [pure IAckOk, IRetry <$> genWindow, IDeadLetter <$> genMaybe genAdversarialText]++genDispositionRow :: Gen DispositionRow+genDispositionRow = DispositionRow <$> genName <*> genInboxAction <*> pure noLoc++genDecodeSpec :: Gen DecodeSpec+genDecodeSpec =+ DecodeSpec+ <$> ((\first second -> first <> " " <> second) <$> genWireWord <*> genWireWord)+ <*> arbitrary+ <*> choose (0, 5)++genIntake :: Gen IntakeNode+genIntake =+ IntakeNode+ <$> genName+ <*> genName+ <*> genName+ <*> nonEmptyList genName+ <*> smallList (BindRow <$> genName <*> genWireSource <*> arbitrary <*> arbitrary)+ <*> genName+ <*> genName+ <*> elements [InkPersistFull, InkPersistDedupeOnly]+ <*> genDecodeSpec+ <*> smallList genDispositionRow+ <*> pure noLoc++genDeriveSpec :: Gen DeriveSpec+genDeriveSpec = DeriveSpec <$> genMaybe genAdversarialText++genEmit :: Gen EmitNode+genEmit =+ EmitNode+ <$> genName+ <*> genName+ <*> genName+ <*> genAdversarialText+ <*> genName+ <*> genName+ <*> smallList (EmitMapRow <$> genAdversarialText <*> genName <*> pure noLoc)+ <*> arbitrary+ <*> genDeriveSpec+ <*> genDeriveSpec+ <*> pure noLoc++genPublisher :: Gen PublisherNode+genPublisher =+ PublisherNode+ <$> genName+ <*> genName+ <*> genName+ <*> choose (0, 5)+ <*> (BackoffSpec <$> genName <*> genWindow <*> genMaybe genWindow <*> genMaybe (elements ["1.0", "2.0", "3"]))+ <*> genName+ <*> pure noLoc++genWqField :: Gen WqField+genWqField = WqField <$> genName <*> genAdversarialText <*> genName <*> arbitrary++genWqDispRow :: Gen WqDispRow+genWqDispRow = WqDispRow <$> genName <*> genInboxAction <*> pure noLoc++genWorkqueue :: Gen WorkqueueNode+genWorkqueue =+ WorkqueueNode+ <$> genName+ <*> genAdversarialText+ <*> genAdversarialText+ <*> genAdversarialText+ <*> genAdversarialText+ <*> elements [WqUnordered, WqFifoThroughput, WqFifoRoundRobin]+ <*> genMaybe (WqGroupKey <$> genName <*> genName <*> genMaybe genAdversarialText)+ <*> oneof [pure WqStandard, pure WqUnlogged, WqPartitioned <$> genAdversarialText <*> genAdversarialText]+ <*> genName+ <*> smallList genWqField+ <*> choose (0, 5)+ <*> genWindow+ <*> arbitrary+ <*> smallList genWqDispRow+ <*> pure noLoc++genReadModel :: Gen ReadModelNode+genReadModel =+ ReadModelNode+ <$> genName+ <*> genAdversarialText+ <*> genAdversarialText+ <*> smallList (RmColumn <$> genWireWord <*> genName <*> arbitrary)+ <*> choose (0, 5)+ <*> genAdversarialText+ <*> elements [Strong, Eventual]+ <*> genMaybe (oneof [pure RmEntireLog, RmCategory <$> genAdversarialText])+ <*> elements [RmInline, RmSubscription]+ <*> genMaybe genAdversarialText+ <*> pure noLoc++genPgmqDispatch :: Gen PgmqDispatchNode+genPgmqDispatch =+ PgmqDispatchNode+ <$> genName+ <*> genName+ <*> genName+ <*> genName+ <*> genName+ <*> genName+ <*> genName+ <*> genName+ <*> genName+ <*> genName+ <*> pure noLoc++genWfBodyItem :: Gen WfBodyItem+genWfBodyItem = sized go+ where+ go size =+ oneof $+ [ WfStep <$> genWireWord <*> genName <*> pure noLoc,+ WfAwait <$> genWireWord <*> genName <*> pure noLoc,+ WfSleep <$> genWireWord <*> genName <*> pure noLoc,+ WfChild <$> genWireWord <*> genName <*> genName <*> pure noLoc,+ WfContinueAsNew <$> genName <*> pure noLoc+ ]+ ++ [ WfPatch <$> genWireWord <*> resize (size `div` 2) (smallList genWfBodyItem) <*> pure noLoc+ | size > 0+ ]++genWorkflow :: Gen WorkflowNode+genWorkflow =+ WorkflowNode+ <$> genName+ <*> genAdversarialText+ <*> genName+ <*> smallList genField+ <*> genName+ <*> genMaybe genName+ <*> genName+ <*> smallList genWfBodyItem+ <*> pure noLoc++genOperationShape :: Gen OperationShape+genOperationShape =+ oneof+ [ CommandOp <$> genName <*> genName <*> genName <*> smallList genName,+ QueryOp <$> genName <*> genName <*> ((\parts -> T.unwords parts) <$> nonEmptyList genName) <*> genName,+ SignalOp <$> genWireWord <*> genName <*> genName <*> genName <*> genName,+ RunOp <$> genName <*> genName <*> genName+ ]++genOperation :: Gen OperationNode+genOperation = OperationNode <$> genName <*> genOperationShape <*> pure noLoc++allNodeTags :: [String]+allNodeTags = ["aggregate", "process", "router", "contract", "intake", "emit", "publisher", "workqueue", "pgmq-dispatch", "readmodel", "workflow", "operation"]++nodeTag :: Node -> String+nodeTag = \case+ NAggregate _ -> "aggregate"+ NProcess _ -> "process"+ NRouter _ -> "router"+ NContract _ -> "contract"+ NIntake _ -> "intake"+ NEmit _ -> "emit"+ NPublisher _ -> "publisher"+ NWorkqueue _ -> "workqueue"+ NPgmqDispatch _ -> "pgmq-dispatch"+ NReadModel _ -> "readmodel"+ NWorkflow _ -> "workflow"+ NOperation _ -> "operation"++consumerNominalFor :: Name -> NominalOwnership+consumerNominalFor name =+ ConsumerNominal+ ConsumerNominalBinding+ { consumerNominalHaskell = HaskellSource "domain" "Domain.Types" name,+ consumerNominalBinding = QualifiedValueName "Domain.Bindings.binding",+ consumerNominalBindingVersion = BindingVersion "1",+ consumerNominalCanonical = CanonicalTypeId ("domain." <> name <> ".v1"),+ consumerNominalFixtures = QualifiedValueName "Domain.Bindings.fixtures",+ consumerNominalInitial = Just (QualifiedValueName "Domain.Bindings.initialValue")+ }++genId :: Gen IdDecl+genId = IdDecl <$> genName <*> genWire <*> pure Nothing <*> pure noLoc++genEnum :: Gen EnumDecl+genEnum = EnumDecl <$> genName <*> smallList ((,) <$> genName <*> genWire) <*> pure Nothing <*> pure noLoc++genRule :: Gen RuleDecl+genRule =+ RuleDecl+ <$> genName+ <*> genName+ <*> genName+ <*> nonEmptyList ((,) <$> genName <*> genExpr)+ <*> pure noLoc++genMappedDecls :: Gen [MappedDecl]+genMappedDecls = do+ count <- choose (0, 4 :: Int)+ let names = take count ["MappedA", "MappedB", "MappedC", "MappedD"]+ traverse (genMappedDecl names) names++genMappedDecl :: [Name] -> Name -> Gen MappedDecl+genMappedDecl names name =+ oneof+ [ MappedStructural name+ <$> genMaybe genHaskellSource+ <*> genMaybe genAdversarialText+ <*> genMaybe genAdversarialText+ <*> genMaybe genAdversarialText+ <*> genMaybe genAdversarialText+ <*> genMaybe genAdversarialText+ <*> genMappedShape names+ <*> pure noLoc,+ MappedOpaque name+ <$> genMaybe genHaskellSource+ <*> genMaybe genAdversarialText+ <*> genMaybe genAdversarialText+ <*> genMaybe genAdversarialText+ <*> genMaybe genAdversarialText+ <*> pure noLoc+ ]++genHaskellSource :: Gen HaskellSource+genHaskellSource =+ HaskellSource+ <$> genWire+ <*> genModuleRoot+ <*> genName++genMappedShape :: [Name] -> Gen MappedShape+genMappedShape names =+ oneof+ [ ShapeRecord+ <$> genName+ <*> elements [RejectUnknown, IgnoreUnknown]+ <*> smallList (genWireField names),+ ShapeEnum <$> smallList (WireEnum <$> genName <*> genAdversarialText <*> pure noLoc),+ ShapeUnion+ <$> (TaggedObject <$> genAdversarialText <*> genAdversarialText <*> elements [RejectUnknown, IgnoreUnknown])+ <*> smallList (WireArm <$> genName <*> genAdversarialText <*> genMaybe (genTypeExpr names) <*> pure noLoc)+ ]++genWireField :: [Name] -> Gen WireField+genWireField names =+ WireField+ <$> genName+ <*> genAdversarialText+ <*> genTypeExpr names+ <*> elements [PRequired, POptional]+ <*> genMaybe genOnMissing+ <*> pure noLoc++genTypeExpr :: [Name] -> Gen TypeExpr+genTypeExpr names = sized (go . min 3)+ where+ go 0 = base+ go depth =+ frequency+ [ (4, base),+ (1, TOptional <$> go (depth - 1)),+ (1, TList <$> go (depth - 1)),+ (1, TMap <$> go (depth - 1))+ ]+ -- This generator renders through the unversioned/version-1 grammar. Keep+ -- successor-only Integer coverage in the dedicated version-2 properties.+ base = elements ([TText, TInt, TBool, TNatural, TTime, TJson] ++ map TRef names)++genOnMissing :: Gen OnMissing+genOnMissing =+ oneof+ [ pure OmNull,+ OmText <$> genAdversarialText,+ OmInt <$> choose (-10, 10),+ OmBool <$> arbitrary,+ pure OmEmptyList,+ pure OmEmptyMap,+ OmCtor <$> genName+ ]++genSpec :: Gen Spec+genSpec = do+ contextName <- genWire+ moduleRoot <- genMaybe genModuleRoot+ layout <- genMaybe (elements [GeneratedPrefix, CollocatedLeaf])+ ids <- smallList genId+ enums <- smallList genEnum+ rules <- smallList genRule+ mapped <- genMappedDecls+ nodes <- smallList genNode+ pure (Spec contextName moduleRoot layout ids enums rules [] mapped nodes)+ where+ genNode =+ oneof+ [ NAggregate <$> genAggregate,+ NProcess <$> genProcess,+ NRouter <$> genRouter,+ NContract <$> genContract,+ NIntake <$> genIntake,+ NEmit <$> genEmit,+ NPublisher <$> genPublisher,+ NWorkqueue <$> genWorkqueue,+ NPgmqDispatch <$> genPgmqDispatch,+ NReadModel <$> genReadModel,+ NWorkflow <$> genWorkflow,+ NOperation <$> genOperation+ ]++-- | A dotted PascalCase module prefix, e.g. @Acme@ or @Acme.Services@.+genModuleRoot :: Gen T.Text+genModuleRoot = do+ n <- choose (1, 3 :: Int)+ segs <- vectorOf n (elements ["Acme", "Services", "Hospital", "Domain", "Core"])+ pure (T.intercalate "." segs)
+ test/conformance-aggregate-scalars/AggregateScalars/ScalarLedger/Holes.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE QualifiedDo #-}+{-# LANGUAGE TypeApplications #-}++-- This is a HAND-OWNED hole module. keiro-dsl creates it once and never+-- overwrites it. The generated harness pins this implementation's scalar+-- guards, writes, event, and forward/replay behavior.+module AggregateScalars.ScalarLedger.Holes (+ scalarLedgerTransducer,+ dishonestWireScalarsRecorded,+ -- (no projection)+) where++import Data.Time.Clock (UTCTime)+import Generated.AggregateScalars.ScalarLedger.Domain+import Keiki.Builder ((=:))+import qualified Keiki.Builder as B+import Keiki.Core (HsPred, SymTransducer, WireCtor (..), (.&&), (.==), (.>=))+import Numeric.Natural (Natural)++scalarLedgerTransducer ::+ SymTransducer+ (HsPred ScalarLedgerRegs ScalarLedgerCommand)+ ScalarLedgerRegs+ ScalarLedgerVertex+ ScalarLedgerCommand+ ScalarLedgerEvent+scalarLedgerTransducer =+ B.buildTransducer ScalarLedgerEmpty initialScalarLedgerRegs isTerminal do+ B.from ScalarLedgerEmpty do+ B.onCmd inCtorRecord $ \d -> B.do+ B.requireGuard+ ( d.observedAt+ .== d.observedAt+ .&& d.observedAt+ .>= d.observedAt+ .&& d.revision+ .== d.revision+ .&& d.revision+ .>= d.revision+ )+ B.slot @"observedAt" =: d.observedAt+ B.slot @"revision" =: d.revision+ B.emit+ emitWire+ ScalarsRecordedTermFields+ { observedAt = d.observedAt+ , revision = d.revision+ }+ B.goto ScalarLedgerRecorded+ where+ isTerminal = \case+ ScalarLedgerRecorded -> True+ _ -> False++-- The mutation test changes this indirection to the dormant dishonest ctor.+emitWire :: WireCtor ScalarLedgerEvent (UTCTime, (Natural, ()))+emitWire = wireScalarsRecorded++-- This builder pins every revision to one. The rewrite is idempotent, while+-- the unchanged output terms still prove both command fields were recovered.+dishonestWireScalarsRecorded :: WireCtor ScalarLedgerEvent (UTCTime, (Natural, ()))+dishonestWireScalarsRecorded =+ wireScalarsRecorded+ { wcBuild = wcBuild wireScalarsRecorded . forceRevisionOne+ }++forceRevisionOne :: (UTCTime, (Natural, ())) -> (UTCTime, (Natural, ()))+forceRevisionOne (observedAtValue, (_revisionValue, ())) =+ (observedAtValue, (1, ()))
+ test/conformance-aggregate-scalars/Generated/AggregateScalars/ReplayAudit.hs view
@@ -0,0 +1,24 @@+{-# LANGUAGE GADTs #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+--+-- Deployment contract:+-- * replay-neutral diff: no data audit is required;+-- * affected diff: run AuditTargeted with the emitted affected set+-- against a production copy under the candidate binary;+-- * one-time runtime cutover: run AuditFull;+-- * any non-zero audit exit blocks deployment.+module Generated.AggregateScalars.ReplayAudit (auditTargets) where++import Generated.AggregateScalars.ScalarLedger.EventStream qualified as ScalarLedger+import Keiro.ReplayAudit (AuditTarget (..), SomeAuditTarget (..), streamInCategory)+import Keiro.Stream qualified as Stream++auditTargets :: [SomeAuditTarget]+auditTargets =+ [ SomeAuditTarget+ AuditTarget+ { eventStream = ScalarLedger.scalarLedgerEventStream+ , category = Stream.categoryText ScalarLedger.scalarLedgerCategory+ , mkStream = streamInCategory (Stream.categoryText ScalarLedger.scalarLedgerCategory)+ }+ ]
+ test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Codec.hs view
@@ -0,0 +1,49 @@+{-# LANGUAGE OverloadedRecordDot #-}++-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalars.ScalarLedger.Codec (+ scalarLedgerCodec,+ parseScalarLedgerEvent,+ encodeScalarLedgerEvent,+) where++import Data.Aeson (Value, object, withObject, (.:), (.=))+import Data.Aeson.Types (Parser, parseEither)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Text (Text)+import qualified Data.Text as T+import Generated.AggregateScalars.ScalarLedger.Domain+import Keiro.Codec (Codec (..), EventType (..))++scalarLedgerCodec :: Codec ScalarLedgerEvent+scalarLedgerCodec =+ Codec+ { eventTypes = EventType "ScalarsRecorded" :| []+ , eventType = \case+ ScalarsRecorded{} -> EventType "ScalarsRecorded"+ , schemaVersion = 1+ , encode = encodeScalarLedgerEvent+ , decode = parseScalarLedgerEvent+ , upcasters = []+ }++encodeScalarLedgerEvent :: ScalarLedgerEvent -> Value+encodeScalarLedgerEvent = \case+ ScalarsRecorded payload ->+ object+ [ "kind" .= ("ScalarsRecorded" :: Text)+ , "observedAt" .= payload.observedAt+ , "revision" .= payload.revision+ ]++parseScalarLedgerEvent :: EventType -> Value -> Either Text ScalarLedgerEvent+parseScalarLedgerEvent (EventType tag) = mapLeftText . parseEither (withObject "ScalarLedgerEvent" go)+ where+ go o = do+ case tag of+ "ScalarsRecorded" ->+ ScalarsRecorded <$> (ScalarsRecordedData <$> o .: "observedAt" <*> o .: "revision")+ _ -> fail "unknown event type"++mapLeftText :: Either String b -> Either Text b+mapLeftText = either (Left . T.pack) Right
+ test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Domain.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE TypeApplications #-}+{-# OPTIONS_GHC -Wno-unused-top-binds #-}++-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalars.ScalarLedger.Domain where++import Data.Aeson (FromJSON, ToJSON)+import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime (..), picosecondsToDiffTime)+import GHC.Generics (Generic)+import Keiki.Core (RegFile (..))+import Keiki.Generics.TH (deriveAggregateCtorsAll, deriveWireCtorsAll)+import Keiki.Shape (CanonicalStateShape, CanonicalTypeName)+import Numeric.Natural (Natural)++data ScalarLedgerVertex = ScalarLedgerEmpty | ScalarLedgerRecorded+ deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)+ deriving anyclass (ToJSON, FromJSON)+instance CanonicalStateShape ScalarLedgerVertex+instance CanonicalTypeName ScalarLedgerVertex++data RecordData = RecordData+ { observedAt :: !UTCTime+ , revision :: !Natural+ }+ deriving stock (Generic, Eq, Show)++data ScalarLedgerCommand = Record !RecordData+ deriving stock (Generic, Eq, Show)++data ScalarsRecordedData = ScalarsRecordedData+ { observedAt :: !UTCTime+ , revision :: !Natural+ }+ deriving stock (Generic, Eq, Show)++data ScalarLedgerEvent = ScalarsRecorded !ScalarsRecordedData+ deriving stock (Generic, Eq, Show)++type ScalarLedgerRegs =+ '[ '("observedAt", UTCTime)+ , '("revision", Natural)+ ]++initialScalarLedgerRegs :: RegFile ScalarLedgerRegs+initialScalarLedgerRegs =+ RCons (Proxy @"observedAt") (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) $+ RCons (Proxy @"revision") 0 RNil++$(deriveAggregateCtorsAll ''ScalarLedgerCommand ''ScalarLedgerRegs)++$(deriveWireCtorsAll ''ScalarLedgerEvent)
+ test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/EventStream.hs view
@@ -0,0 +1,58 @@+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalars.ScalarLedger.EventStream+ ( scalarLedgerCategory+ , scalarLedgerEventStream+ , scalarLedgerEventStreamDef+ , ScalarLedgerEventStream+ , ScalarLedgerEventStreamDef+ , scalarLedgerSnapshotFixture+ ) where++import Generated.AggregateScalars.ScalarLedger.Domain+import Generated.AggregateScalars.ScalarLedger.Codec (scalarLedgerCodec)+import AggregateScalars.ScalarLedger.Holes (scalarLedgerTransducer)+import Keiki.Core (HsPred)+import Keiro.EventStream (EventStream (..), SnapshotPolicy (..))+import Keiro.EventStream.Validate (ValidatedEventStream, mkEventStreamOrThrow)+import Data.Text (Text)+import Keiro.Snapshot.Codec (defaultStateCodec, withFoldFingerprint)+import Keiro.Stream qualified as Stream++-- The validated aggregate stream category (hole-kind 5: referenced, never retyped).+-- Entity streams are '<category>-<id>' via Keiro.Stream.entityStream.+-- categoryUnsafe is safe here because this generated literal passed the DSL category proof.+scalarLedgerCategory :: Stream.StreamCategory a+scalarLedgerCategory = Stream.categoryUnsafe "scalarLedger"++type ScalarLedgerEventStreamDef =+ EventStream (HsPred ScalarLedgerRegs ScalarLedgerCommand) ScalarLedgerRegs ScalarLedgerVertex ScalarLedgerCommand ScalarLedgerEvent++type ScalarLedgerEventStream =+ ValidatedEventStream (HsPred ScalarLedgerRegs ScalarLedgerCommand) ScalarLedgerRegs ScalarLedgerVertex ScalarLedgerCommand ScalarLedgerEvent++scalarLedgerEventStreamDef :: ScalarLedgerEventStreamDef+scalarLedgerEventStreamDef =+ EventStream+ { transducer = scalarLedgerTransducer+ , initialState = ScalarLedgerEmpty+ , initialRegisters = initialScalarLedgerRegs+ , eventCodec = scalarLedgerCodec+ , resolveStreamName = Stream.streamName+ , snapshotPolicy = Every 1+ -- The snapshot discriminator composes: the spec's state-codec version (bump it+ -- in the spec's `state-codec version=` clause), keiki's register and+ -- control-state shape hashes, and this fold fingerprint derived from the+ -- spec's transition surface (guards, writes, emits, states, register+ -- initials, referenced rules). Spec-visible fold changes invalidate old+ -- snapshots automatically. Fold changes made ONLY in the hand-owned Holes+ -- module are invisible here: bump `state-codec version=` manually or old+ -- snapshots will be served stale.+ , stateCodec = Just (withFoldFingerprint "f1fd9ca5a924e6b9" (defaultStateCodec 1))+ }++scalarLedgerSnapshotFixture :: (Int, Text)+scalarLedgerSnapshotFixture = (1, "aggregate-scalars-v1")++scalarLedgerEventStream :: ScalarLedgerEventStream+scalarLedgerEventStream =+ mkEventStreamOrThrow "ScalarLedger" scalarLedgerEventStreamDef
+ test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Harness.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedLabels #-}++-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalars.ScalarLedger.Harness (harnessAssertions) where++import AggregateScalars.ScalarLedger.Holes (scalarLedgerTransducer)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime (..), picosecondsToDiffTime)+import Generated.AggregateScalars.ScalarLedger.Codec (encodeScalarLedgerEvent, parseScalarLedgerEvent, scalarLedgerCodec)+import Generated.AggregateScalars.ScalarLedger.Domain+import Keiki.Core (applyEventsEither, defaultValidationOptions, step, validateTransducer, (!))+import Keiro.Codec (eventType)++{- | (label, passed). A driver runs these and exits non-zero on any False,+naming the failing assertion. Filling a hole wrongly turns a specific+entry False; the scaffold cannot.+-}+harnessAssertions :: [(String, Bool)]+harnessAssertions =+ [ ("validateTransducer is empty", null (validateTransducer defaultValidationOptions scalarLedgerTransducer))+ , ("clock-free: spec samples no wall clock", True)+ , ("golden round-trip: ScalarsRecorded", roundTrips sampleEventScalarsRecorded)+ , ("accepts Record from ScalarLedgerEmpty", acceptRecord)+ ]+ ++ forwardReplayRecord++roundTrips :: ScalarLedgerEvent -> Bool+roundTrips e = parseScalarLedgerEvent (eventType scalarLedgerCodec e) (encodeScalarLedgerEvent e) == Right e++sampleEventScalarsRecorded :: ScalarLedgerEvent+sampleEventScalarsRecorded = (ScalarsRecorded (ScalarsRecordedData (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) 0))++acceptRecord :: Bool+acceptRecord =+ case step scalarLedgerTransducer (ScalarLedgerEmpty, initialScalarLedgerRegs) ((Record (RecordData (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) 0))) of+ Just (v, _, _) -> v == ScalarLedgerRecorded+ Nothing -> False++-- forward/replay equality (plan 147): cross the persisted codec boundary,+-- replay the emitted chain, and compare the final vertex and every register.+forwardReplayRecord :: [(String, Bool)]+forwardReplayRecord =+ case step scalarLedgerTransducer (ScalarLedgerEmpty, initialScalarLedgerRegs) ((Record (RecordData (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) 0))) of+ Nothing -> [(prefix <> "forward step accepted", False)]+ Just (forwardVertex, forwardRegs, emitted) ->+ case mapM (\event -> parseScalarLedgerEvent (eventType scalarLedgerCodec event) (encodeScalarLedgerEvent event)) emitted of+ Left _ -> [(prefix <> "emitted chain decodes", False)]+ Right decodedEvents ->+ case applyEventsEither scalarLedgerTransducer (ScalarLedgerEmpty, initialScalarLedgerRegs) decodedEvents of+ Left _ -> [(prefix <> "replay succeeds", False)]+ Right (replayVertex, replayRegs) ->+ [ (prefix <> "final vertex", replayVertex == forwardVertex)+ , (prefix <> "register observedAt", (replayRegs ! #observedAt) == (forwardRegs ! #observedAt))+ , (prefix <> "register revision", (replayRegs ! #revision) == (forwardRegs ! #revision))+ ]+ where+ prefix = "forward/replay equality: Record from ScalarLedgerEmpty -- "
+ test/conformance-aggregate-scalars/Generated/AggregateScalars/ScalarLedger/Projection.hs view
@@ -0,0 +1,2 @@+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalars.ScalarLedger.Projection () where
+ test/conformance-aggregate-scalars/Main.hs view
@@ -0,0 +1,114 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedLabels #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE QualifiedDo #-}+{-# LANGUAGE TypeApplications #-}++module Main (main) where++import Control.Exception (evaluate)+import Control.Monad (forM_, unless)+import Data.Aeson (Result (..), Value (..), object, toJSON, (.=))+import Data.Aeson qualified as Aeson+import Data.Proxy (Proxy (..))+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime (..), picosecondsToDiffTime)+import Generated.AggregateScalars.ScalarLedger.Codec (encodeScalarLedgerEvent, parseScalarLedgerEvent, scalarLedgerCodec)+import Generated.AggregateScalars.ScalarLedger.Domain+import Generated.AggregateScalars.ScalarLedger.EventStream (scalarLedgerEventStream, scalarLedgerEventStreamDef)+import Generated.AggregateScalars.ScalarLedger.Harness (harnessAssertions)+import Keiki.Builder qualified as B+import Keiki.Core (HsPred, SymTransducer, TransducerValidationWarning (..), ValidationOptions (..), defaultValidationOptions, lit, tadd, validateTransducer, (!), (.>=))+import Keiki.Shape (CanonicalTypeName (..))+import Keiro.Codec (eventType)+import Keiro.EventStream (EventStream (..), StateCodec (..))+import Numeric.Natural (Natural)+import System.Exit (exitFailure)++main :: IO ()+main = do+ _ <- evaluate scalarLedgerEventStream+ let checks =+ harnessAssertions+ <> [ ("event codec preserves picosecond Time and positive Natural", eventRoundTrip)+ , ("event JSON is exact at picosecond precision", exactEventJson)+ , ("snapshot codec preserves initial Time and Natural zero", snapshotRoundTrip)+ , ("Natural JSON accepts zero", naturalJsonAccepts 0)+ , ("Natural JSON accepts a positive integer", naturalJsonAccepts 7)+ , ("Natural JSON rejects a negative integer", naturalJsonRejects (Number (-1)))+ , ("Natural JSON rejects a fractional number", naturalJsonRejects (Number 1.5))+ , ("Natural canonical type name is stable", canonicalTypeName (Proxy @Natural) == "Natural")+ , ("Natural arithmetic is structural in Keiki", naturalArithmeticIsStructural)+ ]+ forM_ checks $ \(label, passed) ->+ putStrLn ((if passed then "PASS " else "FAIL ") <> label)+ unless (all snd checks) exitFailure++sampleTime :: UTCTime+sampleTime = UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)++sampleEvent :: ScalarLedgerEvent+sampleEvent = ScalarsRecorded ScalarsRecordedData{observedAt = sampleTime, revision = 7}++eventRoundTrip :: Bool+eventRoundTrip =+ parseScalarLedgerEvent+ (eventType scalarLedgerCodec sampleEvent)+ (encodeScalarLedgerEvent sampleEvent)+ == Right sampleEvent++exactEventJson :: Bool+exactEventJson =+ encodeScalarLedgerEvent sampleEvent+ == object+ [ "kind" .= ("ScalarsRecorded" :: String)+ , "observedAt" .= sampleTime+ , "revision" .= (7 :: Natural)+ ]++snapshotRoundTrip :: Bool+snapshotRoundTrip = case stateCodec scalarLedgerEventStreamDef of+ Nothing -> False+ Just codec ->+ let encoded = encode codec (initialState scalarLedgerEventStreamDef, initialRegisters scalarLedgerEventStreamDef)+ in case decode codec encoded of+ Left _ -> False+ Right (vertex, registers) ->+ vertex == ScalarLedgerEmpty+ && registers ! #observedAt == sampleTime+ && registers ! #revision == 0+ && encode codec (vertex, registers) == encoded++naturalJsonAccepts :: Natural -> Bool+naturalJsonAccepts expected = Aeson.fromJSON (toJSON expected) == Success expected++naturalJsonRejects :: Value -> Bool+naturalJsonRejects value = case Aeson.fromJSON value :: Result Natural of+ Error _ -> True+ Success _ -> False++naturalArithmeticIsStructural :: Bool+naturalArithmeticIsStructural = not (any isOpaque warnings)+ where+ warnings =+ validateTransducer+ defaultValidationOptions{warnOpaqueGuards = True}+ naturalArithmeticTransducer+ isOpaque OpaqueGuard{} = True+ isOpaque _ = False++naturalArithmeticTransducer ::+ SymTransducer+ (HsPred ScalarLedgerRegs ScalarLedgerCommand)+ ScalarLedgerRegs+ ScalarLedgerVertex+ ScalarLedgerCommand+ ScalarLedgerEvent+naturalArithmeticTransducer =+ B.buildTransducer ScalarLedgerEmpty initialScalarLedgerRegs (const False) do+ B.from ScalarLedgerEmpty do+ B.onCmd inCtorRecord $ \command -> B.do+ B.requireGuard (tadd command.revision (lit 1) .>= command.revision)+ B.noEmit+ B.goto ScalarLedgerEmpty
test/conformance-codec-compare/Conformance/CodecCompare/Historical.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE ImportQualifiedPost #-} {-# LANGUAGE OverloadedRecordDot #-} module Conformance.CodecCompare.Historical (
test/conformance-codec-compare/Main.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE ImportQualifiedPost #-} module Main (main) where
test/conformance-coldstart/Generated/Billing/Subscription/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.Billing.Subscription.Codec (
test/conformance-coldstart/Generated/Billing/Subscription/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-coldstart/Generated/Billing/Subscription/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.Billing.Subscription.Harness (harnessAssertions) where
test/conformance-coldstart/Generated/Billing/Subscription/Projection.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.Billing.Subscription.Projection ( subscriptionsProjection
test/conformance-contract/Generated/HospitalCapacity/Emergency/Contract.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Emergency.Contract
test/conformance-contract/Main.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} {- | Conformance driver for the scaffolded EP-4 contract layer. Compiling this component proves the scaffolded @Generated.…Emergency.Contract@ module (the
test/conformance-dispatch-full/Generated/HospitalCapacity/Reservation_work/Queue.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-dispatch-full/Generated/HospitalCapacity/Reservation_work/QueueCodec.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-dispatch-full/Generated/HospitalCapacity/Reservation_work/QueuePolicy.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation_work.QueuePolicy (
test/conformance-dispatch-full/HospitalCapacity/ReservationWork/WorkqueueJob.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- HAND-FILLED pgmq dispatch service (EP-5 M5 full-service integration): the -- declarative Job value (queue + codec + retry policy, all from the scaffolded
test/conformance-dispatch-full/Main.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} {- | EP-5 M5 full-service conformance: a complete pgmq dispatch service — the scaffolded Job codec + retry policy plus a filled worker handler, assembled
test/conformance-intake-full/Main.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} {- | EP-4 M5 full-service conformance: a complete integration service — the scaffolded inbox dedupe/disposition plus a filled inbox transaction runner
test/conformance-newsurface/Generated/TransferRouting/Hospital/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.TransferRouting.Hospital.Codec (
test/conformance-newsurface/Generated/TransferRouting/Hospital/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-newsurface/Generated/TransferRouting/Hospital/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.TransferRouting.Hospital.Harness (harnessAssertions) where
test/conformance-newsurface/Generated/TransferRouting/HospitalTransferRouter/Router.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.TransferRouting.HospitalTransferRouter.Router (
test/conformance-newsurface/Generated/TransferRouting/Hospital_load/ReadModel.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.TransferRouting.Hospital_load.ReadModel (
test/conformance-newsurface/Generated/TransferRouting/Hospital_load/ReadModelTable.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.TransferRouting.Hospital_load.ReadModelTable (hospitalLoadQualifiedTable) where
test/conformance-newsurface/Main.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} module Main (main) where
test/conformance-newsurface/TransferRouting/HospitalTransferRouter/RouterValue.hs view
@@ -1,7 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} module TransferRouting.HospitalTransferRouter.RouterValue ( AcceptedHospitalTransferNeed (..),
+ test/conformance-nominal-scalars/Generated/NominalScalars/Nominal/Shape/OrderStatus.hs view
@@ -0,0 +1,19 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE LambdaCase #-}++-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.NominalScalars.Nominal.Shape.OrderStatus (+ OrderStatusRepresentation (..),+ orderStatusRepresentationText,+) where++import Data.Text (Text)+import GHC.Generics (Generic)++data OrderStatusRepresentation = Draft | Submitted+ deriving stock (Eq, Generic, Ord, Show, Enum, Bounded)++orderStatusRepresentationText :: OrderStatusRepresentation -> Text+orderStatusRepresentationText = \case+ Draft -> "draft"+ Submitted -> "submitted"
+ test/conformance-nominal-scalars/Generated/NominalScalars/NominalLedger/Codec.hs view
@@ -0,0 +1,80 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE TypeApplications #-}++-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.NominalScalars.NominalLedger.Codec (+ nominalLedgerCodec,+ parseNominalLedgerEvent,+ encodeNominalLedgerEvent,+) where++import Data.Aeson (Value, object, withObject, (.:), (.=))+import Data.Aeson.Types (Parser, parseEither)+import qualified Data.KindID as KindID+import Data.List.NonEmpty (NonEmpty (..))+import Data.Text (Text)+import qualified Data.Text as T+import qualified Generated.NominalScalars.Nominal.Shape.OrderStatus as Representation+import Generated.NominalScalars.NominalLedger.Domain+import Keiro.Codec (Codec (..), EventType (..))+import Keiro.Codec.Nominal (nominalFromRepresentation, nominalToRepresentation)+import qualified NominalConformance.Bindings as Bindings+import qualified NominalConformance.Domain++parseOrderIdNominal :: Text -> Parser NominalConformance.Domain.OrderId+parseOrderIdNominal input = case KindID.parseText @"ord" input of+ Left reason -> fail (show reason)+ Right representation -> pure (nominalFromRepresentation Bindings.orderIdBinding representation)++parseOrderStatusNominal :: Text -> Parser NominalConformance.Domain.OrderStatus+parseOrderStatusNominal = \case+ "draft" -> pure (nominalFromRepresentation Bindings.orderStatusBinding Representation.Draft)+ "submitted" -> pure (nominalFromRepresentation Bindings.orderStatusBinding Representation.Submitted)+ _ -> fail "unknown OrderStatus wire value"++nominalLedgerCodec :: Codec NominalLedgerEvent+nominalLedgerCodec =+ Codec+ { eventTypes = EventType "NominalsRecorded" :| []+ , eventType = \case NominalsRecorded{} -> EventType "NominalsRecorded"+ , schemaVersion = 1+ , encode = encodeNominalLedgerEvent+ , decode = parseNominalLedgerEvent+ , upcasters = []+ }++encodeNominalLedgerEvent :: NominalLedgerEvent -> Value+encodeNominalLedgerEvent = \case+ NominalsRecorded payload ->+ object+ [ "kind" .= ("NominalsRecorded" :: Text)+ , "orderId" .= KindID.toText (nominalToRepresentation Bindings.orderIdBinding payload.orderId)+ , "status" .= Representation.orderStatusRepresentationText (nominalToRepresentation Bindings.orderStatusBinding payload.status)+ , "accountNumber" .= nominalToRepresentation Bindings.accountNumberBinding payload.accountNumber+ , "riskScore" .= nominalToRepresentation Bindings.riskScoreBinding payload.riskScore+ , "sequenceNumber" .= nominalToRepresentation Bindings.sequenceNumberBinding payload.sequenceNumber+ , "featureFlag" .= nominalToRepresentation Bindings.featureFlagBinding payload.featureFlag+ , "observedAt" .= nominalToRepresentation Bindings.observedAtBinding payload.observedAt+ ]++parseNominalLedgerEvent :: EventType -> Value -> Either Text NominalLedgerEvent+parseNominalLedgerEvent (EventType tag) = mapLeftText . parseEither (withObject "NominalLedgerEvent" go)+ where+ go objectValue = case tag of+ "NominalsRecorded" ->+ NominalsRecorded+ <$> ( NominalsRecordedData+ <$> (objectValue .: "orderId" >>= parseOrderIdNominal)+ <*> (objectValue .: "status" >>= parseOrderStatusNominal)+ <*> (nominalFromRepresentation Bindings.accountNumberBinding <$> objectValue .: "accountNumber")+ <*> (nominalFromRepresentation Bindings.riskScoreBinding <$> objectValue .: "riskScore")+ <*> (nominalFromRepresentation Bindings.sequenceNumberBinding <$> objectValue .: "sequenceNumber")+ <*> (nominalFromRepresentation Bindings.featureFlagBinding <$> objectValue .: "featureFlag")+ <*> (nominalFromRepresentation Bindings.observedAtBinding <$> objectValue .: "observedAt")+ )+ _ -> fail "unknown event type"++mapLeftText :: Either String value -> Either Text value+mapLeftText = either (Left . T.pack) Right
+ test/conformance-nominal-scalars/Generated/NominalScalars/NominalLedger/Domain.hs view
@@ -0,0 +1,71 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE TypeApplications #-}++-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.NominalScalars.NominalLedger.Domain where++import Data.Aeson (FromJSON, ToJSON)+import Data.Proxy (Proxy (..))+import GHC.Generics (Generic)+import Keiki.Core (RegFile (..))+import Keiki.Shape (CanonicalStateShape, CanonicalTypeName)+import qualified NominalConformance.Bindings as Bindings+import NominalConformance.Domain++data NominalLedgerVertex = NominalLedgerEmpty | NominalLedgerRecorded+ deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)+ deriving anyclass (FromJSON, ToJSON)++instance CanonicalStateShape NominalLedgerVertex+instance CanonicalTypeName NominalLedgerVertex++data RecordNominalsData = RecordNominalsData+ { orderId :: !OrderId+ , status :: !OrderStatus+ , accountNumber :: !AccountNumber+ , riskScore :: !RiskScore+ , sequenceNumber :: !SequenceNumber+ , featureFlag :: !FeatureFlag+ , observedAt :: !ObservedAt+ }+ deriving stock (Generic, Eq, Show)++data NominalLedgerCommand = RecordNominals !RecordNominalsData+ deriving stock (Generic, Eq, Show)++data NominalsRecordedData = NominalsRecordedData+ { orderId :: !OrderId+ , status :: !OrderStatus+ , accountNumber :: !AccountNumber+ , riskScore :: !RiskScore+ , sequenceNumber :: !SequenceNumber+ , featureFlag :: !FeatureFlag+ , observedAt :: !ObservedAt+ }+ deriving stock (Generic, Eq, Show)++data NominalLedgerEvent = NominalsRecorded !NominalsRecordedData+ deriving stock (Generic, Eq, Show)++type NominalLedgerRegs =+ '[ '("orderId", OrderId)+ , '("status", OrderStatus)+ , '("accountNumber", AccountNumber)+ , '("riskScore", RiskScore)+ , '("sequenceNumber", SequenceNumber)+ , '("featureFlag", FeatureFlag)+ , '("observedAt", ObservedAt)+ ]++initialNominalLedgerRegs :: RegFile NominalLedgerRegs+initialNominalLedgerRegs =+ RCons (Proxy @"orderId") Bindings.initialOrderId $+ RCons (Proxy @"status") Bindings.initialOrderStatus $+ RCons (Proxy @"accountNumber") Bindings.initialAccountNumber $+ RCons (Proxy @"riskScore") Bindings.initialRiskScore $+ RCons (Proxy @"sequenceNumber") Bindings.initialSequenceNumber $+ RCons (Proxy @"featureFlag") Bindings.initialFeatureFlag $+ RCons (Proxy @"observedAt") Bindings.initialObservedAt RNil
+ test/conformance-nominal-scalars/Generated/NominalScalars/NominalProjections.hs view
@@ -0,0 +1,69 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}++-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.NominalScalars.NominalProjections where++import Data.Text (Text)+import Data.Time (UTCTime)+import Keiki.Core (FieldProjection (..), FieldWitness, fieldWitness)+import Keiro.Codec.Nominal (nominalToRepresentation)+import qualified NominalConformance.Bindings as Bindings+import NominalConformance.Domain+import Numeric.Natural (Natural)++data AccountNumberNominalProjection+instance FieldProjection AccountNumberNominalProjection where+ type FieldName AccountNumberNominalProjection = "AccountNumber"+ type FieldOwner AccountNumberNominalProjection = AccountNumber+ type FieldResult AccountNumberNominalProjection = Text+ fieldShapeId _ = "nominal.AccountNumber.v1"+ projectFieldValue _ = nominalToRepresentation Bindings.accountNumberBinding++accountNumberWitness :: FieldWitness AccountNumberNominalProjection+accountNumberWitness = fieldWitness @AccountNumberNominalProjection++data RiskScoreNominalProjection+instance FieldProjection RiskScoreNominalProjection where+ type FieldName RiskScoreNominalProjection = "RiskScore"+ type FieldOwner RiskScoreNominalProjection = RiskScore+ type FieldResult RiskScoreNominalProjection = Int+ fieldShapeId _ = "nominal.RiskScore.v1"+ projectFieldValue _ = nominalToRepresentation Bindings.riskScoreBinding++riskScoreWitness :: FieldWitness RiskScoreNominalProjection+riskScoreWitness = fieldWitness @RiskScoreNominalProjection++data SequenceNumberNominalProjection+instance FieldProjection SequenceNumberNominalProjection where+ type FieldName SequenceNumberNominalProjection = "SequenceNumber"+ type FieldOwner SequenceNumberNominalProjection = SequenceNumber+ type FieldResult SequenceNumberNominalProjection = Natural+ fieldShapeId _ = "nominal.SequenceNumber.v1"+ projectFieldValue _ = nominalToRepresentation Bindings.sequenceNumberBinding++sequenceNumberWitness :: FieldWitness SequenceNumberNominalProjection+sequenceNumberWitness = fieldWitness @SequenceNumberNominalProjection++data FeatureFlagNominalProjection+instance FieldProjection FeatureFlagNominalProjection where+ type FieldName FeatureFlagNominalProjection = "FeatureFlag"+ type FieldOwner FeatureFlagNominalProjection = FeatureFlag+ type FieldResult FeatureFlagNominalProjection = Bool+ fieldShapeId _ = "nominal.FeatureFlag.v1"+ projectFieldValue _ = nominalToRepresentation Bindings.featureFlagBinding++featureFlagWitness :: FieldWitness FeatureFlagNominalProjection+featureFlagWitness = fieldWitness @FeatureFlagNominalProjection++data ObservedAtNominalProjection+instance FieldProjection ObservedAtNominalProjection where+ type FieldName ObservedAtNominalProjection = "ObservedAt"+ type FieldOwner ObservedAtNominalProjection = ObservedAt+ type FieldResult ObservedAtNominalProjection = UTCTime+ fieldShapeId _ = "nominal.ObservedAt.v1"+ projectFieldValue _ = nominalToRepresentation Bindings.observedAtBinding++observedAtWitness :: FieldWitness ObservedAtNominalProjection+observedAtWitness = fieldWitness @ObservedAtNominalProjection
+ test/conformance-nominal-scalars/Main.hs view
@@ -0,0 +1,281 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedLabels #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE TypeApplications #-}++module Main (main) where++import Control.Monad (forM_, unless)+import Data.Aeson (Value (..), object, toJSON, (.=))+import Data.Aeson.Key (Key)+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.KindID as KindID+import qualified Data.List.NonEmpty as NE+import Data.Proxy (Proxy (..))+import qualified Generated.NominalScalars.Nominal.Shape.OrderStatus as Representation+import Generated.NominalScalars.NominalLedger.Codec+import Generated.NominalScalars.NominalLedger.Domain+import qualified Generated.NominalScalars.NominalProjections as Projections+import Keiki.Core (Index, evalPred, fieldWitnessAgrees, lit, regProj, (!), (.==), (.>=))+import Keiki.Shape (CanonicalTypeName (..))+import Keiro.Codec (EventType (..), eventType)+import Keiro.Codec.Nominal+import qualified Keiro.EventStream as EventStream+import Keiro.Snapshot.Codec (defaultStateCodec)+import qualified NominalConformance.Bindings as Bindings+import NominalConformance.Domain+import Numeric.Natural (Natural)+import System.Environment (lookupEnv)+import System.Exit (exitFailure)++main :: IO ()+main = do+ mutation <- lookupEnv "KEIRO_NOMINAL_MUTATION"+ let checks =+ bindingLawChecks+ <> [ ("expected wire parity: all nominal categories", expectedWireParity)+ , ("enum representation covers every constructor and wire spelling exactly once", enumCoverage)+ , ("event codec exact JSON bytes", exactEventJson)+ , ("event codec round-trip", eventRoundTrip)+ , ("pre-adoption valid ord payload still decodes", validHistoricalPayloadDecodes)+ , ("wrong-prefix ID rejection", wrongPrefixRejected)+ , ("malformed ID rejection", malformedIdRejected)+ , ("unknown enum rejection", unknownEnumRejected)+ , ("snapshot cache round-trip", snapshotRoundTrip)+ , ("canonical nominal identities", canonicalIdentities)+ , ("scalar projection witness agreement", projectionAgreement)+ , ("scalar equality support", equalityChecks)+ , ("Int Natural and Time ordering support", orderingChecks)+ , ("forward execution equals decoded replay", forwardReplayAgreement)+ ]+ <> mutationChecks mutation+ forM_ checks $ \(label, passed) ->+ putStrLn ((if passed then "PASS " else "FAIL ") <> label)+ unless (all snd checks) exitFailure++mutationChecks :: Maybe String -> [(String, Bool)]+mutationChecks Nothing = []+mutationChecks (Just "enum-transpose") = [("mutation gate: transposed enum representation preserves expected wire", transposedEnumWireParity)]+mutationChecks (Just "scalar-wire") = [("mutation gate: changed scalar expected wire remains exact", changedScalarWireParity)]+mutationChecks (Just "id-one-direction") = [("mutation gate: one-direction ID suffix preserves domain law", oneDirectionIdLaw)]+mutationChecks (Just other) = [("unknown mutation: " <> other, False)]++transposedEnumWireParity :: Bool+transposedEnumWireParity =+ all+ ( \fixture ->+ String (Representation.orderStatusRepresentationText (nominalToRepresentation transposed (nominalFixtureDomain fixture)))+ == nominalFixtureWire fixture+ )+ (NE.toList (nominalFixtureCases Bindings.orderStatusFixtures))+ where+ transposed =+ NominalBinding+ { nominalToRepresentation = \case+ AwaitingApproval -> Representation.Submitted+ Accepted -> Representation.Draft+ , nominalFromRepresentation = \case+ Representation.Draft -> Accepted+ Representation.Submitted -> AwaitingApproval+ }++changedScalarWireParity :: Bool+changedScalarWireParity =+ toJSON (nominalToRepresentation Bindings.riskScoreBinding (RiskScore 7)) == toJSON (8 :: Int)++oneDirectionIdLaw :: Bool+oneDirectionIdLaw = case KindID.parseText @"ord" "ord_00041061050r3gg28a1c60t3ge" of+ Left _ -> False+ Right changed ->+ nominalDomainRoundTrip+ Bindings.orderIdBinding{nominalFromRepresentation = const (OrderId changed)}+ Bindings.sampleOrderId++bindingLawChecks :: [(String, Bool)]+bindingLawChecks =+ lawChecks "OrderId" Bindings.orderIdBinding Bindings.orderIdFixtures+ <> lawChecks "OrderStatus" Bindings.orderStatusBinding Bindings.orderStatusFixtures+ <> lawChecks "AccountNumber" Bindings.accountNumberBinding Bindings.accountNumberFixtures+ <> lawChecks "RiskScore" Bindings.riskScoreBinding Bindings.riskScoreFixtures+ <> lawChecks "SequenceNumber" Bindings.sequenceNumberBinding Bindings.sequenceNumberFixtures+ <> lawChecks "FeatureFlag" Bindings.featureFlagBinding Bindings.featureFlagFixtures+ <> lawChecks "ObservedAt" Bindings.observedAtBinding Bindings.observedAtFixtures++lawChecks :: (Eq domain, Eq representation) => String -> NominalBinding domain representation -> NominalFixtureCases domain -> [(String, Bool)]+lawChecks label binding fixtures =+ [ ("binding domain law: " <> label, all (nominalDomainRoundTrip binding . nominalFixtureDomain) values)+ ,+ ( "binding representation law: " <> label+ , all+ (\fixture -> nominalRepresentationRoundTrip binding (nominalToRepresentation binding (nominalFixtureDomain fixture)))+ values+ )+ ]+ where+ values = NE.toList (nominalFixtureCases fixtures)++sampleEvent :: NominalLedgerEvent+sampleEvent =+ NominalsRecorded+ NominalsRecordedData+ { orderId = Bindings.sampleOrderId+ , status = Accepted+ , accountNumber = AccountNumber "acct-007"+ , riskScore = RiskScore 7+ , sequenceNumber = SequenceNumber 11+ , featureFlag = FeatureFlag True+ , observedAt = ObservedAt Bindings.sampleTime+ }++expectedEventJson :: Value+expectedEventJson =+ object+ [ "kind" .= ("NominalsRecorded" :: String)+ , "orderId" .= Bindings.validOrderIdText+ , "status" .= ("submitted" :: String)+ , "accountNumber" .= ("acct-007" :: String)+ , "riskScore" .= (7 :: Int)+ , "sequenceNumber" .= (11 :: Natural)+ , "featureFlag" .= True+ , "observedAt" .= Bindings.sampleTime+ ]++exactEventJson :: Bool+exactEventJson = encodeNominalLedgerEvent sampleEvent == expectedEventJson++eventRoundTrip :: Bool+eventRoundTrip =+ parseNominalLedgerEvent (eventType nominalLedgerCodec sampleEvent) (encodeNominalLedgerEvent sampleEvent)+ == Right sampleEvent++validHistoricalPayloadDecodes :: Bool+validHistoricalPayloadDecodes =+ parseNominalLedgerEvent (EventType "NominalsRecorded") expectedEventJson == Right sampleEvent++wrongPrefixRejected :: Bool+wrongPrefixRejected = rejects (replace "orderId" (String "usr_00041061050r3gg28a1c60t3gf") expectedEventJson)++malformedIdRejected :: Bool+malformedIdRejected = rejects (replace "orderId" (String "not-a-typeid") expectedEventJson)++unknownEnumRejected :: Bool+unknownEnumRejected = rejects (replace "status" (String "retired") expectedEventJson)++rejects :: Value -> Bool+rejects value = case parseNominalLedgerEvent (EventType "NominalsRecorded") value of+ Left _ -> True+ Right _ -> False++replace :: Key -> Value -> Value -> Value+replace key value (Object fields) = Object (KeyMap.insert key value fields)+replace _ _ other = other++expectedWireParity :: Bool+expectedWireParity =+ and+ [ wire Bindings.orderIdFixtures (String . KindID.toText . nominalToRepresentation Bindings.orderIdBinding)+ , wire Bindings.orderStatusFixtures (String . Representation.orderStatusRepresentationText . nominalToRepresentation Bindings.orderStatusBinding)+ , wire Bindings.accountNumberFixtures (toJSON . nominalToRepresentation Bindings.accountNumberBinding)+ , wire Bindings.riskScoreFixtures (toJSON . nominalToRepresentation Bindings.riskScoreBinding)+ , wire Bindings.sequenceNumberFixtures (toJSON . nominalToRepresentation Bindings.sequenceNumberBinding)+ , wire Bindings.featureFlagFixtures (toJSON . nominalToRepresentation Bindings.featureFlagBinding)+ , wire Bindings.observedAtFixtures (toJSON . nominalToRepresentation Bindings.observedAtBinding)+ ]+ where+ wire fixtures encodeRepresentation =+ all+ (\fixture -> encodeRepresentation (nominalFixtureDomain fixture) == nominalFixtureWire fixture)+ (NE.toList (nominalFixtureCases fixtures))++enumCoverage :: Bool+enumCoverage =+ representations == [Representation.Draft, Representation.Submitted]+ && wires == [String "draft", String "submitted"]+ where+ fixtures = NE.toList (nominalFixtureCases Bindings.orderStatusFixtures)+ representations = map (nominalToRepresentation Bindings.orderStatusBinding . nominalFixtureDomain) fixtures+ wires = map nominalFixtureWire fixtures++snapshotRoundTrip :: Bool+snapshotRoundTrip = case EventStream.decode codec encoded of+ Left _ -> False+ Right (vertex, registers) ->+ vertex == NominalLedgerEmpty+ && registers ! #orderId == Bindings.initialOrderId+ && registers ! #status == Bindings.initialOrderStatus+ && registers ! #accountNumber == Bindings.initialAccountNumber+ && registers ! #riskScore == Bindings.initialRiskScore+ && registers ! #sequenceNumber == Bindings.initialSequenceNumber+ && registers ! #featureFlag == Bindings.initialFeatureFlag+ && registers ! #observedAt == Bindings.initialObservedAt+ && EventStream.encode codec (vertex, registers) == encoded+ where+ codec = defaultStateCodec @NominalLedgerRegs @NominalLedgerVertex 1+ encoded = EventStream.encode codec (NominalLedgerEmpty, initialNominalLedgerRegs)++canonicalIdentities :: Bool+canonicalIdentities =+ and+ [ canonicalTypeName (Proxy @OrderId) == "nominal.OrderId.v1"+ , canonicalTypeName (Proxy @OrderStatus) == "nominal.OrderStatus.v1"+ , canonicalTypeName (Proxy @AccountNumber) == "nominal.AccountNumber.v1"+ , canonicalTypeName (Proxy @RiskScore) == "nominal.RiskScore.v1"+ , canonicalTypeName (Proxy @SequenceNumber) == "nominal.SequenceNumber.v1"+ , canonicalTypeName (Proxy @FeatureFlag) == "nominal.FeatureFlag.v1"+ , canonicalTypeName (Proxy @ObservedAt) == "nominal.ObservedAt.v1"+ ]++projectionAgreement :: Bool+projectionAgreement =+ and+ [ fieldWitnessAgrees Projections.accountNumberWitness (nominalToRepresentation Bindings.accountNumberBinding) Bindings.initialAccountNumber+ , fieldWitnessAgrees Projections.riskScoreWitness (nominalToRepresentation Bindings.riskScoreBinding) Bindings.initialRiskScore+ , fieldWitnessAgrees Projections.sequenceNumberWitness (nominalToRepresentation Bindings.sequenceNumberBinding) Bindings.initialSequenceNumber+ , fieldWitnessAgrees Projections.featureFlagWitness (nominalToRepresentation Bindings.featureFlagBinding) Bindings.initialFeatureFlag+ , fieldWitnessAgrees Projections.observedAtWitness (nominalToRepresentation Bindings.observedAtBinding) Bindings.initialObservedAt+ ]++equalityChecks :: Bool+equalityChecks =+ and+ [ evalPred (regProj Projections.accountNumberWitness accountIx .== lit "acct-initial") initialNominalLedgerRegs ()+ , evalPred (regProj Projections.riskScoreWitness riskIx .== lit 0) initialNominalLedgerRegs ()+ , evalPred (regProj Projections.sequenceNumberWitness sequenceIx .== lit 0) initialNominalLedgerRegs ()+ , evalPred (regProj Projections.featureFlagWitness featureIx .== lit False) initialNominalLedgerRegs ()+ , evalPred (regProj Projections.observedAtWitness observedIx .== lit Bindings.sampleTime) initialNominalLedgerRegs ()+ ]++orderingChecks :: Bool+orderingChecks =+ and+ [ evalPred (regProj Projections.riskScoreWitness riskIx .>= lit 0) initialNominalLedgerRegs ()+ , evalPred (regProj Projections.sequenceNumberWitness sequenceIx .>= lit 0) initialNominalLedgerRegs ()+ , evalPred (regProj Projections.observedAtWitness observedIx .>= lit Bindings.sampleTime) initialNominalLedgerRegs ()+ ]++accountIx :: Index NominalLedgerRegs AccountNumber+accountIx = #accountNumber+riskIx :: Index NominalLedgerRegs RiskScore+riskIx = #riskScore+sequenceIx :: Index NominalLedgerRegs SequenceNumber+sequenceIx = #sequenceNumber+featureIx :: Index NominalLedgerRegs FeatureFlag+featureIx = #featureFlag+observedIx :: Index NominalLedgerRegs ObservedAt+observedIx = #observedAt++forwardReplayAgreement :: Bool+forwardReplayAgreement = case parseNominalLedgerEvent (EventType "NominalsRecorded") (encodeNominalLedgerEvent sampleEvent) of+ Left _ -> False+ Right decoded -> applyEvent decoded == applyEvent sampleEvent+ where+ applyEvent (NominalsRecorded payload) =+ ( payload.orderId+ , payload.status+ , payload.accountNumber+ , payload.riskScore+ , payload.sequenceNumber+ , payload.featureFlag+ , payload.observedAt+ )
+ test/conformance-nominal-scalars/NominalConformance/Bindings.hs view
@@ -0,0 +1,109 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE TypeApplications #-}++module NominalConformance.Bindings where++import Data.Aeson (Value (..), toJSON)+import Data.KindID (KindID)+import qualified Data.KindID as KindID+import Data.List.NonEmpty (NonEmpty (..))+import Data.Text (Text)+import Data.Time (UTCTime)+import Data.Time.Format.ISO8601 (iso8601ParseM)+import qualified Generated.NominalScalars.Nominal.Shape.OrderStatus as Representation+import Keiro.Codec.Nominal+import NominalConformance.Domain+import Numeric.Natural (Natural)++orderIdBinding :: NominalBinding OrderId (KindID "ord")+orderIdBinding = NominalBinding unOrderId OrderId++orderStatusBinding :: NominalBinding OrderStatus Representation.OrderStatusRepresentation+orderStatusBinding =+ NominalBinding+ { nominalToRepresentation = \case+ AwaitingApproval -> Representation.Draft+ Accepted -> Representation.Submitted+ , nominalFromRepresentation = \case+ Representation.Draft -> AwaitingApproval+ Representation.Submitted -> Accepted+ }++accountNumberBinding :: NominalBinding AccountNumber Text+accountNumberBinding = NominalBinding unAccountNumber AccountNumber++riskScoreBinding :: NominalBinding RiskScore Int+riskScoreBinding = NominalBinding unRiskScore RiskScore++sequenceNumberBinding :: NominalBinding SequenceNumber Natural+sequenceNumberBinding = NominalBinding unSequenceNumber SequenceNumber++featureFlagBinding :: NominalBinding FeatureFlag Bool+featureFlagBinding = NominalBinding unFeatureFlag FeatureFlag++observedAtBinding :: NominalBinding ObservedAt UTCTime+observedAtBinding = NominalBinding unObservedAt ObservedAt++validOrderIdText :: Text+validOrderIdText = "ord_00041061050r3gg28a1c60t3gf"++sampleOrderId :: OrderId+sampleOrderId = case KindID.parseText @"ord" validOrderIdText of+ Left reason -> error ("invalid committed conformance TypeID: " <> show reason)+ Right value -> OrderId value++sampleTime :: UTCTime+sampleTime = case iso8601ParseM "2026-07-31T12:34:56Z" of+ Nothing -> error "invalid committed conformance timestamp"+ Just value -> value++orderIdFixtures :: NominalFixtureCases OrderId+orderIdFixtures = cases [NominalFixture "order-id" (String validOrderIdText) sampleOrderId]++orderStatusFixtures :: NominalFixtureCases OrderStatus+orderStatusFixtures =+ cases+ [ NominalFixture "draft" (String "draft") AwaitingApproval+ , NominalFixture "submitted" (String "submitted") Accepted+ ]++accountNumberFixtures :: NominalFixtureCases AccountNumber+accountNumberFixtures = cases [NominalFixture "account" (String "acct-007") (AccountNumber "acct-007")]++riskScoreFixtures :: NominalFixtureCases RiskScore+riskScoreFixtures = cases [NominalFixture "risk" (toJSON (7 :: Int)) (RiskScore 7)]++sequenceNumberFixtures :: NominalFixtureCases SequenceNumber+sequenceNumberFixtures = cases [NominalFixture "sequence" (toJSON (11 :: Natural)) (SequenceNumber 11)]++featureFlagFixtures :: NominalFixtureCases FeatureFlag+featureFlagFixtures = cases [NominalFixture "feature" (Bool True) (FeatureFlag True)]++observedAtFixtures :: NominalFixtureCases ObservedAt+observedAtFixtures = cases [NominalFixture "observed-at" (toJSON sampleTime) (ObservedAt sampleTime)]++initialOrderId :: OrderId+initialOrderId = sampleOrderId++initialOrderStatus :: OrderStatus+initialOrderStatus = AwaitingApproval++initialAccountNumber :: AccountNumber+initialAccountNumber = AccountNumber "acct-initial"++initialRiskScore :: RiskScore+initialRiskScore = RiskScore 0++initialSequenceNumber :: SequenceNumber+initialSequenceNumber = SequenceNumber 0++initialFeatureFlag :: FeatureFlag+initialFeatureFlag = FeatureFlag False++initialObservedAt :: ObservedAt+initialObservedAt = ObservedAt sampleTime++cases :: [NominalFixture domain] -> NominalFixtureCases domain+cases (firstFixture : rest) = NominalFixtureCases (firstFixture :| rest)+cases [] = error "conformance fixtures must be non-empty"
+ test/conformance-nominal-scalars/NominalConformance/Domain.hs view
@@ -0,0 +1,64 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}++module NominalConformance.Domain where++import Data.Aeson (FromJSON, ToJSON)+import Data.KindID (KindID)+import Data.Text (Text)+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Keiki.Shape (CanonicalTypeName (..))+import Numeric.Natural (Natural)++newtype OrderId = OrderId {unOrderId :: KindID "ord"}+ deriving stock (Eq, Generic, Show)+ deriving newtype (FromJSON, ToJSON)++instance CanonicalTypeName OrderId where+ canonicalTypeName _ = "nominal.OrderId.v1"++data OrderStatus = AwaitingApproval | Accepted+ deriving stock (Eq, Generic, Ord, Show)+ deriving anyclass (FromJSON, ToJSON)++instance CanonicalTypeName OrderStatus where+ canonicalTypeName _ = "nominal.OrderStatus.v1"++newtype AccountNumber = AccountNumber {unAccountNumber :: Text}+ deriving stock (Eq, Generic, Ord, Show)+ deriving newtype (FromJSON, ToJSON)++instance CanonicalTypeName AccountNumber where+ canonicalTypeName _ = "nominal.AccountNumber.v1"++newtype RiskScore = RiskScore {unRiskScore :: Int}+ deriving stock (Eq, Generic, Ord, Show)+ deriving newtype (FromJSON, ToJSON)++instance CanonicalTypeName RiskScore where+ canonicalTypeName _ = "nominal.RiskScore.v1"++newtype SequenceNumber = SequenceNumber {unSequenceNumber :: Natural}+ deriving stock (Eq, Generic, Ord, Show)+ deriving newtype (FromJSON, ToJSON)++instance CanonicalTypeName SequenceNumber where+ canonicalTypeName _ = "nominal.SequenceNumber.v1"++newtype FeatureFlag = FeatureFlag {unFeatureFlag :: Bool}+ deriving stock (Eq, Generic, Ord, Show)+ deriving newtype (FromJSON, ToJSON)++instance CanonicalTypeName FeatureFlag where+ canonicalTypeName _ = "nominal.FeatureFlag.v1"++newtype ObservedAt = ObservedAt {unObservedAt :: UTCTime}+ deriving stock (Eq, Generic, Ord, Show)+ deriving newtype (FromJSON, ToJSON)++instance CanonicalTypeName ObservedAt where+ canonicalTypeName _ = "nominal.ObservedAt.v1"
test/conformance-process-full/Generated/SurgeDemo/Hospital/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.SurgeDemo.Hospital.Codec (
test/conformance-process-full/Generated/SurgeDemo/Hospital/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-process-full/Generated/SurgeDemo/Hospital/Projection.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.SurgeDemo.Hospital.Projection (
test/conformance-process-full/Generated/SurgeDemo/Surge/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.SurgeDemo.Surge.Codec (
test/conformance-process-full/Generated/SurgeDemo/Surge/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-process-full/Generated/SurgeDemo/Surge/Projection.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.SurgeDemo.Surge.Projection (
test/conformance-process-full/Generated/SurgeDemo/SurgeFlow/Process.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.SurgeDemo.SurgeFlow.Process (
test/conformance-process-full/Main.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {- | EP-3 M5 full-service conformance: a complete process service — the scaffolded Surge (saga) + Hospital (target) aggregates with FILLED
test/conformance-process-full/SurgeDemo/SurgeFlow/Manager.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} -- HAND-FILLED process-manager value (EP-3 M5 full-service integration): the -- @handle@ hole filled against the live Keiro.ProcessManager API, wiring the
test/conformance-process-runtime/Generated/HospitalCapacity/HospitalSurge/Process.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.HospitalSurge.Process (
test/conformance-process/Generated/HospitalCapacity/HospitalSurge/ProcessHarness.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.HospitalSurge.ProcessHarness (processHarnessValues) where
test/conformance-queue-runtime/Generated/HospitalCapacity/Reservation_work/Queue.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-queue-runtime/Generated/HospitalCapacity/Reservation_work/QueueCodec.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-queue-runtime/Generated/HospitalCapacity/Reservation_work/QueuePolicy.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation_work.QueuePolicy (
test/conformance-queue/Generated/HospitalCapacity/Reservation_work/Queue.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-queue/Generated/HospitalCapacity/Reservation_work/QueueCodec.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-readmodel-runtime/Generated/HospitalCapacity/Transfer_decisions/ReadModel.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Transfer_decisions.ReadModel (
test/conformance-readmodel-runtime/Generated/HospitalCapacity/Transfer_decisions/ReadModelTable.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Transfer_decisions.ReadModelTable (transferDecisionsQualifiedTable) where
test/conformance-readmodel-runtime/Main.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} module Main (main) where
test/conformance-replay/Generated/ReplayDivergence/Note/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.ReplayDivergence.Note.Codec (
test/conformance-replay/Generated/ReplayDivergence/Note/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-replay/Generated/ReplayDivergence/Note/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.ReplayDivergence.Note.Harness (harnessAssertions) where
test/conformance-router-full/Generated/IncidentPaging/Page/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.IncidentPaging.Page.Codec (
test/conformance-router-full/Generated/IncidentPaging/Page/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-router-full/Generated/IncidentPaging/PagingRouter/Router.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.IncidentPaging.PagingRouter.Router (
test/conformance-router-runtime/Generated/IncidentPaging/PagingRouter/Router.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.IncidentPaging.PagingRouter.Router (
+ test/conformance-scalar-expressions/AggregateScalarExpressions/ScalarAccount/Holes.hs view
@@ -0,0 +1,53 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE QualifiedDo #-}+{-# LANGUAGE TypeApplications #-}+-- This is a HAND-OWNED version-2 hook module. keiro-dsl creates it once+-- and never overwrites it. Generated code owns every transition envelope+-- and every declared guard/write; this module supplies event fields and+-- explicitly selected Hole behavior only.+module AggregateScalarExpressions.ScalarAccount.Holes+ ( transition1OpenAdjustOutput1Adjusted+ , transition2ReviewedCloseOutput1ClosedEvent+ , transition2ReviewedCloseHole+ , transition2ReviewedCloseHoleFoldVersion+ ) where++import Generated.AggregateScalarExpressions.ScalarAccount.Domain+import Keiki.Builder qualified as B+import Keiki.Generics (RegFieldsOf)+++import Keiki.Core qualified as K+import Keiro.Snapshot.Codec (FoldVersion (..))++-- Hand-owned event-field hook inside the generated transition envelope.+transition1OpenAdjustOutput1Adjusted :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> AdjustedTermFields ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData)+transition1OpenAdjustOutput1Adjusted d = AdjustedTermFields+ { balance = d.balance+ , requested = d.requested+ , machine = d.machine+ , label = d.label+ , active = d.active+ , mode = d.mode+ , requestId = d.requestId+ , observedAt = d.observedAt+ , limits = d.limits+ }++-- Hand-owned event-field hook inside the generated transition envelope.+transition2ReviewedCloseOutput1ClosedEvent :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf CloseData) -> ClosedEventTermFields ScalarAccountRegs ScalarAccountCommand (RegFieldsOf CloseData)+transition2ReviewedCloseOutput1ClosedEvent d = ClosedEventTermFields+ { balance = d.balance+ }++-- HOLE: add the predicate and ordered register updates for this transition.+-- The generated transducer still owns command matching, mode, emits, and goto.+transition2ReviewedCloseHole d =+ B.requireGuard (K.PEq (K.TApp1 id d.balance) d.balance)++-- Bump this token whenever the Hole predicate or updates change.+transition2ReviewedCloseHoleFoldVersion :: FoldVersion+transition2ReviewedCloseHoleFoldVersion = FoldVersion "transition2ReviewedClose-fold-v1"
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ReplayAudit.hs view
@@ -0,0 +1,24 @@+{-# LANGUAGE GADTs #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+--+-- Deployment contract:+-- * replay-neutral diff: no data audit is required;+-- * affected diff: run AuditTargeted with the emitted affected set+-- against a production copy under the candidate binary;+-- * one-time runtime cutover: run AuditFull;+-- * any non-zero audit exit blocks deployment.+module Generated.AggregateScalarExpressions.ReplayAudit (auditTargets) where++import Generated.AggregateScalarExpressions.ScalarAccount.EventStream qualified as ScalarAccount+import Keiro.ReplayAudit (AuditTarget (..), SomeAuditTarget (..), streamInCategory)+import Keiro.Stream qualified as Stream++auditTargets :: [SomeAuditTarget]+auditTargets =+ [ SomeAuditTarget+ AuditTarget+ { eventStream = ScalarAccount.scalarAccountEventStream+ , category = Stream.categoryText ScalarAccount.scalarAccountCategory+ , mkStream = streamInCategory (Stream.categoryText ScalarAccount.scalarAccountCategory)+ }+ ]
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Codec.hs view
@@ -0,0 +1,110 @@+{-# LANGUAGE OverloadedRecordDot #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.ScalarAccount.Codec (+ scalarAccountCodec,+ parseScalarAccountEvent,+ encodeScalarAccountEvent,+ encodeLimitsMapped,+ decodeLimitsMapped,+) where++import Generated.AggregateScalarExpressions.ScalarAccount.Domain+import Control.Monad (unless)+import Data.Aeson (Value (..), object, parseJSON, toJSON, withObject, withText, (.:), (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (Parser, parseEither)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import qualified Data.Text as T+import Keiro.Codec.Structural (bindingFromShape, bindingToShape)+import Keiro.Codec (Codec (..), EventType (..))++import Generated.AggregateScalarExpressions.Structural.Shape.Limits qualified+import ScalarExpressions.Bindings qualified+import ScalarExpressions.Domain qualified++parseAccountMode :: Text -> Parser AccountMode+parseAccountMode = \case+ "normal" -> pure Normal+ "restricted" -> pure Restricted+ _ -> fail "unknown AccountMode"++encodeLimitsMapped :: ScalarExpressions.Domain.Limits -> Value+encodeLimitsMapped = encodeLimitsShape . bindingToShape ScalarExpressions.Bindings.limitsBinding++parseLimitsMapped :: Value -> Parser ScalarExpressions.Domain.Limits+parseLimitsMapped value = bindingFromShape ScalarExpressions.Bindings.limitsBinding <$> parseLimitsShape value++decodeLimitsMapped :: Value -> Either Text ScalarExpressions.Domain.Limits+decodeLimitsMapped = mapLeftText . parseEither parseLimitsMapped++encodeLimitsShape :: Generated.AggregateScalarExpressions.Structural.Shape.Limits.LimitsShape -> Value+encodeLimitsShape shape =+ object+ [ "minimum" .= toJSON (Generated.AggregateScalarExpressions.Structural.Shape.Limits.minimum shape)+ , "ceiling" .= toJSON (Generated.AggregateScalarExpressions.Structural.Shape.Limits.ceiling shape)+ ]++parseLimitsShape :: Value -> Parser Generated.AggregateScalarExpressions.Structural.Shape.Limits.LimitsShape+parseLimitsShape = withObject "LimitsShape" $ \objectValue -> do+ rejectUnknownFields "Limits" ["minimum", "ceiling"] objectValue+ Generated.AggregateScalarExpressions.Structural.Shape.Limits.Limits+ <$> ((objectValue .: "minimum" :: Parser Value) >>= (parseJSON))+ <*> ((objectValue .: "ceiling" :: Parser Value) >>= (parseJSON))++scalarAccountCodec :: Codec ScalarAccountEvent+scalarAccountCodec =+ Codec+ { eventTypes = EventType "Adjusted" :| [EventType "ClosedEvent"]+ , eventType = \case+ Adjusted{} -> EventType "Adjusted"+ ClosedEvent{} -> EventType "ClosedEvent"+ , schemaVersion = 1+ , encode = encodeScalarAccountEvent+ , decode = parseScalarAccountEvent+ , upcasters = []+ }++encodeScalarAccountEvent :: ScalarAccountEvent -> Value+encodeScalarAccountEvent = \case+ Adjusted payload ->+ object+ [ "kind" .= ("Adjusted" :: Text)+ , "balance" .= payload.balance+ , "requested" .= payload.requested+ , "machine" .= payload.machine+ , "label" .= payload.label+ , "active" .= payload.active+ , "mode" .= accountModeText payload.mode+ , "requestId" .= requestIdText payload.requestId+ , "observedAt" .= payload.observedAt+ , "limits" .= encodeLimitsMapped payload.limits+ ]+ ClosedEvent payload ->+ object+ [ "kind" .= ("ClosedEvent" :: Text)+ , "balance" .= payload.balance+ ]++parseScalarAccountEvent :: EventType -> Value -> Either Text ScalarAccountEvent+parseScalarAccountEvent (EventType tag) = mapLeftText . parseEither (withObject "ScalarAccountEvent" go)+ where+ go o = do+ case tag of+ "Adjusted" ->+ Adjusted <$> (AdjustedData <$> o .: "balance" <*> o .: "requested" <*> o .: "machine" <*> o .: "label" <*> o .: "active" <*> (o .: "mode" >>= parseAccountMode) <*> (RequestId <$> o .: "requestId") <*> o .: "observedAt" <*> (o .: "limits" >>= parseLimitsMapped))+ "ClosedEvent" ->+ ClosedEvent <$> (ClosedEventData <$> o .: "balance")+ _ -> fail "unknown event type"++mapLeftText :: Either String b -> Either Text b+mapLeftText = either (Left . T.pack) Right++rejectUnknownFields :: String -> [Text] -> KeyMap.KeyMap Value -> Parser ()+rejectUnknownFields label allowed objectValue =+ unless (null extras) (fail (label <> " contains unknown fields: " <> show extras))+ where+ extras = filter (`notElem` allowed) (map Key.toText (KeyMap.keys objectValue))
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Domain.hs view
@@ -0,0 +1,121 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE TypeApplications #-}+{-# OPTIONS_GHC -Wno-unused-top-binds #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.ScalarAccount.Domain where++import Data.Aeson (FromJSON, ToJSON)+import Data.Proxy (Proxy (..))+import Data.Text (Text)+import GHC.Generics (Generic)+import Keiki.Core (RegFile (..))+import Keiki.Shape (CanonicalStateShape, CanonicalTypeName)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime(..), picosecondsToDiffTime)+import Numeric.Natural (Natural)+import ScalarExpressions.Bindings qualified+import ScalarExpressions.Domain qualified+import Keiki.Generics.TH (deriveAggregateCtorsAll, deriveWireCtorsAll)++newtype RequestId = RequestId Text+ deriving stock (Generic, Eq, Ord, Show)+ deriving anyclass (ToJSON, FromJSON)+instance CanonicalTypeName RequestId++requestIdText :: RequestId -> Text+requestIdText (RequestId t) = t++data AccountMode = Normal | Restricted+ deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)+ deriving anyclass (ToJSON, FromJSON)+instance CanonicalTypeName AccountMode++accountModeText :: AccountMode -> Text+accountModeText = \case+ Normal -> "normal"+ Restricted -> "restricted"++data ScalarAccountVertex = ScalarAccountOpen | ScalarAccountReviewed | ScalarAccountClosed+ deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)+ deriving anyclass (ToJSON, FromJSON)+instance CanonicalStateShape ScalarAccountVertex+instance CanonicalTypeName ScalarAccountVertex++data AdjustData = AdjustData+ { balance :: !Integer+ , requested :: !Natural+ , machine :: !Int+ , label :: !Text+ , active :: !Bool+ , mode :: !AccountMode+ , requestId :: !RequestId+ , observedAt :: !UTCTime+ , limits :: !ScalarExpressions.Domain.Limits+ }+ deriving stock (Generic, Eq, Show)++data CloseData = CloseData+ { balance :: !Integer+ }+ deriving stock (Generic, Eq, Show)++data ScalarAccountCommand = Adjust !AdjustData+ | Close !CloseData+ deriving stock (Generic, Eq, Show)++data AdjustedData = AdjustedData+ { balance :: !Integer+ , requested :: !Natural+ , machine :: !Int+ , label :: !Text+ , active :: !Bool+ , mode :: !AccountMode+ , requestId :: !RequestId+ , observedAt :: !UTCTime+ , limits :: !ScalarExpressions.Domain.Limits+ }+ deriving stock (Generic, Eq, Show)++data ClosedEventData = ClosedEventData+ { balance :: !Integer+ }+ deriving stock (Generic, Eq, Show)++data ScalarAccountEvent = Adjusted !AdjustedData+ | ClosedEvent !ClosedEventData+ deriving stock (Generic, Eq, Show)++type ScalarAccountRegs =+ '[ '("balance", Integer)+ , '("reserved", Natural)+ , '("capacity", Natural)+ , '("machine", Int)+ , '("label", Text)+ , '("active", Bool)+ , '("mode", AccountMode)+ , '("requestId", RequestId)+ , '("openedAt", UTCTime)+ , '("limits", ScalarExpressions.Domain.Limits)+ ]++initialScalarAccountRegs :: RegFile ScalarAccountRegs+initialScalarAccountRegs =+ RCons (Proxy @"balance") 0 $+ RCons (Proxy @"reserved") 0 $+ RCons (Proxy @"capacity") 5 $+ RCons (Proxy @"machine") 0 $+ RCons (Proxy @"label") "" $+ RCons (Proxy @"active") False $+ RCons (Proxy @"mode") Normal $+ RCons (Proxy @"requestId") (RequestId "") $+ RCons (Proxy @"openedAt") (UTCTime (fromGregorian 2026 1 1) (picosecondsToDiffTime 0)) $+ RCons (Proxy @"limits") ScalarExpressions.Bindings.initialLimits RNil++$(deriveAggregateCtorsAll ''ScalarAccountCommand ''ScalarAccountRegs)++++$(deriveWireCtorsAll ''ScalarAccountEvent)
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/EventStream.hs view
@@ -0,0 +1,58 @@+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.ScalarAccount.EventStream+ ( scalarAccountCategory+ , scalarAccountEventStream+ , scalarAccountEventStreamDef+ , ScalarAccountEventStream+ , ScalarAccountEventStreamDef+ , scalarAccountSnapshotFixture+ ) where++import Generated.AggregateScalarExpressions.ScalarAccount.Domain+import Generated.AggregateScalarExpressions.ScalarAccount.Codec (scalarAccountCodec)+import Generated.AggregateScalarExpressions.ScalarAccount.Transducer (scalarAccountFoldFingerprint, scalarAccountTransducer)+import Keiki.Core (HsPred)+import Keiro.EventStream (EventStream (..), SnapshotPolicy (..))+import Keiro.EventStream.Validate (ValidatedEventStream, mkEventStreamOrThrow)+import Data.Text (Text)+import Keiro.Snapshot.Codec (defaultStateCodec, withFoldFingerprint)+import Keiro.Stream qualified as Stream++-- The validated aggregate stream category (hole-kind 5: referenced, never retyped).+-- Entity streams are '<category>-<id>' via Keiro.Stream.entityStream.+-- categoryUnsafe is safe here because this generated literal passed the DSL category proof.+scalarAccountCategory :: Stream.StreamCategory a+scalarAccountCategory = Stream.categoryUnsafe "scalarAccount"++type ScalarAccountEventStreamDef =+ EventStream (HsPred ScalarAccountRegs ScalarAccountCommand) ScalarAccountRegs ScalarAccountVertex ScalarAccountCommand ScalarAccountEvent++type ScalarAccountEventStream =+ ValidatedEventStream (HsPred ScalarAccountRegs ScalarAccountCommand) ScalarAccountRegs ScalarAccountVertex ScalarAccountCommand ScalarAccountEvent++scalarAccountEventStreamDef :: ScalarAccountEventStreamDef+scalarAccountEventStreamDef =+ EventStream+ { transducer = scalarAccountTransducer+ , initialState = ScalarAccountOpen+ , initialRegisters = initialScalarAccountRegs+ , eventCodec = scalarAccountCodec+ , resolveStreamName = Stream.streamName+ , snapshotPolicy = Every 1+ -- The snapshot discriminator composes: the spec's state-codec version (bump it+ -- in the spec's `state-codec version=` clause), keiki's register and+ -- control-state shape hashes, and this fold fingerprint derived from the+ -- spec's transition surface (guards, writes, emits, states, register+ -- initials, referenced rules). Spec-visible fold changes invalidate old+ -- snapshots automatically. Version-2 Hole-owned transitions additionally+ -- compose their explicit hand-owned FoldVersion tokens here; bump the+ -- corresponding token whenever that Hole behavior changes.+ , stateCodec = Just (withFoldFingerprint scalarAccountFoldFingerprint (defaultStateCodec 1))+ }++scalarAccountSnapshotFixture :: (Int, Text)+scalarAccountSnapshotFixture = (1, "aggregate-scalar-expressions-v2")++scalarAccountEventStream :: ScalarAccountEventStream+scalarAccountEventStream =+ mkEventStreamOrThrow "ScalarAccount" scalarAccountEventStreamDef
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Expressions.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedLabels #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE TypeApplications #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.ScalarAccount.Expressions+ ( transition1OpenAdjustGuard+ , transition1OpenAdjustWriteBalance+ , transition1OpenAdjustWriteReserved+ , transition1OpenAdjustWriteMachine+ , transition1OpenAdjustWriteLabel+ , transition1OpenAdjustWriteActive+ , transition1OpenAdjustWriteMode+ , transition1OpenAdjustWriteRequestId+ , transition1OpenAdjustWriteOpenedAt+ , transition1OpenAdjustWriteLimits+ ) where++import Generated.AggregateScalarExpressions.ScalarAccount.Domain+import Keiki.Builder qualified as B+import Keiki.Core qualified as K+import Keiki.Generics (RegFieldsOf)+import Data.Text (Text)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime (..), picosecondsToDiffTime)+import Numeric.Natural (Natural)+import Generated.AggregateScalarExpressions.StructuralProjections qualified as StructuralProjections+import ScalarExpressions.Domain qualified++transition1OpenAdjustGuard :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.HsPred ScalarAccountRegs ScalarAccountCommand+transition1OpenAdjustGuard d = K.PAnd (K.PAnd (K.PAnd (K.PAnd (K.PCmp K.CmpGe (K.tadd (d.balance) (B.reg @"balance")) (K.lit (-100 :: Integer))) (K.PCmp K.CmpLe (K.tadd (B.reg @"reserved") (d.requested)) (B.reg @"capacity"))) (K.PCmp K.CmpGe (d.observedAt) (B.reg @"openedAt"))) (K.PCmp K.CmpGe (K.inpProj StructuralProjections.structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZWitness inCtorAdjust (#limits :: K.Index (RegFieldsOf AdjustData) ScalarExpressions.Domain.Limits)) (K.regProj StructuralProjections.structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZWitness (#limits :: K.Index ScalarAccountRegs ScalarExpressions.Domain.Limits)))) (K.PEq (d.active) (K.lit False))++transition1OpenAdjustWriteBalance :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) Integer+transition1OpenAdjustWriteBalance d = K.tadd (B.reg @"balance") (K.tmul (d.balance) (K.lit (2 :: Integer)))++transition1OpenAdjustWriteReserved :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) Natural+transition1OpenAdjustWriteReserved d = K.tadd (B.reg @"reserved") (K.tsub (d.requested) (B.reg @"capacity"))++transition1OpenAdjustWriteMachine :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) Int+transition1OpenAdjustWriteMachine d = K.lit (-7 :: Int)++transition1OpenAdjustWriteLabel :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) Text+transition1OpenAdjustWriteLabel d = K.lit ("adjusted" :: Text)++transition1OpenAdjustWriteActive :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) Bool+transition1OpenAdjustWriteActive d = K.lit True++transition1OpenAdjustWriteMode :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) AccountMode+transition1OpenAdjustWriteMode d = K.lit Restricted++transition1OpenAdjustWriteRequestId :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) RequestId+transition1OpenAdjustWriteRequestId d = K.lit (RequestId "req_00041061050r3gg28a1c60t3gf")++transition1OpenAdjustWriteOpenedAt :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) UTCTime+transition1OpenAdjustWriteOpenedAt d = K.lit (UTCTime (fromGregorian 2026 2 3) (picosecondsToDiffTime 14706000000000000))++transition1OpenAdjustWriteLimits :: B.PayloadProj ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) -> K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) ScalarExpressions.Domain.Limits+transition1OpenAdjustWriteLimits d = d.limits
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Harness.hs view
@@ -0,0 +1,147 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedLabels #-}+{-# LANGUAGE TypeApplications #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.ScalarAccount.Harness (harnessAssertions) where++import Generated.AggregateScalarExpressions.ScalarAccount.Domain+import Generated.AggregateScalarExpressions.ScalarAccount.Codec (encodeScalarAccountEvent, parseScalarAccountEvent, scalarAccountCodec, encodeLimitsMapped, decodeLimitsMapped)+import Generated.AggregateScalarExpressions.ScalarAccount.Transducer (scalarAccountTransducer)+import Keiki.Core (applyEventsEither, defaultValidationOptions, step, validateTransducer, fieldWitnessAgrees, (!))+import Keiro.Codec (eventType)+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as AesonKey+import Data.Aeson.KeyMap qualified as AesonKeyMap+import Data.Either (isLeft, isRight)+import Data.List (nub)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Maybe (isJust, isNothing)+import Data.Proxy (Proxy (..))+import Data.Text qualified as T+import Keiki.Shape (CanonicalTypeName (..))+import Keiro.Codec.Structural (FixtureCases (..), bindingDomainRoundTrip, bindingShapeRoundTrip, bindingToShape)+import ScalarExpressions.Bindings qualified+import Generated.AggregateScalarExpressions.Structural.Shape.Limits qualified+import ScalarExpressions.Domain qualified+import Generated.AggregateScalarExpressions.StructuralProjections qualified as StructuralProjections+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime(..), picosecondsToDiffTime)++{- | (label, passed). A driver runs these and exits non-zero on any False,+naming the failing assertion. Filling a hole wrongly turns a specific+entry False; the scaffold cannot.+-}+harnessAssertions :: [(String, Bool)]+harnessAssertions =+ [ ("validateTransducer is empty", null (validateTransducer defaultValidationOptions scalarAccountTransducer))+ , ("clock-free: spec samples no wall clock", True)+ , ("golden round-trip: Adjusted", roundTrips sampleEventAdjusted)+ , ("golden round-trip: ClosedEvent", roundTrips sampleEventClosedEvent)+ , ("accepts Adjust from ScalarAccountOpen", acceptAdjust)+ ]+ ++ mappedConformanceAssertions+ ++ forwardReplayAdjust++roundTrips :: ScalarAccountEvent -> Bool+roundTrips e = parseScalarAccountEvent (eventType scalarAccountCodec e) (encodeScalarAccountEvent e) == Right e++sampleEventAdjusted :: ScalarAccountEvent+sampleEventAdjusted = (Adjusted (AdjustedData 0 0 0 "sample-label" False Normal (RequestId "sample") (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) (snd (NonEmpty.head (fixtureCases ScalarExpressions.Bindings.limitsCases)))))++sampleEventClosedEvent :: ScalarAccountEvent+sampleEventClosedEvent = (ClosedEvent (ClosedEventData 0))++acceptAdjust :: Bool+acceptAdjust =+ case step scalarAccountTransducer (ScalarAccountOpen, initialScalarAccountRegs) ((Adjust (AdjustData 0 0 0 "sample-label" False Normal (RequestId "sample") (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) (snd (NonEmpty.head (fixtureCases ScalarExpressions.Bindings.limitsCases)))))) of+ Just (v, _, _) -> v == ScalarAccountReviewed+ Nothing -> False++-- forward/replay equality (plan 147): cross the persisted codec boundary,+-- replay the emitted chain, and compare the final vertex and every register.+forwardReplayAdjust :: [(String, Bool)]+forwardReplayAdjust =+ case step scalarAccountTransducer (ScalarAccountOpen, initialScalarAccountRegs) ((Adjust (AdjustData 0 0 0 "sample-label" False Normal (RequestId "sample") (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) (snd (NonEmpty.head (fixtureCases ScalarExpressions.Bindings.limitsCases)))))) of+ Nothing -> [(prefix <> "forward step accepted", False)]+ Just (forwardVertex, forwardRegs, emitted) ->+ case mapM (\event -> parseScalarAccountEvent (eventType scalarAccountCodec event) (encodeScalarAccountEvent event)) emitted of+ Left _ -> [(prefix <> "emitted chain decodes", False)]+ Right decodedEvents ->+ case applyEventsEither scalarAccountTransducer (ScalarAccountOpen, initialScalarAccountRegs) decodedEvents of+ Left _ -> [(prefix <> "replay succeeds", False)]+ Right (replayVertex, replayRegs) ->+ [ (prefix <> "final vertex", replayVertex == forwardVertex)+ , (prefix <> "register balance", (replayRegs ! #balance) == (forwardRegs ! #balance))+ , (prefix <> "register reserved", (replayRegs ! #reserved) == (forwardRegs ! #reserved))+ , (prefix <> "register capacity", (replayRegs ! #capacity) == (forwardRegs ! #capacity))+ , (prefix <> "register machine", (replayRegs ! #machine) == (forwardRegs ! #machine))+ , (prefix <> "register label", (replayRegs ! #label) == (forwardRegs ! #label))+ , (prefix <> "register active", (replayRegs ! #active) == (forwardRegs ! #active))+ , (prefix <> "register mode", (replayRegs ! #mode) == (forwardRegs ! #mode))+ , (prefix <> "register requestId", (replayRegs ! #requestId) == (forwardRegs ! #requestId))+ , (prefix <> "register openedAt", (replayRegs ! #openedAt) == (forwardRegs ! #openedAt))+ , (prefix <> "register limits", (replayRegs ! #limits) == (forwardRegs ! #limits))+ ]+ where+ prefix = "forward/replay equality: Adjust from ScalarAccountOpen -- "++mappedConformanceAssertions :: [(String, Bool)]+mappedConformanceAssertions =+ concat+ [ limitsBindingAssertions+ , [("fixture coverage: scalar-expressions.Limits.v1", coverageLimits)]+ , adjustedLimitsAssertions+ , structuralWirePolicyAssertions+ , structuralProjectionAssertions+ ]++validFixtureLabels :: NonEmpty.NonEmpty (T.Text, value) -> Bool+validFixtureLabels cases =+ all (not . T.null) labels && length labels == length (nub labels)+ where+ labels = map fst (NonEmpty.toList cases)++limitsBindingAssertions :: [(String, Bool)]+limitsBindingAssertions =+ ("fixture labels: scalar-expressions.Limits.v1", validFixtureLabels cases) :+ ("canonical identity: scalar-expressions.Limits.v1", canonicalTypeName (Proxy @ScalarExpressions.Domain.Limits) == "scalar-expressions.Limits.v1") :+ concat+ [ [ ("binding domain round-trip: scalar-expressions.Limits.v1/" <> T.unpack label, bindingDomainRoundTrip ScalarExpressions.Bindings.limitsBinding value)+ , ("binding shape round-trip: scalar-expressions.Limits.v1/" <> T.unpack label, bindingShapeRoundTrip ScalarExpressions.Bindings.limitsBinding (bindingToShape ScalarExpressions.Bindings.limitsBinding value))+ ]+ | (label, value) <- NonEmpty.toList cases+ ]+ where+ cases = fixtureCases ScalarExpressions.Bindings.limitsCases++coverageLimits :: Bool+coverageLimits = True++adjustedLimitsAssertions :: [(String, Bool)]+adjustedLimitsAssertions =+ [ ("mapped codec round-trip: Adjusted/limits/" <> T.unpack label, roundTrips (Adjusted (AdjustedData 0 0 0 "sample-label" False Normal (RequestId "sample") (UTCTime (fromGregorian 2026 1 2) (picosecondsToDiffTime 11045123456789012)) mappedValue)))+ | (label, mappedValue) <- NonEmpty.toList (fixtureCases ScalarExpressions.Bindings.limitsCases)+ ]++structuralWirePolicyAssertions :: [(String, Bool)]+structuralWirePolicyAssertions =+ [ ("wire policy unknown fields: scalar-expressions.Limits.v1", all (\(_, value) -> isLeft (decodeLimitsMapped (insertObjectField "__keiro_unknown" (Aeson.Bool True) (encodeLimitsMapped value)))) (NonEmpty.toList (fixtureCases ScalarExpressions.Bindings.limitsCases)))+ ]++structuralProjectionAssertions :: [(String, Bool)]+structuralProjectionAssertions =+ [ ("projection witness agreement: scalar-expressions.Limits.v1/ceiling", all (\(_, owner) -> fieldWitnessAgrees StructuralProjections.structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67ZWitness (\referenceOwner -> Generated.AggregateScalarExpressions.Structural.Shape.Limits.ceiling (bindingToShape ScalarExpressions.Bindings.limitsBinding referenceOwner)) owner) (NonEmpty.toList (fixtureCases ScalarExpressions.Bindings.limitsCases)))+ , ("projection witness agreement: scalar-expressions.Limits.v1/minimum", all (\(_, owner) -> fieldWitnessAgrees StructuralProjections.structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZWitness (\referenceOwner -> Generated.AggregateScalarExpressions.Structural.Shape.Limits.minimum (bindingToShape ScalarExpressions.Bindings.limitsBinding referenceOwner)) owner) (NonEmpty.toList (fixtureCases ScalarExpressions.Bindings.limitsCases)))+ ]++deleteObjectField :: T.Text -> Aeson.Value -> Aeson.Value+deleteObjectField key (Aeson.Object objectValue) = Aeson.Object (AesonKeyMap.delete (AesonKey.fromText key) objectValue)+deleteObjectField _ value = value++insertObjectField :: T.Text -> Aeson.Value -> Aeson.Value -> Aeson.Value+insertObjectField key inserted (Aeson.Object objectValue) = Aeson.Object (AesonKeyMap.insert (AesonKey.fromText key) inserted objectValue)+insertObjectField _ _ value = value++objectField :: T.Text -> Aeson.Value -> Maybe Aeson.Value+objectField key (Aeson.Object objectValue) = AesonKeyMap.lookup (AesonKey.fromText key) objectValue+objectField _ _ = Nothing
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Projection.hs view
@@ -0,0 +1,2 @@+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.ScalarAccount.Projection () where
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/ScalarAccount/Transducer.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE QualifiedDo #-}+{-# LANGUAGE TypeApplications #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.ScalarAccount.Transducer+ ( scalarAccountTransducer+ , scalarAccountFoldFingerprint+ , BehaviorOwnership (..)+ , scalarAccountPredicateVerifications+ ) where++import Generated.AggregateScalarExpressions.ScalarAccount.Domain+import AggregateScalarExpressions.ScalarAccount.Holes qualified as Holes+import Data.Text (Text)+import Keiki.Builder qualified as B+import Keiki.Core (HsPred, SymTransducer)+import Keiki.Core qualified as K+import Keiki.Symbolic qualified as S+import Generated.AggregateScalarExpressions.ScalarAccount.Expressions qualified as Expressions+import Data.Text qualified as T+import Keiki.Builder ((=:))+import Keiro.Snapshot.Codec (FoldVersion (..))++scalarAccountTransducer+ :: SymTransducer+ (HsPred ScalarAccountRegs ScalarAccountCommand)+ ScalarAccountRegs+ ScalarAccountVertex+ ScalarAccountCommand+ ScalarAccountEvent+scalarAccountTransducer =+ B.buildTransducer ScalarAccountOpen initialScalarAccountRegs isTerminal do+ B.from ScalarAccountOpen do+ B.onCmd inCtorAdjust $ \d -> B.do+ B.requireGuard (Expressions.transition1OpenAdjustGuard d)+ B.slot @"balance" =: Expressions.transition1OpenAdjustWriteBalance d+ B.slot @"reserved" =: Expressions.transition1OpenAdjustWriteReserved d+ B.slot @"machine" =: Expressions.transition1OpenAdjustWriteMachine d+ B.slot @"label" =: Expressions.transition1OpenAdjustWriteLabel d+ B.slot @"active" =: Expressions.transition1OpenAdjustWriteActive d+ B.slot @"mode" =: Expressions.transition1OpenAdjustWriteMode d+ B.slot @"requestId" =: Expressions.transition1OpenAdjustWriteRequestId d+ B.slot @"openedAt" =: Expressions.transition1OpenAdjustWriteOpenedAt d+ B.slot @"limits" =: Expressions.transition1OpenAdjustWriteLimits d+ B.emit wireAdjusted (Holes.transition1OpenAdjustOutput1Adjusted d)+ B.goto ScalarAccountReviewed+ B.from ScalarAccountReviewed do+ B.onCmd inCtorClose $ \d -> B.do+ Holes.transition2ReviewedCloseHole d+ B.emit wireClosedEvent (Holes.transition2ReviewedCloseOutput1ClosedEvent d)+ B.goto ScalarAccountClosed+ where+ isTerminal = \case+ ScalarAccountClosed -> True+ _ -> False++scalarAccountFoldFingerprint :: Text+scalarAccountFoldFingerprint = T.intercalate "|" ("dec0c7c5688740a8" : [foldToken Holes.transition2ReviewedCloseHoleFoldVersion] ) where foldToken (FoldVersion token) = T.pack (show (T.length token)) <> ":" <> token++data BehaviorOwnership = GeneratedOwned | HoleOwned+ deriving stock (Eq, Show)++-- Every checked transition predicate is audited through Keiki's conservative+-- symbolic verifier. Opaque Hole terms remain explicitly unverified.+scalarAccountPredicateVerifications :: IO [(Text, BehaviorOwnership, S.PredicateVerification)]+scalarAccountPredicateVerifications = sequence+ [ verifyTransition "transition1OpenAdjust" GeneratedOwned ScalarAccountOpen 0+ , verifyTransition "transition2ReviewedClose" HoleOwned ScalarAccountReviewed 0+ ]+ where+ verifyTransition label owner source edgeIndex =+ case drop edgeIndex (K.edgesOut scalarAccountTransducer source) of+ K.Edge predicate _ _ _ _ : _ -> (\result -> (label, owner, result)) <$> S.verifyPredicate predicate+ [] -> pure (label, owner, S.UnverifiedSolverFailure "generated transition edge missing")
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/Structural/Shape/Limits.hs view
@@ -0,0 +1,13 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DuplicateRecordFields #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+module Generated.AggregateScalarExpressions.Structural.Shape.Limits (LimitsShape (..)) where++import GHC.Generics (Generic)+import Numeric.Natural (Natural)++data LimitsShape = Limits+ { minimum :: !Integer+ , ceiling :: !Natural+ }+ deriving stock (Eq, Generic, Show)
+ test/conformance-scalar-expressions/Generated/AggregateScalarExpressions/StructuralProjections.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+-- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.+-- Equality witnesses are emitted for Text, Int, Bool, Natural, and UTCTime.+-- Int, Natural, and UTCTime belong to Keiki's ordered subset.+module Generated.AggregateScalarExpressions.StructuralProjections+ ( structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67ZWitness+ , structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZWitness+ ) where++import Data.Text (Text)+import Data.Time (UTCTime)+import Numeric.Natural (Natural)+import Keiro.Codec.Structural (bindingToShape)+import Keiki.Core (FieldProjection (..), FieldWitness, fieldWitness)+import Generated.AggregateScalarExpressions.Structural.Shape.Limits qualified+import ScalarExpressions.Bindings qualified+import ScalarExpressions.Domain qualified++data StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67Z++instance FieldProjection StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67Z where+ type FieldName StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67Z = "/ceiling"+ type FieldOwner StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67Z = ScalarExpressions.Domain.Limits+ type FieldResult StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67Z = Natural+ fieldShapeId _ = "scalar-expressions.Limits.v1"+ projectFieldValue _ owner = Generated.AggregateScalarExpressions.Structural.Shape.Limits.ceiling (bindingToShape ScalarExpressions.Bindings.limitsBinding owner)++structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67ZWitness :: FieldWitness StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67Z+structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67ZWitness = fieldWitness @StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC63ZC65ZC69ZC6cZC69ZC6eZC67Z++data StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZ++instance FieldProjection StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZ where+ type FieldName StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZ = "/minimum"+ type FieldOwner StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZ = ScalarExpressions.Domain.Limits+ type FieldResult StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZ = Integer+ fieldShapeId _ = "scalar-expressions.Limits.v1"+ projectFieldValue _ owner = Generated.AggregateScalarExpressions.Structural.Shape.Limits.minimum (bindingToShape ScalarExpressions.Bindings.limitsBinding owner)++structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZWitness :: FieldWitness StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZ+structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZWitness = fieldWitness @StructuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZ
+ test/conformance-scalar-expressions/Main.hs view
@@ -0,0 +1,235 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedLabels #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE TypeApplications #-}++module Main (main) where++import Control.Monad (forM, forM_, unless)+import Data.Text qualified as T+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime (..), secondsToDiffTime)+import Generated.AggregateScalarExpressions.ScalarAccount.Codec (encodeScalarAccountEvent, parseScalarAccountEvent, scalarAccountCodec)+import Generated.AggregateScalarExpressions.ScalarAccount.Domain+import Generated.AggregateScalarExpressions.ScalarAccount.EventStream (scalarAccountEventStreamDef)+import Generated.AggregateScalarExpressions.ScalarAccount.Harness (harnessAssertions)+import Generated.AggregateScalarExpressions.ScalarAccount.Transducer+import Generated.AggregateScalarExpressions.StructuralProjections qualified as StructuralProjections+import Keiki.Core qualified as K+import Keiki.Generics (RegFieldsOf)+import Keiki.Symbolic qualified as S+import Keiro.Codec (eventType)+import Keiro.EventStream (EventStream (..), StateCodec (..))+import Keiro.Snapshot.Codec (defaultStateCodec, withFoldFingerprint)+import Numeric.Natural (Natural)+import ScalarExpressions.Domain qualified as Domain+import System.Exit (exitFailure)++main :: IO ()+main = do+ arithmeticAgreement <- scalarArithmeticAgreement+ repeatedPathAgreement <- repeatedPathUsesOneSymbol+ verificationReport <- scalarAccountPredicateVerifications+ let checks =+ harnessAssertions+ <> [ ("finite scalar oracle agrees with generated execution", finiteOracleAgreement)+ , ("Natural 2 - 5 is total monus zero", naturalMonusExample)+ , ("Integer/Natural oracle, concrete terms, and symbolic formulas agree", arithmeticAgreement)+ , ("repeated required scalar paths share one symbolic identity", repeatedPathAgreement)+ , ("generated guard is verified and opaque Hole remains unverified", verificationReport == expectedVerificationReport)+ , ("Hole envelope preserves declared event and target", holeEnvelopeAgreement)+ , ("encoded replay and full replay after snapshot invalidation agree", fullReplayAgreement)+ , ("Hole fold version participates in snapshot identity", snapshotFingerprintAgreement)+ ]+ forM_ checks $ \(label, passed) ->+ putStrLn ((if passed then "PASS " else "FAIL ") <> label)+ unless (all snd checks) exitFailure++expectedVerificationReport :: [(T.Text, BehaviorOwnership, S.PredicateVerification)]+expectedVerificationReport =+ [ ("transition1OpenAdjust", GeneratedOwned, S.VerifiedSatisfiable)+ , ("transition2ReviewedClose", HoleOwned, S.UnverifiedOpaque)+ ]++initialTime :: UTCTime+initialTime = UTCTime (fromGregorian 2026 1 1) 0++writtenTime :: UTCTime+writtenTime = UTCTime (fromGregorian 2026 2 3) (secondsToDiffTime 14706)++commandTime :: UTCTime+commandTime = UTCTime (fromGregorian 2026 1 2) (secondsToDiffTime 17)++mkAdjust :: Integer -> Natural -> Bool -> UTCTime -> Integer -> ScalarAccountCommand+mkAdjust balanceValue requestedValue activeValue observedAtValue minimumValue =+ Adjust+ AdjustData+ { balance = balanceValue+ , requested = requestedValue+ , machine = 41+ , label = "input-label"+ , active = activeValue+ , mode = Normal+ , requestId = RequestId "req_input"+ , observedAt = observedAtValue+ , limits = Domain.Limits minimumValue 13+ }++oracleAccepts :: Integer -> Natural -> Bool -> UTCTime -> Integer -> Bool+oracleAccepts balanceValue requestedValue activeValue observedAtValue minimumValue =+ balanceValue >= (-100)+ && requestedValue <= 5+ && observedAtValue >= initialTime+ && minimumValue >= 0+ && not activeValue++finiteOracleAgreement :: Bool+finiteOracleAgreement =+ and+ [ caseAgrees balanceValue requestedValue activeValue observedAtValue minimumValue+ | balanceValue <- [-101, -100, -1, 0, 50]+ , requestedValue <- [0, 2, 5, 6]+ , activeValue <- [False, True]+ , observedAtValue <- [UTCTime (fromGregorian 2025 12 31) 0, initialTime, commandTime]+ , minimumValue <- [-1, 0, 2]+ ]++caseAgrees :: Integer -> Natural -> Bool -> UTCTime -> Integer -> Bool+caseAgrees balanceValue requestedValue activeValue observedAtValue minimumValue =+ case K.step scalarAccountTransducer (ScalarAccountOpen, initialScalarAccountRegs) command of+ Nothing -> not accepted+ Just (vertex, registers, events) ->+ accepted+ && vertex == ScalarAccountReviewed+ && registers K.! #balance == balanceValue * 2+ && registers K.! #reserved == naturalMonus requestedValue 5+ && registers K.! #capacity == 5+ && registers K.! #machine == (-7)+ && registers K.! #label == "adjusted"+ && registers K.! #active+ && registers K.! #mode == Restricted+ && registers K.! #requestId == RequestId "req_00041061050r3gg28a1c60t3gf"+ && registers K.! #openedAt == writtenTime+ && registers K.! #limits == Domain.Limits minimumValue 13+ && events == expectedEvents+ where+ accepted = oracleAccepts balanceValue requestedValue activeValue observedAtValue minimumValue+ command = mkAdjust balanceValue requestedValue activeValue observedAtValue minimumValue+ expectedEvents =+ [ Adjusted+ AdjustedData+ { balance = balanceValue+ , requested = requestedValue+ , machine = 41+ , label = "input-label"+ , active = activeValue+ , mode = Normal+ , requestId = RequestId "req_input"+ , observedAt = observedAtValue+ , limits = Domain.Limits minimumValue 13+ }+ ]++naturalMonus :: Natural -> Natural -> Natural+naturalMonus left right+ | left >= right = left - right+ | otherwise = 0++naturalMonusExample :: Bool+naturalMonusExample =+ case K.step scalarAccountTransducer (ScalarAccountOpen, initialScalarAccountRegs) (mkAdjust 0 2 False commandTime 0) of+ Just (_, registers, _) -> registers K.! #reserved == 0+ Nothing -> False++scalarArithmeticAgreement :: IO Bool+scalarArithmeticAgreement = do+ integerResults <- forM [(left, right) | left <- [-4 .. 4], right <- [-4 .. 4]] $ \(left, right) -> do+ let terms =+ [ (K.tadd (K.lit left) (K.lit right) :: K.Term '[] () '[] Integer, left + right)+ , (K.tsub (K.lit left) (K.lit right), left - right)+ , (K.tmul (K.lit left) (K.lit right), left * right)+ ]+ concrete = all (\(term, expected) -> K.evalTerm term K.RNil () == expected) terms+ symbolic <- forM terms $ \(term, expected) ->+ S.verifyPredicate (K.PEq term (K.lit expected) :: K.HsPred '[] ())+ pure (concrete && all (== S.VerifiedSatisfiable) symbolic)+ naturalResults <- forM [(left, right) | left <- [0 .. 8], right <- [0 .. 8]] $ \(left, right) -> do+ let terms =+ [ (K.tadd (K.lit left) (K.lit right) :: K.Term '[] () '[] Natural, left + right)+ , (K.tsub (K.lit left) (K.lit right), naturalMonus left right)+ , (K.tmul (K.lit left) (K.lit right), left * right)+ ]+ concrete = all (\(term, expected) -> K.evalTerm term K.RNil () == expected) terms+ symbolic <- forM terms $ \(term, expected) ->+ S.verifyPredicate (K.PEq term (K.lit expected) :: K.HsPred '[] ())+ pure (concrete && all (== S.VerifiedSatisfiable) symbolic)+ pure (and integerResults && and naturalResults)++repeatedPathUsesOneSymbol :: IO Bool+repeatedPathUsesOneSymbol = do+ let projected :: K.Term ScalarAccountRegs ScalarAccountCommand (RegFieldsOf AdjustData) Integer+ projected =+ K.inpProj+ StructuralProjections.structuralProjectionC4cZC69ZC6dZC69ZC74ZC73ZC2fZC6dZC69ZC6eZC69ZC6dZC75ZC6dZWitness+ inCtorAdjust+ (#limits :: K.Index (RegFieldsOf AdjustData) Domain.Limits)+ contradiction :: K.HsPred ScalarAccountRegs ScalarAccountCommand+ contradiction = K.PAnd (K.PCmp K.CmpGe projected (K.lit 1)) (K.PCmp K.CmpLt projected (K.lit 1))+ (== S.VerifiedUnsatisfiable) <$> S.verifyPredicate contradiction++holeEnvelopeAgreement :: Bool+holeEnvelopeAgreement =+ case K.step scalarAccountTransducer (ScalarAccountOpen, initialScalarAccountRegs) (mkAdjust 1 2 False commandTime 0) of+ Nothing -> False+ Just (reviewed, registers, _) ->+ case K.step scalarAccountTransducer (reviewed, registers) (Close (CloseData 77)) of+ Just (target, closedRegisters, events) ->+ target == ScalarAccountClosed+ && closedRegisters K.! #balance == registers K.! #balance+ && events == [ClosedEvent (ClosedEventData 77)]+ Nothing -> False++fullReplayAgreement :: Bool+fullReplayAgreement =+ case K.step scalarAccountTransducer initialPair adjustCommand of+ Nothing -> False+ Just (reviewed, adjustedRegisters, adjustedEvents) ->+ case K.step scalarAccountTransducer (reviewed, adjustedRegisters) closeCommand of+ Nothing -> False+ Just (forwardVertex, forwardRegisters, closedEvents) ->+ case traverse codecRoundTrip (adjustedEvents <> closedEvents) of+ Left _ -> False+ Right decodedEvents ->+ case K.applyEventsEither scalarAccountTransducer initialPair decodedEvents of+ Left _ -> False+ Right (replayVertex, replayRegisters) ->+ replayVertex == forwardVertex+ && replayRegisters K.! #balance == forwardRegisters K.! #balance+ && replayRegisters K.! #reserved == forwardRegisters K.! #reserved+ && replayRegisters K.! #capacity == forwardRegisters K.! #capacity+ && replayRegisters K.! #machine == forwardRegisters K.! #machine+ && replayRegisters K.! #label == forwardRegisters K.! #label+ && replayRegisters K.! #active == forwardRegisters K.! #active+ && replayRegisters K.! #mode == forwardRegisters K.! #mode+ && replayRegisters K.! #requestId == forwardRegisters K.! #requestId+ && replayRegisters K.! #openedAt == forwardRegisters K.! #openedAt+ && replayRegisters K.! #limits == forwardRegisters K.! #limits+ where+ initialPair = (ScalarAccountOpen, initialScalarAccountRegs)+ adjustCommand = mkAdjust 3 2 False commandTime 1+ closeCommand = Close (CloseData 77)+ codecRoundTrip event =+ parseScalarAccountEvent+ (eventType scalarAccountCodec event)+ (encodeScalarAccountEvent event)++snapshotFingerprintAgreement :: Bool+snapshotFingerprintAgreement =+ case stateCodec scalarAccountEventStreamDef of+ Nothing -> False+ Just currentCodec ->+ let changedCodec :: StateCodec (ScalarAccountVertex, K.RegFile ScalarAccountRegs)+ changedCodec = withFoldFingerprint (scalarAccountFoldFingerprint <> "-mutated") (defaultStateCodec 1)+ in "transition2ReviewedClose-fold-v1" `T.isSuffixOf` scalarAccountFoldFingerprint+ && scalarAccountFoldFingerprint `T.isInfixOf` stateShapeHash currentCodec+ && stateShapeHash currentCodec /= stateShapeHash changedCodec
+ test/conformance-scalar-expressions/ScalarExpressions/Bindings.hs view
@@ -0,0 +1,39 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE LambdaCase #-}++-- This is a HAND-OWNED consumer binding skeleton. keiro-dsl creates it once+-- and never overwrites it. Fill each HOLE and run the generated harness.+module ScalarExpressions.Bindings (+ initialLimits+ , limitsCases+ , limitsBinding+) where++import Generated.AggregateScalarExpressions.Structural.Shape.Limits qualified+import Data.List.NonEmpty (NonEmpty (..))+import Keiro.Codec.Structural (FixtureCases (..), StructuralBinding (..))+import ScalarExpressions.Domain qualified++-- HOLE: provide the initial register value for Limits+initialLimits :: ScalarExpressions.Domain.Limits+initialLimits = ScalarExpressions.Domain.Limits 0 5++-- HOLE: provide deterministic labelled conformance fixtures for Limits+limitsCases :: FixtureCases ScalarExpressions.Domain.Limits+limitsCases =+ FixtureCases+ ( ("initial", initialLimits)+ :| [ ("expanded", ScalarExpressions.Domain.Limits 2 13)+ , ("negative-minimum", ScalarExpressions.Domain.Limits (-3) 8)+ ]+ )++-- HOLE: complete both total directions; wire policy remains in the generated codec.+limitsBinding :: StructuralBinding ScalarExpressions.Domain.Limits Generated.AggregateScalarExpressions.Structural.Shape.Limits.LimitsShape+limitsBinding =+ StructuralBinding+ { bindingToShape = \case+ ScalarExpressions.Domain.Limits minimumValue ceilingValue -> Generated.AggregateScalarExpressions.Structural.Shape.Limits.Limits minimumValue ceilingValue+ , bindingFromShape = \case+ Generated.AggregateScalarExpressions.Structural.Shape.Limits.Limits minimumValue ceilingValue -> ScalarExpressions.Domain.Limits minimumValue ceilingValue+ }
+ test/conformance-scalar-expressions/ScalarExpressions/Domain.hs view
@@ -0,0 +1,22 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}++module ScalarExpressions.Domain (Limits (..)) where++import Data.Aeson (FromJSON, ToJSON)+import Data.Proxy (Proxy)+import Data.Text (Text)+import GHC.Generics (Generic)+import Keiki.Shape (CanonicalTypeName (..))+import Numeric.Natural (Natural)++data Limits = Limits+ { minimum :: !Integer+ , ceiling :: !Natural+ }+ deriving stock (Eq, Generic, Show)+ deriving anyclass (FromJSON, ToJSON)++instance CanonicalTypeName Limits where+ canonicalTypeName :: Proxy Limits -> Text+ canonicalTypeName _ = "scalar-expressions.Limits.v1"
test/conformance-skeletons/SkelAggregate/Generated/MyService/Thing/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelAggregate.Generated.MyService.Thing.Codec (
test/conformance-skeletons/SkelAggregate/Generated/MyService/Thing/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-skeletons/SkelAggregate/Generated/MyService/Thing/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelAggregate.Generated.MyService.Thing.Harness (harnessAssertions) where
test/conformance-skeletons/SkelContract/Generated/MyService/MyContract/Contract.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-skeletons/SkelEmit/Generated/MyService/MyContract/Contract.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-skeletons/SkelIntake/Generated/MyService/MyContract/Contract.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-skeletons/SkelProcess/Generated/MyService/Hospital/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelProcess.Generated.MyService.Hospital.Codec (
test/conformance-skeletons/SkelProcess/Generated/MyService/Hospital/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-skeletons/SkelProcess/Generated/MyService/Hospital/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelProcess.Generated.MyService.Hospital.Harness (harnessAssertions) where
test/conformance-skeletons/SkelProcess/Generated/MyService/HospitalSurge/Process.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelProcess.Generated.MyService.HospitalSurge.Process (
test/conformance-skeletons/SkelProcess/Generated/MyService/HospitalSurge/ProcessHarness.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelProcess.Generated.MyService.HospitalSurge.ProcessHarness (processHarnessValues) where
test/conformance-skeletons/SkelProcess/Generated/MyService/Surge/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelProcess.Generated.MyService.Surge.Codec (
test/conformance-skeletons/SkelProcess/Generated/MyService/Surge/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-skeletons/SkelProcess/Generated/MyService/Surge/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelProcess.Generated.MyService.Surge.Harness (harnessAssertions) where
test/conformance-skeletons/SkelQueue/Generated/MyService/Accepted_transfer_needs/ReadModel.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelQueue.Generated.MyService.Accepted_transfer_needs.ReadModel (
test/conformance-skeletons/SkelQueue/Generated/MyService/Accepted_transfer_needs/ReadModelTable.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelQueue.Generated.MyService.Accepted_transfer_needs.ReadModelTable (acceptedTransferNeedsQualifiedTable) where
test/conformance-skeletons/SkelQueue/Generated/MyService/Reservation_work/Queue.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-skeletons/SkelQueue/Generated/MyService/Reservation_work/QueueCodec.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-skeletons/SkelQueue/Generated/MyService/Reservation_work/QueuePolicy.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelQueue.Generated.MyService.Reservation_work.QueuePolicy (
test/conformance-skeletons/SkelQueue/Generated/MyService/Transfer_decisions/ReadModel.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelQueue.Generated.MyService.Transfer_decisions.ReadModel (
test/conformance-skeletons/SkelQueue/Generated/MyService/Transfer_decisions/ReadModelTable.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelQueue.Generated.MyService.Transfer_decisions.ReadModelTable (transferDecisionsQualifiedTable) where
test/conformance-skeletons/SkelRouter/Generated/MyService/Page/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelRouter.Generated.MyService.Page.Codec (
test/conformance-skeletons/SkelRouter/Generated/MyService/Page/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-skeletons/SkelRouter/Generated/MyService/Page/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelRouter.Generated.MyService.Page.Harness (harnessAssertions) where
test/conformance-skeletons/SkelRouter/Generated/MyService/PagingRouter/Router.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelRouter.Generated.MyService.PagingRouter.Router (
test/conformance-skeletons/SkelWorkflow/Generated/MyService/HospitalTransferReservation/WorkflowFacts.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelWorkflow.Generated.MyService.HospitalTransferReservation.WorkflowFacts (workflowFacts) where
test/conformance-skeletons/SkelWorkflow/Generated/MyService/HospitalTransferReservation/WorkflowRuntime.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module SkelWorkflow.Generated.MyService.HospitalTransferReservation.WorkflowRuntime (
test/conformance-snapshot/Generated/HospitalCapacity/Reservation/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation.Codec (
test/conformance-snapshot/Generated/HospitalCapacity/Reservation/Domain.hs view
@@ -1,7 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-snapshot/Main.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE DataKinds #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} module Main (main) where
test/conformance-structural/Conformance/Structural/Bindings.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} module Conformance.Structural.Bindings ( artifactInfoBinding,
test/conformance-structural/Generated/StructuralConformance/ArtifactCatalog/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.StructuralConformance.ArtifactCatalog.Codec (
test/conformance-structural/Generated/StructuralConformance/ArtifactCatalog/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-structural/Generated/StructuralConformance/ArtifactCatalog/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec.
test/conformance-structural/Generated/StructuralConformance/Structural/CodecCompare/ArtifactInfo.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl codec comparison; non-production migration evidence; do not edit. -- This module compares historical and generated codecs in consumer-owned tests only.
test/conformance-v2/Generated/HospitalCapacity/Reservation/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation.Codec (
test/conformance-v2/Generated/HospitalCapacity/Reservation/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance-v2/Generated/HospitalCapacity/Reservation/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation.Harness (harnessAssertions) where
test/conformance-v2/Generated/HospitalCapacity/Reservation/Projection.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation.Projection (
test/conformance-v2/HospitalCapacity/Reservation/Holes.hs view
@@ -1,7 +1,6 @@ {-# LANGUAGE BlockArguments #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QualifiedDo #-} {-# LANGUAGE TypeApplications #-}
test/conformance-workflow-full/Generated/HospitalCapacity/HospitalTransferReservation/WorkflowRuntime.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.HospitalTransferReservation.WorkflowRuntime (
test/conformance-workflow-full/HospitalCapacity/HospitalTransferReservation/WorkflowBody.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE OverloadedStrings #-} -- HAND-FILLED workflow body (EP-6 M5 full-service integration): the ordered -- step/await body — the behaviour-bearing hole — written against the live
test/conformance-workflow-runtime/Generated/HospitalCapacity/HospitalTransferReservation/WorkflowRuntime.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.HospitalTransferReservation.WorkflowRuntime (
test/conformance-workflow-runtime/Main.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} {- | EP-6 workflow runtime conformance: the scaffolded @WorkflowRuntime@ — the WorkflowName and the awakeable-id derivation — compiled against the LIVE
test/conformance-workflow/Generated/HospitalCapacity/HospitalTransferReservation/WorkflowFacts.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.HospitalTransferReservation.WorkflowFacts (workflowFacts) where
test/conformance/Generated/HospitalCapacity/Reservation/Codec.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation.Codec (
test/conformance/Generated/HospitalCapacity/Reservation/Domain.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-unused-top-binds #-}
test/conformance/Generated/HospitalCapacity/Reservation/Harness.hs view
@@ -1,6 +1,5 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedLabels #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation.Harness (harnessAssertions) where
test/conformance/Generated/HospitalCapacity/Reservation/Projection.hs view
@@ -1,5 +1,4 @@ {-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} -- @generated by keiro-dsl; do not edit. Regenerated from the .keiro spec. module Generated.HospitalCapacity.Reservation.Projection (