Skip to content

Recover more of an application, describe it across projects, and report what cannot be recovered - #2413

Closed
woksin wants to merge 35 commits into
mainfrom
feat/screenplay-strings
Closed

Recover more of an application, describe it across projects, and report what cannot be recovered#2413
woksin wants to merge 35 commits into
mainfrom
feat/screenplay-strings

Conversation

@woksin

@woksin woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Added

Changed

Fixed

woksin and others added 30 commits July 28, 2026 20:40
…angling

A reactor observing what a sibling bounded context publishes refers to an
event a package declares. The document had nothing to say about it beyond
SP0021, and then referred to a name it never introduced - which the official
Screenplay compiler reports as an unknown event.

Screenplay already has the construct for this: the compiler reads the last
segment of an 'import' as the name of an event that is known. Every undeclared
name is now looked for in the assemblies the compilation references, and the
one that is found is imported rather than reported. SP0021 is left for what
is really unresolvable - a name nothing at all declares an event under, where
importing it would state that a package declares something it does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A compilation loaded from a project carries what every source generator
emitted to the intermediate folder. Those files declare real members of the
slice, so the folders they sit in were counted among the folders the slice
is written across - which reported a slice sitting in one folder as spread
over two, claimed the folder was shared with the next slice to be handed the
same generator output, and widened the screen scan to a build folder.

Generated source now answers neither where a slice is written nor where the
project is. Where a build wrote every last file the whole set is kept, since
answering with nothing writes every path against the machine that generated it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kebab case is idiomatic for the runtime name of a Chronicle constraint and a
Screenplay identifier cannot carry a hyphen, so a transformation is forced.
Deleting the separators was the least readable one available - every one of
Ada's thirty one constraints came out as a run-together lower case word, and
the word boundaries the source stated were thrown away with them.

Each character an identifier cannot hold now ends a word instead, and the
words are joined in Pascal case: 'unique-timesheet-start' reads as
'UniqueTimesheetStart'. This is every identifier position rather than only a
constraint. A name carrying no separator is left exactly as it is, so nothing
the application already writes as one word changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving a type strips the collection it sits in and then strips the
optionality of the element, while marking one as personal data stripped only
the collection. A collection of an optional value therefore registered the
mark under 'Nullable' - a name no concept is declared with - so the document
said the value was not sensitive while the runtime encrypted it.

Both now strip the same wrappers through one path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The statement pattern anchors on a line starting with 'import', which a
commented out one still does, so a component carrying the leftovers of a
change bound its screen to queries it never calls.

Comments are now removed before statements are read, and the line breaks a
block comment spanned are kept - joining the line after one to the line
before would hide the real import that follows it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only an interface parameter was read as a collaborator rather than as input,
which is not the whole of what a host hands a query. A cancellation token,
the page asked for and the order asked for are all filled in from the request
and none of them is an interface, so each was stated as caller input - a
parameter no caller sends, typed by a name the document never declares.

They are named explicitly, because there is no property of a type that tells
infrastructure from a value a caller really sends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sequence jumped from SP0028 to SP0030 with nothing saying why. A code is
what a consumer suppresses and groups on, so a number handed to something
else later would silently change what an existing suppression means. The gap
is now stated as deliberate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An accented letter can be written as one character or as a letter followed by
a combining mark, and Unicode calls the two canonically equal. A combining
mark is not a letter, so stripping first kept the accent of one spelling and
quietly removed it from the other - two documents for one application,
decided by how an editor happened to save the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C# says a value may be absent in two ways - a nullable annotation on a
reference type and a Nullable wrapper on a value type - and Screenplay says
it in one, a trailing question mark. Unwrapping distinguished the two all
along and nothing asserted that either survived as far as the printed text,
where getting it wrong describes a shape the application does not have or
names a type the document never declares.

The specification is end to end and covers a command property, an event
property, an enumeration, a collection of optional values and the parameter
of a query.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generator resolved them against the assembly it was reading and the
syntax builder resolved them again against the domain of the model, so one
generation ran two resolutions with two different fallbacks and let whichever
came last decide. They agree today, which is the only reason nothing showed.

The emitter is now the single place the emission half resolves, and the
builder is handed options that are already resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every Screenplay specification builds its compilation from source strings,
which is what makes them hermetic and what puts a class of defect out of
their reach by construction: a compilation built that way has no intermediate
folder, no source generator output on disk and no referenced package
declaring an event, so a generator that mis-attributes one of those produces
a wrong document that every specification still passes. Two shipped that way.

A console project reads a real project through MSBuild, generates from it and
reads the document back with the compiler the language ships. It has to come
back with nothing to say, warnings included - a warning there is the document
referring to something it never introduces, which is this generator being
wrong rather than the application.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Any compilation error at all made SP0024 an error saying "nothing recovered
from it describes the application reliably". That claim is false whenever
recovery succeeded, and it usually did: a host handing over a compilation
assembled without the compile items a build generates leaves every reference
to a generated type unresolved, and the errors then sit in code declaring no
artifact while every command, event and reactor is read exactly as written.
One real application produced 607 such errors alongside a complete and
accurate document, which the host then discarded on the error alone.

The severity now follows how many artifacts were recovered from a declaration
no compilation error sits inside. None - nothing recovered at all, or every
declaration something came out of being one the compiler could not make sense
of - stays an error, because no part of that document can be trusted. Any at
all is a warning stating how many errors there were, how many artifacts came
through anyway and what usually causes it, so the result is successful and
the document is kept.

A count is used rather than a proportion because any threshold would make the
same recovery pass for a large application and fail for a small one. Zero is
the only number that means recovery was prevented rather than dented.

Suppressing "declares nothing" is unchanged and, in the warning branch, can
never apply - a warning is only reached when something was recovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SP0024 suppressed the document check on the code alone, which was right while
it was always an error: a model recovered from symbols the compiler never
accepted describes an application that does not exist, so a document made from
it being poor is the broken build rather than a second defect.

Now that it can be a warning, that reasoning inverts. A warning says what was
recovered stands, and a document built from a model that stands is exactly
what the check exists for. Suppressing it there would hand back a document the
language rejects with nothing wrong reported and a successful result, which is
the one outcome the check was added to make impossible.

The suppression therefore follows the severity rather than the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generator takes a Compilation and nothing else, so assembling one the way
a real build would is the caller's job - and nothing said so. The part hosts
get wrong is source generators: no workspace loading mode runs them, and an
Arc application leans on generation heavily enough that a compilation loaded
without them is missing every type they emit.

Documents the contract, how to run the generators before handing the
compilation over, and what the generator does when a host does not - which is
now two different things depending on how much survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A message reaches a rule through a lambda wherever an application declares
its messages once and names them from every validator rather than repeating
the text. The lambda computes nothing there - it names a constant the
compiler already substituted - but only the direct form was read, so those
applications ended up with a document carrying no message on any rule at all.

The lambda that really does build its text while the request runs stays
reported, and the report now names the expression it could not write down
rather than only saying that there was one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A rule holding an end date to the start date it was sent with names that
start date with a lambda, which is the same shape a chain names the property
it is declared on. Only a constant was read, so every one of those rules
looked like a comparison given nothing to compare against and was dropped -
taking the layer a domain is really written in with it. A rule operand is a
host expression, so the path is written as one.

A rule held to a condition is stated as though it always holds, because a
rule carries no condition of its own yet (Cratis/Screenplay#32). The report
now carries the condition, so a reader can tell a rule that hardly ever
applies from one that nearly always does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A record is referred to by name and never declared, so nothing inside one was
ever named on its own and every concept reached only through a child record or
a read model stayed out of the document. Where such a concept is marked as
personal data, the document then understated what the application holds about
people - which is the opposite of what declaring concepts is for.

The walk is cycle safe and ordered by name, so a record referring to itself
ends and the same source always yields the same document. The shape of the
record itself still has nowhere to go (Cratis/Screenplay#29), so a property
carrying one now says so rather than leaving the reader to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An import naming something a slice does not declare says nothing, which holds
for a component, a command and a package and was taken to hold for everything.
It does not hold for a query another slice declares - a screen aggregating
several read models is what an Event Modeling screen routinely is, and those
bindings were dropped without a word.

Writing them down is what the language cannot do: a binding names a query by
the bare name its own slice declares it under, and an application declares
All once per read model (Cratis/Screenplay#28). Where the import was written
does say which slice was meant, so the screen, the query and that slice are
reported instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where components are written against a view model, the component imports the
view model and the view model imports the query - so reading the component
alone found the name of a file and nothing about what the screen reads, and
those screens ended up bound to nothing at all.

The view model is written down rather than guessed at: the component names
the module, it sits in the slice's own folder, and what it imports is read
exactly the way the component's own imports are. One hop and no further, and
only within that folder, because a chain across folders would be inferring an
architecture rather than reading one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SP0013, SP0015, SP0016 and SP0020 each report something the application
really declares that the document cannot yet hold. Which of them is a
permanent gap and which is waiting on a specific change to the language
was carried nowhere, so a reader had no way to tell a report that will
always stand from one that disappears the moment an issue is resolved.

Each reference is written into the explanation rather than appended to
it, so it reads as the reason the gap exists, matching how SP0035 and
SP0036 already cite theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TypeRegistry had grown past the size a file is meant to stay under by
answering two questions at once: what name to write for a type, and what
the application's concepts are. The second is a registry with rules of
its own - what counts as a concept, which declaration wins when two share
a name, how a personal-data mark and a validation rule arriving later
still find the concept they belong to - and none of that has anything to
do with unwrapping a collection or reporting what a name loses.

ConceptRegistry now owns the concepts, and TypeRegistry keeps type
resolution and the two things that fall out of writing a name. The
enumeration and ConceptAs branches that were repeated between naming a
type and walking what it carries collapse into one TryRegister, which is
the same decision stated once.

Behavior is unchanged: the document generated for a real application is
byte-identical before and after, and all 973 specs pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An application written as several projects has one source root per
project and a single directory to write all of their paths relative to,
worked out from the roots together. Saying a project's root as a
directory rather than only as the paths made from it is what lets that
be asked at all.

The comparing of directories moves out with it. The same three
questions - normalize, share a prefix, is this a file system root - are
asked of one project's directories and of the roots of all of them, so
they belong beside neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A command handing its work to an aggregate root reads the behavior's
body, and that body is the one an analysis reads that need not belong
to the project the command does. A workspace loads a project reference
as a compilation, so the sibling project's source is right there - and
asking the wrong compilation for a semantic model is not a wrong answer
but an ArgumentException. An aggregate root in a domain project called
from the project above it therefore crashed generation rather than
describing the layered application it is.

A tree belonging to no project handed over is one that cannot be read
at all, and that is now said rather than leaving the command stated as
producing nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A layered application is not described by any one of its projects. The
generator took a single compilation, so pointing it at one project of a
solution described that project and referred to the events of the rest
as things it never introduces - the difference between describing the
project you pointed at and describing the application, which is the
stated goal of the tool.

Generate now also takes a list. Each compilation contributes what its
own assembly declares, and what the projects hold together becomes one
document: a namespace two of them declare into is one slice, a concept
three of them refer to is declared once, a policy the host registers is
found by the artifact naming it, an aggregate root in the project below
is one a command hands its work to, and an event a sibling project
declares is one the application has rather than one it imports. An
event a package declares is still imported, because a package really is
outside the application.

Nothing of this reaches the document through the order the list arrives
in. Nothing decides what order a host enumerates the projects of a
solution in, so they are put into assembly name order before anything
is read.

Paths stay relative and now say which project a file belongs to: they
are written relative to the directory the projects are all under.
Projects that share no directory keep their own roots, because a path
relative to the root of a file system is the machine's own layout
wearing a relative path's clothes, and that is reported (SP0038). What
two projects declare under one name in one slice cannot both be
described, so the first is kept and the second is reported (SP0037).

The single-compilation overload is unchanged and now runs through the
same path as a list of one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two projects built from source strings, one referencing the other the
way a workspace loads a project reference, is the whole shape of a
layered application - and every one of these fails against a generator
that reads one compilation, most of them by describing half of what the
projects hold.

Each is a claim that can fail on its own: the slice two projects write,
the name only one of them can be described under, the concept declared
once however many refer to it, the policy the host registers for an
artifact elsewhere, the aggregate root a project above hands its work
to, the event a sibling declares against the event a package does, the
directory the paths are relative to when the projects share one and
when they do not, and the same projects handed over the other way round
printing the same bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The end to end check exists to get behind the seam every specification
is built from strings on, and a compilation built from strings cannot
have a sibling project loaded as a compilation reference - which is
exactly where an application of several projects lives. Handing the
harness a solution rather than a project is what puts a real layered
application through the same gate: every project of it that is not a
specification project, generated into one document, read back by the
Screenplay compiler.

Handing it a single project does exactly what it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
woksin and others added 4 commits July 29, 2026 01:39
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>
An application that speaks more than one language declares every message
in a resource and names it from the validator. What the validator names
is a property a build writes from that resource, whose body is a lookup
resolved against the culture of the caller - so the compiler holds no
value for it, and asking for one brought it back indistinguishable from
a message assembled while the request runs. Such an application stated
no message on any rule at all, which is the whole of what it says about
what it will not accept.

The key is stated rather than the text, because the text is one of
several the application holds and settling on one would describe a
language rather than an application. It is qualified by the resource
declaring it, since a key is unique to that resource and to nothing
wider - a real application names the same rule the same way in two areas
of itself, and bare those two are one key that can carry only one text.

What identifies the property is the shape of its getter rather than
where it was declared or what it was called, because the file name and
the naming are conventions of one generator while the lookup is what
makes it a resource at all. A message put together from a resource
rather than referring to one is still reported, and so is a key the
grammar has no way of writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The analyzer specification states what is recovered and what is not: a
key qualified by the resource declaring it, two resources naming one key
kept apart, a message put together from a resource left off its rule, a
key the grammar cannot write left off its rule, and a message a constant
holds still recovered by the route that already existed.

The generator specification reads the document back. A key is the one
thing on a message line written unquoted, so where a message that cannot
be written merely reads wrong, a reference that cannot be written is a
line the compiler rejects - and a rejected line takes the whole document
with it rather than the one rule it sits on.

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. Top of the stack: this → #2411#2410#2408main.

The grammar was checked before anything was built on it

$strings. is accepted where a validation message goes, confirmed two independent ways: the grammar (LocalizableString = StringLiteral | "$strings.", Path) and the real parser (ValidationRuleParser, which accepts dotted \w segments), plus Screenplay's own round-trip spec for it. Arc's emission side was already wired for this and had simply never produced a key — all the work was in analysis.

Three decisions

Qualified, $strings.<ResourceClass>.<Key> — not a style preference. In the reference application 6 keys collide across two resource classes (Approve_OrgNumberRequired, Submit_NotAdministered, … in both CustomersMessages and NetworkMessages), carrying different text in different features. Bare keys would collapse those into one key that can hold only one string.

The key comes from the GetString literal, not the property name. StronglyTypedResourceBuilder mangles non-identifier characters when it makes the property name, so the literal is the real resx key and the property name is a derived approximation. Recognition is by the shape of the getter — a static string property whose getter contains exactly one ResourceManager.GetString(...) with a literal first argument — never by file name or naming convention. It deliberately misses a resource class compiled into a referenced assembly, where there is no getter syntax to read; guessing the key from the property name there was refused.

No new diagnostic codes. SP0041–SP0043 were allocated and went unused: every remaining drop is still "a validator declares a rule that could not be expressed declaratively", which is what SP0016 already says, and its existing sentence already covers the resource case. A code a consumer cannot act on differently is not worth minting.

The issue's estimate was wrong, and the correction is the interesting part

#2409 predicted SP0016 would fall by ~247. It fell by 133 — 643 → 510. That estimate was mine, and it conflated two different things.

The premise held: 509 of 510 WithMessage calls are the resource shape, none are composed, and all 510 are now readable. But recovering a message only removes an SP0016 if the message attaches to a rule the document actually states — and usually it does not:

Rule the message attaches to Count Has a declarative counterpart
Must / MustAsync ~248 no
NotEqual, InclusiveBetween, IsInEnum ~37 no
NotEmpty, MaximumLength, GreaterThan(OrEqualTo), Matches, LessThanOrEqualTo, Equal, EmailAddress ~123 yes

For the ~285 whose rule lives in code, the rule is dropped and the message has nothing to attach to. Those still report SP0016, but now name $strings.InvoicingMessages.Foo rather than raw lambda text, so what was lost is inspectable.

133 recovered against ~123 countable by regex — which under-counts multi-line chains — means every message reachable by this change was recovered. Closing the rest requires Must to gain a declarative counterpart, which is a language question, not more message recovery.

Verification

  • Release build with a file logger across 3 TFMs: 0 errors, 0 warnings.
  • 1121 specs (1106 before, +15). Five confirmed to fail against pre-fix code; the perimeter facts — composed message stays dropped, unwritable key stays dropped, plain constant still recovers — pass either way, correctly, since they guard unchanged behaviour.
  • End-to-end gate: exit 0, reads back clean.
  • Reference application, measured back-to-back: SP0016 643 → 510, both runs read back clean, and the document goes from 0 to 121 lines carrying message $strings.… across 10 resource classes.
  • Determinism: byte-identical across consecutive runs.

Caveat: that application is under active concurrent edit — a trial run before the measurement pair showed a different baseline entirely (9,164 lines vs 9,209). The reported pair ran back-to-back in one shell invocation, and every non-SP0016 count is identical between them, which is what isolates the delta to this change rather than to the moving source.

@woksin woksin changed the title Write the key a validation message is looked up by Recover more of an application, describe it across projects, and report what cannot be recovered Jul 29, 2026
@woksin
woksin changed the base branch from feat/screenplay-specifications to main July 29, 2026 07:53
@woksin

woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Consolidated reviewer context for the whole change. This PR supersedes #2408, #2410 and #2411; all 34 commits are here.

This is agent-authored code that no human has read yet. Green CI is not sign-off. Do not merge without explicit review.

Where to look first

The SP0024 / SP0034 suppression interaction. SP0024 (source did not compile) previously suppressed SP0034 (the generator reading its own printed document back) unconditionally. It is now narrowed to the Error branch. As a Warning, SP0024 says the model stands — and suppressing SP0034 there would hand back a document the Screenplay compiler rejects while reporting IsSuccess == true, the one outcome SP0034 exists to prevent. Two changes interacting in a way neither issue anticipated.

The end-to-end CI gate (Source/DotNET/Screenplay.EndToEnd/) is the most valuable thing here and deliberately is not in the release notes. It generates from TestApps/AspNetCore and compiles the result with the Cratis.Screenplay compiler, failing on any compiler diagnostic including warnings. Note that because the workflow triggers on pull_request_target, a job added in a PR runs from the base branch and cannot execute on its own PR — it was proven by dispatching the workflow against the branch directly, where it generated 53 lines and reported "reads back clean" on a runner.

Specification volume. The document roughly doubles: 5,348 → 9,263 lines on the reference application, of which ~3,900 are the 507 recovered specifications. That is ~7 lines per scenario rather than the explosion feared, because most values are runtime identities and get dropped. No option to disable was added, on the reasoning that if a larger application does struggle, the right lever is a per-slice filter driven by a real complaint rather than a global switch added on speculation. Whether 507 scenarios belong in a document meant to be read is a judgment left to you.

Four of the issues rested on wrong evidence

All checked against source; corrections posted to the issues themselves.

And one estimate of mine was wrong: #2409 predicted SP0016 would fall by ~247; it falls by 133. Every readable message was recovered — but ~285 attach to Must/MustAsync and friends, which have no declarative counterpart, so the rule is dropped and the message has nothing to attach to. Closing those is a language question.

Defects found that no issue described

  • A conditional given was read as unconditional, stating a world nobody specified. Cost 29 specifications to fix; a document that says less and is true.
  • A command reaching an aggregate root in another project crashed with SyntaxTree is not part of the compilation — already reachable on the single-project path.
  • Two parallel branches claimed SP0037/SP0038 for different meanings. Diagnostic codes are a consumer-facing contract that is never reused, so this was resolved before merge: multi-project keeps them, specifications took SP0039/SP0040.

Decisions taken against the issues, deliberately

Diagnostics on the reference application

Code Plan baseline After
SP0011 6 2 property-reference operands emitted
SP0016 643 510 resource keys recovered
SP0021 1 0 imported, and gone entirely under multi-project
SP0024 1 0 host supplies build-generated compile items
SP0025 3 0 obj/ folders excluded from slice attribution
SP0031 0 1 newly reachable enum name collision, reported
SP0035/36 0 13 / 29 undeclarable record shapes; cross-slice bindings previously silent
SP0039/40 0 136 / 1554 unreadable scenarios and scenario values, previously not attempted

Verification

  • Full Arc.slnx clean Release build: 0 errors, 1 pre-existing CS0436 in TestApps/Chronicle that reproduces on main.
  • 1121 specs, from 690 when this line of work began. Each phase's additions were confirmed to fail against pre-fix code by reverting and re-running.
  • Full Arc suite passes; two Arc.Core.Specs failures per full run are the known parallel flake — 1552/1552 isolated.
  • Reference application, whole solution: reads back clean under the official Screenplay compiler, 0 import lines, 229–230 file paths all relative and rooted per project, byte-identical across consecutive runs.

Caveat on every measurement above: that application is under active concurrent edit — absolute line counts drift by tens of lines between runs. Every before/after pair was taken back-to-back in one shell invocation, which is what isolates a delta to the change rather than to the moving source.

@woksin

woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

One addition to the reviewer notes above, on the weakest content in the generated document.

347 of the 507 recovered specifications end in then error "" — an empty reason. That is roughly two thirds of them, and many are as thin as:

specification WhenExchangingAndMagicLinkIsNotActive
  when ExchangeToken
  then error ""

This is forced by the grammar, not a shortcut: then error requires a StringLiteral (grammar.md:299, and the parser regex requires the quotes). The source asserts only IsSuccess.ShouldBeFalse(), which names no reason, and inventing "rejected" or "validation failed" would put a sentence in the document that the application never states.

Filed upstream as Cratis/Screenplay#46, proposing a bare then error meaning "rejected, for a reason the specification does not name" — keeping then error "…" for the case where a reason genuinely is named. Once that lands, these 347 lines become honest rather than blank-looking, with no change to what is recovered.

Until then the options are to keep them as they are, or to skip scenarios whose only assertion is an unnamed failure — a one-line change that would drop those 347 from the document. Kept as they are, on the grounds that the scenario name carries the meaning and "it fails" is true; happy to switch if you would rather the document did not carry them.

The page had fallen behind the generator, and one row of it had become
false: model-bound children and nested objects are read back, so a reader
was being told to reach for the fluent form to get something the
attributes already give. What is left of that row is narrower and worth
saying precisely - a removal is read from the type of the child rather
than from the property holding the collection, and nothing carries a
scope being emptied again at all.

Checking the rest of that table against the source turned up two more
things it was wrong about. The rationale for read model tags was
transport, which is not why they are absent - a read model has no
declaration to hang one on, and event tags are recovered and printed.
And the table claimed every row is reported, while paging, sorting,
route templates and read model tags pass without a word; each row that
does report now names its code so the exception is visible rather than
asserted.

Three capabilities went undocumented. Scenarios recovered from a slice's
own specifications are the largest thing the document gained and belong
beside Screens, as the other construct read from outside the slice file.
Several projects read as one application is a statement about what the
generator is handed, so it sits with the rest of the host contract.
Resource keys are one instance of a discipline the page never stated -
that a value is only ever what the source states - and stating it once
keeps the scenarios section from having to repeat it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +124 to +134
foreach (var assembly in compilations
.SelectMany(_ => _.SourceModule.ReferencedAssemblySymbols)
.Where(_ => !analyzed.Contains(_.Identity.Name))
.DistinctBy(_ => _.Identity.GetDisplayName(), StringComparer.Ordinal)
.OrderBy(_ => _.Identity.GetDisplayName(), StringComparer.Ordinal))
{
if (names.Any(assembly.TypeNames.Contains))
{
Collect(assembly.GlobalNamespace, names, found);
}
}
Comment on lines +32 to +38
foreach (var compilation in compilations)
{
if (compilation.ContainsSyntaxTree(tree))
{
return compilation.GetSemanticModel(tree);
}
}
Comment on lines +70 to +76
foreach (var assignment in creation.Initializer?.Expressions.OfType<AssignmentExpressionSyntax>() ?? [])
{
if (assignment.Left is IdentifierNameSyntax identifier)
{
yield return (identifier.Identifier.ValueText, assignment.Right);
}
}
Comment on lines +94 to +102
foreach (var project in solution.Projects
.Where(_ => !IsSpecifications(_.Name))
.OrderBy(_ => _.Name, StringComparer.Ordinal))
{
if (await project.GetCompilationAsync() is { } compilation)
{
compilations.Add(compilation);
}
}
Comment on lines +67 to +78
foreach (var property in type.DeclaredProperties())
{
var carried = UnderlyingTypes.Of(property.Type);
var name = carried.ToDisplayString();

found.TryAdd(name, carried);

if (IsRecord(carried) && walked.Add(name))
{
Walk(carried, found, walked);
}
}
@woksin

woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #2418, which now targets main directly and carries all 38 commits — everything here plus the end-to-end gate hardening — as one change with combined release notes.

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

Closing so there is one Arc PR to review rather than two that have to be merged in order.

@woksin woksin closed this Jul 29, 2026
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