Skip to content

Describe an application split across several projects as one document - #2410

Closed
woksin wants to merge 6 commits into
feat/screenplay-phase2from
feat/screenplay-multiproject
Closed

Describe an application split across several projects as one document#2410
woksin wants to merge 6 commits into
feat/screenplay-phase2from
feat/screenplay-multiproject

Conversation

@woksin

@woksin woksin commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Added

Fixed

woksin and others added 6 commits July 29, 2026 01:05
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>
@woksin woksin added the patch label Jul 28, 2026
@woksin

woksin commented Jul 28, 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 #2408; it must merge after.

API

ScreenplayGenerationResult Generate(IReadOnlyList<Compilation> compilations, ScreenplayOptions options);
ApplicationModelAnalysis Analyze(IReadOnlyList<Compilation> compilations, ScreenplayOptions options);

The single-Compilation overloads keep their signatures and delegate to a list of one, so there is a single code path rather than two that can drift. Published surface unchanged.

Ordering. Compilations are sorted by assembly name, tie-broken by the ordinally-first source file path, before anything is read — caller order never reaches the model. What projects genuinely share is unified (one type registry, one aggregate-root catalogue, one cross-slice query set, one diagnostics sequence); everything reading symbols of a single compilation stays per project.

The four judgment calls

  • A namespace two projects declare into is one slice, not two. A contracts project publishing a slice's events beside the project handling its commands is the ordinary layered shape, and two slices of one name would be a document saying the same word twice. Name collisions within the joined slice keep the first and report SP0037.
  • SegmentsToSkip stays one value for the whole application. A per-assembly value would need a dictionary keyed on assembly names that a caller configuring it has no reason to know, and the projects of one application share a namespace root.
  • Path roots are resolved per project and then written relative to the directory the projects share, so paths read Core/Features/… and Lobby/… rather than two files both printing Features/…. For a single project this is identical to today. Where projects share nothing but the file-system root, each keeps its own root — never absolute — and that is reported as SP0038. Path roots were a defect once before, so this was checked directly: every file line on the real solution is relative.
  • Screens stay per slice, but cross-slice query detection is now application-wide, so a screen reading through a query a sibling project declares is reported rather than dropped.

A latent bug found on the way

A command handing work to an aggregate root in another project crashed with ArgumentException: SyntaxTree is not part of the compilation. This was already reachable on the single-project path, because a workspace loads project references as compilations. A new SemanticModels resolves a tree through whichever project wrote it; a tree in no supplied compilation is reported rather than silently dropped.

Verification

  • Release build: 0 errors, 0 warnings. All five commits were exported and built independently: 7 projects, 0/0 each.
  • 1049 specs (973 before, +76). Because every new spec needs the new API, load-bearingness was verified by reverting each piece of logic individually — the report lists which specs fail for each revert, covering slice union, shared source root, shared type registry, cross-project aggregates, cross-project policies, and compilation ordering.
  • Two-project hermetic fixture with a real CompilationReference: one document, zero Screenplay-compiler diagnostics, no SP0021, the shared concept declared once. A second fixture proves both orderings give byte-identical text.
  • End-to-end gate: exit 0, reads back clean, diagnostics unchanged.
  • Real solution, 6 non-spec projects: exit 0, 5,639 lines, reads back clean. The import Lobby.Invitations.InvitationToJoinAdaAccepted present in the single-project document is gone, replaced by a real feature Lobby with Publishing/Receiving slices declaring the event — which is the point of the change.

Two honest caveats

The single-project regression check is parent-vs-mine under identical environment, not against the published baseline. The first run reproduced the stated 5530 lines / 1100 diagnostics exactly; every run afterwards — including runs of the unmodified parent code, confirmed by stashing — gives 5527 / 1108, because that application's design-time build mutated its own gitignored obj/ on first load. Byte-identity between parent and this branch was verified twice with cmp under the same state.

ScreenplayProjectSelection in the CLI is not done. Handing all non-spec projects of a solution to the generator still errors on ambiguity; that lives in Cratis/cli with an open PR against it and was out of scope here. The harness change in the last commit teaches the test harness to accept a solution — it is not the CLI and does not substitute for that work.

@woksin

woksin commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Independent verification of the multi-project result, run separately from the work itself.

Generated from the reference application's full solution (Ada.slnx) with this branch: exit 0, 5,641 lines, "The generated document reads back clean" under the Screenplay compiler.

  • 0 file lines that are absolute or point into obj/ — path roots were a real defect once, and they hold under the harder multi-project case.
  • Paths root at Core/ (224) and Lobby/ (5), correctly disambiguated per project rather than both printing Features/….
  • 0 import lines — the dangling InvitationToJoinAdaAccepted import present in the single-project document is gone.
  • feature Lobby is present, with the event declared by a real slice rather than referenced from outside.

Line count differs slightly from the figure in the report above (5,641 vs 5,639) because that application's working tree is being edited concurrently; it is drift in the source, not in the generator.

CI on this PR is fully green: 43 passing, 0 failing, 45 checks.

@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