Skip to content

Recover the specifications a slice carries into the document - #2411

Closed
woksin wants to merge 5 commits into
feat/screenplay-multiprojectfrom
feat/screenplay-specifications
Closed

Recover the specifications a slice carries into the document#2411
woksin wants to merge 5 commits into
feat/screenplay-multiprojectfrom
feat/screenplay-specifications

Conversation

@woksin

@woksin woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Added

woksin and others added 5 commits July 29, 2026 01:38
Screenplay has a specification construct - given, when, then - and nothing
in the model to fill it from, so the generator has never written one. This
introduces the shape a scenario is recovered into and the names the source
of one is recognized by, ahead of the reading and the writing that follow.

A given, a when and a then are the same shape - a name and a list of values -
so one model covers all three and the step it sits in says which it is. A
rejection carries the reason the source names for it, and an empty one where
the source names none: the scenario is named after the words the source uses
for it, so what it was about is already said there and inventing a sentence
to repeat it would describe an application nobody wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Cratis application carries its integration specifications beside the slice
they are about, in a shape rigid enough to read: what had happened is stated
against a scenario or appended to the event log, one command is issued, and
each assertion says one thing about what followed. That is the specification
construct exactly, so the scenarios were there all along and the document
simply never carried them.

Both shapes Arc documents are read - the pipeline driven in process and a
running host driven over HTTP - matched on the names of the testing types
alone, so neither testing package has to be referenced. A specification about
a collaborator is passed over rather than reported, because what is read is
decided by what a specification touches rather than by where it sits.

Values are recovered the way a produces mapping is, one source shorter: a
scenario refers to nothing outside itself, so a constant is the only source of
a value there is. A value that is code is left out and the rest of the
scenario stands. A step that is code, or one that only happens under a
condition, takes the scenario with it - an example missing the state it
started from is a different example from the one the source states, and
stating it would be the one failure mode a reader cannot catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A document stating what a slice does and carrying none of the scenarios
proving it loses half the story, so every scenario recovered is written into
the slice it belongs to, ordered by name like everything else in a slice.

Nothing a step states is left out over its name. Every other block decides
what a line is from its first word, so a property named after a directive
collides with it; the values of a step are written one level deeper than the
step itself and the step takes every line beneath it as a value, whatever its
first word says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both shapes Arc documents are specified against - the pipeline driven in
process and a running host driven over HTTP - along with a rejection named
and unnamed, a value that is code, and each of the three ways a scenario is
left out whole. The testing surface is declared as source rather than
referenced, which asks the recognition the only question worth asking of it:
whether the names alone are enough.

The end to end specification carries a scenario from source through the
printed text and back through the Screenplay compiler, because a scenario is
the one construct where the value of a property and the name of a step sit
one indentation apart - and a document nobody reads back is a document that
compiles by luck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lines of work built from the same base are stacked here: reading an
application written as several projects, and recovering the scenarios a
slice is specified by.

Both branches had independently claimed SP0037 and SP0038 for different
meanings. A code is never reused and never renumbered, because consumers
suppress and group on it, so one number cannot carry two meanings. The
multiproject codes are already published on an open pull request whose
reviewer notes cite them, so those keep the numbers - SP0037 stays
RepeatedDeclarationAcrossProjects and SP0038 stays ProjectsWithoutASharedRoot
- and the specification codes move on: UnreadableSpecification to SP0039 and
UnreadableSpecificationValue to SP0040.

ApplicationModelAnalyzer had to place specification recovery in the flow that
now reads several compilations. A scenario reads the symbols of the project
it is written in, so it is recovered per project exactly as an artifact is.
Which slice it belongs to is a different question: it names no slice, it is
placed by the nearest namespace above it that declares one, and nothing says
that slice is declared by the project the scenario sits in - a bounded
context handling its commands beside the contracts project publishing its
events is specified from the project holding the handlers. So each project
is asked for its scenarios only once the slices of every project have been
joined, and placement is decided against the slices of the whole
application. The scenarios landing in one slice are then joined by the rule
the union already applies to everything else within a slice: one of a name,
the first kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@woksin woksin added the patch label Jul 29, 2026
@woksin

woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context — deliberately kept out of the description.

This is agent-authored code that no human has read yet. Green CI is not sign-off. Do not merge without explicit review. Stacked on #2410, which is stacked on #2408; this merges last of the three.

What is recognized

A class is treated as a Chronicle integration specification when it is a non-abstract top-level class with a Because, and either holds a CommandScenario<T> or touches the recognized testing surface. Matching is on fully-qualified metadata names, so neither testing package has to be referenced.

Screenplay in-process shape running-host shape
given E Scenario.Given.ForEventSource(x).Events(new E(…)) EventLog.Append(x, new E(…))
given readmodel R …ForEventSource(x).ReadModel(new R(…))
when C Scenario.Execute/Validate(new C(…)) Client.ExecuteCommand(url, new C(…))
then E ShouldHaveAppendedEvent<…, E> same
then error "…" ShouldHaveConstraintViolationFor(x), else ShouldNotBeSuccessful, ShouldHaveValidationErrors, … same

The issue's evidence was out of date about its own reference application. #2404 describes EventLog.Append and ExecuteCommand; that application uses Scenario.Execute exclusively and has zero occurrences of either. Supporting only the documented shape would have recovered nothing. Both are supported.

Two judgement calls

then error "" when the source names no reason. Screenplay requires a string, and ShouldNotBeSuccessful() names none. Writing "rejected" or "validation" would be an invented sentence; the scenario's name already carries the reason. All 347 then error lines on the reference application are unnamed. If you would rather such scenarios were skipped than written with an empty reason, that is a one-line change.

A conditional step takes the whole scenario with it. A real correctness bug was found and fixed mid-implementation: if (CandidatePersonErased) { …ReadModel(new ErasedPerson(…)); } was read as an unconditional given for every derived spec, stating a world nobody specified. Anything under if/switch/loop/ternary/&&/??/lambda/catch now makes the scenario unreadable. Cost: 29 fewer specifications, 54 skips — a document that says less and is true.

Placement, which the merge changed

Specifications are read per project (all semantic work on one compilation), but placed after SliceUnion joins the slices. SpecificationPlacement.SliceOf walks strictly upward for a namespace declaring a slice, so a scenario written in the project holding the handlers, against a slice whose events come from a contracts project, would find nothing above it and be dropped. Merging goes through SliceUnion.Specifications, reusing the union's existing "first of a name kept, the rest reported" rule.

Volume, which was the open question

+3,460 lines for 507 scenarios — about 7 lines each, 62% growth, not the multiple I expected. Most values are runtime identities and get dropped, so a scenario averages 2.4 value lines. No option to disable was added, and I agree with that: if a larger application does make a document unwieldy, the right lever is a per-slice or per-module filter driven by a real complaint, not a global off switch added on speculation.

Verification

  • Release build 0 errors / 0 warnings across net8.0/net9.0/net10.0.
  • 1106 specs, exactly the 1030 + 1049 − 973 the merge predicted.
  • End-to-end gate: exit 0, reads back clean.
  • Reference application, single project: 9,110 lines, 507 specification blocks, reads back clean.
  • Reference application, whole solution: 9,290 lines, 520 specification blocks, 0 import lines, 229 file lines all relative (224 Core/, 5 Lobby/, 0 absolute) — this is the check that proves specifications and multi-project coexist.
  • Determinism: byte-identical across consecutive runs for all three targets.

Two corrections the agent made to its own work, worth reading

A "zero specifications recovered" result was not the merge. That application's obj/project.assets.json had been restored without the Debug-only test packages, so the compilation carried all 1,422 spec syntax trees but no xunit or testing package — 13,000+ errors and nothing matched. Proven pre-existing by building the pre-merge tip and getting byte-identical output with the same zero. A plain dotnet restore fixed it; no tracked file was touched.

A "nondeterminism" finding was a broken guard, not a real defect. Five distinct hashes across six runs looked like nondeterminism in the multi-project path. The cause was the application being edited concurrently, while the guard — find -newermt "-30 minutes" — silently matches nothing on BSD find and so reported "no concurrent edits" while files were being written. Redone with touch-marker files, everything is byte-identical.

Known limits

  • Values held in fields are not followed one hop, losing 77 scenarios to .ReadModel(TargetUser) / Execute(_command) built in Establish. Closable, and the highest-value follow-up.
  • then readmodel is never emitted — nothing in the recognized assertion surface asserts read-model state, so there is no source shape to read it from, despite the issue listing it.
  • EventScenario-based constraint specs are treated as unit-level and not reported; they append an event as their action, which specification has no when for.
  • The solution load pulls in a netstandard2.0 analyzer project as an application project (ProjectCompilation.IsSpecifications filters by name only). It contributes nothing to the document, but the workspace warns about it. Pre-existing, untouched.

@woksin

woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #2413, which now targets main directly and carries all 34 commits of this stack as one change with combined release notes covering #2393, #2394, #2395, #2396, #2397, #2398, #2399, #2400, #2401, #2403, #2404 and #2409.

Nothing is lost — every commit here is an ancestor of that branch, and the branch is kept. Reviewer context for all of it is consolidated into a single comment there.

Closing so there is one PR to review and one set of release notes rather than four that have to be merged in order.

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.

1 participant