Skip to content

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

Merged
woksin merged 39 commits into
mainfrom
feat/screenplay-gate-hardening
Jul 29, 2026
Merged

Recover more of an application, describe it across projects, and report what cannot be recovered#2418
woksin merged 39 commits into
mainfrom
feat/screenplay-gate-hardening

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 8 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>
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>
The check generated the document of a fifty three line application, which
holds none of the shapes every defect in this line of work was really found
on: a property named after a directive, a constant of an enumeration, a name
written apart, and the scenarios a slice is specified by. A gate that cannot
fail on any of those protects nothing.

The Arc application now carries a command whose properties are called
Description and Concurrency, and a concept whose name carries a separator. The
Chronicle application carries an event whose property is called Tag and holds
several values, a projection setting a property to a constant of an
enumeration, and two scenarios written where a slice is specified - beside the
source it specifies rather than in a project of its own. Both documents are
generated and read back, because they are two applications rather than one.

Each is handled correctly today, so the check still passes; what changes is
that it now fails when any of them stops being. Removing description or
concurrency from the words a command body reserves, or tag from an event body,
each has the language reject the document outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a real application SP0040 fired 1554 times out of 2666 diagnostics - over
half of everything reported, and nearly all of it saying that a scenario rested
an identity on something made at run time. That is not a gap. A fresh identity
has no value to state, so a document leaving one out says exactly as much as
the source does, and per-occurrence reports of it bury the values a reader
could act on.

Recognizing it is chosen over aggregating per scenario because the difference
is real rather than presentational: aggregating would count a non-difference
along with the differences, and reporting per value is what lets a reader count
what a scenario states against what the source states.

What counts as one is narrow. A static method taking nothing and handing back
the type it is declared on has nothing to derive a value from; narrowing to a
Guid and to the concepts wrapping one value keeps it to the types an identity
is written as. It is followed back through the field a scenario holds it in,
because two steps agreeing on one identity is why it is held rather than
written twice. An identity derived from something - TimesheetId.For(engagement,
year, month) - is a value the source really states and is still reported.

The same application now reports 912, with the document byte for byte
unchanged. What is left is values the reader could inline: a date, a list of
strings, a sentinel, a field holding an organization number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which projects of a solution are the application was decided by their names, so
a Roslyn analyzer sitting beside it was read in as one of them - a
netstandard2.0 compilation described as part of an application it only builds.
It contributes nothing to the document and reports that the source it was read
from did not compile, which is true of it and says nothing about the
application.

Every artifact is declared with an attribute the framework ships, so a project
resolving neither the Arc nor the Chronicle one cannot declare a single thing
the document is made of. That is asked first, of what a project can see; the
name is asked second and only of the specifications, because a specification
project references the same framework the application does and nothing else
tells the two apart.

On this solution it turns away ten projects and takes four of the six reports
that the source did not compile with them, leaving the document byte for byte
what it was. The workspace still warns about a project reference with no
matching metadata reference: that is reported while the solution is opened,
before anything has been chosen, and no filter here can reach it.

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 #2413; deliberately kept as its own PR rather than folded into it, because #2413 is already 35 commits and 146 files.

Why this exists

The end-to-end gate generated from a 53-line document. Every defect that mattered in this line of work was found on a real 9,000-line application, not there: the Description collision, the silently absorbed Tag property, enum constants printed as integers, path roots anchored on the wrong directory, a conditional given read as unconditional. A gate that cannot fail on any of those is closer to decoration than protection.

TestApps now carries those shapes, and CI reads back both applications rather than one.

The gate was proven able to fail, not assumed to be

Three deliberate breakages, each a one-line removal from ReservedWords, rebuilt and restored:

Shape Broken Restored
description exit 1Invalid description 'description String' exit 0
concurrency exit 1Invalid concurrency declaration 'concurrency Int' exit 0
tag exit 1Invalid tag value 'ApprovalLabel[]' exit 0

The Tag fixture is a collection on purpose: a swallowed tag X[] is rejected by the language, whereas tag X compiles silently — so the collection form is the one that can move an exit code.

What the gate still cannot catch, stated plainly

The enum-as-integer and undeclared-concept regressions do not move the exit code. The shipped Screenplay compiler was probed directly: set status to 6 where the concept declares pending/granted/declined produces no diagnostic, and a reference to a concept the document never declares produces no diagnostic. Both shapes are in the fixture, so a regression shows up as a changed document and changed diagnostics that a reviewer can see — but CI stays green. Closing that needs a committed golden document, which is a larger change and was not made.

A correction to the premise I was given

The diagnostic firing 1554 times is SP0040 (UnreadableSpecificationValue), not SP0038 (ProjectsWithoutASharedRoot). The description identified it; the number did not.

Decision: recognise a freshly generated identity and do not report it — rather than aggregate. Per-value reporting is what lets a reader count what a scenario states against what the source states; aggregating would fold a non-difference in with the real ones instead of removing it. A fresh identity has nothing to state, so leaving it out says exactly as much as the source does.

Recognition is narrow: a static, parameterless method named New/NewGuid returning the type that declares it, where that type is System.Guid or derives from ConceptAs<T> — a method with no input has nothing to derive a value from. It follows an identifier back through the field initializer a scenario holds an identity in, since two steps agreeing on one identity is why it is held rather than written twice.

On the reference application: SP0040 1554 → 912, document byte-identical. The remaining 912 are different in kind and should stay — new DateOnly(2026, 9, 1), ["5+ years C#"], string.Empty, OrganizationNumber.NotSet, and derived identities such as TimesheetId.For(engagementId, year, month), which is a value the source really does state.

Project filtering

The harness filtered candidate projects by name, so a netstandard2.0 analyzer project loaded as an application project. It now asks whether a compilation can resolve CommandAttribute or EventTypeAttribute — a project that resolves neither cannot declare anything a document is made of. On Arc.slnx that turns away 10 projects and takes 4 of the 6 SP0024 reports with them; document byte-identical.

Misses: a host project that sees the framework but declares nothing is still read and contributes an empty model. The specification-project name filter was kept — nothing about what a specs project can see distinguishes it, since referencing the framework is the point of it, and replacing that check would pull fixture commands and events into the document as real artifacts.

Verification

  • Release build 0 errors / 0 warnings; full Arc.slnx clean Release 0 errors, 1 pre-existing CS0436.
  • 1132 specs (1121 before, +11). The two fix-proving assertions were confirmed to fail against pre-fix code; the third is a perimeter guard against over-broad recognition and passes either way by construction.
  • Gate on TestApps/AspNetCore: exit 0, 64 lines (was 53), SP0032 x2 — the two new collisions.
  • Gate on TestApps/Chronicle: exit 0, 75 lines (was 21), including two recovered specification blocks.
  • Determinism: two consecutive runs on each, byte-identical.

Disclosure

Three commits were amended once before pushing: the full Release solution build turned up NU1201 from the new project reference in commit 1, so commits 2 and 3 were saved as patches, reset to commit 1, the TargetFrameworks fix amended in, and the patches re-applied — so each commit builds on its own. Nothing had been published at the time; no rebase onto another branch, no force-push.

@woksin woksin changed the title Hold the generator to the defects it has already shipped 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-strings to main July 29, 2026 10:52
@woksin

woksin commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Consolidated reviewer context for the whole change. This PR supersedes #2408, #2410, #2411 and #2413; all 38 commits are here, across 160 files.

This is agent-authored code that no human has read yet. Green CI is not sign-off. Do not merge without explicit review. It is large — the reviewing order below is meant to make that tractable.

Read these first

1. 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.

2. The end-to-end gate, and what it still cannot catch. Source/DotNET/Screenplay.EndToEnd/ generates from TestApps/AspNetCore and TestApps/Chronicle and compiles both, failing on any compiler diagnostic including warnings. It was proven able to fail rather than assumed to be — three deliberate breakages, each exiting 1 and returning to 0 on restore:

Shape Broken Restored
description exit 1 — Invalid description 'description String' exit 0
concurrency exit 1 — Invalid concurrency declaration 'concurrency Int' exit 0
tag exit 1 — Invalid tag value 'ApprovalLabel[]' exit 0

But the enum-as-integer and undeclared-concept regressions do not move the exit code. The shipped Screenplay compiler was probed directly: neither produces a diagnostic. Those shapes are in the fixture, so a regression shows as a changed document a reviewer can see, but CI stays green. Closing that needs a committed golden document, deliberately not bolted on here.

Note also 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. The gate was proven on a runner by dispatching the workflow against the branch directly.

3. Specification volume. The document roughly doubles — 5,348 → 9,263 lines on the reference application, ~3,900 of it the 507 recovered specifications, about seven lines each. No option to disable was added, on the reasoning that if a larger application struggles, the right lever is a per-slice filter driven by a real complaint rather than a global switch on speculation. Whether 507 scenarios belong in a document meant to be read is a judgment left to you.

Related: 347 of those 507 end in then error "". That is forced by the grammar — then error requires a StringLiteral — and the source asserts only that something was rejected, without naming a reason. Inventing one would state something the application never says. Filed upstream as Cratis/Screenplay#46.

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, which have no declarative counterpart — filed as Cratis/Screenplay#47.

Defects found that no issue described

Deliberate decisions against the issues

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
SP0035/36 0 13 / 29 undeclarable record shapes; cross-slice bindings previously silent
SP0039/40 0 136 / 912 unreadable scenarios and values, previously not attempted

Verification

  • Full Arc.slnx clean Release: 0 errors, 1 pre-existing CS0436 in TestApps/Chronicle that reproduces on main.
  • 1132 specs, from 690 when this 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, all file paths relative and rooted per project, byte-identical across consecutive runs.
  • Documentation/verify-markdown.sh passes — though its link step scans zero links, filed as verify-markdown.sh reports success while scanning zero links #2416.

Caveat on every measurement: that application is under active concurrent edit; absolute counts drift 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 merged commit 8228ee5 into main Jul 29, 2026
48 of 51 checks 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.

1 participant