keiro-dsl-0.2.0.0: src/Keiro/Dsl/Harness.hs
{- | 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.
-}
module Keiro.Dsl.Harness (
harnessFor,
harnessProcess,
harnessRouter,
harnessReadModel,
harnessWorkflow,
) where
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.Grammar
import Keiro.Dsl.ReadModelShape (deriveShapeHash, registryNameFor, subscriptionNameFor)
import Keiro.Dsl.Scaffold
{- | 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 ctx spec agg =
[ ScaffoldModule
{ modulePath = T.unpack (T.replace "." "/" (aGenPrefix a) <> "/Harness.hs")
, moduleText = emitHarness a
, kind = Generated
, origin = "aggregate " <> aggName agg <> locSuffix (aggLoc agg)
}
]
where
a = resolveAgg ctx spec 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 (wfLoc w)
}
, ScaffoldModule
{ modulePath = T.unpack (T.replace "." "/" genPrefix <> "/WorkflowRuntime.hs")
, moduleText = emitWorkflowRuntime genPrefix w
, kind = Generated
, origin = "workflow " <> wfId w <> locSuffix (wfLoc 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 :: Agg -> Text
emitHarness a =
nl $
[ "{-# LANGUAGE OverloadedStrings #-}"
, generatedBanner
, "module " <> aGenPrefix a <> ".Harness (harnessAssertions) where"
, ""
, "import " <> aGenPrefix a <> ".Domain"
, "import " <> aGenPrefix a <> ".Codec (encode" <> nm <> "Event, parse" <> nm <> "Event" <> codecValueImport <> ")"
, "import " <> aHolePrefix a <> ".Holes (" <> lowerFirst nm <> "Transducer)"
, "import Keiki.Core (defaultValidationOptions, step, validateTransducer)"
, codecDecodeRawImport
, ""
, "{- | (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
]
++ [ " , (\"upcaster wired: a v" <> tInt m <> " " <> rcName e <> " payload decodes through the chain\", upcasts" <> rcName e <> ")"
| e <- upcastEvents
, Just m <- [rcUpcastFrom e]
]
++ [ " ]"
, ""
, "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 (upcastDecl a) upcastEvents
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]
codecValueImport = ", " <> lowerFirst nm <> "Codec"
codecDecodeRawImport =
if null upcastEvents
then "import Keiro.Codec (eventType)"
else "import Keiro.Codec (EventType (..), decodeRaw, eventType)"
{- | A wiring-proof assertion: feed a current-shape payload tagged at the
upcaster's source version through @decodeRaw@, which runs the upcaster chain
then @decode@. Red while the upcaster hole returns @Left@; green once filled.
(The grammar records only the current event shape, not the per-version field
delta, so this proves the chain is wired and the hole must be filled rather
than re-deriving the exact old payload.)
-}
upcastDecl :: Agg -> ResolvedCtor -> [Text]
upcastDecl a e = case rcUpcastFrom e of
Nothing -> []
Just m ->
[ ""
, "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 <> "))"
]
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\").
-}
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\")"
-- | @(<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 ty | (_, ty) <- rcFields rc]
sampleValue :: Agg -> Text -> Text
sampleValue a ty = case fieldCat a ty of
IdCat -> "(" <> ty <> " \"sample\")"
EnumCat -> maybe ("(error \"no enum ctor\")") id (firstEnumCtor a ty)
OtherCat
| ty == "Bool" -> "False"
| ty == "Int" -> "0"
| ty == "Text" -> "\"sample\""
| ty == aVertexType a -> initialVertex a
| otherwise -> "(error \"sample: unsupported type " <> ty <> "\")"