Skip to content

Split scaffolding out of the renderer, and render Given/When/Then into specifications - #35

Merged
woksin merged 8 commits into
mainfrom
feat/split-scaffolding-out-of-the-renderer
Aug 14, 2026
Merged

Split scaffolding out of the renderer, and render Given/When/Then into specifications#35
woksin merged 8 commits into
mainfrom
feat/split-scaffolding-out-of-the-renderer

Conversation

@woksin

@woksin woksin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Added

  • Cratis.Stage.Rendering.Cratis.Scaffolding — scaffolds the Cratis project a rendered application is placed into, from the Cratis templates. Take it alongside the renderer and pass TemplateEngineProjectScaffolder to CratisRenderer to keep today's behavior. (The renderer package hands every consumer the runtime it generates code for #34)
  • A Screenplay specification exercising its slice's command renders as a Cratis spec — a CommandScenario<T> in the when_ folder layout, wrapped in #if DEBUG. Appended events are asserted against the event source the command's identifier names, narrowed to the values the specification states; a rejection asserts both that the command failed and that it failed as validation. (Render Given/When/Then into Cratis Specifications #14)

Changed

Removed

Fixed

woksin added 3 commits August 13, 2026 11:31
The Cratis meta-package contributes GlobalUsings.cs through Cratis.props,
so every consumer of the renderer silently gained 28 global usings and
the ambiguities they bring - 160 compilation errors in the CLI alone.
Naming Arc, Arc.Chronicle, Arc.Swagger, Chronicle and MongoDB.Driver
directly restores the same compilation for the specs without any build
assets, and drops 16 packages from what a consumer restores.
The renderer needs Arc, Chronicle and the Mongo driver to compile its
rendered output inside a spec, and needs none of them to render. Gating
the references on Debug - the same condition Directory.Build.props
already uses for the test packages, and Directory.Build.targets for the
spec files themselves - keeps the specs and takes the runtime out of the
package.

It also stops the type-discovery generator, which was reaching the
compilation transitively rather than through the two direct references
ExcludeAssets was applied to. The Release assembly goes from 698 Cratis
type references and nine Arc and Chronicle assemblies to Screenplay and
Stage.Contracts alone.
The renderer needed the template engine for one thing: scaffolding a
project around the files it writes. That engine brings the whole NuGet
client, which Microsoft.Build.Locator refuses to run beside, so any
consumer had to choose between hosting MSBuild and rendering at all -
the CLI needs both.

IProjectScaffolder stays as the seam and CreateDefault now renders into
the target directory without scaffolding, which is the common case: a
document regenerated into a project that already exists. Scaffolding
moves to Cratis.Stage.Rendering.Cratis.Scaffolding for callers that
want it.

The renderer package is now Cratis.Stage.Contracts and Cratis.Screenplay
and nothing else.
@woksin woksin added the major label Aug 13, 2026
@woksin

woksin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context.

Labelled major, deliberately as one. Three things here remove public surface — the generator marker, the five scaffolding types, and two sets of package dependencies — so they go out as a single break rather than three. CompatibilitySuppressions.xml records each one; it is worth reading as the diff of what left the package.

Cratis.Arc.Generated.GeneratedMarker is in that file and was never ours. The Cratis type-discovery generator emitted it into this assembly, and it ships in 2.3.0 today. That is also what put nine Arc and Chronicle assemblies and 698 type references into a text generator's AssemblyRef table; gating the references on Debug removes the generator with them, and the Release assembly is now Screenplay and Stage.Contracts alone — 90 type references, both real.

Verified against a real consumer, not just the specs. Packed to a local feed, pointed Cratis/cli#62's branch at it and deleted all three csproj workarounds it needed against 2.3.0 — the Cratis reference in two projects, the NuGet.Frameworks exclusion, the Microsoft.Build.Framework exclusion. The CLI builds clean, its 446 specs pass, and:

$ cratis render invoicing.play --target ./Invoicing
{"target":"…/Invoicing","documents":1,"notCarried":44}

33 C# files, exit 0, and 44 lines on stderr naming what the rendered application does not carry. That is the render arrow running as a command for the first time.

NuGet.Frameworks had exactly one path in, which is why removing the template engine is the whole fix rather than a mitigation:

NuGet.Frameworks <- NuGet.Common <- NuGet.Configuration <- Microsoft.TemplateEngine.Edge <- Cratis.Stage.Rendering.Cratis

A mistake worth naming, since it was on this branch and I told you it was green: the first commit removed Cratis and Cratis.Arc.MongoDB from Directory.Packages.props, which Source/Stage and Source/Host still reference. I had only built Rendering.Cratis in isolation, never the solution, so NU1010 never surfaced until this commit. Restored, and everything since is measured against dotnet build Stage.slnx — 407 specs across four projects, Debug and Release both zero warnings.

Not verified: no rendered application was booted against a live Chronicle. The scaffolding package's own two specs moved with it and still pass, but nothing here proves the template engine behaves identically from its new home beyond those.

If you'd rather scaffolding left the repo entirely and became cratis new in the CLI, the seam is unchanged — IProjectScaffolder stays here either way, and the move is deleting one project and one package reference.

@woksin

woksin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@einari Two things need you here — the second more than the first.

Why this is a major

Not the moved types — those keep their namespace, so a consumer adds a package reference and recompiles. It is this:

public static CratisRenderer CreateDefault()   // same signature, no longer scaffolds a project

Same API, different behavior, no compiler error. That is the definition of a major, and it is unavoidable: the scaffolder now lives in another package, so CreateDefault() cannot keep wiring it. Everything else in the break — five moved types, Cratis.Arc.Generated.GeneratedMarker, the dropped dependencies — would be arguable on its own; the silent behavior change is not.

The blast radius, measured

Studio is the only live consumerCratis.Stage.Rendering.Cratis 2.3.0, and it calls exactly the method whose behavior changes:

// Studio Source/Core/Program.cs:171
builder.Services.AddSingleton<IRenderer>(_ => CratisRenderer.CreateDefault());

And it is unaffected in practice. SliceCodeGenerator renders into a git repository the user already has, and calls Directory.CreateDirectory(target) itself before rendering. Scaffolding a Cratis project template into that directory was a no-op at best — TemplateEngineProjectScaffolder declines when a project already exists — and actively wrong at worst, if a repository happened not to have one. Studio's only other use of the package is …Rendering.Cratis.Naming, which is untouched.

So the cost of this major on the one consumer that exists is a version bump. The CLI's use of it is still unmerged.

What I would like you to decide

  1. Cut 3.0.0 for this, or hold it and batch? Stage.Contracts cannot express the Screenplay 2.x constructs, so they cannot survive a round trip #23 is the other pending Stage major — the cardinality change, Slice.CommandCommands, Slice.ReadModelReadModels, ReadModelDefinition.ProjectionProjections. Two majors in a row for one package is worse than one carrying both, and this branch is ready whenever the other is. Holding costs only that Add 'cratis render' as a root command for rendering a Screenplay solution cli#62 waits.
  2. Where scaffolding should live. I put it in a Cratis.Stage.Rendering.Cratis.Scaffolding package because it keeps the capability and the seam is unchanged either way. If you would rather it left this repo entirely and became cratis new, that is deleting one project and one package reference from this branch.

Everything else on the PR I have decided and written down — the label, the regenerated suppression, the split shape — and all three are one comment away from being reversed.

A specification exercising the slice's own command renders as a
CommandScenario<T> spec - one file each, in the when_ folder layout the
conventions use, wrapped in #if DEBUG. Appended events assert against
the event source the command's identifier names; a rejection asserts
both that the command failed and that it failed as validation, because
ShouldNotBeSuccessful alone cannot tell a rejection from an exception.

A specification that cannot be rendered faithfully is not rendered at
all and says why. The interesting case is 'given': CommandScenario does
support seeding prior events, so the gap is in the document rather than
the target - a given event names no event source, and it is frequently
not the command's own. A specification asserting that a duplicate
invoice number is rejected seeds an InvoiceRegistered for a different
invoice than the one it registers, and seeding it against the command's
id would assert something else entirely.
woksin added 2 commits August 13, 2026 18:45
…-out-of-the-renderer

# Conflicts:
#	Directory.Packages.props
#	Source/Rendering.Cratis/Rendering.Cratis.csproj
The spec that compiles rendered output parsed without DEBUG defined, so
every rendered specification - which sits entirely inside #if DEBUG -
compiled to nothing and the assertion passed on an empty compilation
unit. It proved nothing about the thing it was written for.

Defining the symbol makes it real, and it immediately found what it was
blind to: a stated date renders as a parse against the invariant
culture, and nothing emitted the using it needs. The renderer now
decides that from what it rendered.
@woksin woksin changed the title Split scaffolding out of the renderer, and take its Cratis references in Debug only Split scaffolding out of the renderer, and render Given/When/Then into specifications Aug 13, 2026
@woksin woksin added minor and removed major labels Aug 13, 2026
@woksin

woksin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

#36 is folded into this one and closed — same review, one release.

Relabelled majorminor, and here is the case

I argued for major on CratisRenderer.CreateDefault() changing behavior silently. That reasoning still stands in the abstract; what it was missing is who it happens to. So I measured the only consumer.

Studio is it, on Cratis.Stage.Rendering.Cratis 2.3.0. Every part of the break misses it:

Break Studio
Cratis / Cratis.Arc.MongoDB dropped as dependencies references both directly in Source/Core/Core.csproj — nothing to lose
five scaffolding types moved uses none of them; its only other use of the package is …Rendering.Cratis.Naming
Cratis.Arc.Generated.GeneratedMarker removed generator debris, unreferenceable
CreateDefault() stops scaffolding calls it — see below

The one real consequence, stated plainly rather than buried: Studio stops scaffolding a Cratis project template into a repository that does not already contain one. SliceCodeGenerator renders into the user's own git repository and calls Directory.CreateDirectory(target) itself first, so scaffolding was a no-op wherever a project existed — and where one did not, dropping an app template into someone's repository is behavior worth losing, not preserving.

So the blast radius of the whole "major" is one behavior nobody wants, on one internal consumer, in this org. minor is the honest label; major was me applying the letter of the rule to a package with no external audience.

Reviewed it, and found a defect of my own

The assertion I had called the one that matters was vacuous. RenderedOutput.Errors parsed without DEBUG defined, and a rendered specification sits entirely inside #if DEBUG — so the preprocessor dropped the whole file and the compilation succeeded on an empty compilation unit. should_render_specs_that_compile was passing on nothing.

Defining the symbol made it real, and it immediately caught what it had been blind to: a stated date renders as DateOnly.Parse(…, CultureInfo.InvariantCulture) and nothing emitted using System.Globalization; — so any specification stating a date rendered a spec that did not compile. SpecificationValues.NeedsGlobalization existed and was never called; the renderer now decides it from what it rendered. The fixture gained a Date property so the case is covered, and reverting the fix now fails both that spec and the compile assertion.

That is two vacuity traps in one file in one session — the ambient-usings gap on #33, and this. Worth remembering that a compile-based assertion is only as honest as its parse options.

Verification

  • 423 specs across four projects, Debug and Release both zero warnings, dotnet build Stage.slnx.
  • Packed and consumed for real: cli#62's branch with all three csproj workarounds deleted builds clean, its 446 specs pass, and cratis render invoicing.play writes 33 files with 44 reported drops, exit 0.
  • On invoicing.play, 1 of 4 specifications renders and compiles; the other 3 are declined with the reason on stderr.
  • CompatibilitySuppressions.xml records every removed type — worth reading as the diff of what left the package.

Not verified: no rendered specification has been executed, and no rendered application booted against a live Chronicle.

Still yours to call: whether to cut this now or hold it to batch with #23's cardinality major, and whether scaffolding should stay in this repo or become cratis new. Neither blocks the review.

SpecificationAssertions.Of renders EventSourceId.Unspecified when the
specification states no value for the command's identifier, and nothing
had ever compiled that branch - the fixture always stated one. It does
now, which is the only way to know the rendering is real rather than
plausible.
@woksin

woksin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Two more from reviewing my own work, both the same shape as the vacuity bug and both now covered by compiling rather than reading.

EventSourceId.Unspecified was never compiled. SpecificationAssertions.Of renders it when a specification states no value for the command's identifier, and the fixture always stated one — so that branch had only ever been read. It compiles now, and asserts the diagnostic that goes with it.

That is the third case in this PR where the difference between plausible and real was one compilation: the ambient usings on #33, CultureInfo without its using, and this. The pattern is worth stating once — every branch of a code generator needs its own compile, because a branch that only renders is a branch that only looks right.

Nothing else changed. 425 specs across four projects, Debug and Release both zero warnings, CI green.

A rendered spec sits in a child namespace of its slice, so it finds the
command it exercises and nothing else. It now resolves imports the way
every other renderer does - an event a sibling slice declares, or a
concept placed above the slice, compiled against nothing before.

Alongside that, five things the review found:

- a fanout stating one event type twice declared the same fact twice
- a specification expecting both a rejection and appended events
  rendered only the rejection and dropped the events without saying so;
  it is declined instead, because a rejected command appends nothing
- the event source id was rendered from the document's raw text while
  the command constructed its identity through Guid.Parse, so any
  casing difference asserted against a stream the command never
  appended to
- a stated 'then' value the event does not declare, or that is not a
  literal, was dropped from the predicate silently
- a stated 1.5 for an Int rendered as 2, and a value outside int range
  rendered a literal the generated application could not compile
@woksin

woksin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Ran a review over the specification rendering. Eight findings, all real, all mine, all fixed — pushed. Two of them produced generated code that does not compile, which is the class of defect this whole PR exists to stop.

# What Why it mattered
1 The rendered spec never called ReferencedNamespaces.Resolve A spec sits in a child namespace of its slice, so it finds the command and nothing else. A then event a sibling slice declares — the exact case ApplicationSet.Events is global for — rendered ShouldHaveAppendedEvent<Cmd, OtherSliceEvent> with no using. CS0246. The resolver's own doc comment names this failure; my renderer was the one that did not use it.
2 A fanout named both facts the same then InvoiceLineAdded twice is legal Screenplay; the class declared should_have_appended_invoice_line_added() twice. CS0111.
3 A specification expecting both a rejection and events dropped the events Unrenderable admitted it, Render took the rejection branch, every then <Event> vanished with no diagnostic. Declined now — a rejected command appends nothing, so the document contradicts itself and rendering either half asserts something it did not state.
4 The event source id came from the document's raw text The command constructs its identity through Guid.Parse(...), whose ToString() is canonical lowercase; the assertion used the literal. A document writing 9C858901-… asserted against a stream the command never appended to — and the generated spec would have failed against correct application code, pointing at the slice rather than at the renderer.
5 Stated then values were dropped silently A value naming a property the event does not declare, or a non-literal source, vanished from the predicate with no diagnostic — while the sibling path did diagnose an undeclared event.
6 Int rounded and overflowed A stated 1.5 rendered as 2; a value outside int range rendered a long literal into an int parameter. CS1503, and a value the document never stated.
7 The decline message was factually wrong A slice may declare several commands; only the first renders. The message said the slice "does not declare" it, sending a reader to the wrong document.
8 One diagnostic for three different causes "States no value" was also emitted when a value was stated but not as a string — untrue for an Int-backed identifier.

Covered by compiling, not by reading. A new spec renders a specification whose then event lives in a sibling slice and which states that event twice, then compiles the spec together with both slices and the concept. All four of its assertions fail if either fix is reverted — including should_render_output_that_compiles.

That is now five defects in this PR found by taking a branch the fixture did not take. The pattern is consistent enough to be worth stating as a rule for this renderer: every branch of a code generator needs its own compile.

431 specs across four projects, Debug and Release both zero warnings.

@woksin

woksin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@einari Short version — my earlier comments here were too long.

What this does. Two things. The renderer stops shipping the Arc/Chronicle runtime and the template engine, which is your Debug-condition suggestion (it also removed a marker type the Cratis generator had been baking in). And Screenplay specifications now render as CommandScenario specs instead of being listed as "not rendered".

I dropped the label from major to minor. Studio is the only consumer and none of the break reaches it — it already references Cratis and Cratis.Arc.MongoDB directly, and uses none of the types that moved. The one behavior that changes: it stops scaffolding a project template into a repository that does not have one, which its own code path made a no-op anyway.

Ignore what I said earlier about holding this to batch with #23. That mattered while it was a major. A minor does not need to wait for one.

One question, and it is the only thing left:

Should scaffolding live in the new Cratis.Stage.Rendering.Cratis.Scaffolding package, or leave Stage entirely and become cratis new in the CLI?

I built the first. The seam is identical either way, so switching is deleting a project. Say which and I will adjust — otherwise this is ready to merge.

@einari

einari commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@woksin Scaffolding should happen in Stage, since we should also use this component directly from Studio - so a full render should be owned by Stage.

@woksin

woksin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

That is what is built — Source/Rendering.Cratis.Scaffolding, shipping as Cratis.Stage.Rendering.Cratis.Scaffolding, so scaffolding stays in Stage and a consumer that wants a full render takes that package alongside the renderer. Studio can reference it directly. Merging.

The renderer itself no longer carries it, which is the point of the split — Cratis.Stage.Rendering.Cratis drops to two dependencies (Stage.Contracts, Screenplay) and stops handing every consumer the Arc/Chronicle runtime and the template engine.

@woksin
woksin merged commit f68f359 into main Aug 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants