diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml
index 9db62aeaa..763239ee4 100644
--- a/.github/workflows/dotnet-build.yml
+++ b/.github/workflows/dotnet-build.yml
@@ -176,6 +176,57 @@ jobs:
- name: Test (${{ matrix.project }})
run: dotnet test ${{ matrix.project }} --no-build --configuration Release --framework net10.0
+ # Every Screenplay specification builds its compilation from source strings, which is what makes them hermetic -
+ # and what puts a whole 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. This reads a real project through MSBuild and holds the document it generates to the compiler
+ # the language ships - it has to come back with nothing to say, warnings included.
+ screenplay-end-to-end:
+ needs: [dotnet-build]
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
+
+ - name: Setup .Net
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ ${{ env.DOTNET8_VERSION }}
+ ${{ env.DOTNET9_VERSION }}
+ ${{ env.DOTNET10_VERSION }}
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v6
+ with:
+ path: ~/.nuget/packages
+ key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.*.props', '**/Directory.*.targets') }}
+ restore-keys: |
+ nuget-${{ runner.os }}-
+
+ - name: Download build output
+ uses: actions/download-artifact@v4
+ with:
+ name: build-output
+
+ - name: Extract build output
+ run: tar -xzf build-output.tar.gz
+
+ # The build output artifact carries bin folders only, and reading a project through MSBuild needs the assets
+ # file that restore writes to obj.
+ - name: Restore the application to generate from
+ run: dotnet restore TestApps/AspNetCore/AspNetCore.csproj
+
+ - name: Generate and read back the document of a real application
+ run: |
+ dotnet Source/DotNET/Screenplay.EndToEnd/bin/Release/net10.0/Cratis.Arc.Screenplay.EndToEnd.dll \
+ TestApps/AspNetCore/AspNetCore.csproj \
+ "${RUNNER_TEMP}/AspNetCore.play"
+
proxy-specs:
needs: [dotnet-build]
runs-on: ubuntu-latest
diff --git a/Arc.slnx b/Arc.slnx
index 67d6a64e7..5bdfa91d1 100644
--- a/Arc.slnx
+++ b/Arc.slnx
@@ -31,6 +31,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b62c03507..690fc5ebb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -67,6 +67,12 @@
+
+
+
+
diff --git a/Documentation/generating-a-screenplay.md b/Documentation/generating-a-screenplay.md
index 021813dd3..cf553c09b 100644
--- a/Documentation/generating-a-screenplay.md
+++ b/Documentation/generating-a-screenplay.md
@@ -35,7 +35,7 @@ The generator is used through the Cratis CLI, which ships separately from Arc:
cratis screenplay generate ./MyApp/MyApp.csproj --file MyApp.play
```
-Point it at a project or a solution. Anything the generator could not express is reported on the way out, and a run that reports an error exits non-zero — so a broken build never quietly produces a document that looks fine.
+Point it at a project or a solution. A solution is one application rather than several, so its projects are read together into a single document — see [an application written as several projects](#an-application-written-as-several-projects). Anything the generator could not express is reported on the way out, and a run that reports an error exits non-zero — so a document nothing trustworthy went into never quietly looks fine. What the CLI has to do before it hands the source over is covered in [what the generator expects of its host](#what-the-generator-expects-of-its-host).
Because output is reproducible, a CI step can regenerate and fail when the committed `.play` no longer matches the source.
@@ -48,11 +48,73 @@ Warning SP0019: The query 'Raw' returns 'IActionResult', which says how the resu
transported rather than what it is, so the query was left out (Library.Messaging.Feed)
```
-Diagnostics come in three severities. **Information** means something is worth knowing but the document is complete. **Warning** means something was left out. **Error** means the document should not be trusted at all — the most common cause being source that did not compile.
+Diagnostics come in three severities. **Information** means something is worth knowing but the document is complete. **Warning** means something was left out. **Error** means the document should not be trusted at all — either because the generator produced something the language rejects, or because nothing at all was recovered from source the compiler accepted.
+
+## What the generator expects of its host
+
+The generator takes a Roslyn `Compilation` and nothing else. It never opens a project file or loads a workspace, which is what lets it be driven from a CLI, from a specification, or from an editor. The other side of that bargain is that **assembling the compilation the way a real build would is the caller's job** — the generator reads what it is handed and cannot tell a missing type from a type that was never written.
+
+The part hosts get wrong is source generators. `MSBuildWorkspace.GetCompilationAsync()` does not run them, and neither does any loading mode that stops at the compile items the project file lists. An Arc application leans on generation heavily — `[LoggerMessage]` partial classes, strongly-typed resource designer classes, the proxy and metrics generators — so a compilation loaded that way is missing every type those emit, and every reference to one becomes an unresolved-symbol error. This is the common case rather than an edge case.
+
+A host should run the project's generators before handing the compilation over:
+
+```csharp
+var driver = CSharpGeneratorDriver.Create(
+ generators: project.AnalyzerReferences
+ .SelectMany(reference => reference.GetGenerators(LanguageNames.CSharp))
+ .Select(GeneratorExtensions.AsSourceGenerator),
+ parseOptions: (CSharpParseOptions)project.ParseOptions!);
+
+driver.RunGeneratorsAndUpdateCompilation(compilation, out var generated, out _);
+```
+
+Then generate from `generated` rather than from `compilation`.
+
+### What happens when it does not
+
+Nothing is hidden, and nothing correct is thrown away. A compilation carrying errors is still analyzed, and `SP0024` says what happened — how many errors there were, the first of them, and how many artifacts came through anyway:
+
+```text
+Warning SP0024: The source did not compile - 607 error(s), the first being 'The name
+'AccountsMessages' does not exist in the current context'. 341 artifact(s) were recovered
+anyway, 341 of them from a declaration no error sits inside, so the document describes those
+exactly as the source states them - a missing type named like 'SomethingMessages' or a
+designer class usually means the compilation was handed over without the compile items a
+build generates (Accounts)
+```
+
+Its **severity is decided rather than fixed**, because "the source did not compile" covers two outcomes that could not be further apart:
+
+- **Warning** when at least one artifact was recovered from a declaration no compilation error sits inside. Those artifacts are described exactly as their source states them whatever failed elsewhere, so the run is successful and the document is worth keeping. A compilation missing its generated symbols lands here — the errors sit in code that declares no artifact, and the model is unaffected.
+- **Error** when none were, either because nothing was recovered at all or because every declaration something came out of is one the compiler could not make sense of. There is then no part of the document a reader could trust, and a host following the contract exits non-zero.
+
+A count is used rather than a proportion deliberately: any threshold would make the same recovery pass for a large application and fail for a small one, and zero is the only number that means recovery was *prevented* rather than merely dented.
+
+Either way the document is written out, so what was recovered can be read.
+
+### An application written as several projects
+
+Nothing says an application is one project. A layered one puts its contracts in a project of their own; a host sitting beside the bounded contexts it serves is two projects at least — and no single one of them describes the application. Pointed at any one, the generator would describe half of it and refer to events it never introduced.
+
+So `Generate` takes a list of compilations as well as a single one, and a host generating from a solution hands over one per project:
+
+```csharp
+var result = generator.Generate([contracts, application, host], options);
+```
+
+What comes back is one document rather than one per project, because the boundaries between projects are a build concern rather than something the model has:
+
+- A namespace two projects declare into is **one slice**, whichever project each artifact sits in.
+- A concept is **declared once**, however many projects refer to it.
+- An event a sibling project declares is one the application **has**, so it is declared like anything else — not imported the way an event from a referenced package outside the application is.
+
+Paths stay readable because they are written relative to the directory all the projects sit under rather than to each project's own root, so every path opens with the project it belongs to — `Library/Shipping/Dispatching/Dispatching.cs` beside `Library.Contracts/Ordering/Placing/Placing.cs`. Projects checked out in unrelated places share no such directory; each one's paths then fall back to its own root, and `SP0038` says so, because two files can then come out as the same path.
+
+The order the projects arrive in never reaches the document. Nothing decides what order a host enumerates a solution in, so they are sorted by assembly name before anything is read and the same projects always print the same bytes. Where that order has to decide something it says so: two projects declaring the same artifact name into one slice keep the first and report `SP0037`.
## The generator checks its own output
-Every diagnostic above names something *your application* declared that the language cannot hold. There is one that names a defect in the generator instead.
+Every diagnostic above names something about *your application* — a construct the language cannot hold, source that did not compile, projects that share no directory. There is one that names a defect in the generator instead.
After the document is written, the generator hands it straight back to the Screenplay compiler. If the compiler rejects it, `SP0034` is reported as an error — because a `.play` that does not compile is output nobody can use, and there is no way of writing an application that avoids it. This is not a mode you turn on: it runs on every generation, since the only way a rejected document is ever found is by reading each one back.
@@ -65,7 +127,72 @@ and the document is returned as it stands so the line can be read (Library)
The document is still written out, so you can open it at the reported line and see what happened. If you hit this, it is a bug worth [reporting](https://github.com/Cratis/Arc/issues) — include the line, and the C# declaration it came from.
-Source that did not compile (`SP0024`) suppresses `SP0034`. A model recovered from symbols the compiler never accepted describes an application that does not exist, so a poor document made from it is a consequence of the broken build rather than a second, separate defect. Fix the build and generate again.
+Source that did not compile (`SP0024`) suppresses `SP0034` **when it is reported as an error** — a model recovered from symbols the compiler never accepted describes an application that does not exist, so a poor document made from it is a consequence of the broken build rather than a second, separate defect. Fix the build and generate again.
+
+As a warning it suppresses nothing. That severity says the model stands, and a document built from a model that stands is exactly what the check exists for — suppressing it there would hand back a `.play` the language rejects with nothing wrong reported.
+
+## A value is only ever what the source states
+
+Wherever the document states a value — the mapping a command's `produces` writes into an event, the values a scenario is issued with, the message a validation rule fails with — it states what the source states and nothing beyond it. Two sources survive: a constant the compiler already holds, and a path into the command's own input. Anything arrived at while the request runs is left out and reported, because a guessed value is worse than an absent one.
+
+Messages are where that bites. An application speaking more than one language declares each message once in a resource and names it from the validator, and the property it names resolves its text against the caller's culture — so there is no text for the compiler to hold, and no single text the document could honestly state.
+
+The key is there to be read even though the text is not, and it is the better of the two to take: text would settle the document on one language the application itself never settled on. So the key is what is written, unresolved:
+
+```text
+command RequestBook
+ title String
+ validate
+ title not empty message $strings.RequestMessages.TitleRequired
+```
+
+A key is qualified by the class declaring it, because a key is unique to its own resource and to nothing wider. Two areas of one application both requiring an organization number is ordinary rather than a mistake, and bare, those two would be one key that can carry only one text.
+
+A message genuinely put together in code — `string.Format`, an interpolation — still has no text to write down, and is reported as `SP0016` rather than guessed at. So is a key the language has no way of writing: a reference is a path of bare words, and a resource key is under no such constraint.
+
+## The scenarios a slice is specified by
+
+A `.play` says what a slice does. The Chronicle integration specs in the folder beneath it already say the same thing by example — what had happened, the command that was issued, what followed — which is exactly the shape of a Screenplay `specification`. So they are read too, and the document carries the examples proving the model rather than only the model:
+
+```text
+slice StateChange Registration
+
+ command RegisterAuthor
+ name String
+ produces AuthorRegistered
+ name = name
+
+ event AuthorRegistered
+ name String
+
+ specification WhenRegisteringAndTheNameIsTaken
+ given AuthorRegistered
+ name = "Jane Austen"
+ given readmodel Author
+ id = "author"
+ name = "Jane Austen"
+ when RegisterAuthor
+ name = "Jane Austen"
+ then error "unique-author-name"
+```
+
+- **`given`** is what the specification started from — the events it seeded, and the read model it pinned.
+- **`when`** is the command it executed.
+- **`then`** is each event it asserted was appended, and **`then error`** a rejection it asserted.
+
+Both shapes Arc documents are read: the in-process one driving the pipeline through a scenario (`Scenario.Given…`, `Scenario.Execute`) and the one driving a running host (`EventLog.Append`, `Client.ExecuteCommand`). Which calls are which is decided by the type each one sits on, so neither testing package has to be referenced for either to be read.
+
+A rejection the source asserts without naming a reason is written as `then error ""`. The grammar requires a string there, the source gives none, and inventing one would put a sentence in the document the application never states. [Cratis/Screenplay#46](https://github.com/Cratis/Screenplay/issues/46) proposes a bare `then error` for exactly this.
+
+Expect the document to grow. On a real application this roughly doubled it, at about seven lines per scenario.
+
+### A scenario is read whole or not at all
+
+A mapping stands on its own, so one that cannot be read can be left out while the rest of its block still says something true. A scenario cannot: an example missing the state it started from, the command it issued, or the outcome it expected is not that example — it is a different one nobody wrote. So a step that cannot be read takes the whole scenario with it, and `SP0039` names the scenario and what made it unreadable.
+
+Conditional steps are the common case. Anything written under an `if`, a `switch`, a loop, a ternary or a lambda happened in some runs of the specification and not in others, and the source text does not say which — so a step recovered as unconditional would state a world nobody specified. That is the one failure mode a reader has no way of catching, which is why it is reported rather than guessed at.
+
+Values are the exception, because a value stands on its own the way a mapping does. They follow [the discipline every other value follows](#a-value-is-only-ever-what-the-source-states): the identity two steps agree on is routinely a fresh identifier held in a field rather than something written down, and such a value is reported on its own while the rest of the scenario stands.
## Screens
@@ -108,23 +235,24 @@ These are part of the language, but nothing in C# says them, so a generated docu
| `capture` | Describes ingesting an external system. Nothing in an Arc application declares one. |
| `persona` | Who uses the system is a product decision, not a code artifact. |
| `seed` | Sample data is a modeling concern, not something the source states. |
-| The widgets of a `screen` — `title`, `section`, `table`, `summary`, `action`, `navigate to`, `layout` | What a screen *shows and does* is JSX. Its `file` reference and its `data` bindings are generated; the rest would be a guess — see [Screens](#screens). |
-| `@sensitive` | Only `[PII]` has a counterpart. Other sensitivity levels are not declared in Arc. |
+| The declarative body of a `screen` — `title`, `section`, `table`, `summary`, `action`, `navigate to`, `layout` | What a screen *shows and does* is JSX. Its `file` reference and its `data` bindings are generated; the rest would be a guess — see [Screens](#screens). |
+| `@sensitive` | `@pii` is the one of the two concept attributes with a counterpart — `[PII]`. Nothing in Arc or Chronicle says `@sensitive`. |
### Detail Screenplay cannot represent
-These exist in your application and have no counterpart in the language. Each is reported as a diagnostic when encountered, so the document tells you it is silent about them:
+These exist in your application and do not reach the document — most of them because the language has no counterpart. Almost all are reported as a diagnostic when encountered, so the document tells you it is silent about them. The two rows naming no code are the exception: nothing is reported for those.
| In Arc or Chronicle | Why it is not in the document |
|---|---|
-| Event generations, `[Tombstone]`, `[CompensationFor]` | Screenplay describes the current shape of an event. It has no notion of versioning, of a deletion marker, or of one event compensating another. |
-| Reducer folds (`IReducerFor`) | The fold is code. The read model and the events it observes are recovered; the logic that combines them is not. |
-| Aggregate roots no command reaches | The events an aggregate root applies are stated through the command that hands its work to it. One that nothing calls has nothing to state them through — a document has no construct for a class that decides on its own. |
+| Event generations, `[Tombstone]`, `[CompensationFor]` | Screenplay describes the current shape of an event. It has no notion of versioning, of a deletion marker, or of one event compensating another. `SP0014`. |
+| Reducer folds (`IReducerFor`) | The fold is code. The read model and the events it observes are recovered; the logic that combines them is not. `SP0020`. |
+| Aggregate roots no command reaches | The events an aggregate root applies are stated through the command that hands its work to it. One that nothing calls has nothing to state them through — a document has no construct for a class that decides on its own. `SP0018`. |
| A behavior deciding on the state an aggregate root holds | A `produces when` condition compares the input of the command, which is all a document knows at the moment the command is issued. A behavior refusing to act on what it has already seen is a real decision with nowhere to go, so the event is stated unconditionally and `SP0027` reports the decision. A behavior deciding on one of its own *parameters* is recovered, because the call site says which command input that parameter was given. |
-| Inline `policy` code and requirements built in code | `RequireAssertion(…)` and a policy registered from an `AuthorizationPolicy` built elsewhere are code. `RequireAuthenticatedUser`, `RequireRole` and `RequireClaim` are recovered; the rest is reported. |
-| The event source id from a `(TKey, TEvent)` handler | The event is recovered; the identifier saying *which* event source it goes to has no counterpart. |
-| Read model tags, query paging and sorting, custom routes | These are transport and storage concerns. The document describes the model, not how it is served. |
-| Child and nested objects declared with model-bound attributes | Not read back yet. The fluent form of the same projection is. |
+| Inline `policy` code and requirements built in code | `RequireAssertion(…)` and a policy registered from an `AuthorizationPolicy` built elsewhere are code. `RequireAuthenticatedUser`, `RequireRole`, and `RequireClaim` given the values it accepts are recovered; the rest is reported as `SP0026` — including a `RequireClaim` naming only a claim type, which a policy condition has no way to state. |
+| The event source id from a `(TKey, TEvent)` handler | The event is recovered; the identifier saying *which* event source it goes to has no counterpart. `SP0013`. |
+| Emptying a scope with `[ClearWith]`; removing a child with `[RemovedWith]` on the property holding it | Nothing in the model a projection is built from carries a scope being emptied again, so `[ClearWith]` has nowhere to go (`SP0015`). A removal does have somewhere — but it is read from the type of the child, alongside the events filling that child in, so the same removal written beside the collection is reported as `SP0007` instead. |
+| Read model tags | A read model has no declaration of its own — it appears as the type a query returns — so there is nowhere to hang a tag. Tags on *events* are recovered and written out. |
+| Query paging and sorting, custom routes | These say how a model is served rather than what it is. The parameters the host fills in are left out, and a route template has no counterpart. |
If a generated `.play` is missing something you expected, the diagnostics are the first place to look — the omission is almost always reported.
diff --git a/Source/DotNET/Screenplay.EndToEnd/Program.cs b/Source/DotNET/Screenplay.EndToEnd/Program.cs
new file mode 100644
index 000000000..2fac478f3
--- /dev/null
+++ b/Source/DotNET/Screenplay.EndToEnd/Program.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay;
+using Cratis.Arc.Screenplay.EndToEnd;
+using Cratis.Screenplay;
+
+if (args.Length < 2)
+{
+ Console.WriteLine("Usage:");
+ Console.WriteLine(" Cratis.Arc.Screenplay.EndToEnd ");
+
+ return 2;
+}
+
+var project = Path.GetFullPath(args[0]);
+var output = Path.GetFullPath(args[1]);
+
+Console.WriteLine($"Generating the Screenplay document of '{project}'");
+
+var failures = new List();
+var compilations = await ProjectCompilation.Of(project, failures);
+
+foreach (var failure in failures)
+{
+ Console.WriteLine($" workspace: {failure}");
+}
+
+if (compilations.Count == 0)
+{
+ Console.WriteLine($"'{project}' yielded no compilation, so there is nothing to generate from");
+
+ return 1;
+}
+
+foreach (var loaded in compilations)
+{
+ Console.WriteLine($" project: {loaded.AssemblyName}");
+}
+
+var generated = new ScreenplayGenerator().Generate(compilations, new ScreenplayOptions());
+
+Directory.CreateDirectory(Path.GetDirectoryName(output)!);
+await File.WriteAllTextAsync(output, generated.Source);
+
+Console.WriteLine($"Wrote {generated.Source.Split('\n').Length} lines to '{output}'");
+
+foreach (var group in generated.Diagnostics.GroupBy(_ => _.Code).OrderBy(_ => _.Key, StringComparer.Ordinal))
+{
+ Console.WriteLine($" {group.Key} x{group.Count()}");
+}
+
+var errors = generated.Diagnostics.Where(_ => _.Severity == ScreenplayDiagnosticSeverity.Error).ToList();
+foreach (var error in errors)
+{
+ Console.WriteLine($" generation error {error.Code}: {error.Message}");
+}
+
+var compiled = new ScreenplayCompiler().Compile(generated.Source);
+var rejected = compiled.Diagnostics.ToList();
+foreach (var diagnostic in rejected)
+{
+ Console.WriteLine($" document {diagnostic.Severity} on line {diagnostic.Location.Line}: {diagnostic.Message}");
+}
+
+// The document has to compile clean, warnings included. A warning the language reports is the generator writing a
+// document that refers to something it never introduces, which is a defect here rather than in the application - and
+// it is precisely the class of defect no specification built from source strings can reach.
+if (rejected.Count > 0)
+{
+ Console.WriteLine($"The generated document did not read back clean - {rejected.Count} diagnostic(s)");
+
+ return 1;
+}
+
+if (errors.Count > 0)
+{
+ Console.WriteLine($"Generation reported {errors.Count} error(s)");
+
+ return 1;
+}
+
+Console.WriteLine("The generated document reads back clean");
+
+return 0;
diff --git a/Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.cs b/Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.cs
new file mode 100644
index 000000000..3b75f846a
--- /dev/null
+++ b/Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Runtime.CompilerServices;
+using Microsoft.Build.Locator;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.MSBuild;
+
+namespace Cratis.Arc.Screenplay.EndToEnd;
+
+///
+/// Reads a real project, or a real solution, into the compilations the generator is handed.
+///
+///
+/// The generator itself never loads a workspace - it takes compilations and nothing else - which is what lets every
+/// specification build them from source strings. That seam is exactly what this check exists to get behind: a
+/// compilation built from strings has no intermediate folder, no source generator output on disk and no referenced
+/// package that declares an event, so a defect that only shows in one of those is invisible to a specification by
+/// construction. Two shipped that way.
+///
+/// A solution is read as every project it holds that is not a specification project, which is what an application
+/// written as several projects really is. Whether a project is one is decided by its name, the convention a solution
+/// is laid out by.
+///
+///
+public static class ProjectCompilation
+{
+ static readonly string[] _specifications = ["Specs", "Tests", "Specs.AppHost"];
+ static readonly string[] _solutions = [".sln", ".slnx", ".slnf"];
+
+ ///
+ /// Loads a project or a solution and everything it references.
+ ///
+ /// The full path of the project or solution file.
+ /// Everything the workspace reported while loading, in the order it reported them.
+ /// The compilations, empty when nothing yielded one.
+ ///
+ /// Registering the SDK has to happen before any MSBuild type is touched, which is why the members touching one
+ /// are marked as not inlinable - the JIT would otherwise resolve those types while registration is still running.
+ ///
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static async Task> Of(string path, ICollection failures)
+ {
+ if (!MSBuildLocator.IsRegistered)
+ {
+ MSBuildLocator.RegisterDefaults();
+ }
+
+ return IsSolution(path) ? await LoadSolution(path, failures) : await LoadProject(path, failures);
+ }
+
+ ///
+ /// Determines whether a path names a solution rather than a project.
+ ///
+ /// The path to check.
+ /// True when it names a solution.
+ static bool IsSolution(string path) =>
+ Array.Exists(_solutions, extension => string.Equals(Path.GetExtension(path), extension, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Loads a single project.
+ ///
+ /// The full path of the project file.
+ /// Everything the workspace reported while loading.
+ /// The compilation, empty when the project yielded none.
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static async Task> LoadProject(string path, ICollection failures)
+ {
+ var reported = new Lock();
+ using var workspace = MSBuildWorkspace.Create();
+ using var subscription = workspace.RegisterWorkspaceFailedHandler(args => Report(args, reported, failures));
+
+ var project = await workspace.OpenProjectAsync(path);
+
+ return await project.GetCompilationAsync() is { } compilation ? [compilation] : [];
+ }
+
+ ///
+ /// Loads every project of a solution that holds part of the application.
+ ///
+ /// The full path of the solution file.
+ /// Everything the workspace reported while loading.
+ /// The compilations, ordered by the name of the project each one came from.
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static async Task> LoadSolution(string path, ICollection failures)
+ {
+ var reported = new Lock();
+ using var workspace = MSBuildWorkspace.Create();
+ using var subscription = workspace.RegisterWorkspaceFailedHandler(args => Report(args, reported, failures));
+
+ var solution = await workspace.OpenSolutionAsync(path);
+ var compilations = new List();
+
+ foreach (var project in solution.Projects
+ .Where(_ => !IsSpecifications(_.Name))
+ .OrderBy(_ => _.Name, StringComparer.Ordinal))
+ {
+ if (await project.GetCompilationAsync() is { } compilation)
+ {
+ compilations.Add(compilation);
+ }
+ }
+
+ return compilations;
+ }
+
+ ///
+ /// Determines whether a project holds specifications rather than part of the application.
+ ///
+ /// The name of the project.
+ /// True when it holds specifications.
+ static bool IsSpecifications(string name) =>
+ Array.Exists(_specifications, suffix =>
+ string.Equals(name, suffix, StringComparison.Ordinal) ||
+ name.EndsWith($".{suffix}", StringComparison.Ordinal));
+
+ ///
+ /// Records what the workspace reported while loading.
+ ///
+ /// What was reported.
+ /// The lock guarding the collection.
+ /// The failures collected so far.
+ static void Report(WorkspaceDiagnosticEventArgs args, Lock reported, ICollection failures)
+ {
+ lock (reported)
+ {
+ failures.Add(args.Diagnostic.Message);
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay.EndToEnd/Screenplay.EndToEnd.csproj b/Source/DotNET/Screenplay.EndToEnd/Screenplay.EndToEnd.csproj
new file mode 100644
index 000000000..b4a640c85
--- /dev/null
+++ b/Source/DotNET/Screenplay.EndToEnd/Screenplay.EndToEnd.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ Cratis.Arc.Screenplay.EndToEnd
+ Cratis.Arc.Screenplay.EndToEnd
+
+ net10.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/DotNET/Screenplay.Specs/Analyzed.cs b/Source/DotNET/Screenplay.Specs/Analyzed.cs
index 60ca2afa2..79a8bf3b9 100644
--- a/Source/DotNET/Screenplay.Specs/Analyzed.cs
+++ b/Source/DotNET/Screenplay.Specs/Analyzed.cs
@@ -54,6 +54,46 @@ public static class Analyzed
public static ApplicationModelAnalysis Source(params (string Path, string Text)[] sources) =>
Source(DeclaredUserInterfaceFiles.None, sources);
+ ///
+ /// Compiles source referencing a package and recovers the model it describes.
+ ///
+ /// The package the source references.
+ /// The source files, keyed by the path each one is compiled as.
+ /// The .
+ public static ApplicationModelAnalysis SourceReferencing(MetadataReference package, params (string Path, string Text)[] sources) =>
+ new ApplicationModelAnalyzer(DeclaredUserInterfaceFiles.None)
+ .Analyze(Compile([package], sources), new ScreenplayOptions().WithDefaults(AssemblyName));
+
+ ///
+ /// Compiles source into the assembly image a referenced package really is.
+ ///
+ /// The name of the assembly.
+ /// The source.
+ /// The .
+ /// Thrown when the source of the package does not compile.
+ ///
+ /// The image is emitted rather than referenced as a compilation, because a package the application depends on is
+ /// metadata with no syntax tree behind it - which is the whole reason nothing in the compilation declares what it
+ /// holds.
+ ///
+ public static MetadataReference Package(string name, string text)
+ {
+ var compilation = CSharpCompilation.Create(
+ name,
+ [CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(documentationMode: DocumentationMode.Parse))],
+ _references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable));
+
+ using var stream = new MemoryStream();
+ var emitted = compilation.Emit(stream);
+ if (!emitted.Success)
+ {
+ throw new InvalidOperationException($"The package '{name}' did not compile - {emitted.Diagnostics.First(_ => _.Severity == DiagnosticSeverity.Error)}");
+ }
+
+ return MetadataReference.CreateFromImage(stream.ToArray());
+ }
+
///
/// Compiles source and recovers the model it describes.
///
@@ -87,23 +127,98 @@ public static ApplicationModelAnalysis Source(IUserInterfaceFiles files, params
///
/// The source files, keyed by the path each one is compiled as.
/// The .
- public static Compilation Compile(params (string Path, string Text)[] sources) =>
+ public static Compilation Compile(params (string Path, string Text)[] sources) => Compile([], sources);
+
+ ///
+ /// Compiles source referencing further packages into a compilation.
+ ///
+ /// The packages the source references beyond the platform.
+ /// The source files, keyed by the path each one is compiled as.
+ /// The .
+ public static Compilation Compile(IEnumerable packages, params (string Path, string Text)[] sources) =>
CSharpCompilation.Create(
AssemblyName,
sources.Append(Root).Select(_ => CSharpSyntaxTree.ParseText(
_.Text,
new CSharpParseOptions(documentationMode: DocumentationMode.Parse),
path: _.Path)),
- _references,
+ _references.Concat(packages),
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable));
+
+ ///
+ /// Compiles source into one project of an application written as several.
+ ///
+ /// The name of the assembly the project builds.
+ /// What the project references beyond the platform, its sibling projects included.
+ /// The source files, keyed by the path each one is compiled as.
+ /// The .
+ ///
+ /// Nothing is added to what the specification declares, unlike the compilations built around a single file, which
+ /// carry a root of their own so that paths in the document start somewhere sensible. A project of a real
+ /// application already has its own root, and a specification about several of them has to say where each one is
+ /// written or there is nothing for the paths of the document to be relative to.
+ ///
+ public static Compilation Project(
+ string name,
+ IEnumerable references,
+ params (string Path, string Text)[] sources) =>
+ CSharpCompilation.Create(
+ name,
+ sources.Select(_ => CSharpSyntaxTree.ParseText(
+ _.Text,
+ new CSharpParseOptions(documentationMode: DocumentationMode.Parse),
+ path: _.Path)),
+ _references.Concat(references),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable));
+ ///
+ /// Recovers the model the projects of an application describe together.
+ ///
+ /// The projects, in whatever order the specification hands them over.
+ /// The .
+ ///
+ /// No name is offered, which is what a host generating from several projects without configuring one does - no
+ /// single assembly names an application written as several.
+ ///
+ public static ApplicationModelAnalysis Projects(params Compilation[] compilations) =>
+ new ApplicationModelAnalyzer(DeclaredUserInterfaceFiles.None)
+ .Analyze(compilations, new ScreenplayOptions().WithDefaults(null));
+
+ ///
+ /// Gets everything the compiler itself reported about a compilation.
+ ///
+ /// The compilation to read.
+ /// The errors, empty when the source compiles.
+ public static IEnumerable ErrorsIn(Compilation compilation) =>
+ compilation
+ .GetDiagnostics()
+ .Where(_ => _.Severity == DiagnosticSeverity.Error)
+ .Select(_ => _.ToString());
+
///
/// Gets everything the compiler itself reported, so that a specification never asserts against broken source.
///
/// The source files, keyed by the path each one is compiled as.
/// The errors, empty when the source compiles.
- public static IEnumerable ErrorsIn(params (string Path, string Text)[] sources) =>
- Compile(sources)
+ public static IEnumerable ErrorsIn(params (string Path, string Text)[] sources) => ErrorsIn([], sources);
+
+ ///
+ /// Gets everything the compiler itself reported for source referencing a package.
+ ///
+ /// The package the source references.
+ /// The source files, keyed by the path each one is compiled as.
+ /// The errors, empty when the source compiles.
+ public static IEnumerable ErrorsIn(MetadataReference package, params (string Path, string Text)[] sources) =>
+ ErrorsIn([package], sources);
+
+ ///
+ /// Gets everything the compiler itself reported for source referencing further packages.
+ ///
+ /// The packages the source references beyond the platform.
+ /// The source files, keyed by the path each one is compiled as.
+ /// The errors, empty when the source compiles.
+ public static IEnumerable ErrorsIn(IEnumerable packages, params (string Path, string Text)[] sources) =>
+ Compile(packages, sources)
.GetDiagnostics()
.Where(_ => _.Severity == DiagnosticSeverity.Error)
.Select(_ => _.ToString());
diff --git a/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs
new file mode 100644
index 000000000..ce10be200
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs
@@ -0,0 +1,111 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay;
+
+///
+/// Declares the testing surface a Chronicle integration specification is written against, as source.
+///
+///
+/// The generator recognizes these by the fully qualified name of the type declaring each member, which is exactly
+/// what lets it read a specification without the testing packages being referenced. Declaring them here rather than
+/// referencing them keeps every specification about reading them hermetic, and asks the recognition the only
+/// question worth asking of it - whether the names alone are enough.
+///
+public static class IntegrationTesting
+{
+ ///
+ /// The path the surface is compiled as.
+ ///
+ public const string Path = "Library/Testing/IntegrationTesting.cs";
+
+ ///
+ /// The source of the surface.
+ ///
+ public const string Source = """
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.EventSequences;
+
+ namespace Cratis.Arc.Testing.Commands
+ {
+ public class CommandScenario
+ {
+ public Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioChronicleGivenBuilder Given => new();
+
+ public IEventSequence EventSequence => null!;
+
+ public Task Execute(TCommand command) => Task.FromResult(new Result());
+
+ public Task Validate(TCommand command) => Task.FromResult(new Result());
+ }
+
+ public class Result
+ {
+ public bool IsSuccess => true;
+ }
+ }
+
+ namespace Cratis.Arc.Chronicle.Testing.Commands
+ {
+ public class CommandScenarioChronicleGivenBuilder
+ {
+ public CommandScenarioSourceGivenBuilder ForEventSource(EventSourceId eventSourceId) => new();
+ }
+
+ public class CommandScenarioSourceGivenBuilder
+ {
+ public void Events(params object[] events)
+ {
+ }
+
+ public void ReadModel(TReadModel readModel)
+ where TReadModel : class
+ {
+ }
+ }
+ }
+
+ namespace Cratis.Chronicle.XUnit.Integration
+ {
+ public static class HttpClientExtensions
+ {
+ public static Task ExecuteCommand(
+ this HttpClient client,
+ string requestUri,
+ TCommand command) => Task.FromResult(new Cratis.Arc.Testing.Commands.Result());
+ }
+ }
+
+ namespace Cratis.Chronicle.Testing.EventSequences
+ {
+ public static class EventSequenceShouldExtensions
+ {
+ public static void ShouldHaveAppendedEvent(this IEventSequence sequence, EventSourceId eventSourceId)
+ {
+ }
+
+ public static void ShouldHaveTailSequenceNumber(this IEventSequence sequence, int expected)
+ {
+ }
+
+ public static void ShouldBeSuccessful(this Cratis.Arc.Testing.Commands.Result result)
+ {
+ }
+
+ public static void ShouldNotBeSuccessful(this Cratis.Arc.Testing.Commands.Result result)
+ {
+ }
+
+ public static void ShouldHaveConstraintViolationFor(this Cratis.Arc.Testing.Commands.Result result, string constraintName)
+ {
+ }
+
+ public static void ShouldBeFalse(this bool value)
+ {
+ }
+ }
+ }
+ """;
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs
new file mode 100644
index 000000000..b8a1f9e57
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// What a caller sends is what the document describes a query by. A cancellation token, the page asked for and the
+/// order asked for are all filled in by the host from the request rather than sent as arguments, and none of them is
+/// an interface - so stating them as caller input puts parameters in the document that no caller sends, typed by
+/// names the document never declares.
+///
+public class a_query_the_host_hands_more_than_arguments : Specification
+{
+ const string Source = """
+ using System.Collections.Generic;
+ using System.Threading;
+ using Cratis.Arc.Queries;
+ using Cratis.Arc.Queries.ModelBound;
+
+ namespace Library.Authors.Listing;
+
+ [ReadModel]
+ public record Author
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public static IEnumerable AuthorsByName(
+ string name,
+ CancellationToken cancellationToken,
+ Paging paging,
+ Sorting sorting,
+ QueryContext context) => [];
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+ QueryModel _query;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(("Library/Authors/Listing/Listing.cs", Source));
+ _query = _analysis.Slice().Queries.Single();
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_key_the_query_by_what_the_caller_sends() => _query.By!.Name.ShouldEqual("name");
+ [Fact] void should_leave_out_everything_the_host_fills_in() => _query.Filters.ShouldBeEmpty();
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_importing_a_query_another_slice_declares.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_importing_a_query_another_slice_declares.cs
new file mode 100644
index 000000000..ecd6a38d7
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_importing_a_query_another_slice_declares.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A screen aggregating several read models is what an Event Modeling screen routinely is, so an import naming a query
+/// another slice declares is a binding rather than the noise every other unmatched import is. It cannot be written
+/// down - a binding names a query by the bare name its own slice declares it under, and an application declares the
+/// same name once per read model - so the screen, the query and the slice declaring it are reported instead of the
+/// binding disappearing without a word.
+///
+public class a_screen_importing_a_query_another_slice_declares : Specification
+{
+ const string Listing = """
+ using System.Collections.Generic;
+ using Cratis.Arc.Queries.ModelBound;
+
+ namespace Library.Authors.Listing;
+
+ [ReadModel]
+ public record Author
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public static IEnumerable All() => [];
+ }
+ """;
+
+ const string Lending = """
+ using System.Collections.Generic;
+ using Cratis.Arc.Queries.ModelBound;
+
+ namespace Library.Lending.Loans;
+
+ [ReadModel]
+ public record Loan
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public static IEnumerable All() => [];
+ }
+ """;
+
+ const string Component = """
+ import { All } from './Loans';
+ import { All as AllAuthors } from '../../Authors/Listing/Listing';
+ import { All as AllCatalogs } from '../../Catalogs/Listing/Listing';
+ import { DataTable } from 'primereact/datatable';
+
+ export const LoanBoard = () => ;
+ """;
+
+ static readonly DeclaredUserInterfaceFiles _files = DeclaredUserInterfaceFiles.Holding(
+ ("Library/Lending/Loans/LoanBoard.tsx", Component));
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(
+ _files,
+ ("Library/Authors/Listing/Listing.cs", Listing),
+ ("Library/Lending/Loans/Loans.cs", Lending));
+
+ ScreenplayDiagnostic Reported =>
+ _analysis.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.CrossSliceQueryBinding);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Listing), ("Library/Lending/Loans/Loans.cs", Lending)).ShouldBeEmpty();
+ [Fact] void should_still_bind_the_query_its_own_slice_declares() => _analysis.Model.Slices.First(_ => _.Namespace == "Library.Lending.Loans").Screens.Single().Data.Select(_ => _.Query).ShouldContainOnly(["All"]);
+ [Fact] void should_report_the_binding_it_could_not_write_down() => _analysis.Diagnostics.Count(_ => _.Code == ScreenplayDiagnosticCodes.CrossSliceQueryBinding).ShouldEqual(1);
+ [Fact] void should_name_the_screen_reading_through_it() => Reported.Message.Contains("'LoanBoard'", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_name_the_slice_declaring_it() => Reported.Message.Contains("'Library.Authors.Listing'", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_locate_it_against_the_slice_the_screen_belongs_to() => Reported.Location.ShouldEqual("Library.Lending.Loans");
+ [Fact] void should_say_nothing_of_an_import_resolving_to_no_slice_at_all() => _analysis.Diagnostics.Any(_ => _.Message.Contains("Catalogs", StringComparison.Ordinal)).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_naming_its_queries_through_a_view_model.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_naming_its_queries_through_a_view_model.cs
new file mode 100644
index 000000000..75291f626
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_naming_its_queries_through_a_view_model.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// 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 finds a file name and nothing about what the screen reads. The view model
+/// belongs to the same slice and sits in the same folder, so what it imports is what the screen reads - and it is
+/// followed exactly one hop, because a chain across folders would be inferring an architecture rather than reading one.
+///
+public class a_screen_naming_its_queries_through_a_view_model : Specification
+{
+ const string Source = """
+ using System.Collections.Generic;
+ using Cratis.Arc.Queries.ModelBound;
+
+ namespace Library.Lending.Loans;
+
+ [ReadModel]
+ public record Loan
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public static IEnumerable All() => [];
+
+ public static IEnumerable Overdue() => [];
+ }
+ """;
+
+ const string Component = """
+ import { useLoanBoard } from './LoanBoardViewModel';
+ import { DataTable } from 'primereact/datatable';
+
+ export const LoanBoard = () => ;
+ """;
+
+ const string ViewModel = """
+ import { All } from './Loans';
+
+ export const useLoanBoard = () => All.use()[0];
+ """;
+
+ const string Elsewhere = """
+ import { Overdue } from './Loans';
+
+ export const useOverdue = () => Overdue.use()[0];
+ """;
+
+ static readonly DeclaredUserInterfaceFiles _files = DeclaredUserInterfaceFiles.Holding(
+ ("Library/Lending/Loans/LoanBoard.tsx", Component),
+ ("Library/Lending/Loans/LoanBoardViewModel.ts", ViewModel),
+ ("Library/Lending/Loans/LoanBoardHelpers.ts", Elsewhere));
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(_files, ("Library/Lending/Loans/Loans.cs", Source));
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Lending/Loans/Loans.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_bind_the_query_the_view_model_reads_through() => _analysis.Slice().Screens.Single().Data.Select(_ => _.Query).ShouldContainOnly(["All"]);
+ [Fact] void should_leave_a_module_that_is_no_view_model_unread() => _analysis.Slice().Screens.Single().Data.Any(_ => _.Query == "Overdue").ShouldBeFalse();
+ [Fact] void should_report_only_what_no_screen_states() => _analysis.Diagnostics.Select(_ => _.Code).ShouldContainOnly([ScreenplayDiagnosticCodes.ScreenStructureNotInferred]);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_whose_imports_are_commented_out.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_whose_imports_are_commented_out.cs
new file mode 100644
index 000000000..7ec98ca3b
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_whose_imports_are_commented_out.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// An import that has been commented out is an import the component does not make, so a binding recovered from one
+/// says the screen reads through a query it never calls. A commented import is also the most ordinary thing to find
+/// in a component under change, which makes it exactly the case the reader has to be right about.
+///
+public class a_screen_whose_imports_are_commented_out : Specification
+{
+ const string Source = """
+ using System.Collections.Generic;
+ using Cratis.Arc.Queries.ModelBound;
+
+ namespace Library.Authors.Listing;
+
+ [ReadModel]
+ public record Author
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public static IEnumerable AllAuthors() => [];
+
+ public static IEnumerable RetiredAuthors() => [];
+
+ public static IEnumerable HonoraryAuthors() => [];
+ }
+ """;
+
+ const string Component = """
+ import { DataTable } from 'primereact/datatable';
+ // import { RetiredAuthors } from './RetiredAuthors';
+ /*
+ import { HonoraryAuthors } from './HonoraryAuthors';
+ */
+ import { AllAuthors } from './AllAuthors';
+
+ export const AuthorList = () => ;
+ """;
+
+ static readonly DeclaredUserInterfaceFiles _files = DeclaredUserInterfaceFiles.Holding(
+ ("Library/Authors/Listing/AuthorList.tsx", Component));
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _data;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_files, ("Library/Authors/Listing/Listing.cs", Source));
+ _data = _analysis.Slice().Screens.Single().Data;
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_bind_only_the_query_the_component_really_imports() => _data.Select(_ => _.Query).ShouldContainOnly(["AllAuthors"]);
+ [Fact] void should_report_only_what_no_screen_states() => _analysis.Diagnostics.Select(_ => _.Code).ShouldContainOnly([ScreenplayDiagnosticCodes.ScreenStructureNotInferred]);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_a_source_generator_contributed_to.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_a_source_generator_contributed_to.cs
new file mode 100644
index 000000000..637888ebd
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_a_source_generator_contributed_to.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A compilation loaded from a project carries what every source generator emitted to the intermediate folder of the
+/// build. Those files declare real members of the slice and say nothing at all about where it is written, so counting
+/// them reports a slice sitting in one folder as spread over two and looks for its screens in a build folder.
+///
+public class a_slice_a_source_generator_contributed_to : Specification
+{
+ const string Written = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+
+ namespace Library.Onboarding.Registry;
+
+ [EventType]
+ public record CompanyRegistered(string Name);
+
+ public partial class RegistryNotifier : IReactor
+ {
+ public Task CompanyRegistered(CompanyRegistered @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ const string Emitted = """
+ namespace Library.Onboarding.Registry;
+
+ public partial class RegistryNotifier
+ {
+ public string Describe() => nameof(RegistryNotifier);
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Onboarding/Registry/Registry.cs", Written),
+ ("Library/obj/Debug/net10.0/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Registry.Logging.g.cs", Emitted)
+ ];
+
+ static readonly DeclaredUserInterfaceFiles _files = new(
+ "Library/Onboarding/Registry/RegistryPage.tsx",
+ "Library/obj/Debug/net10.0/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Emitted.tsx");
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _screens;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_files, _sources);
+ _screens = _analysis.Slice().Screens;
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_not_report_the_slice_as_spread_over_folders() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.AmbiguousScreenFile).ShouldBeFalse();
+ [Fact] void should_take_its_screens_only_from_the_folder_it_is_written_in() => _screens.Select(_ => _.Name).ShouldContainOnly(["RegistryPage"]);
+ [Fact] void should_report_nothing_beyond_what_no_screen_states() => _analysis.Diagnostics.Select(_ => _.Code).Distinct().ShouldContainOnly([ScreenplayDiagnosticCodes.ScreenStructureNotInferred]);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs
new file mode 100644
index 000000000..f3e6a15e2
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs
@@ -0,0 +1,87 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// The other shape Arc documents drives a running host rather than the pipeline in process: what the event log
+/// already held is appended to it directly, the command goes over HTTP, and the steps live on a nested context with
+/// only the assertions left outside. It says the same thing as the in-process shape and has to read as the same
+/// thing, or a document would describe an application differently depending on how it was specified.
+///
+public class a_specification_against_a_running_host : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.EventSequences;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Cratis.Chronicle.XUnit.Integration;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_name_is_already_taken
+ {
+ public class context
+ {
+ public const string AuthorName = "Jane Austen";
+
+ protected IEventLog EventLog = null!;
+ protected HttpClient Client = null!;
+
+ async Task Establish() => await EventLog.Append("author", new AuthorRegistered(AuthorName));
+
+ async Task Because() => await Client.ExecuteCommand("/api/authors/register", new RegisterAuthor(AuthorName));
+ }
+
+ readonly IEventSequence _sequence = null!;
+
+ [Fact] void should_register_the_author() => _sequence.ShouldHaveAppendedEvent("author");
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_name_is_already_taken.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _specification;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single();
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_read_the_steps_written_on_the_nested_context() => _specification.Given.Single().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_state_the_value_appended_to_the_log() => _specification.Given.Single().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]);
+ [Fact] void should_issue_the_command_sent_over_http() => _specification.When.Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_state_the_values_the_command_was_sent_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]);
+ [Fact] void should_read_the_assertions_left_outside_the_context() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs
new file mode 100644
index 000000000..4d21f9698
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs
@@ -0,0 +1,93 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A command being turned down is half of what a slice is specified for, and the language says it as a rejection
+/// with a reason. A source naming the reason - the constraint that held - has it carried across; a source saying
+/// only that the command did not go through has no reason to carry, and a made up sentence would describe an
+/// application nobody wrote. The scenario is named after the words the source uses for it, so the reason is already
+/// said there.
+///
+public class a_specification_expecting_a_rejection : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_name_is_taken
+ {
+ public const string UniqueAuthorName = "unique-author-name";
+
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen"));
+
+ [Fact] void should_not_register_the_author() => _result.ShouldHaveConstraintViolationFor(UniqueAuthorName);
+ }
+
+ public class and_nothing_was_given
+ {
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen"));
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ [Fact] void should_say_so_on_the_result() => _result.IsSuccess.ShouldBeFalse();
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_name_is_taken.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _named;
+ SpecificationModel _unnamed;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ var specifications = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ToList();
+ _named = specifications.Single(_ => _.Name.EndsWith("and_the_name_is_taken", StringComparison.Ordinal));
+ _unnamed = specifications.Single(_ => _.Name.EndsWith("and_nothing_was_given", StringComparison.Ordinal));
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_read_both_scenarios_written_in_one_file() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(2);
+ [Fact] void should_carry_across_the_reason_the_source_names() => _named.Errors.ShouldContainOnly(["unique-author-name"]);
+ [Fact] void should_expect_no_event_alongside_a_rejection() => _named.Then.ShouldBeEmpty();
+ [Fact] void should_state_a_rejection_the_source_names_no_reason_for() => _unnamed.Errors.ShouldContainOnly([string.Empty]);
+ [Fact] void should_state_two_ways_of_saying_the_same_rejection_once() => _unnamed.Errors.Count().ShouldEqual(1);
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs
new file mode 100644
index 000000000..e7e32794e
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// Most of the specifications an application carries stand a collaborator up behind a substitute and say what it was
+/// asked to do. That is a statement about the inside of a slice rather than about its behavior, so the language has
+/// nowhere to put it - and reporting each one would say a gap exists where none does. They are passed over entirely,
+/// which is only safe because what is read is decided by what a specification touches rather than by where it sits.
+///
+public class a_specification_of_a_collaborator : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public interface INameFormatter
+ {
+ string Format(string name);
+ }
+
+ public class and_the_name_is_formatted
+ {
+ string _result = string.Empty;
+
+ void Because() => _result = new Formatter().Format("jane austen");
+
+ [Fact] void should_capitalize_the_name() => _result.ShouldEqualTheExpected("Jane Austen");
+ }
+
+ public class Formatter : INameFormatter
+ {
+ public string Format(string name) => name;
+ }
+
+ public static class Assertions
+ {
+ public static void ShouldEqualTheExpected(this string actual, string expected)
+ {
+ }
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_name_is_formatted.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(_sources);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_specify_the_slice_by_nothing() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty();
+ [Fact] void should_report_no_scenario_left_out() => _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification).ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs
new file mode 100644
index 000000000..bc8d41d10
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// The scenarios a slice is specified by sit in a folder beneath it, which is a namespace beneath its own, and are
+/// written to a convention rigid enough to read: what had happened is stated against the scenario, the command is
+/// issued through it, and each assertion says one thing about what followed. Reading them is what turns a document
+/// stating what a slice does into one carrying the examples proving it.
+///
+public class a_specification_of_a_slice : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [ReadModel]
+ public record Author(string Id, string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name, int Age)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_author_is_new
+ {
+ readonly CommandScenario _scenario = new();
+
+ void Establish()
+ {
+ _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen"));
+ _scenario.Given.ForEventSource("author").ReadModel(new Author("author", "Jane Austen"));
+ }
+
+ async Task Because() => await _scenario.Execute(new RegisterAuthor("Mary Shelley", 42));
+
+ [Fact] void should_register_the_author() => _scenario.EventSequence.ShouldHaveAppendedEvent("author");
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_author_is_new.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _specification;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single();
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_put_it_under_the_slice_it_specifies() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(1);
+ [Fact] void should_name_it_after_every_word_between_the_slice_and_itself() => _specification.Name.ShouldEqual("when_registering_and_the_author_is_new");
+ [Fact] void should_start_from_the_event_that_had_happened() => _specification.Given.First().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_know_the_event_it_starts_from_is_an_event() => _specification.Given.First().Kind.ShouldEqual(SpecificationStateKind.Event);
+ [Fact] void should_state_the_values_of_the_event_it_starts_from() => _specification.Given.First().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]);
+ [Fact] void should_start_from_the_read_model_that_was_pinned() => _specification.Given.Last().Kind.ShouldEqual(SpecificationStateKind.ReadModel);
+ [Fact] void should_state_the_values_of_the_read_model() => _specification.Given.Last().Values.Select(_ => _.Property).ShouldContainOnly(["Id", "Name"]);
+ [Fact] void should_issue_the_command() => _specification.When.Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_state_the_values_the_command_was_issued_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Mary Shelley")), new PropertyMappingModel("Age", new LiteralSource(42))]);
+ [Fact] void should_expect_the_event_the_assertion_names() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_expect_no_rejection() => _specification.Errors.ShouldBeEmpty();
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs
new file mode 100644
index 000000000..70f7c6d4e
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A scenario written in the host language routinely rests an identity on a value made at run time, which is exactly
+/// what a document stating values has no way to name. Unlike a step, a value stands on its own: leaving one out
+/// leaves the rest of the scenario saying what the source says, so it is left out and said rather than taking the
+/// scenario with it.
+///
+public class a_specification_stating_a_value_that_is_code : Specification
+{
+ const string Slice = """
+ using System;
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(Guid Id, string Name, int Age)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System;
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_identity_is_made_at_run_time
+ {
+ readonly CommandScenario _scenario = new();
+ readonly Guid _id = Guid.NewGuid();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_id, "Jane Austen", 41));
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_identity_is_made_at_run_time.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _specification;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single();
+ }
+
+ ScreenplayDiagnostic LeftOut() =>
+ _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecificationValue);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_keep_the_scenario() => _specification.When.Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_state_every_value_it_can_read() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen")), new PropertyMappingModel("Age", new LiteralSource(41))]);
+ [Fact] void should_leave_out_the_one_it_cannot() => _specification.When.Values.Select(_ => _.Property).ShouldNotContain("Id");
+ [Fact] void should_say_which_value_it_left_out() => LeftOut().Message.ShouldEqual("The value 'when_registering_and_the_identity_is_made_at_run_time' states for 'RegisterAuthor.Id' is code rather than a constant, so the scenario states everything but that value");
+ [Fact] void should_say_where_it_left_it_out() => LeftOut().Location.ShouldEqual("Library.Authors.Registration.when_registering.and_the_identity_is_made_at_run_time");
+ [Fact] void should_report_it_as_information() => LeftOut().Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Information);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs
new file mode 100644
index 000000000..1bace393b
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A scenario is one example, and an example missing the state it started from, the command it issued or the outcome
+/// it expects is a different example from the one the source states. Each of the three is left out whole and said
+/// so, which is the difference between a document with a known gap and one that is quietly wrong about what an
+/// application was specified against.
+///
+public class a_specification_whose_steps_cannot_be_read : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_command_is_held_in_a_field
+ {
+ readonly CommandScenario _scenario = new();
+ RegisterAuthor _command = null!;
+ Result _result = null!;
+
+ void Establish() => _command = new RegisterAuthor("Jane Austen");
+
+ async Task Because() => _result = await _scenario.Execute(_command);
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ }
+
+ public class and_what_it_starts_from_depends_on_a_condition
+ {
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ protected virtual bool AuthorAlreadyRegistered => true;
+
+ void Establish()
+ {
+ if (AuthorAlreadyRegistered)
+ {
+ _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen"));
+ }
+ }
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley"));
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ }
+
+ public class and_nothing_it_expects_has_a_place_in_the_language
+ {
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley"));
+
+ [Fact] void should_succeed() => _result.ShouldBeSuccessful();
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_command_is_held_in_a_field.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(_sources);
+
+ IEnumerable LeftOut() =>
+ _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification);
+
+ bool SaidOf(string scenario, string reason) =>
+ LeftOut().Any(_ => _.Message.Contains(scenario, StringComparison.Ordinal) && _.Message.Contains(reason, StringComparison.Ordinal));
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_leave_out_every_scenario_it_cannot_read() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty();
+ [Fact] void should_report_each_of_them() => LeftOut().Count().ShouldEqual(3);
+ [Fact] void should_say_a_command_put_together_elsewhere_cannot_be_read() => SaidOf("and_the_command_is_held_in_a_field", "the command it issues is put together somewhere this cannot read").ShouldBeTrue();
+ [Fact] void should_say_a_starting_point_under_a_condition_cannot_be_read() => SaidOf("and_what_it_starts_from_depends_on_a_condition", "only happens under a condition").ShouldBeTrue();
+ [Fact] void should_say_an_outcome_the_language_cannot_hold_cannot_be_read() => SaidOf("and_nothing_it_expects_has_a_place_in_the_language", "it expects no event and no rejection").ShouldBeTrue();
+ [Fact] void should_say_where_each_of_them_lives() => LeftOut().Select(_ => _.Location).ShouldContain("Library.Authors.Registration.when_registering.and_the_command_is_held_in_a_field");
+ [Fact] void should_report_them_as_warnings() => LeftOut().Select(_ => _.Severity).ShouldContainOnly([ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning]);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_comparing_one_property_against_another.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_comparing_one_property_against_another.cs
new file mode 100644
index 000000000..1a1fa0409
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_comparing_one_property_against_another.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A rule comparing one property of a command against another names the second one with a lambda, which is the same
+/// shape a rule chain names the property it is declared on. Reading only constants left every such rule looking like a
+/// comparison that had been given nothing to compare against, and dropped the whole of the layer a domain is really
+/// written in. An operand that is genuinely worked out while the request runs is a different thing and stays unread.
+///
+public class a_validator_comparing_one_property_against_another : Specification
+{
+ const string Source = """
+ using System;
+ using Cratis.Arc.Commands;
+ using Cratis.Arc.Commands.ModelBound;
+ using FluentValidation;
+
+ namespace Library.Lending.Reserving;
+
+ [Command]
+ public record ReserveBook(DateOnly StartDate, DateOnly EndDate, DateOnly Deadline)
+ {
+ public void Handle()
+ {
+ }
+ }
+
+ public class ReserveBookValidator : CommandValidator
+ {
+ public ReserveBookValidator()
+ {
+ RuleFor(_ => _.EndDate).GreaterThanOrEqualTo(_ => _.StartDate);
+ RuleFor(_ => _.Deadline).LessThanOrEqualTo(command => command.StartDate);
+ RuleFor(_ => _.StartDate).GreaterThan(DateOnly.FromDateTime(DateTime.UtcNow));
+ }
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _rules;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(Source);
+ _rules = _analysis.Slice().Commands.First().Validations;
+ }
+
+ ValidationRuleModel Rule(string property) => _rules.First(_ => _.Property == property);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_read_the_operand_as_the_property_it_names() => Rule("EndDate").Value.ShouldEqual(new PropertyPathSource("StartDate"));
+ [Fact] void should_read_it_whatever_the_lambda_calls_its_parameter() => Rule("Deadline").Value.ShouldEqual(new PropertyPathSource("StartDate"));
+ [Fact] void should_leave_an_operand_only_the_running_application_knows_unread() => Rule("StartDate").Value.ShouldBeNull();
+ [Fact] void should_report_nothing_about_what_it_read() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_holding_rules_to_a_condition.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_holding_rules_to_a_condition.cs
new file mode 100644
index 000000000..6dd2e13b7
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_holding_rules_to_a_condition.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A rule carries no condition of its own, so a rule held to one is stated as though it always holds. That is a real
+/// difference between the document and the application rather than a rule that could not be read, and a report saying
+/// only that a call had no counterpart leaves a reader unable to tell which. Writing the condition into it is what
+/// makes the difference something they can weigh.
+///
+public class a_validator_holding_rules_to_a_condition : Specification
+{
+ const string Source = """
+ using Cratis.Arc.Commands;
+ using Cratis.Arc.Commands.ModelBound;
+ using FluentValidation;
+
+ namespace Library.Lending.Reserving;
+
+ [Command]
+ public record ReserveBook(string Isbn, string Note, bool IsRush)
+ {
+ public void Handle()
+ {
+ }
+ }
+
+ public class ReserveBookValidator : CommandValidator
+ {
+ public ReserveBookValidator()
+ {
+ RuleFor(_ => _.Isbn).NotEmpty().When(command => command.IsRush);
+ RuleFor(_ => _.Note).MaximumLength(50).Unless(command => command.IsRush);
+ }
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(Source);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_still_state_the_rules_the_condition_held() => _analysis.Slice().Commands.First().Validations.Select(_ => _.Property).ShouldContainOnly(["Isbn", "Note"]);
+ [Fact] void should_report_one_condition_for_each_chain() => _analysis.Diagnostics.Count.ShouldEqual(2);
+ [Fact] void should_report_them_as_rules_it_could_not_express() => _analysis.Diagnostics.Select(_ => _.Code).Distinct(StringComparer.Ordinal).ShouldContainOnly([ScreenplayDiagnosticCodes.UnmappableValidationRule]);
+ [Fact] void should_say_what_the_rules_were_held_to() => _analysis.Diagnostics.Any(_ => _.Message.Contains("When(command => command.IsRush)", StringComparison.Ordinal)).ShouldBeTrue();
+ [Fact] void should_say_the_same_of_a_condition_written_the_other_way_round() => _analysis.Diagnostics.Any(_ => _.Message.Contains("Unless(command => command.IsRush)", StringComparison.Ordinal)).ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_taking_its_messages_from_a_resource.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_taking_its_messages_from_a_resource.cs
new file mode 100644
index 000000000..b3014301e
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_taking_its_messages_from_a_resource.cs
@@ -0,0 +1,139 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// An application speaking 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 every message written that
+/// way came back indistinguishable from one assembled while the request runs. A localized application therefore
+/// stated no message anywhere, which is the whole of what it says about what it will not accept.
+///
+/// The key is what is stated rather than the text, because the text is one of several the application holds and
+/// picking 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.
+///
+///
+public class a_validator_taking_its_messages_from_a_resource : Specification
+{
+ const string Source = """
+ using System.Globalization;
+ using System.Resources;
+ using Cratis.Arc.Commands;
+ using Cratis.Arc.Commands.ModelBound;
+ using FluentValidation;
+
+ namespace Library.Authors.Registration;
+
+ internal class AuthorMessages
+ {
+ static ResourceManager resourceMan;
+ static CultureInfo resourceCulture;
+
+ internal static ResourceManager ResourceManager
+ {
+ get
+ {
+ if (object.ReferenceEquals(resourceMan, null))
+ {
+ resourceMan = new ResourceManager("Library.AuthorMessages", typeof(AuthorMessages).Assembly);
+ }
+ return resourceMan;
+ }
+ }
+
+ internal static string NameRequired
+ {
+ get
+ {
+ return ResourceManager.GetString("NameRequired", resourceCulture);
+ }
+ }
+
+ internal static string Nickname_Required
+ {
+ get
+ {
+ return ResourceManager.GetString("Nickname-Required", resourceCulture);
+ }
+ }
+ }
+
+ internal class PenMessages
+ {
+ static ResourceManager resourceMan;
+ static CultureInfo resourceCulture;
+
+ internal static ResourceManager ResourceManager
+ {
+ get
+ {
+ if (object.ReferenceEquals(resourceMan, null))
+ {
+ resourceMan = new ResourceManager("Library.PenMessages", typeof(PenMessages).Assembly);
+ }
+ return resourceMan;
+ }
+ }
+
+ internal static string NameRequired
+ {
+ get
+ {
+ return ResourceManager.GetString("NameRequired", resourceCulture);
+ }
+ }
+ }
+
+ public static class AuthorConstants
+ {
+ public const string AliasRequired = "An author must have an alias";
+ }
+
+ [Command]
+ public record RegisterAuthor(string Name, string Pen, string Email, string Nickname, string Alias)
+ {
+ public void Handle()
+ {
+ }
+ }
+
+ public class RegisterAuthorValidator : CommandValidator
+ {
+ public RegisterAuthorValidator()
+ {
+ RuleFor(_ => _.Name).NotEmpty().WithMessage(_ => AuthorMessages.NameRequired);
+ RuleFor(_ => _.Pen).NotEmpty().WithMessage(_ => PenMessages.NameRequired);
+ RuleFor(_ => _.Email).NotEmpty().WithMessage(_ => string.Format(AuthorMessages.NameRequired, "an email address"));
+ RuleFor(_ => _.Nickname).NotEmpty().WithMessage(_ => AuthorMessages.Nickname_Required);
+ RuleFor(_ => _.Alias).NotEmpty().WithMessage(_ => AuthorConstants.AliasRequired);
+ }
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _rules;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(Source);
+ _rules = _analysis.Slice().Commands.First().Validations;
+ }
+
+ ValidationRuleModel Rule(string property) => _rules.First(_ => _.Property == property);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn((Analyzed.SlicePath, Source)).ShouldBeEmpty();
+ [Fact] void should_state_every_rule_a_message_was_written_after() => _rules.Select(_ => _.Property).ShouldContainOnly(["Name", "Pen", "Email", "Nickname", "Alias"]);
+ [Fact] void should_state_the_key_a_message_is_looked_up_by() => Rule("Name").Message.ShouldEqual("$strings.AuthorMessages.NameRequired");
+ [Fact] void should_qualify_the_key_by_the_resource_declaring_it() => Rule("Pen").Message.ShouldEqual("$strings.PenMessages.NameRequired");
+ [Fact] void should_leave_a_message_put_together_from_a_resource_off_its_rule() => Rule("Email").Message.ShouldBeNull();
+ [Fact] void should_leave_a_key_it_has_no_way_of_writing_off_its_rule() => Rule("Nickname").Message.ShouldBeNull();
+ [Fact] void should_still_recover_a_message_a_constant_holds() => Rule("Alias").Message.ShouldEqual("An author must have an alias");
+ [Fact] void should_report_only_the_messages_it_could_not_write_down() => _analysis.Diagnostics.Select(_ => _.Code).Distinct(StringComparer.Ordinal).ShouldContainOnly([ScreenplayDiagnosticCodes.UnmappableValidationRule]);
+ [Fact] void should_report_one_for_each_of_them() => _analysis.Diagnostics.Count.ShouldEqual(2);
+ [Fact] void should_say_which_message_it_was() => _analysis.Diagnostics.Any(_ => _.Message.Contains("AuthorMessages.Nickname_Required", StringComparison.Ordinal)).ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_taking_its_messages_from_constants.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_taking_its_messages_from_constants.cs
new file mode 100644
index 000000000..32fdc7bb0
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_taking_its_messages_from_constants.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// Wherever messages are declared once and named from every validator rather than repeated, the message reaches the
+/// rule through a lambda - and the lambda computes nothing, it names a constant the compiler already substituted.
+/// Reading only the direct form left a document with no message on any rule at all in exactly the applications that
+/// took the most care over them. The lambda that really does build its text while the request runs is the one shape
+/// there is nothing to write down for, and it stays reported.
+///
+public class a_validator_taking_its_messages_from_constants : Specification
+{
+ const string Source = """
+ using Cratis.Arc.Commands;
+ using Cratis.Arc.Commands.ModelBound;
+ using FluentValidation;
+
+ namespace Library.Authors.Registration;
+
+ public static class AuthorMessages
+ {
+ public const string NameRequired = "An author must have a name";
+ public static readonly string EmailRequired = "An author must have an email address";
+ }
+
+ [Command]
+ public record RegisterAuthor(string Name, string Pen, string Email, string Nickname)
+ {
+ public void Handle()
+ {
+ }
+ }
+
+ public class RegisterAuthorValidator : CommandValidator
+ {
+ public RegisterAuthorValidator()
+ {
+ RuleFor(_ => _.Name).NotEmpty().WithMessage(_ => AuthorMessages.NameRequired);
+ RuleFor(_ => _.Pen).NotEmpty().WithMessage(_ => "An author must have a pen name");
+ RuleFor(_ => _.Email).NotEmpty().WithMessage(_ => AuthorMessages.EmailRequired);
+ RuleFor(_ => _.Nickname).NotEmpty().WithMessage(command => $"'{command.Nickname}' will not do");
+ }
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _rules;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(Source);
+ _rules = _analysis.Slice().Commands.First().Validations;
+ }
+
+ ValidationRuleModel Rule(string property) => _rules.First(_ => _.Property == property);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_state_every_rule_a_message_was_written_after() => _rules.Select(_ => _.Property).ShouldContainOnly(["Name", "Pen", "Email", "Nickname"]);
+ [Fact] void should_recover_a_message_a_constant_holds() => Rule("Name").Message.ShouldEqual("An author must have a name");
+ [Fact] void should_recover_a_message_written_into_the_lambda() => Rule("Pen").Message.ShouldEqual("An author must have a pen name");
+ [Fact] void should_leave_a_message_only_the_running_application_holds_off_its_rule() => Rule("Email").Message.ShouldBeNull();
+ [Fact] void should_leave_a_message_built_from_the_value_off_its_rule() => Rule("Nickname").Message.ShouldBeNull();
+ [Fact] void should_report_only_the_messages_it_could_not_write_down() => _analysis.Diagnostics.Select(_ => _.Code).Distinct(StringComparer.Ordinal).ShouldContainOnly([ScreenplayDiagnosticCodes.UnmappableValidationRule]);
+ [Fact] void should_report_one_for_each_of_them() => _analysis.Diagnostics.Count.ShouldEqual(2);
+ [Fact] void should_say_which_message_it_was() => _analysis.Diagnostics.Any(_ => _.Message.Contains("AuthorMessages.EmailRequired", StringComparison.Ordinal)).ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_a_referenced_package_declares.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_a_referenced_package_declares.cs
new file mode 100644
index 000000000..7d863611b
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_a_referenced_package_declares.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A reactor observing what a sibling bounded context publishes refers to an event a package declares. The event is
+/// real, so a document saying nothing about it refers to a name it never introduces - and Screenplay has the
+/// construct for exactly this, which is what an import is for.
+///
+public class an_event_a_referenced_package_declares : Specification
+{
+ const string Contracts = """
+ using Cratis.Chronicle.Events;
+
+ namespace Partners.Contracts;
+
+ [EventType]
+ public record InvitationToJoinAdaAccepted(string Email);
+ """;
+
+ const string Slice = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+ using Partners.Contracts;
+
+ namespace Library.Admin.Invitations;
+
+ public class AcceptedInvitationProvisioner : IReactor
+ {
+ public Task Provision(InvitationToJoinAdaAccepted @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources = [("Library/Admin/Invitations/Provision.cs", Slice)];
+
+ MetadataReference _package;
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _package = Analyzed.Package("Partners.Contracts", Contracts);
+
+ void Because() => _analysis = Analyzed.SourceReferencing(_package, _sources);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_package, _sources).ShouldBeEmpty();
+ [Fact] void should_import_the_event_by_its_qualified_name() => _analysis.Model.Imports.ShouldContainOnly(["Partners.Contracts.InvitationToJoinAdaAccepted"]);
+ [Fact] void should_still_observe_it_from_the_reactor() => _analysis.Slice().Reactors.Single().ObservedEvents.ShouldContainOnly(["InvitationToJoinAdaAccepted"]);
+ [Fact] void should_not_report_it_as_undeclared() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation).ShouldBeFalse();
+ [Fact] void should_report_nothing_at_all() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_marking_a_collection_of_optional_values_as_personal_data.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_marking_a_collection_of_optional_values_as_personal_data.cs
new file mode 100644
index 000000000..043895dcc
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_marking_a_collection_of_optional_values_as_personal_data.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A concept is declared once and referenced by name, so a value marked as personal data has to be marked under the
+/// name it is referenced under. A collection of an optional value says one thing about the value and two about how
+/// many there are and whether it may be absent - strip fewer of them than the reference does and the mark lands on
+/// the name of the wrapper, leaving a document that says a value is not sensitive while the runtime encrypts it.
+///
+public class an_event_marking_a_collection_of_optional_values_as_personal_data : Specification
+{
+ const string Source = """
+ using System.Collections.Generic;
+ using Cratis.Chronicle.Compliance.GDPR;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ public enum AuthorStanding
+ {
+ Member,
+ Honorary
+ }
+
+ public enum AuthorTier
+ {
+ Standard,
+ Premium
+ }
+
+ [EventType]
+ public record AuthorRegistered([PII] IEnumerable Standings, IEnumerable Tiers);
+ """;
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(Source);
+
+ bool IsPii(string name) => _analysis.Model.Concepts.Single(_ => _.Name == name).IsPii;
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_mark_the_value_the_collection_holds() => IsPii("AuthorStanding").ShouldBeTrue();
+ [Fact] void should_leave_the_unmarked_value_alone() => IsPii("AuthorTier").ShouldBeFalse();
+ [Fact] void should_declare_a_concept_for_nothing_the_reference_stripped() => _analysis.Model.Concepts.Select(_ => _.Name).ShouldContainOnly(["AuthorStanding", "AuthorTier"]);
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_nothing_declares.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_nothing_declares.cs
new file mode 100644
index 000000000..2a0dbb732
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_nothing_declares.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A constraint can name any type at all, and a type nothing declares as an event is a name no import can introduce -
+/// importing it would state that a package declares an event it does not. This is what is left for the report to say.
+///
+public class an_event_nothing_declares : Specification
+{
+ const string Contracts = """
+ namespace Partners.Contracts;
+
+ public record CustomerRegistered(string Name);
+ """;
+
+ const string Slice = """
+ using Cratis.Chronicle.Events.Constraints;
+ using Partners.Contracts;
+
+ namespace Library.Customers.Registration;
+
+ public class UniqueCustomerConstraint : IConstraint
+ {
+ public void Define(IConstraintBuilder builder) => builder.Unique();
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources = [("Library/Customers/Registration/Registration.cs", Slice)];
+
+ MetadataReference _package;
+ ApplicationModelAnalysis _analysis;
+ ScreenplayDiagnostic _reported;
+
+ void Establish() => _package = Analyzed.Package("Partners.Contracts", Contracts);
+
+ void Because()
+ {
+ _analysis = Analyzed.SourceReferencing(_package, _sources);
+ _reported = _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation);
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_package, _sources).ShouldBeEmpty();
+ [Fact] void should_import_nothing() => _analysis.Model.Imports.ShouldBeEmpty();
+ [Fact] void should_name_the_event_in_the_report() => _reported.Message.Contains("CustomerRegistered", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_locate_the_report_at_the_slice() => _reported.Location.ShouldEqual("Library.Customers.Registration");
+ [Fact] void should_report_it_as_a_loss() => _reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/concepts_carried_only_inside_a_record.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/concepts_carried_only_inside_a_record.cs
new file mode 100644
index 000000000..18f5127f8
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/concepts_carried_only_inside_a_record.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A record is referred to by name and never declared, so nothing inside it is ever named on its own. Collecting only
+/// the types written straight onto an artifact therefore lost every concept reached through one - and a concept marked
+/// as personal data lost that way leaves a document understating what the application holds about people, which is the
+/// one thing declaring concepts is most for. A concept can be declared wherever it was reached from, so it is.
+///
+public class concepts_carried_only_inside_a_record : Specification
+{
+ const string Source = """
+ using System;
+ using System.Collections.Generic;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Compliance.GDPR;
+ using Cratis.Chronicle.Events;
+ using Cratis.Concepts;
+
+ namespace Library.Authors.Registration;
+
+ public record AuthorId(Guid Value) : ConceptAs(Value);
+
+ [PII("The name of a person")]
+ public record FirstName(string Value) : ConceptAs(Value);
+
+ public record MentorNote(string Value) : ConceptAs(Value);
+
+ public record ShelfCode(string Value) : ConceptAs(Value);
+
+ public enum ContactPreference { Email, Phone }
+
+ public record PersonalDetails(FirstName First, ContactPreference Preference);
+
+ public record Mentorship(MentorNote Note, Mentorship? Next);
+
+ [EventType]
+ public record AuthorRegistered(AuthorId Id, PersonalDetails Details, IEnumerable Mentors);
+
+ [ReadModel]
+ public record Author
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public ShelfCode Shelf { get; init; } = new(string.Empty);
+
+ public static IEnumerable All() => [];
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(Source);
+
+ ConceptModel Concept(string name) => _analysis.Model.Concepts.First(_ => _.Name == name);
+
+ IEnumerable Shapes =>
+ _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UndeclarableShape);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_declare_a_concept_carried_inside_a_record() => Concept("FirstName").Primitive.ShouldEqual(ScreenplayPrimitive.String);
+ [Fact] void should_keep_what_that_concept_says_about_personal_data() => Concept("FirstName").IsPii.ShouldBeTrue();
+ [Fact] void should_declare_an_enumeration_carried_inside_a_record() => Concept("ContactPreference").EnumValues.ShouldContainOnly(["Email", "Phone"]);
+ [Fact] void should_declare_a_concept_carried_inside_a_record_a_collection_holds() => Concept("MentorNote").Primitive.ShouldEqual(ScreenplayPrimitive.String);
+ [Fact] void should_declare_a_concept_only_a_read_model_carries() => Concept("ShelfCode").Primitive.ShouldEqual(ScreenplayPrimitive.String);
+ [Fact] void should_walk_a_record_referring_to_itself_only_once() => _analysis.Model.Concepts.Select(_ => _.Name).ShouldContainOnly(["AuthorId", "ContactPreference", "FirstName", "MentorNote", "ShelfCode"]);
+ [Fact] void should_say_the_shape_of_a_record_a_property_carries_is_not_declared() => Shapes.Count().ShouldEqual(2);
+ [Fact] void should_name_the_records_it_could_not_declare() => Shapes.All(_ => _.Message.Contains("PersonalDetails", StringComparison.Ordinal) || _.Message.Contains("Mentorship", StringComparison.Ordinal)).ShouldBeTrue();
+ [Fact] void should_not_say_it_of_a_read_model_the_slice_describes() => Shapes.Any(_ => _.Message.Contains("Author'", StringComparison.Ordinal)).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_an_error_sits_in_a_partial_the_build_wrote.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_an_error_sits_in_a_partial_the_build_wrote.cs
new file mode 100644
index 000000000..e70e26486
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_an_error_sits_in_a_partial_the_build_wrote.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing.source_that_did_not_compile;
+
+///
+/// A type is written in as many places as it has partial declarations, and the Cratis house style puts one of them in
+/// the intermediate folder of the build. An error there is an error inside the artifact just as much as one in the
+/// file a person wrote, so every place a type is written counts - reading only the first would call a declaration
+/// clean on the strength of the half that happens to be listed first.
+///
+public class and_an_error_sits_in_a_partial_the_build_wrote : Specification
+{
+ const string Written = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+
+ namespace Library.Onboarding.Registry;
+
+ [EventType]
+ public record CompanyRegistered(string Name);
+
+ public partial class RegistryNotifier : IReactor
+ {
+ public Task CompanyRegistered(CompanyRegistered @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ const string Emitted = """
+ namespace Library.Onboarding.Registry;
+
+ public partial class RegistryNotifier
+ {
+ public string Describe() => RegistryMessages.Describe;
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Onboarding/Registry/Registry.cs", Written),
+ ("Library/obj/Debug/net10.0/Generated/Registry.Logging.g.cs", Emitted)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(_sources);
+
+ ScreenplayDiagnostic Reported => _analysis.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
+
+ [Fact] void should_be_analyzing_source_that_really_does_not_compile() => Analyzed.ErrorsIn(_sources).ShouldNotBeEmpty();
+ [Fact] void should_report_it_as_a_warning() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+ [Fact] void should_not_count_the_reactor_the_error_is_written_inside() => Reported.Message.Contains("2 artifact(s) were recovered anyway, 1 of them", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_still_return_the_reactor() => _analysis.Slice().Reactors.Single().Name.ShouldEqual("RegistryNotifier");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_every_artifact_it_recovered_carries_an_error.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_every_artifact_it_recovered_carries_an_error.cs
new file mode 100644
index 000000000..04996a40a
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_every_artifact_it_recovered_carries_an_error.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing.source_that_did_not_compile;
+
+///
+/// Something was recovered, but every declaration it came out of is one the compiler could not make sense of - so
+/// what the document says about each of them is read from source that never held together. That is the other way
+/// recovery is genuinely prevented, and it is an error for the same reason as recovering nothing at all: there is
+/// no part of the document a reader could trust.
+///
+public class and_every_artifact_it_recovered_carries_an_error : Specification
+{
+ const string Source = """
+ using Cratis.Arc.Commands.ModelBound;
+
+ namespace Library.Authors.Registration;
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public void Handle() => ThisDoesNotExist();
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(Source);
+
+ ScreenplayDiagnostic Reported => _analysis.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
+
+ [Fact] void should_be_analyzing_source_that_really_does_not_compile() => Analyzed.ErrorsIn((Analyzed.SlicePath, Source)).ShouldNotBeEmpty();
+ [Fact] void should_report_it_as_an_error() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Error);
+ [Fact] void should_say_how_much_it_recovered() => Reported.Message.Contains("1 artifact(s) were recovered", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_say_the_errors_sit_inside_what_it_recovered() => Reported.Message.Contains("every declaration they were read from is one an error sits inside", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_say_the_document_cannot_be_relied_on() => Reported.Message.Contains("describes the application reliably", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_still_return_whatever_it_recovered() => _analysis.Slice().Commands.Single().Name.ShouldEqual("RegisterAuthor");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_nothing_at_all_was_recovered.cs
similarity index 53%
rename from Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile.cs
rename to Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_nothing_at_all_was_recovered.cs
index b5cb660f1..bf521480c 100644
--- a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile.cs
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_nothing_at_all_was_recovered.cs
@@ -3,29 +3,22 @@
using Cratis.Arc.Screenplay.Analysis;
-namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing.source_that_did_not_compile;
///
-/// Source that does not build still yields symbols, and analyzing them produces a document that looks like an
-/// answer while describing an application that does not exist. A nearly empty document handed back with nothing
-/// wrong reported is the one outcome nobody can act on, so this is an error - which is what makes a host exit non
-/// zero - while whatever was recovered is still returned for whoever wants to look at it.
+/// A build that produced nothing an artifact could be read out of is the case the claim was always true for - a
+/// document describing an empty application handed back with nothing wrong reported is the one outcome nobody can
+/// act on, so this is an error and a host exits non zero on it. It is also the case that must not be mistaken for
+/// an application which simply declares no artifact yet, so "declares nothing" stays suppressed.
///
-public class source_that_did_not_compile : Specification
+public class and_nothing_at_all_was_recovered : Specification
{
const string Source = """
- using Cratis.Arc.Commands.ModelBound;
- using Cratis.Chronicle.Events;
+ namespace Library.Plumbing;
- namespace Library.Authors.Registration;
-
- [EventType]
- public record AuthorRegistered(string Name);
-
- [Command]
- public record RegisterAuthor(string Name)
+ public class NotAnArtifact
{
- public AuthorRegistered Handle() => new(ThisDoesNotExist);
+ public string Name() => ThisDoesNotExist;
}
""";
@@ -35,11 +28,13 @@ public record RegisterAuthor(string Name)
ScreenplayDiagnostic Reported => _analysis.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
- [Fact] void should_be_analyzing_source_that_really_does_not_compile() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldNotBeEmpty();
+ [Fact] void should_be_analyzing_source_that_really_does_not_compile() => Analyzed.ErrorsIn((Analyzed.SlicePath, Source)).ShouldNotBeEmpty();
[Fact] void should_report_that_the_source_did_not_compile() => _analysis.Diagnostics.Select(_ => _.Code).ShouldContain(ScreenplayDiagnosticCodes.SourceDidNotCompile);
[Fact] void should_report_it_as_an_error() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Error);
+ [Fact] void should_say_nothing_at_all_came_out_of_it() => Reported.Message.Contains("Nothing at all was recovered from it", StringComparison.Ordinal).ShouldBeTrue();
[Fact] void should_say_the_document_cannot_be_relied_on() => Reported.Message.Contains("describes the application reliably", StringComparison.Ordinal).ShouldBeTrue();
[Fact] void should_quote_the_first_thing_the_compiler_said() => Reported.Message.Contains("ThisDoesNotExist", StringComparison.Ordinal).ShouldBeTrue();
- [Fact] void should_still_return_whatever_it_recovered() => _analysis.Slice().Commands.Single().Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_not_also_say_the_application_declares_nothing() => _analysis.Diagnostics.Select(_ => _.Code).ShouldNotContain(ScreenplayDiagnosticCodes.AnalysisUnavailable);
+ [Fact] void should_recover_no_slice() => _analysis.Model.Slices.ShouldBeEmpty();
[Fact] void should_not_throw_instead() => _analysis.Model.ShouldNotBeNull();
}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_only_some_of_what_it_recovered_carries_an_error.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_only_some_of_what_it_recovered_carries_an_error.cs
new file mode 100644
index 000000000..5054f2cbb
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_only_some_of_what_it_recovered_carries_an_error.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing.source_that_did_not_compile;
+
+///
+/// This is the boundary between the two severities. One artifact was read from a declaration an error sits inside
+/// and one was not, and the one that was not is described exactly as its source states it - so "nothing recovered
+/// describes the application reliably" is already false with a single clean declaration. The severity therefore
+/// turns on whether any survived rather than on whether any was hit, and the count says which is which.
+///
+public class and_only_some_of_what_it_recovered_carries_an_error : Specification
+{
+ const string Source = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(ThisDoesNotExist);
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(Source);
+
+ ScreenplayDiagnostic Reported => _analysis.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
+
+ [Fact] void should_be_analyzing_source_that_really_does_not_compile() => Analyzed.ErrorsIn((Analyzed.SlicePath, Source)).ShouldNotBeEmpty();
+ [Fact] void should_report_it_as_a_warning() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+ [Fact] void should_report_nothing_at_all_as_an_error() => _analysis.Diagnostics.Any(_ => _.Severity == ScreenplayDiagnosticSeverity.Error).ShouldBeFalse();
+ [Fact] void should_count_only_the_one_no_error_sits_inside() => Reported.Message.Contains("2 artifact(s) were recovered anyway, 1 of them", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_quote_the_first_thing_the_compiler_said() => Reported.Message.Contains("ThisDoesNotExist", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_still_return_whatever_it_recovered() => _analysis.Slice().Commands.Single().Name.ShouldEqual("RegisterAuthor");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_the_errors_sit_outside_what_it_recovered.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_the_errors_sit_outside_what_it_recovered.cs
new file mode 100644
index 000000000..83e8793f2
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/source_that_did_not_compile/and_the_errors_sit_outside_what_it_recovered.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing.source_that_did_not_compile;
+
+///
+/// This is the case that made the claim a lie. A host handing over a compilation assembled without the compile items
+/// a build generates leaves every reference to a strongly typed resource class unresolved, and the errors then sit in
+/// source that declares no artifact at all while every command and event is read exactly as written. Calling that an
+/// error says something untrue about a document that is entirely correct and makes the host throw it away, so it is a
+/// warning that states both facts - what failed, and how much came through anyway.
+///
+public class and_the_errors_sit_outside_what_it_recovered : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Wording = """
+ namespace Library.Authors;
+
+ public static class Wording
+ {
+ public static string NameIsRequired() => AuthorsMessages.NameIsRequired;
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Wording.cs", Wording)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(_sources);
+
+ ScreenplayDiagnostic Reported => _analysis.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
+
+ [Fact] void should_be_analyzing_source_that_really_does_not_compile() => Analyzed.ErrorsIn(_sources).ShouldNotBeEmpty();
+ [Fact] void should_report_that_the_source_did_not_compile() => _analysis.Diagnostics.Select(_ => _.Code).ShouldContain(ScreenplayDiagnosticCodes.SourceDidNotCompile);
+ [Fact] void should_report_it_as_a_warning() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+ [Fact] void should_report_nothing_at_all_as_an_error() => _analysis.Diagnostics.Any(_ => _.Severity == ScreenplayDiagnosticSeverity.Error).ShouldBeFalse();
+ [Fact] void should_say_how_many_errors_there_were() => Reported.Message.Contains("1 error(s)", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_quote_the_first_thing_the_compiler_said() => Reported.Message.Contains("AuthorsMessages", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_say_how_much_it_recovered_anyway() => Reported.Message.Contains("2 artifact(s) were recovered anyway, 2 of them", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_hint_at_what_the_host_left_out() => Reported.Message.Contains("without the compile items a build generates", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_not_claim_nothing_describes_the_application_reliably() => Reported.Message.Contains("describes the application reliably", StringComparison.Ordinal).ShouldBeFalse();
+ [Fact] void should_recover_the_command() => _analysis.Slice().Commands.Single().Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_recover_the_event() => _analysis.Slice().Events.Single().Name.ShouldEqual("AuthorRegistered");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_command_two_of_them_declare_under_one_name.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_command_two_of_them_declare_under_one_name.cs
new file mode 100644
index 000000000..85e301e89
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_command_two_of_them_declare_under_one_name.cs
@@ -0,0 +1,86 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// Two projects declaring into one namespace write one slice, and everything within a slice is named once - a
+/// document declaring two commands called PlaceOrder in one slice says the same word twice and means it
+/// differently. Only the first can be described, so the second has to be reported rather than dropped where nobody
+/// would see it.
+///
+public class a_command_two_of_them_declare_under_one_name : Specification
+{
+ const string First = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Ordering.Placing;
+
+ [EventType]
+ public record OrderPlaced(string Reference);
+
+ [Command]
+ public record PlaceOrder(string Reference)
+ {
+ public OrderPlaced Handle() => new(Reference);
+ }
+ """;
+
+ const string Second = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Ordering.Placing;
+
+ [EventType]
+ public record OrderRejected(string Reference);
+
+ [Command]
+ public record PlaceOrder(string Reference)
+ {
+ public OrderRejected Handle() => new(Reference);
+ }
+ """;
+
+ Compilation _adapter;
+ Compilation _application;
+ ApplicationModelAnalysis _analysis;
+ SliceModel _slice;
+ ScreenplayDiagnostic _reported;
+
+ void Establish()
+ {
+ _adapter = Analyzed.Project(
+ "Library.Adapter",
+ [],
+ ("Source/Library.Adapter/Adapter.cs", "namespace Library.Adapter;"),
+ ("Source/Library.Adapter/Ordering/Placing/Placing.cs", Second));
+
+ _application = Analyzed.Project(
+ "Library",
+ [],
+ ("Source/Library/Program.cs", "namespace Library;"),
+ ("Source/Library/Ordering/Placing/Placing.cs", First));
+ }
+
+ void Because()
+ {
+ _analysis = Analyzed.Projects(_adapter, _application);
+ _slice = _analysis.Model.Slices.Single();
+ _reported = _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.RepeatedDeclarationAcrossProjects);
+ }
+
+ [Fact] void should_compile_the_one_project() => Analyzed.ErrorsIn(_adapter).ShouldBeEmpty();
+ [Fact] void should_compile_the_other_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_declare_the_command_once() => _slice.Commands.Count().ShouldEqual(1);
+ [Fact] void should_keep_the_one_the_first_project_read_declares() => _slice.Commands.Single().Produces.Single().EventName.ShouldEqual("OrderPlaced");
+ [Fact] void should_keep_everything_that_does_not_repeat_a_name() => _slice.Events.Select(_ => _.Name).ShouldContainOnly(["OrderPlaced", "OrderRejected"]);
+ [Fact] void should_say_what_was_left_out() => _reported.Message.Contains("'PlaceOrder' is a command", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_locate_the_report_at_the_slice() => _reported.Location.ShouldEqual("Library.Ordering.Placing");
+ [Fact] void should_report_it_as_a_loss() => _reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_concept_two_of_them_refer_to.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_concept_two_of_them_refer_to.cs
new file mode 100644
index 000000000..d6f1650df
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_concept_two_of_them_refer_to.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// A concept is declared once at the top of the document and referred to by name from there on, which is a fact
+/// about the document rather than about a project. A shared kernel referred to from every project of an application
+/// is the ordinary case, and declaring it once per project would leave a document declaring the same concept three
+/// times - and the rules a validator states for it attached to whichever copy happened to be first.
+///
+public class a_concept_two_of_them_refer_to : Specification
+{
+ const string Kernel = """
+ using Cratis.Arc.Validation;
+ using Cratis.Concepts;
+ using FluentValidation;
+
+ namespace Library;
+
+ public record Isbn(string Value) : ConceptAs(Value);
+
+ public class IsbnValidator : ConceptValidator
+ {
+ public IsbnValidator()
+ {
+ RuleFor(_ => _.Value).NotEmpty();
+ }
+ }
+ """;
+
+ const string Reserving = """
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Lending.Reserving;
+
+ [EventType]
+ public record BookReserved(Isbn Isbn);
+ """;
+
+ const string Returning = """
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Lending.Returning;
+
+ [EventType]
+ public record BookReturned(Isbn Isbn);
+ """;
+
+ Compilation _kernel;
+ Compilation _lending;
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish()
+ {
+ _kernel = Analyzed.Project(
+ "Library.Kernel",
+ [],
+ ("Source/Library.Kernel/Kernel.cs", "namespace Library.Kernel;"),
+ ("Source/Library.Kernel/Isbn.cs", Kernel));
+
+ _lending = Analyzed.Project(
+ "Library.Lending",
+ [_kernel.ToMetadataReference()],
+ ("Source/Library.Lending/Lending.cs", "namespace Library.Lending;"),
+ ("Source/Library.Lending/Reserving/Reserving.cs", Reserving),
+ ("Source/Library.Lending/Returning/Returning.cs", Returning));
+ }
+
+ void Because() => _analysis = Analyzed.Projects(_lending, _kernel);
+
+ [Fact] void should_compile_the_kernel_project() => Analyzed.ErrorsIn(_kernel).ShouldBeEmpty();
+ [Fact] void should_compile_the_lending_project() => Analyzed.ErrorsIn(_lending).ShouldBeEmpty();
+ [Fact] void should_declare_the_concept_once() => _analysis.Model.Concepts.Count(_ => string.Equals(_.Name, "Isbn", StringComparison.Ordinal)).ShouldEqual(1);
+ [Fact] void should_attach_the_rules_the_project_declaring_it_states() => _analysis.Model.Concepts.Single(_ => string.Equals(_.Name, "Isbn", StringComparison.Ordinal)).Validations.ShouldNotBeEmpty();
+ [Fact] void should_not_report_it_as_sharing_its_name() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.AmbiguousConceptName).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_namespace_two_of_them_declare_into.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_namespace_two_of_them_declare_into.cs
new file mode 100644
index 000000000..cffc9b420
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_namespace_two_of_them_declare_into.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// A slice is recovered from a namespace, and nothing says a namespace belongs to one project. A contracts project
+/// publishing the events of a slice while the project beside it handles the command producing them writes one slice
+/// from two compilations - and a document holding it twice would say the slice twice and describe half of it in
+/// each.
+///
+public class a_namespace_two_of_them_declare_into : Specification
+{
+ const string Contracts = """
+ using Cratis.Chronicle.Events;
+ using Cratis.Concepts;
+
+ namespace Library.Ordering.Placing;
+
+ public record OrderReference(string Value) : ConceptAs(Value);
+
+ [EventType]
+ public record OrderPlaced(OrderReference Reference);
+ """;
+
+ const string Handling = """
+ using Cratis.Arc.Commands.ModelBound;
+
+ namespace Library.Ordering.Placing;
+
+ [Command]
+ public record PlaceOrder(OrderReference Reference)
+ {
+ public OrderPlaced Handle() => new(Reference);
+ }
+ """;
+
+ Compilation _contracts;
+ Compilation _application;
+ ApplicationModelAnalysis _analysis;
+ SliceModel _slice;
+
+ void Establish()
+ {
+ _contracts = Analyzed.Project(
+ "Library.Contracts",
+ [],
+ ("Source/Library.Contracts/Contracts.cs", "namespace Library.Contracts;"),
+ ("Source/Library.Contracts/Ordering/Placing/Placing.cs", Contracts));
+
+ _application = Analyzed.Project(
+ "Library",
+ [_contracts.ToMetadataReference()],
+ ("Source/Library/Program.cs", "namespace Library;"),
+ ("Source/Library/Ordering/Placing/Placing.cs", Handling));
+ }
+
+ void Because()
+ {
+ _analysis = Analyzed.Projects(_contracts, _application);
+ _slice = _analysis.Model.Slices.Single();
+ }
+
+ [Fact] void should_compile_the_contracts_project() => Analyzed.ErrorsIn(_contracts).ShouldBeEmpty();
+ [Fact] void should_compile_the_application_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_describe_the_namespace_as_one_slice() => _slice.Namespace.ShouldEqual("Library.Ordering.Placing");
+ [Fact] void should_hold_the_command_the_one_project_declares() => _slice.Commands.Single().Name.ShouldEqual("PlaceOrder");
+ [Fact] void should_hold_the_event_the_other_project_declares() => _slice.Events.Single().Name.ShouldEqual("OrderPlaced");
+ [Fact] void should_state_what_the_command_produces() => _slice.Commands.Single().Produces.Single().EventName.ShouldEqual("OrderPlaced");
+ [Fact] void should_take_the_kind_from_everything_the_projects_hold_together() => _slice.Kind.ShouldEqual(SliceKind.StateChange);
+ [Fact] void should_report_nothing_at_all() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_policy_registered_where_the_host_is_composed.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_policy_registered_where_the_host_is_composed.cs
new file mode 100644
index 000000000..e261434b4
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/a_policy_registered_where_the_host_is_composed.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// An artifact names a policy and the composition root says what it means, and the whole point of layering an
+/// application is that the host is not where the behavior lives. Reading only the project the artifact is in would
+/// find no registration at all and declare every policy of a layered application as one whose rule is not stated.
+///
+public class a_policy_registered_where_the_host_is_composed : Specification
+{
+ const string Framework = """
+ using System;
+
+ namespace Microsoft.AspNetCore.Authorization;
+
+ public class AuthorizationPolicyBuilder
+ {
+ public AuthorizationPolicyBuilder RequireRole(params string[] roles) => this;
+ }
+
+ public class AuthorizationOptions
+ {
+ public void AddPolicy(string name, Action configurePolicy)
+ {
+ }
+ }
+ """;
+
+ const string Composition = """
+ using Microsoft.AspNetCore.Authorization;
+
+ namespace Library.Host;
+
+ public static class Composition
+ {
+ public static void Authorization(AuthorizationOptions options) =>
+ options.AddPolicy("CanReserve", policy => policy.RequireRole("Librarian"));
+ }
+ """;
+
+ const string Slice = """
+ using Cratis.Arc.Authorization;
+ using Cratis.Arc.Commands.ModelBound;
+
+ namespace Library.Lending.Reserving;
+
+ [Command]
+ [Authorize(Policy = "CanReserve")]
+ public record ReserveBook(string Isbn)
+ {
+ public void Handle()
+ {
+ }
+ }
+ """;
+
+ Compilation _domain;
+ Compilation _host;
+ ApplicationModelAnalysis _analysis;
+
+ void Establish()
+ {
+ _domain = Analyzed.Project(
+ "Library.Domain",
+ [],
+ ("Source/Library.Domain/Domain.cs", "namespace Library.Domain;"),
+ ("Source/Library.Domain/Lending/Reserving/Reserving.cs", Slice));
+
+ _host = Analyzed.Project(
+ "Library.Host",
+ [],
+ ("Source/Library.Host/Authorization.cs", Framework),
+ ("Source/Library.Host/Composition.cs", Composition));
+ }
+
+ void Because() => _analysis = Analyzed.Projects(_domain, _host);
+
+ PolicyModel Policy => _analysis.Model.Policies.Single();
+
+ [Fact] void should_compile_the_domain_project() => Analyzed.ErrorsIn(_domain).ShouldBeEmpty();
+ [Fact] void should_compile_the_host_project() => Analyzed.ErrorsIn(_host).ShouldBeEmpty();
+ [Fact] void should_declare_the_policy_the_command_names() => Policy.Name.ShouldEqual("CanReserve");
+ [Fact] void should_state_what_the_host_registered_it_as_requiring() => Policy.Requirement.ShouldBeOfExactType();
+ [Fact] void should_not_report_it_as_unregistered() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.PolicyRequirementsUnrecoverable).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_aggregate_root_a_command_in_another_of_them_uses.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_aggregate_root_a_command_in_another_of_them_uses.cs
new file mode 100644
index 000000000..72b11218d
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_aggregate_root_a_command_in_another_of_them_uses.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// What an aggregate root applies is stated through the command that hands it its work, and an aggregate root
+/// nothing calls is reported because its events then go unstated. An aggregate root in a domain project called by a
+/// command in the project above it is called by the application, and reading the two projects apart would report
+/// every aggregate root of a layered application as one nothing uses.
+///
+public class an_aggregate_root_a_command_in_another_of_them_uses : Specification
+{
+ const string Domain = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Chronicle.Aggregates;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Lending.Reservations;
+
+ [EventType]
+ public record BookReserved(string Isbn, string Member);
+
+ public class Reservation : AggregateRoot
+ {
+ public Task Reserve(string isbn, string member) => Apply(new BookReserved(isbn, member));
+ }
+ """;
+
+ const string Application = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Commands.ModelBound;
+ using Library.Lending.Reservations;
+
+ namespace Library.Lending.Reserving;
+
+ [Command]
+ public record ReserveBook(string Isbn, string MemberId)
+ {
+ public async Task Handle(Reservation reservation)
+ {
+ await reservation.Reserve(Isbn, MemberId);
+ await reservation.Commit();
+ }
+ }
+ """;
+
+ Compilation _domain;
+ Compilation _application;
+ ApplicationModelAnalysis _analysis;
+
+ void Establish()
+ {
+ _domain = Analyzed.Project(
+ "Library.Domain",
+ [],
+ ("Source/Library.Domain/Domain.cs", "namespace Library.Domain;"),
+ ("Source/Library.Domain/Lending/Reservations/Reservations.cs", Domain));
+
+ _application = Analyzed.Project(
+ "Library.Application",
+ [_domain.ToMetadataReference()],
+ ("Source/Library.Application/Application.cs", "namespace Library.Application;"),
+ ("Source/Library.Application/Lending/Reserving/Reserving.cs", Application));
+ }
+
+ void Because() => _analysis = Analyzed.Projects(_domain, _application);
+
+ ProducesModel Produces => _analysis.Model.Slices.SelectMany(_ => _.Commands).Single().Produces.Single();
+
+ [Fact] void should_compile_the_domain_project() => Analyzed.ErrorsIn(_domain).ShouldBeEmpty();
+ [Fact] void should_compile_the_application_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_state_the_event_the_aggregate_root_applies_as_produced_by_the_command() => Produces.EventName.ShouldEqual("BookReserved");
+ [Fact] void should_not_report_the_aggregate_root_as_one_nothing_uses() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.AggregateRootWithoutCounterpart).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_event_a_package_beyond_all_of_them_declares.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_event_a_package_beyond_all_of_them_declares.cs
new file mode 100644
index 000000000..bad4d8849
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_event_a_package_beyond_all_of_them_declares.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// A sibling project stops being something to import because it is part of the application. A package is not, and
+/// leaving the projects out of the search must not leave the packages out with them - an event a sibling bounded
+/// context publishes is still real, still referred to, and still has to be stated rather than left as a name the
+/// document never introduces.
+///
+public class an_event_a_package_beyond_all_of_them_declares : Specification
+{
+ const string Package = """
+ using Cratis.Chronicle.Events;
+
+ namespace Partners.Contracts;
+
+ [EventType]
+ public record InvitationAccepted(string Email);
+ """;
+
+ const string Contracts = """
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Ordering.Placing;
+
+ [EventType]
+ public record OrderPlaced(string Reference);
+ """;
+
+ const string Slice = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+ using Partners.Contracts;
+
+ namespace Library.Admin.Invitations;
+
+ public class AcceptedInvitationProvisioner : IReactor
+ {
+ public Task Provision(InvitationAccepted @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ MetadataReference _package;
+ Compilation _contracts;
+ Compilation _application;
+ ApplicationModelAnalysis _analysis;
+
+ void Establish()
+ {
+ _package = Analyzed.Package("Partners.Contracts", Package);
+
+ _contracts = Analyzed.Project(
+ "Library.Contracts",
+ [],
+ ("Source/Library.Contracts/Contracts.cs", "namespace Library.Contracts;"),
+ ("Source/Library.Contracts/Ordering/Placing/Placing.cs", Contracts));
+
+ _application = Analyzed.Project(
+ "Library",
+ [_contracts.ToMetadataReference(), _package],
+ ("Source/Library/Program.cs", "namespace Library;"),
+ ("Source/Library/Admin/Invitations/Provision.cs", Slice));
+ }
+
+ void Because() => _analysis = Analyzed.Projects(_application, _contracts);
+
+ [Fact] void should_compile_the_contracts_project() => Analyzed.ErrorsIn(_contracts).ShouldBeEmpty();
+ [Fact] void should_compile_the_application_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_import_the_event_the_package_declares() => _analysis.Model.Imports.ShouldContainOnly(["Partners.Contracts.InvitationAccepted"]);
+ [Fact] void should_not_import_the_event_a_project_of_the_application_declares() => _analysis.Model.Imports.Any(_ => _.Contains("OrderPlaced", StringComparison.Ordinal)).ShouldBeFalse();
+ [Fact] void should_not_report_anything_as_undeclared() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_event_a_sibling_project_declares.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_event_a_sibling_project_declares.cs
new file mode 100644
index 000000000..ef78dba3d
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/an_event_a_sibling_project_declares.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// An event a referenced package declares is imported, because it is real and outside the application. A sibling
+/// project reaches the analyzer through exactly the same door - a project reference is a referenced assembly - and
+/// it is not outside the application at all. Importing it would state a dependency on part of the application
+/// itself, so it has to be declared like anything else the application holds.
+///
+public class an_event_a_sibling_project_declares : Specification
+{
+ const string Contracts = """
+ using Cratis.Chronicle.Events;
+
+ namespace Partners.Invitations;
+
+ [EventType]
+ public record InvitationAccepted(string Email);
+ """;
+
+ const string Slice = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+ using Partners.Invitations;
+
+ namespace Library.Admin.Invitations;
+
+ public class AcceptedInvitationProvisioner : IReactor
+ {
+ public Task Provision(InvitationAccepted @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ Compilation _contracts;
+ Compilation _application;
+ ApplicationModelAnalysis _analysis;
+
+ void Establish()
+ {
+ _contracts = Analyzed.Project(
+ "Partners.Contracts",
+ [],
+ ("Source/Partners.Contracts/Contracts.cs", "namespace Partners;"),
+ ("Source/Partners.Contracts/Invitations/Invitations.cs", Contracts));
+
+ _application = Analyzed.Project(
+ "Library",
+ [_contracts.ToMetadataReference()],
+ ("Source/Library/Program.cs", "namespace Library;"),
+ ("Source/Library/Admin/Invitations/Provision.cs", Slice));
+ }
+
+ void Because() => _analysis = Analyzed.Projects(_application, _contracts);
+
+ SliceModel SliceIn(string @namespace) => _analysis.Model.Slices.Single(_ => string.Equals(_.Namespace, @namespace, StringComparison.Ordinal));
+
+ [Fact] void should_compile_the_contracts_project() => Analyzed.ErrorsIn(_contracts).ShouldBeEmpty();
+ [Fact] void should_compile_the_application_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_declare_the_event_in_the_slice_the_sibling_project_wrote_it_in() => SliceIn("Partners.Invitations").Events.Single().Name.ShouldEqual("InvitationAccepted");
+ [Fact] void should_still_observe_it_from_the_reactor() => SliceIn("Library.Admin.Invitations").Reactors.Single().ObservedEvents.ShouldContainOnly(["InvitationAccepted"]);
+ [Fact] void should_import_nothing() => _analysis.Model.Imports.ShouldBeEmpty();
+ [Fact] void should_not_report_it_as_undeclared() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation).ShouldBeFalse();
+ [Fact] void should_report_nothing_at_all() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/projects_that_share_no_directory.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/projects_that_share_no_directory.cs
new file mode 100644
index 000000000..05211c29c
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/projects_that_share_no_directory.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// Projects checked out beside each other in unrelated places share nothing but the root of the file system, and
+/// writing a path relative to that is not writing it relative to anything - it leaves the machine's own layout
+/// behind while looking like a relative path, which is the one thing a path in a document must never do. Each
+/// project's own root is used instead, and a path then says where a file sits within its project and nothing about
+/// which project that is, so it is said outright.
+///
+public class projects_that_share_no_directory : Specification
+{
+ const string Placing = """
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Ordering.Placing;
+
+ [EventType]
+ public record OrderPlaced(string Reference);
+ """;
+
+ const string Shipping = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+ using Library.Ordering.Placing;
+
+ namespace Library.Shipping.Dispatching;
+
+ public class Dispatcher : IReactor
+ {
+ public Task Dispatch(OrderPlaced @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ Compilation _contracts;
+ Compilation _application;
+ ApplicationModelAnalysis _analysis;
+ ScreenplayDiagnostic _reported;
+
+ void Establish()
+ {
+ _contracts = Analyzed.Project(
+ "Library.Contracts",
+ [],
+ ("/partners/contracts/Contracts.cs", "namespace Library.Contracts;"),
+ ("/partners/contracts/Ordering/Placing/Placing.cs", Placing));
+
+ _application = Analyzed.Project(
+ "Library",
+ [_contracts.ToMetadataReference()],
+ ("/library/source/Program.cs", "namespace Library;"),
+ ("/library/source/Shipping/Dispatching/Dispatching.cs", Shipping));
+ }
+
+ void Because()
+ {
+ _analysis = Analyzed.Projects(_contracts, _application);
+ _reported = _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.ProjectsWithoutASharedRoot);
+ }
+
+ ReactorModel Reactor => _analysis.Model.Slices.SelectMany(_ => _.Reactors).Single();
+
+ [Fact] void should_compile_the_contracts_project() => Analyzed.ErrorsIn(_contracts).ShouldBeEmpty();
+ [Fact] void should_compile_the_application_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_write_the_path_relative_to_the_root_of_its_own_project() => Reactor.SourceFilePath.ShouldEqual("Shipping/Dispatching/Dispatching.cs");
+ [Fact] void should_leave_the_layout_of_the_machine_out_of_the_path() => Reactor.SourceFilePath!.StartsWith('/').ShouldBeFalse();
+ [Fact] void should_say_how_many_projects_could_not_be_placed_together() => _reported.Message.Contains("The 2 projects", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_report_it_as_a_loss() => _reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/projects_written_under_one_directory.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/projects_written_under_one_directory.cs
new file mode 100644
index 000000000..43fd77d51
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/projects_written_under_one_directory.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// Every path in a document is written relative to a directory, or the document carries the absolute layout of the
+/// machine that generated it and nobody can commit or diff it. One project answers that with its own root. Several
+/// projects each have their own, and using them would leave Ordering/Placing.cs as the path of a file in two
+/// projects with nothing saying which - so the directory they are all written under is used and every path opens
+/// with the project it belongs to.
+///
+public class projects_written_under_one_directory : Specification
+{
+ const string Placing = """
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Ordering.Placing;
+
+ [EventType]
+ public record OrderPlaced(string Reference);
+ """;
+
+ const string Shipping = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+ using Library.Ordering.Placing;
+
+ namespace Library.Shipping.Dispatching;
+
+ public class Dispatcher : IReactor
+ {
+ public Task Dispatch(OrderPlaced @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ Compilation _contracts;
+ Compilation _application;
+ ApplicationModelAnalysis _analysis;
+
+ void Establish()
+ {
+ _contracts = Analyzed.Project(
+ "Library.Contracts",
+ [],
+ ("Source/Library.Contracts/Contracts.cs", "namespace Library.Contracts;"),
+ ("Source/Library.Contracts/Ordering/Placing/Placing.cs", Placing));
+
+ _application = Analyzed.Project(
+ "Library",
+ [_contracts.ToMetadataReference()],
+ ("Source/Library/Program.cs", "namespace Library;"),
+ ("Source/Library/Shipping/Dispatching/Dispatching.cs", Shipping));
+ }
+
+ void Because() => _analysis = Analyzed.Projects(_contracts, _application);
+
+ ReactorModel Reactor => _analysis.Model.Slices.SelectMany(_ => _.Reactors).Single();
+
+ [Fact] void should_compile_the_contracts_project() => Analyzed.ErrorsIn(_contracts).ShouldBeEmpty();
+ [Fact] void should_compile_the_application_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_write_the_path_relative_to_the_directory_the_projects_share() => Reactor.SourceFilePath.ShouldEqual("Library/Shipping/Dispatching/Dispatching.cs");
+ [Fact] void should_not_report_the_projects_as_sharing_nothing() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.ProjectsWithoutASharedRoot).ShouldBeFalse();
+ [Fact] void should_report_nothing_at_all() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/the_same_projects_handed_over_in_another_order.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/the_same_projects_handed_over_in_another_order.cs
new file mode 100644
index 000000000..dd0f165e2
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing_the_projects_of_an_application/the_same_projects_handed_over_in_another_order.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing_the_projects_of_an_application;
+
+///
+/// Two projects declaring an artifact of one name in one slice is where "the first wins" becomes visible, and it is
+/// therefore where the order the projects arrive in could decide what the document says. Nothing decides that order -
+/// a solution names its projects in whatever order its file happens to list them - so the answer has to be the same
+/// list either way round or the document reorders itself for reasons nobody can see.
+///
+public class the_same_projects_handed_over_in_another_order : Specification
+{
+ const string First = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Ordering.Placing;
+
+ [EventType]
+ public record OrderPlaced(string Reference);
+
+ [Command]
+ public record PlaceOrder(string Reference)
+ {
+ public OrderPlaced Handle() => new(Reference);
+ }
+ """;
+
+ const string Second = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Ordering.Placing;
+
+ [EventType]
+ public record OrderRejected(string Reference);
+
+ [Command]
+ public record PlaceOrder(string Reference)
+ {
+ public OrderRejected Handle() => new(Reference);
+ }
+ """;
+
+ string _asGiven;
+ string _reversed;
+
+ void Because()
+ {
+ _asGiven = Kept(Adapter(), Application());
+ _reversed = Kept(Application(), Adapter());
+ }
+
+ static Compilation Adapter() =>
+ Analyzed.Project(
+ "Library.Adapter",
+ [],
+ ("Source/Library.Adapter/Adapter.cs", "namespace Library.Adapter;"),
+ ("Source/Library.Adapter/Ordering/Placing/Placing.cs", Second));
+
+ static Compilation Application() =>
+ Analyzed.Project(
+ "Library",
+ [],
+ ("Source/Library/Program.cs", "namespace Library;"),
+ ("Source/Library/Ordering/Placing/Placing.cs", First));
+
+ static string Kept(params Compilation[] compilations) =>
+ Analyzed.Projects(compilations)
+ .Model.Slices.Single()
+ .Commands.Single()
+ .Produces.Single()
+ .EventName;
+
+ [Fact] void should_keep_a_command_at_all() => _asGiven.ShouldNotBeEmpty();
+ [Fact] void should_keep_the_same_one_either_way() => _reversed.ShouldEqual(_asGiven);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_build_wrote_all_of_it.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_build_wrote_all_of_it.cs
new file mode 100644
index 000000000..5b7598155
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_build_wrote_all_of_it.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_GeneratedSource.when_excluding_generated_source;
+
+///
+/// Where a build wrote every last file, leaving nothing is not an improvement on leaving all of it - the question of
+/// where the application sits still has to be answered, and answering it with nothing writes every path in the
+/// document against the machine that generated it.
+///
+public class and_a_build_wrote_all_of_it : Specification
+{
+ static readonly string[] _paths =
+ [
+ "Library/obj/Debug/net10.0/Some.Generator/Registration.g.cs",
+ "Library/obj/Debug/net10.0/Some.Generator/Listing.g.cs"
+ ];
+
+ IReadOnlyList _result;
+
+ void Because() => _result = GeneratedSource.Excluded(_paths);
+
+ [Fact] void should_fall_back_to_all_of_it() => _result.ShouldContainOnly(_paths);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_person_wrote_some_of_it.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_person_wrote_some_of_it.cs
new file mode 100644
index 000000000..9befe37ab
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_person_wrote_some_of_it.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_GeneratedSource.when_excluding_generated_source;
+
+///
+/// Where a person wrote some of the source, that is the whole of the answer to where the application is written.
+///
+public class and_a_person_wrote_some_of_it : Specification
+{
+ IReadOnlyList _result;
+
+ void Because() => _result = GeneratedSource.Excluded(
+ [
+ "Library/Authors/Registration/Registration.cs",
+ "Library/obj/Debug/net10.0/Some.Generator/Registration.g.cs",
+ null,
+ " "
+ ]);
+
+ [Fact] void should_keep_only_what_a_person_wrote() => _result.ShouldContainOnly(["Library/Authors/Registration/Registration.cs"]);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_recognizing_a_path.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_recognizing_a_path.cs
new file mode 100644
index 000000000..7733907ba
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_recognizing_a_path.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_GeneratedSource;
+
+///
+/// Source a build wrote is told from source a person wrote by where it sits and by what it is called, because those
+/// are the only two things a path says. Both halves matter - a generator emits under the intermediate folder, and a
+/// designer writes a companion file next to the source it belongs to.
+///
+public class when_recognizing_a_path : Specification
+{
+ bool _underTheIntermediateFolder;
+ bool _underTheOutputFolder;
+ bool _windowsSeparated;
+ bool _namedAsGenerated;
+ bool _writtenByAPerson;
+ bool _namedAfterTheOutputFolder;
+ bool _nothingAtAll;
+
+ void Because()
+ {
+ _underTheIntermediateFolder = GeneratedSource.Is("Library/obj/Debug/net10.0/Some.Generator/Slice.g.cs");
+ _underTheOutputFolder = GeneratedSource.Is("Library/bin/Release/net10.0/Slice.cs");
+ _windowsSeparated = GeneratedSource.Is(@"C:\Work\Library\obj\Debug\Slice.cs");
+ _namedAsGenerated = GeneratedSource.Is("Library/Authors/Registration/Registration.generated.cs");
+ _writtenByAPerson = GeneratedSource.Is("Library/Authors/Registration/Registration.cs");
+ _namedAfterTheOutputFolder = GeneratedSource.Is("Library/Authors/obj.cs");
+ _nothingAtAll = GeneratedSource.Is(null);
+ }
+
+ [Fact] void should_recognize_source_under_the_intermediate_folder() => _underTheIntermediateFolder.ShouldBeTrue();
+ [Fact] void should_recognize_source_under_the_output_folder() => _underTheOutputFolder.ShouldBeTrue();
+ [Fact] void should_recognize_a_path_separated_the_way_windows_separates_one() => _windowsSeparated.ShouldBeTrue();
+ [Fact] void should_recognize_a_file_named_as_generated() => _namedAsGenerated.ShouldBeTrue();
+ [Fact] void should_leave_source_a_person_wrote_alone() => _writtenByAPerson.ShouldBeFalse();
+ [Fact] void should_not_read_a_file_name_as_a_folder() => _namedAfterTheOutputFolder.ShouldBeFalse();
+ [Fact] void should_answer_for_a_path_that_is_not_there() => _nothingAtAll.ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenImports/when_reading_imports/and_some_of_them_are_commented_out.cs b/Source/DotNET/Screenplay.Specs/for_ScreenImports/when_reading_imports/and_some_of_them_are_commented_out.cs
new file mode 100644
index 000000000..fc14c3832
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenImports/when_reading_imports/and_some_of_them_are_commented_out.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Screens;
+
+namespace Cratis.Arc.Screenplay.for_ScreenImports.when_reading_imports;
+
+///
+/// A statement is recognized by starting a line, which a commented one still does. Removing what a comment holds is
+/// what tells them apart, and the line breaks the comment spanned have to survive it - joining the line after a block
+/// comment to the line before would hide the real import that follows one.
+///
+public class and_some_of_them_are_commented_out : Specification
+{
+ const string Component = """
+ // import { CommentedOnItsOwnLine } from './One';
+ //import { IndentedAndCommented } from './Two';
+ import { Kept } from './Three'; // import { AfterAStatement } from './Four';
+ /* import { CommentedInABlock } from './Five'; */
+ /*
+ import { CommentedOverSeveralLines } from './Six';
+ */
+ import { KeptAfterABlock } from './Seven';
+ """;
+
+ IReadOnlyCollection _names;
+
+ void Because() => _names = ScreenImports.In(Component);
+
+ [Fact] void should_keep_every_import_the_file_really_makes() => _names.ShouldContainOnly(["Kept", "KeptAfterABlock"]);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_constraint_named_the_way_chronicle_names_one.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_constraint_named_the_way_chronicle_names_one.cs
new file mode 100644
index 000000000..491b07653
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_constraint_named_the_way_chronicle_names_one.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission;
+using Cratis.Arc.Screenplay.Library;
+using Cratis.Arc.Screenplay.Model;
+using Cratis.Arc.Screenplay.Verification;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayEmitter.when_emitting;
+
+///
+/// Kebab case is idiomatic for the runtime name of a Chronicle constraint, and a Screenplay identifier cannot carry a
+/// hyphen. Deleting the hyphens leaves one run-together word that reads as nothing, so the words either side of each
+/// one are joined the way a reader would have written them.
+///
+public class a_constraint_named_the_way_chronicle_names_one : given.an_emitter
+{
+ ApplicationModel _model;
+ ScreenplayEmission _emission;
+ RoundTripResult _roundTrip;
+
+ void Establish() =>
+ _model = LibraryApplication.Build() with
+ {
+ Slices =
+ [
+ .. LibraryApplication.Slices(),
+ SliceModel.Empty("Library.Lending.Reserving.Rules", "Rules", SliceKind.StateChange) with
+ {
+ Constraints = [new UniqueEventConstraintModel("unique-book-reservation", "BookReserved")]
+ }
+ ]
+ };
+
+ void Because()
+ {
+ _emission = _emitter.Emit(_model, _options);
+ _roundTrip = RoundTrip.For(_emission.Application);
+ }
+
+ [Fact] void should_name_the_constraint_by_the_words_the_source_stated() => _emission.Source.Contains("constraint UniqueBookReservation", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_not_run_the_words_together() => _emission.Source.Contains("uniquebookreservation", StringComparison.Ordinal).ShouldBeFalse();
+ [Fact] void should_compile_without_errors() => _roundTrip.Errors.ShouldBeEmpty();
+ [Fact] void should_print_the_same_text_on_a_second_pass() => _roundTrip.Reprinted.ShouldEqual(_roundTrip.Printed);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_model_with_nothing_configured_alongside_it.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_model_with_nothing_configured_alongside_it.cs
new file mode 100644
index 000000000..12b016e42
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_model_with_nothing_configured_alongside_it.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission;
+using Cratis.Arc.Screenplay.Library;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayEmitter.when_emitting;
+
+///
+/// A host emitting a model it already has configures nothing, and there is no compilation to name the document
+/// after. The domain the model carries is the only answer available, so the emitter is where the options an emission
+/// runs with are resolved - moving that any deeper meant it happened twice on the way through a generation.
+///
+public class a_model_with_nothing_configured_alongside_it : given.an_emitter
+{
+ ApplicationModel _model;
+ ScreenplayEmission _emission;
+
+ void Establish() => _model = LibraryApplication.Build() with { Domain = "Lending", Module = string.Empty };
+
+ void Because() => _emission = _emitter.Emit(_model, new ScreenplayOptions());
+
+ [Fact] void should_name_the_document_after_the_domain_the_model_carries() => _emission.Source.StartsWith("domain Lending", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_fall_back_to_the_domain_for_the_module() => _emission.Source.Contains("module Lending", StringComparison.Ordinal).ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/an_application_importing_an_event.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/an_application_importing_an_event.cs
new file mode 100644
index 000000000..c8ca10fd3
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/an_application_importing_an_event.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission;
+using Cratis.Arc.Screenplay.Library;
+using Cratis.Arc.Screenplay.Model;
+using Cratis.Arc.Screenplay.Verification;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayEmitter.when_emitting;
+
+///
+/// The Screenplay compiler reads the last segment of an import as the name of an event that is known, which is
+/// exactly what a reactor observing an event of another bounded context needs. Compiling the document back is what
+/// proves it - without the import the language reports the trigger as an event nothing declares.
+///
+public class an_application_importing_an_event : given.an_emitter
+{
+ ApplicationModel _model;
+ ScreenplayEmission _emission;
+ RoundTripResult _roundTrip;
+
+ void Establish() =>
+ _model = LibraryApplication.Build() with
+ {
+ Imports = ["Partners.Contracts.InvitationToJoinAdaAccepted"],
+ Slices =
+ [
+ .. LibraryApplication.Slices(),
+ SliceModel.Empty("Library.Admin.Invitations", "Invitations", SliceKind.Automation) with
+ {
+ Reactors =
+ [
+ new ReactorModel(
+ "AcceptedInvitationProvisioner",
+ ["InvitationToJoinAdaAccepted"],
+ false,
+ "Admin/Invitations/Provision.cs")
+ ]
+ }
+ ]
+ };
+
+ void Because()
+ {
+ _emission = _emitter.Emit(_model, _options);
+ _roundTrip = RoundTrip.For(_emission.Application);
+ }
+
+ [Fact] void should_declare_the_import() => _emission.Source.Contains("import Partners.Contracts.InvitationToJoinAdaAccepted", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_still_observe_it_from_the_reactor() => _emission.Source.Contains("on InvitationToJoinAdaAccepted", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_compile_without_errors() => _roundTrip.Errors.ShouldBeEmpty();
+ [Fact] void should_leave_the_language_nothing_to_warn_about() => _roundTrip.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_print_the_same_text_on_a_second_pass() => _roundTrip.Reprinted.ShouldEqual(_roundTrip.Printed);
+ [Fact] void should_report_nothing_as_unmappable() => _emission.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/given/a_recovered_model.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/given/a_recovered_model.cs
index 15e7dfdd5..83e0e6087 100644
--- a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/given/a_recovered_model.cs
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/given/a_recovered_model.cs
@@ -20,7 +20,11 @@ public class a_recovered_model(ApplicationModel model, params ScreenplayDiagnost
public ScreenplayOptions? Options { get; private set; }
///
- public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptions options)
+ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptions options) =>
+ Analyze([compilation], options);
+
+ ///
+ public ApplicationModelAnalysis Analyze(IReadOnlyList compilations, ScreenplayOptions options)
{
Options = options;
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/LayeredSource.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/LayeredSource.cs
new file mode 100644
index 000000000..638903305
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/LayeredSource.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+
+///
+/// The source of a small application written as two projects, the way a layered Arc application really is written.
+///
+///
+/// A contracts project publishes the events of a bounded context and the project beside it handles the commands that
+/// produce them, which is the arrangement a single project generation described half of. The events live in the
+/// namespace of the slice they belong to, so the command and the events it produces are one slice written from two
+/// compilations - and a second slice is declared by the application project alone, so a document holding only one of
+/// the projects would be visibly short either way round.
+///
+public static class LayeredSource
+{
+ ///
+ /// The name of the assembly the contracts project builds.
+ ///
+ public const string ContractsAssembly = "Library.Contracts";
+
+ ///
+ /// The name of the assembly the application project builds.
+ ///
+ public const string ApplicationAssembly = "Library";
+
+ const string Contracts = """
+ using Cratis.Chronicle.Compliance.GDPR;
+ using Cratis.Chronicle.Events;
+ using Cratis.Concepts;
+
+ namespace Library.Ordering.Placing;
+
+ public record OrderReference(string Value) : ConceptAs(Value);
+
+ [PII("The name of a person")]
+ public record CustomerName(string Value) : ConceptAs(Value);
+
+ ///
+ /// An order was placed by a customer.
+ ///
+ [EventType]
+ public record OrderPlaced(OrderReference Reference, CustomerName Customer);
+ """;
+
+ const string Placing = """
+ using Cratis.Arc.Commands.ModelBound;
+
+ namespace Library.Ordering.Placing;
+
+ ///
+ /// Places an order for a customer.
+ ///
+ [Command]
+ public record PlaceOrder(OrderReference Reference, CustomerName Customer)
+ {
+ public OrderPlaced Handle() => new(Reference, Customer);
+ }
+ """;
+
+ const string Dispatching = """
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.Reactors;
+ using Library.Ordering.Placing;
+
+ namespace Library.Shipping.Dispatching;
+
+ public class Dispatcher : IReactor
+ {
+ public Task Dispatch(OrderPlaced @event, EventContext context) => Task.CompletedTask;
+ }
+ """;
+
+ const string Listing = """
+ using System.Collections.Generic;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Projections;
+ using Library.Ordering.Placing;
+
+ namespace Library.Ordering.Listing;
+
+ [ReadModel]
+ public record Order
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public OrderReference Reference { get; init; } = new(string.Empty);
+
+ public static IEnumerable AllOrders() => [];
+ }
+
+ public class OrderProjection : IProjectionFor
+ {
+ public void Define(IProjectionBuilderFor builder) => builder
+ .AutoMap()
+ .From(_ => _
+ .Set(m => m.Id).ToEventSourceId()
+ .Set(m => m.Reference).To(e => e.Reference));
+ }
+ """;
+
+ ///
+ /// Builds the contracts project, which declares the events of the ordering context.
+ ///
+ /// The .
+ public static Compilation ContractsProject() =>
+ Analyzed.Project(
+ ContractsAssembly,
+ [],
+ ("Source/Library.Contracts/Contracts.cs", "namespace Library.Contracts;"),
+ ("Source/Library.Contracts/Ordering/Placing/Placing.cs", Contracts));
+
+ ///
+ /// Builds the application project, which handles the commands of the ordering context.
+ ///
+ /// The contracts project it references.
+ /// The .
+ ///
+ /// The reference is the compilation itself rather than an emitted image, which is what a project reference within
+ /// one solution really is once a workspace has loaded it - and is the shape in which a sibling project looks
+ /// exactly like the referenced package an import exists for.
+ ///
+ public static Compilation ApplicationProject(Compilation contracts) =>
+ Analyzed.Project(
+ ApplicationAssembly,
+ [contracts.ToMetadataReference()],
+ ("Source/Library/Program.cs", "namespace Library;"),
+ ("Source/Library/Ordering/Placing/Placing.cs", Placing),
+ ("Source/Library/Ordering/Listing/Listing.cs", Listing),
+ ("Source/Library/Shipping/Dispatching/Dispatching.cs", Dispatching));
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/RecoveringSource.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/RecoveringSource.cs
new file mode 100644
index 000000000..ba1949517
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/RecoveringSource.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating.and_the_source_it_read_did_not_compile;
+
+///
+/// Holds source that does not compile and yet gives up everything it declares, which is what a host handing over a
+/// compilation assembled without the compile items a build generates really produces.
+///
+///
+/// The unresolved name sits in a helper class that declares no artifact, so the errors are real while the command and
+/// the event are read from declarations the compiler accepted - the shape the whole recalibration turns on.
+///
+public static class RecoveringSource
+{
+ ///
+ /// The slice, which compiles on its own.
+ ///
+ public const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ ///
+ /// A helper reaching for the strongly typed resource class a build would have generated.
+ ///
+ public const string Wording = """
+ namespace Library.Authors;
+
+ public static class Wording
+ {
+ public static string NameIsRequired() => AuthorsMessages.NameIsRequired;
+ }
+ """;
+
+ ///
+ /// Gets the source files, keyed by the path each one is compiled as.
+ ///
+ /// The source files.
+ public static (string Path, string Text)[] Files() =>
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Wording.cs", Wording)
+ ];
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_nothing_was_recovered_from_it.cs
similarity index 67%
rename from Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile.cs
rename to Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_nothing_was_recovered_from_it.cs
index 19845d8bc..20ae761eb 100644
--- a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile.cs
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_nothing_was_recovered_from_it.cs
@@ -5,15 +5,15 @@
using Cratis.Screenplay;
using Microsoft.CodeAnalysis;
-namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating.and_the_source_it_read_did_not_compile;
///
-/// Source the compiler never accepted still yields symbols, and a model recovered from them describes an application
-/// that does not exist - so a document made from it being poor is the consequence already reported rather than a
-/// second defect. Saying both would send whoever reads the diagnostics hunting for a bug in the generator when the
-/// thing to fix is the build, which is why the same suppression the empty document already gets applies here.
+/// Source the compiler never accepted that yielded no artifact leaves a model describing an application that does not
+/// exist - so a document made from it being poor is the consequence already reported rather than a second defect.
+/// Saying both would send whoever reads the diagnostics hunting for a bug in the generator when the thing to fix is
+/// the build, which is why the same suppression the empty document already gets applies here.
///
-public class and_the_source_it_read_did_not_compile : given.a_document_the_language_rejects
+public class and_nothing_was_recovered_from_it : given.a_document_the_language_rejects
{
const string Source = """
namespace Library.Authors.Registration;
@@ -36,9 +36,12 @@ void Establish()
void Because() => _result = _generator.Generate(_compilation, new ScreenplayOptions());
+ ScreenplayDiagnostic Reported => _result.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
+
[Fact] void should_be_generating_from_source_that_really_does_not_compile() => Analyzed.ErrorsIn((Analyzed.SlicePath, Source)).ShouldNotBeEmpty();
[Fact] void should_be_printing_a_document_the_language_really_rejects() => new ScreenplayCompiler().Compile(Rejected).Success.ShouldBeFalse();
[Fact] void should_report_that_the_source_did_not_compile() => _result.Diagnostics.Select(_ => _.Code).ShouldContain(ScreenplayDiagnosticCodes.SourceDidNotCompile);
+ [Fact] void should_report_it_as_an_error() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Error);
[Fact] void should_not_report_the_document_on_top_of_it() => _result.Diagnostics.Select(_ => _.Code).ShouldNotContain(ScreenplayDiagnosticCodes.DocumentDidNotCompile);
[Fact] void should_still_return_the_document() => _result.Source.ShouldEqual(Rejected);
[Fact] void should_not_be_successful() => _result.IsSuccess.ShouldBeFalse();
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_the_document_it_printed_is_rejected_too.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_the_document_it_printed_is_rejected_too.cs
new file mode 100644
index 000000000..afacb0ffd
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_the_document_it_printed_is_rejected_too.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Screenplay;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating.and_the_source_it_read_did_not_compile;
+
+///
+/// The suppression of the document check follows the severity rather than the code. Source that did not compile but
+/// gave up everything it declares says the model stands, and a document built from a model that stands is exactly
+/// what the check exists for - suppressing it here would hand back a document the language rejects with nothing
+/// wrong reported, which is the one outcome nobody can act on.
+///
+public class and_the_document_it_printed_is_rejected_too : given.a_document_the_language_rejects
+{
+ Compilation _compilation;
+ ScreenplayGenerator _generator;
+ ScreenplayGenerationResult _result;
+
+ void Establish()
+ {
+ _compilation = Analyzed.Compile(RecoveringSource.Files());
+ _generator = new(new ApplicationModelAnalyzer(), _emitter);
+ }
+
+ void Because() => _result = _generator.Generate(_compilation, new ScreenplayOptions());
+
+ ScreenplayDiagnostic Reported => _result.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
+
+ [Fact] void should_be_generating_from_source_that_really_does_not_compile() => Analyzed.ErrorsIn(RecoveringSource.Files()).ShouldNotBeEmpty();
+ [Fact] void should_be_printing_a_document_the_language_really_rejects() => new ScreenplayCompiler().Compile(Rejected).Success.ShouldBeFalse();
+ [Fact] void should_report_the_source_it_read_only_as_a_warning() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+ [Fact] void should_report_the_document_on_top_of_it() => _result.Diagnostics.Select(_ => _.Code).ShouldContain(ScreenplayDiagnosticCodes.DocumentDidNotCompile);
+ [Fact] void should_still_return_the_document() => _result.Source.ShouldEqual(Rejected);
+ [Fact] void should_not_be_successful() => _result.IsSuccess.ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_what_it_recovered_still_stands.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_what_it_recovered_still_stands.cs
new file mode 100644
index 000000000..952c6b3ba
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/and_the_source_it_read_did_not_compile/and_what_it_recovered_still_stands.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Screenplay;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating.and_the_source_it_read_did_not_compile;
+
+///
+/// This is the point of the whole thing. A host that follows the documented contract discards the document on
+/// anything reported as an error, so a compilation that lost only its generated symbols used to cost the caller a
+/// complete and correct document. The errors are still reported - nothing is hidden - but the result is successful,
+/// because what was recovered is described exactly as the source states it.
+///
+public class and_what_it_recovered_still_stands : Specification
+{
+ Compilation _compilation;
+ ScreenplayGenerator _generator;
+ ScreenplayGenerationResult _result;
+ CompilationResult _compiled;
+
+ void Establish()
+ {
+ _compilation = Analyzed.Compile(RecoveringSource.Files());
+ _generator = new();
+ }
+
+ void Because()
+ {
+ _result = _generator.Generate(_compilation, new ScreenplayOptions());
+ _compiled = new ScreenplayCompiler().Compile(_result.Source);
+ }
+
+ ScreenplayDiagnostic Reported => _result.Diagnostics.First(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile);
+
+ [Fact] void should_be_generating_from_source_that_really_does_not_compile() => Analyzed.ErrorsIn(RecoveringSource.Files()).ShouldNotBeEmpty();
+ [Fact] void should_report_that_the_source_did_not_compile() => _result.Diagnostics.Select(_ => _.Code).ShouldContain(ScreenplayDiagnosticCodes.SourceDidNotCompile);
+ [Fact] void should_report_it_as_a_warning() => Reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning);
+ [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue();
+ [Fact] void should_report_nothing_at_all_as_an_error() => _result.Diagnostics.Any(_ => _.Severity == ScreenplayDiagnosticSeverity.Error).ShouldBeFalse();
+ [Fact] void should_describe_the_command_the_source_declares() => _result.Source.Contains("command RegisterAuthor", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_describe_the_event_the_source_declares() => _result.Source.Contains("event AuthorRegistered", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs
new file mode 100644
index 000000000..336b40592
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Screenplay;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+
+///
+/// A scenario recovered from source has to survive being written down: the name it is declared under, the events it
+/// starts from, the command it issues and the rejection it expects are each a line the language reads back, and a
+/// scenario is the one construct where the value of a property and the name of a step sit one indentation apart.
+/// This asks the whole way through, from source to printed text and back again through the compiler.
+///
+public class from_source_carrying_the_specifications_of_a_slice : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Invoicing.Issuing;
+
+ [EventType]
+ public record InvoiceIssued(string Number, int Lines);
+
+ [ReadModel]
+ public record Invoice(string Id, string Number);
+
+ [Command]
+ public record IssueInvoice(string Number, int Lines)
+ {
+ public InvoiceIssued Handle() => new(Number, Lines);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Invoicing.Issuing;
+ using Xunit;
+
+ namespace Library.Invoicing.Issuing.when_issuing;
+
+ public class and_the_invoice_has_no_lines
+ {
+ public const string OneInvoicePerNumber = "one-invoice-per-number";
+
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ void Establish()
+ {
+ _scenario.Given.ForEventSource("invoice").Events(new InvoiceIssued("2026-1", 3));
+ _scenario.Given.ForEventSource("invoice").ReadModel(new Invoice("invoice", "2026-1"));
+ }
+
+ async Task Because() => _result = await _scenario.Execute(new IssueInvoice("2026-2", 0));
+
+ [Fact] void should_not_issue_the_invoice() => _result.ShouldHaveConstraintViolationFor(OneInvoicePerNumber);
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Invoicing/Issuing/Issuing.cs", Slice),
+ ("Library/Invoicing/Issuing/when_issuing/and_the_invoice_has_no_lines.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ScreenplayGenerationResult _result;
+ CompilationResult _compiled;
+ string _reprinted;
+
+ void Because()
+ {
+ _result = new ScreenplayGenerator().Generate(Analyzed.Compile(_sources), new ScreenplayOptions());
+ _compiled = new ScreenplayCompiler().Compile(_result.Source);
+ _reprinted = _compiled.Value is null ? string.Empty : new Cratis.Screenplay.Printing.ScreenplayPrinter().Print(_compiled.Value);
+ }
+
+ bool Says(string text) => _result.Source.Contains(text, StringComparison.Ordinal);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue();
+ [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_print_the_same_text_on_a_second_pass() => _reprinted.ShouldEqual(_result.Source);
+ [Fact] void should_declare_the_scenario_under_the_words_the_source_wrote() => Says("specification WhenIssuingAndTheInvoiceHasNoLines").ShouldBeTrue();
+ [Fact] void should_state_the_event_it_starts_from() => Says("given InvoiceIssued").ShouldBeTrue();
+ [Fact] void should_state_the_read_model_it_starts_from() => Says("given readmodel Invoice").ShouldBeTrue();
+ [Fact] void should_state_the_values_it_starts_from() => Says("number = \"2026-1\"").ShouldBeTrue();
+ [Fact] void should_state_the_command_it_issues() => Says("when IssueInvoice").ShouldBeTrue();
+ [Fact] void should_state_the_values_the_command_carries() => Says("lines = 0").ShouldBeTrue();
+ [Fact] void should_state_the_rejection_and_the_reason_named_for_it() => Says("then error \"one-invoice-per-number\"").ShouldBeTrue();
+ [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_declaring_values_that_may_be_absent.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_declaring_values_that_may_be_absent.cs
new file mode 100644
index 000000000..3166baf49
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_declaring_values_that_may_be_absent.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Screenplay;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+
+///
+/// Whether a value may be absent is written in two different ways in C# - a reference type annotated as nullable and
+/// a value type wrapped in Nullable - and in one way in Screenplay, a trailing question mark. Stripping the
+/// wrapper without carrying what it said across leaves a document claiming every value is always there, which is a
+/// shape the application does not have; carrying it across but naming the wrapper leaves a type nothing declares.
+/// This asks the whole way through, from source to printed text, in every position a type reference is written in.
+///
+public class from_source_declaring_values_that_may_be_absent : Specification
+{
+ const string Invoicing = """
+ using System;
+ using System.Collections.Generic;
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Events;
+ using Cratis.Concepts;
+
+ namespace Library.Invoicing.Grouping;
+
+ public record InvoiceGroupKey(string Value) : ConceptAs(Value);
+
+ public record InvoiceNumber(int Value) : ConceptAs(Value);
+
+ public enum InvoiceStanding
+ {
+ Draft,
+ Issued
+ }
+
+ [EventType]
+ public record InvoiceGrouped(InvoiceGroupKey? Grouping, InvoiceStanding? Standing, IEnumerable Numbers);
+
+ [Command]
+ public record GroupInvoice(InvoiceGroupKey? Reference, InvoiceStanding? Standing, IEnumerable Numbers)
+ {
+ public InvoiceGrouped Handle() => new(Reference, Standing, Numbers);
+ }
+
+ [ReadModel]
+ public record InvoiceGroup
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public static IEnumerable GroupsByKey(InvoiceGroupKey? groupKey) => [];
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources = [("Library/Invoicing/Grouping/Grouping.cs", Invoicing)];
+
+ ScreenplayGenerationResult _result;
+ CompilationResult _compiled;
+ string _reprinted;
+
+ void Because()
+ {
+ _result = new ScreenplayGenerator().Generate(Analyzed.Compile(_sources), new ScreenplayOptions());
+ _compiled = new ScreenplayCompiler().Compile(_result.Source);
+ _reprinted = _compiled.Value is null ? string.Empty : new Cratis.Screenplay.Printing.ScreenplayPrinter().Print(_compiled.Value);
+ }
+
+ bool Says(string text) => _result.Source.Contains(text, StringComparison.Ordinal);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue();
+ [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_print_the_same_text_on_a_second_pass() => _reprinted.ShouldEqual(_result.Source);
+ [Fact] void should_mark_an_optional_concept_on_a_command() => Says("reference InvoiceGroupKey?").ShouldBeTrue();
+ [Fact] void should_mark_an_optional_concept_on_an_event() => Says("grouping InvoiceGroupKey?").ShouldBeTrue();
+ [Fact] void should_mark_an_optional_enumeration() => Says("standing InvoiceStanding?").ShouldBeTrue();
+ [Fact] void should_mark_a_collection_of_optional_values() => Says("numbers InvoiceNumber[]?").ShouldBeTrue();
+ [Fact] void should_mark_an_optional_parameter_of_a_query() => Says("by groupKey InvoiceGroupKey?").ShouldBeTrue();
+ [Fact] void should_never_name_the_wrapper_a_value_may_be_absent_behind() => Says("Nullable").ShouldBeFalse();
+ [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_taking_its_messages_from_a_resource.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_taking_its_messages_from_a_resource.cs
new file mode 100644
index 000000000..76f1ceab3
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_taking_its_messages_from_a_resource.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Emission;
+using Cratis.Screenplay;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+
+///
+/// A key is written onto the message line unquoted, which is the one thing about it the language reads differently
+/// from text. A quoted message is text whatever it holds, so a message that cannot be written is a message that reads
+/// wrong; a reference that cannot be written is a line the compiler rejects, and a rejected line takes the whole
+/// document with it. So the document a resource message ends up in is read back rather than only inspected.
+///
+public class from_source_taking_its_messages_from_a_resource : Specification
+{
+ const string Source = """
+ using System.Globalization;
+ using System.Resources;
+ using Cratis.Arc.Commands;
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+ using FluentValidation;
+
+ namespace Library.Authors.Registration;
+
+ internal class AuthorMessages
+ {
+ static ResourceManager resourceMan;
+ static CultureInfo resourceCulture;
+
+ internal static ResourceManager ResourceManager
+ {
+ get
+ {
+ if (object.ReferenceEquals(resourceMan, null))
+ {
+ resourceMan = new ResourceManager("Library.AuthorMessages", typeof(AuthorMessages).Assembly);
+ }
+ return resourceMan;
+ }
+ }
+
+ internal static string Registration_NameRequired
+ {
+ get
+ {
+ return ResourceManager.GetString("Registration_NameRequired", resourceCulture);
+ }
+ }
+ }
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+
+ public class RegisterAuthorValidator : CommandValidator
+ {
+ public RegisterAuthorValidator()
+ {
+ RuleFor(_ => _.Name).NotEmpty().WithMessage(_ => AuthorMessages.Registration_NameRequired);
+ }
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources = [("Library/Authors/Registration/Registration.cs", Source)];
+
+ ScreenplayGenerationResult _result;
+ CompilationResult _compiled;
+
+ void Because()
+ {
+ _result = new ScreenplayGenerator(
+ new ApplicationModelAnalyzer(DeclaredUserInterfaceFiles.None),
+ new ScreenplayEmitter())
+ .Generate(Analyzed.Compile(_sources), new ScreenplayOptions());
+ _compiled = new ScreenplayCompiler().Compile(_result.Source);
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_write_the_key_unquoted() => _result.Source.ShouldContain("message $strings.AuthorMessages.Registration_NameRequired");
+ [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue();
+ [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_report_nothing_it_left_out() => _result.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_the_projects_of_a_layered_application.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_the_projects_of_a_layered_application.cs
new file mode 100644
index 000000000..d00ffe31d
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_the_projects_of_a_layered_application.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Emission;
+using Cratis.Screenplay;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+
+///
+/// The whole promise again, for the applications that are not one project. Two compilations go in - a contracts
+/// project and the project handling the commands its events belong to - and one document comes out that the
+/// Screenplay compiler accepts without a diagnostic. Pointed at either project alone, the generator described half
+/// of this and referred to an event it never introduced.
+///
+public class from_the_projects_of_a_layered_application : Specification
+{
+ Compilation _contracts;
+ Compilation _application;
+ ScreenplayGenerationResult _result;
+ CompilationResult _compiled;
+
+ void Establish()
+ {
+ _contracts = LayeredSource.ContractsProject();
+ _application = LayeredSource.ApplicationProject(_contracts);
+ }
+
+ void Because()
+ {
+ _result = new ScreenplayGenerator(
+ new ApplicationModelAnalyzer(DeclaredUserInterfaceFiles.None),
+ new ScreenplayEmitter())
+ .Generate([_application, _contracts], new ScreenplayOptions());
+ _compiled = new ScreenplayCompiler().Compile(_result.Source);
+ }
+
+ bool Says(string text) => _result.Source.Contains(text, StringComparison.Ordinal);
+
+ int Counted(string text) => _result.Source.Split(text, StringSplitOptions.None).Length - 1;
+
+ [Fact] void should_compile_the_contracts_project() => Analyzed.ErrorsIn(_contracts).ShouldBeEmpty();
+ [Fact] void should_compile_the_application_project() => Analyzed.ErrorsIn(_application).ShouldBeEmpty();
+ [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue();
+ [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_declare_the_command_the_application_project_holds() => Says("command PlaceOrder").ShouldBeTrue();
+ [Fact] void should_declare_the_event_the_contracts_project_holds() => Says("event OrderPlaced").ShouldBeTrue();
+ [Fact] void should_state_what_the_command_produces() => Says("produces OrderPlaced").ShouldBeTrue();
+ [Fact] void should_declare_the_slice_only_the_application_project_holds() => Says("query AllOrders").ShouldBeTrue();
+ [Fact] void should_observe_the_event_of_one_project_from_a_reactor_in_the_other() => Says("reactor Dispatcher").ShouldBeTrue();
+ [Fact] void should_declare_a_concept_both_projects_refer_to_once() => Counted("concept CustomerName : String @pii").ShouldEqual(1);
+ [Fact] void should_write_the_path_of_a_file_relative_to_the_directory_the_projects_share() => Says("Library/Shipping/Dispatching/Dispatching.cs").ShouldBeTrue();
+ [Fact] void should_import_nothing() => _result.Model.Imports.ShouldBeEmpty();
+ [Fact] void should_not_refer_to_an_event_it_never_introduces() => _result.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation).ShouldBeFalse();
+ [Fact] void should_report_nothing_as_unmappable() => _result.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_the_same_projects_handed_over_in_another_order.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_the_same_projects_handed_over_in_another_order.cs
new file mode 100644
index 000000000..731719339
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_the_same_projects_handed_over_in_another_order.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Emission;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+
+///
+/// The same files in another order already have to print the same bytes. With several projects there is a second
+/// order nobody decides - the one a host enumerates the projects of a solution in - and a document that reordered
+/// itself with it would be exactly as impossible to commit, diff and review. This is that promise, stated as
+/// something that can fail.
+///
+public class from_the_same_projects_handed_over_in_another_order : Specification
+{
+ string _asGiven;
+ string _reversed;
+
+ void Because()
+ {
+ _asGiven = Generate(contracts => [LayeredSource.ApplicationProject(contracts), contracts]);
+ _reversed = Generate(contracts => [contracts, LayeredSource.ApplicationProject(contracts)]);
+ }
+
+ static string Generate(Func> arrange) =>
+ new ScreenplayGenerator(
+ new ApplicationModelAnalyzer(DeclaredUserInterfaceFiles.None),
+ new ScreenplayEmitter())
+ .Generate(arrange(LayeredSource.ContractsProject()), new ScreenplayOptions())
+ .Source;
+
+ [Fact] void should_have_produced_a_document_at_all() => _asGiven.ShouldNotBeEmpty();
+ [Fact] void should_produce_the_same_document_either_way() => _reversed.ShouldEqual(_asGiven);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs
new file mode 100644
index 000000000..84393c044
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayNaming.when_making_a_declaration_name;
+
+///
+/// A runtime name is routinely written with separators - kebab case is idiomatic for a Chronicle constraint - and the
+/// Screenplay identifier rules force some transformation of it. Deleting the separators throws away the word
+/// boundaries the source stated, so each one is read as the boundary it is instead.
+///
+public class from_names_carrying_separators : given.a_naming
+{
+ string _kebabCased;
+ string _snakeCased;
+ string _spaced;
+ string _dotted;
+ string _mixed;
+ string _leadingSeparator;
+ string _withoutASeparator;
+ string _alreadyCamelCased;
+ string _nothingButSeparators;
+
+ void Because()
+ {
+ _kebabCased = _naming.ToDeclarationName("unique-timesheet-start");
+ _snakeCased = _naming.ToDeclarationName("unique_invitation_email");
+ _spaced = _naming.ToDeclarationName("Only one retirement");
+ _dotted = _naming.ToDeclarationName("library.authors.registered");
+ _mixed = _naming.ToDeclarationName("unique invitation-email.address");
+ _leadingSeparator = _naming.ToDeclarationName("-overdue");
+ _withoutASeparator = _naming.ToDeclarationName("AuthorRegistered");
+ _alreadyCamelCased = _naming.ToDeclarationName("authorRegistered");
+ _nothingButSeparators = _naming.ToDeclarationName("---");
+ }
+
+ [Fact] void should_pascal_case_a_kebab_cased_name() => _kebabCased.ShouldEqual("UniqueTimesheetStart");
+ [Fact] void should_pascal_case_a_snake_cased_name() => _snakeCased.ShouldEqual("UniqueInvitationEmail");
+ [Fact] void should_pascal_case_a_name_written_as_words() => _spaced.ShouldEqual("OnlyOneRetirement");
+ [Fact] void should_pascal_case_a_dotted_name() => _dotted.ShouldEqual("LibraryAuthorsRegistered");
+ [Fact] void should_read_every_kind_of_separator_in_one_name() => _mixed.ShouldEqual("UniqueInvitationEmailAddress");
+ [Fact] void should_leave_the_casing_of_one_word_alone() => _leadingSeparator.ShouldEqual("overdue");
+ [Fact] void should_leave_a_name_carrying_no_separator_exactly_as_it_is() => _withoutASeparator.ShouldEqual("AuthorRegistered");
+ [Fact] void should_not_raise_a_name_that_carries_no_separator() => _alreadyCamelCased.ShouldEqual("authorRegistered");
+ [Fact] void should_fall_back_for_a_name_with_no_word_in_it() => _nothingButSeparators.ShouldEqual("_");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_written_two_ways_that_mean_one_thing.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_written_two_ways_that_mean_one_thing.cs
new file mode 100644
index 000000000..7b7876b6b
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_written_two_ways_that_mean_one_thing.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayNaming.when_making_a_declaration_name;
+
+///
+/// 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 compiler does too, and an editor is free to save either. A combining mark is not a
+/// letter though, so composing the name has to happen before anything is stripped from it or one spelling keeps its
+/// accent and the other quietly loses it, which is two documents for one application.
+///
+public class from_names_written_two_ways_that_mean_one_thing : given.a_naming
+{
+ const string Composed = "Andr\u00E9Registered";
+ const string Decomposed = "Andre\u0301Registered";
+
+ string _fromTheComposedName;
+ string _fromTheDecomposedName;
+
+ void Because()
+ {
+ _fromTheComposedName = _naming.ToDeclarationName(Composed);
+ _fromTheDecomposedName = _naming.ToDeclarationName(Decomposed);
+ }
+
+ [Fact] void should_spell_the_two_names_apart_to_begin_with() => Decomposed.ShouldNotEqual(Composed);
+ [Fact] void should_read_the_two_spellings_as_one_name() => _fromTheDecomposedName.ShouldEqual(_fromTheComposedName);
+ [Fact] void should_keep_the_accent_of_the_composed_spelling() => _fromTheComposedName.ShouldEqual(Composed);
+ [Fact] void should_keep_the_accent_of_the_decomposed_spelling() => _fromTheDecomposedName.ShouldEqual(Composed);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_property_name/from_names_carrying_separators.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_property_name/from_names_carrying_separators.cs
new file mode 100644
index 000000000..ea68ac6ed
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_property_name/from_names_carrying_separators.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayNaming.when_making_a_property_name;
+
+///
+/// Reading separators as word boundaries is what every identifier position gets, not only the ones a constraint is
+/// named in. A property line is lower camel cased on top of that, which is the same transformation a name written
+/// with words would be given by hand.
+///
+public class from_names_carrying_separators : given.a_naming
+{
+ string _kebabCased;
+ string _snakeCased;
+ string _leadingUnderscore;
+ string _startingWithADigitAfterASeparator;
+ string _nothingButSeparators;
+
+ void Because()
+ {
+ _kebabCased = _naming.ToPropertyName("unique-timesheet-start");
+ _snakeCased = _naming.ToPropertyName("first_name");
+ _leadingUnderscore = _naming.ToPropertyName("_isbn");
+ _startingWithADigitAfterASeparator = _naming.ToPropertyName("first-1st");
+ _nothingButSeparators = _naming.ToPropertyName("---");
+ }
+
+ [Fact] void should_lower_camel_a_kebab_cased_name() => _kebabCased.ShouldEqual("uniqueTimesheetStart");
+ [Fact] void should_lower_camel_a_snake_cased_name() => _snakeCased.ShouldEqual("firstName");
+ [Fact] void should_drop_a_leading_underscore() => _leadingUnderscore.ShouldEqual("isbn");
+ [Fact] void should_join_a_word_starting_with_a_digit() => _startingWithADigitAfterASeparator.ShouldEqual("first1st");
+ [Fact] void should_fall_back_for_a_name_with_no_word_in_it() => _nothingButSeparators.ShouldEqual("value");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayOptions/when_resolving_defaults/and_they_are_already_resolved.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayOptions/when_resolving_defaults/and_they_are_already_resolved.cs
new file mode 100644
index 000000000..53859f95b
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayOptions/when_resolving_defaults/and_they_are_already_resolved.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayOptions.when_resolving_defaults;
+
+///
+/// A generation resolves for the analysis half against the assembly it is reading, and the emission half resolves
+/// against the domain of the model it is handed. Both run on the way through one generation, so resolving what is
+/// already resolved has to answer the same thing however a second fallback would have answered - otherwise which
+/// entry point was used decides what the document is called.
+///
+public class and_they_are_already_resolved : Specification
+{
+ ScreenplayOptions _resolved;
+ ScreenplayOptions _resolvedAgain;
+
+ void Because()
+ {
+ _resolved = new ScreenplayOptions().WithDefaults("Library");
+ _resolvedAgain = _resolved.WithDefaults("SomethingElse");
+ }
+
+ [Fact] void should_answer_the_same_thing_a_second_time() => _resolvedAgain.ShouldEqual(_resolved);
+ [Fact] void should_not_take_the_second_fallback_for_the_domain() => _resolvedAgain.Domain.ShouldEqual("Library");
+ [Fact] void should_not_take_the_second_fallback_for_the_module() => _resolvedAgain.Module.ShouldEqual("Library");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_command_in_one_of_them.cs b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_command_in_one_of_them.cs
new file mode 100644
index 000000000..b4f9d4d0b
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_command_in_one_of_them.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_SliceKindInference.when_combining_the_parts_of_a_slice;
+
+///
+/// A contracts project holding only the events of a slice reads as a state view on its own, because nothing in it
+/// changes anything. The command in the project beside it is what the slice is really about, and joining the two
+/// has to say so.
+///
+public class with_a_command_in_one_of_them : Specification
+{
+ SliceKind _result;
+
+ void Because() => _result = SliceKindInference.Combine([SliceKind.StateView, SliceKind.StateChange]);
+
+ [Fact] void should_infer_state_change() => _result.ShouldEqual(SliceKind.StateChange);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_reactor_in_one_of_them.cs b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_reactor_in_one_of_them.cs
new file mode 100644
index 000000000..bd017da2a
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_reactor_in_one_of_them.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_SliceKindInference.when_combining_the_parts_of_a_slice;
+
+///
+/// A reactor decides the kind of a slice even when a command sits alongside it, and it decides it just as much when
+/// the command was written in another project - what the slice is about is the reaction wherever the two halves of
+/// it happen to live.
+///
+public class with_a_reactor_in_one_of_them : Specification
+{
+ SliceKind _result;
+
+ void Because() => _result = SliceKindInference.Combine([SliceKind.StateChange, SliceKind.Automation]);
+
+ [Fact] void should_infer_automation() => _result.ShouldEqual(SliceKind.Automation);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_translating_reactor_in_one_of_them.cs b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_translating_reactor_in_one_of_them.cs
new file mode 100644
index 000000000..ff45580c5
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/with_a_translating_reactor_in_one_of_them.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_SliceKindInference.when_combining_the_parts_of_a_slice;
+
+///
+/// A reactor turning an event into further events adapts one part of the model into another, which outranks every
+/// other reading of the slice - including the automation another part of it would be on its own.
+///
+public class with_a_translating_reactor_in_one_of_them : Specification
+{
+ SliceKind _result;
+
+ void Because() => _result = SliceKindInference.Combine([SliceKind.Automation, SliceKind.Translate, SliceKind.StateChange]);
+
+ [Fact] void should_infer_translate() => _result.ShouldEqual(SliceKind.Translate);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/without_a_part_that_changes_anything.cs b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/without_a_part_that_changes_anything.cs
new file mode 100644
index 000000000..f2426c9e0
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_SliceKindInference/when_combining_the_parts_of_a_slice/without_a_part_that_changes_anything.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_SliceKindInference.when_combining_the_parts_of_a_slice;
+
+public class without_a_part_that_changes_anything : Specification
+{
+ SliceKind _result;
+
+ void Because() => _result = SliceKindInference.Combine([SliceKind.StateView, SliceKind.StateView]);
+
+ [Fact] void should_infer_state_view() => _result.ShouldEqual(SliceKind.StateView);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ValidationSyntaxBuilder/when_building/a_rule_comparing_against_another_property.cs b/Source/DotNET/Screenplay.Specs/for_ValidationSyntaxBuilder/when_building/a_rule_comparing_against_another_property.cs
new file mode 100644
index 000000000..ffba486ba
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ValidationSyntaxBuilder/when_building/a_rule_comparing_against_another_property.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+using Cratis.Screenplay.Syntax;
+using ModelRuleKind = Cratis.Arc.Screenplay.Model.ValidationRuleKind;
+
+namespace Cratis.Arc.Screenplay.for_ValidationSyntaxBuilder.when_building;
+
+///
+/// A rule operand is a host expression, so a comparison against another property is written as the path naming it -
+/// endDate >= startDate. Written as a literal instead it would read as a comparison against the word
+/// startDate, which is a document stating something the application does not do.
+///
+public class a_rule_comparing_against_another_property : given.a_validation_syntax_builder
+{
+ IEnumerable _rules;
+
+ void Because() => _rules = _builder
+ .Build(
+ [new("EndDate", ModelRuleKind.GreaterThanOrEqual, new PropertyPathSource("StartDate"), null)],
+ "Library.Lending.Reserving")
+ .OfType()
+ .SelectMany(_ => _.Rules);
+
+ [Fact] void should_state_the_rule() => _rules.Count().ShouldEqual(1);
+ [Fact] void should_write_the_operand_as_a_path() => _rules.Single().Value.ShouldBeOfExactType();
+ [Fact] void should_write_it_the_way_a_property_is_named() => ((PathExpressionSyntax)_rules.Single().Value!).Path.ShouldEqual("startDate");
+ [Fact] void should_report_nothing() => _diagnostics.All.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Aggregates/AggregateRootBehaviors.cs b/Source/DotNET/Screenplay/Analysis/Aggregates/AggregateRootBehaviors.cs
index c7f57af64..2bdc605aa 100644
--- a/Source/DotNET/Screenplay/Analysis/Aggregates/AggregateRootBehaviors.cs
+++ b/Source/DotNET/Screenplay/Analysis/Aggregates/AggregateRootBehaviors.cs
@@ -26,11 +26,10 @@ public static class AggregateRootBehaviors
/// Finds the behaviors a handler body reaches.
///
/// The body of the handler.
- /// The compilation being analyzed.
+ /// The model the body is read through.
/// The behaviors, in the order the handler calls them.
- public static IEnumerable ReachedFrom(SyntaxNode body, Compilation compilation)
+ public static IEnumerable ReachedFrom(SyntaxNode body, SemanticModel semanticModel)
{
- var semanticModel = compilation.GetSemanticModel(body.SyntaxTree);
var reached = new List();
foreach (var call in body.DescendantNodesAndSelf().OfType())
diff --git a/Source/DotNET/Screenplay/Analysis/AnalyzedCompilations.cs b/Source/DotNET/Screenplay/Analysis/AnalyzedCompilations.cs
new file mode 100644
index 000000000..069300b41
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/AnalyzedCompilations.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Puts the compilations an application is analyzed from into the order everything else reads them in.
+///
+///
+/// An application written as several projects is handed over as a list, and nothing decides what order a host builds
+/// that list in - a solution enumerates its projects in whatever order its file happens to name them, and a folder
+/// scan in whatever order the file system answers. A document that reordered itself with the list would be no more
+/// worth committing than one that reordered itself between builds, so the list is ordered here once and every union
+/// downstream is a concatenation in this order.
+///
+/// Assemblies are named uniquely within an application, so the name is the whole of the key in practice. The
+/// ordinally first source file breaks a tie anyway, because two compilations of one assembly - the same project built
+/// for two target frameworks - is a list a host can hand over without meaning to.
+///
+///
+public static class AnalyzedCompilations
+{
+ ///
+ /// Orders the compilations an application is analyzed from.
+ ///
+ /// The compilations to order.
+ /// The compilations, ordered by the name of the assembly each one builds.
+ public static IReadOnlyList Ordered(IEnumerable compilations) =>
+ [
+ .. compilations
+ .OrderBy(_ => _.AssemblyName ?? string.Empty, StringComparer.Ordinal)
+ .ThenBy(FirstFileOf, StringComparer.Ordinal)
+ ];
+
+ ///
+ /// Gets the name the application falls back to when nothing configures one.
+ ///
+ /// The compilations being analyzed.
+ /// The name, or when no single assembly names the application.
+ ///
+ /// One project is the application, so the assembly it builds names it. Several projects are the application
+ /// together and none of them names it - picking one would put the name of a layer where the name of the
+ /// application belongs, and which layer got picked would be an accident of the alphabet. Nothing is offered
+ /// instead, so a caller that says what the application is called is answered and one that does not gets the
+ /// neutral default rather than a wrong name that looks deliberate.
+ ///
+ public static string? NameOf(IReadOnlyList compilations) =>
+ compilations.Count == 1 ? compilations[0].AssemblyName : null;
+
+ ///
+ /// Gets the ordinally first file a compilation was built from.
+ ///
+ /// The compilation to read.
+ /// The path, empty when it was built from none.
+ static string FirstFileOf(Compilation compilation) =>
+ compilation.SyntaxTrees.Select(_ => _.FilePath).Order(StringComparer.Ordinal).FirstOrDefault() ?? string.Empty;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
index b3bdf0f82..300f172a8 100644
--- a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
+++ b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
@@ -1,9 +1,11 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+using Cratis.Arc.Screenplay.Analysis.Events;
using Cratis.Arc.Screenplay.Analysis.Policies;
using Cratis.Arc.Screenplay.Analysis.Screens;
using Cratis.Arc.Screenplay.Analysis.Slices;
+using Cratis.Arc.Screenplay.Analysis.Types;
using Cratis.Arc.Screenplay.Model;
using Microsoft.CodeAnalysis;
@@ -14,8 +16,9 @@ namespace Cratis.Arc.Screenplay.Analysis;
///
/// The the screens of a slice are found through.
///
-/// Namespaces are read in order and everything within a slice is ordered explicitly, so the same compilation always
-/// yields the same model - which is what makes the document it produces something worth committing.
+/// Projects are read in a fixed order, namespaces within a project are read in order and everything within a slice is
+/// ordered explicitly, so the same source always yields the same model whichever order its projects were handed over
+/// in - which is what makes the document it produces something worth committing.
///
public class ApplicationModelAnalyzer(IUserInterfaceFiles userInterfaceFiles) : IApplicationModelAnalyzer
{
@@ -34,76 +37,104 @@ public ApplicationModelAnalyzer()
}
///
- public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptions options)
- {
- var diagnostics = new ScreenplayDiagnostics();
- var failedToCompile = ReportCompilationErrors(compilation, diagnostics);
- var catalog = ArtifactCatalog.From(compilation);
- var readers = ArtifactReaders.For(compilation, catalog, diagnostics);
- var screens = new ScreenReader(
- userInterfaceFiles,
- SourcePaths.For(compilation, catalog),
- new(diagnostics),
- new(userInterfaceFiles, diagnostics));
-
- var reader = new SliceReader(readers, diagnostics, screens);
+ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptions options) =>
+ Analyze([compilation], options);
- var slices = catalog.Namespaces
- .Select(@namespace => reader.Read(@namespace, catalog.In(@namespace)))
- .OfType()
+ ///
+ public ApplicationModelAnalysis Analyze(IReadOnlyList compilations, ScreenplayOptions options)
+ {
+ var ordered = AnalyzedCompilations.Ordered(compilations);
+ var domain = options.Domain ?? AnalyzedCompilations.NameOf(ordered) ?? ScreenplayOptions.DefaultName;
+ var whole = new WholeApplication(ordered, new ScreenplayDiagnostics()) { Files = userInterfaceFiles };
+ var diagnostics = whole.Diagnostics;
+
+ var catalogs = ordered.Select(ArtifactCatalog.From).ToList();
+ var paths = SourceRoots.Across(ordered, catalogs, diagnostics, domain);
+ var projects = ordered
+ .Select((compilation, index) => CompilationAnalysis.Of(compilation, catalogs[index], paths[index], whole))
.ToList();
- ConceptValidations.Link(catalog, readers);
- readers.AggregateRoots.Report(diagnostics);
- ReportTypesTheDocumentCannotName(readers, diagnostics, compilation.AssemblyName);
- ReportEventsFromOutside(slices, diagnostics);
- ReportNamespacesWithoutStructure(slices, diagnostics, options.SegmentsToSkip ?? 0);
+ foreach (var project in projects)
+ {
+ project.LinkConceptValidations();
+ }
+
+ whole.AggregateRoots.Report(diagnostics);
+ whole.Elsewhere.Report(diagnostics);
+ TypesTheDocumentCannotName.Report(whole.Types, diagnostics, domain);
+
+ var joined = SliceUnion.Of(projects.SelectMany(_ => _.Slices), diagnostics);
+ var slices = Specified(projects, joined, diagnostics);
+ var imports = ExternalEvents.Resolve(ordered, slices, diagnostics);
+ NamespacesWithoutStructure.Report(slices, diagnostics, options.SegmentsToSkip ?? 0);
+
+ // How serious source that did not compile is depends on how much survived it, so it is reported once the
+ // slices are in rather than on the way in. Source that did not compile still suppresses "declares nothing" -
+ // an empty document from a broken build is the broken build, not an application with nothing in it - and
+ // when it is reported as a warning the suppression can never apply, because a warning is only ever reached
+ // when something was recovered and something recovered is a slice. Each project answers for its own source,
+ // so a build broken in one of them says nothing about the ones that built.
+ var failedToCompile = false;
+ foreach (var project in projects)
+ {
+ failedToCompile |= project.ReportCompilationErrors(diagnostics);
+ }
if (slices.Count == 0 && !failedToCompile)
{
diagnostics.Information(
ScreenplayDiagnosticCodes.AnalysisUnavailable,
"The source declares nothing that can be expressed, so the generated document describes nothing",
- compilation.AssemblyName);
+ domain);
}
return new(
new ApplicationModel(
- options.Domain ?? compilation.AssemblyName ?? ScreenplayOptions.DefaultName,
+ domain,
options.Module ?? options.Domain ?? ScreenplayOptions.DefaultName,
- readers.Types.Concepts,
- new PolicyCatalog(compilation, diagnostics).Declare(slices.SelectMany(AuthorizationsIn)),
- slices),
+ whole.Types.Concepts,
+ new PolicyCatalog(ordered, domain, diagnostics).Declare(slices.SelectMany(AuthorizationsIn)),
+ slices)
+ {
+ Imports = imports
+ },
diagnostics.All);
}
///
- /// Reports source that did not compile, which nothing recovered from it can be relied on past.
+ /// Puts every scenario the application specifies its slices by under the slice it belongs to.
///
- /// The compilation to check.
+ /// The projects the application is written as, in the order they were read.
+ /// The slices of the application, joined across every project.
/// The diagnostics to report to.
- /// True when the source did not compile.
+ /// The slices, each carrying the scenarios it is specified by.
///
- /// A compilation that does not build still yields symbols, and analyzing them produces a document that looks
- /// like an answer while describing an application that does not exist. Reporting it as an error rather than a
- /// warning is what makes a host exit non zero, because a nearly empty document and a success code is the one
- /// outcome nobody can act on. The model is still returned - what was recovered is worth seeing, as long as
- /// nobody is told it is trustworthy.
+ /// A scenario is written in one project and reads the symbols of that project's compilation, so it is recovered
+ /// per project exactly as an artifact is. Which slice it belongs to is a different question: a scenario names no
+ /// slice, it is placed by the namespace above it that declares one, and nothing says the project declaring that
+ /// slice is the project the scenario was written in - a bounded context handling its commands beside the contracts
+ /// project publishing its events is specified from the project holding the handlers. So placement is decided
+ /// against the slices of the whole application rather than the slices of the project the scenario came from, which
+ /// is why this reads them once the union is in rather than within each project.
///
- static bool ReportCompilationErrors(Compilation compilation, ScreenplayDiagnostics diagnostics)
+ static IReadOnlyList Specified(
+ IReadOnlyList projects,
+ IReadOnlyList slices,
+ ScreenplayDiagnostics diagnostics)
{
- var errors = compilation.GetDiagnostics().Where(_ => _.Severity == DiagnosticSeverity.Error).ToList();
- if (errors.Count == 0)
- {
- return false;
- }
+ var namespaces = slices.Select(_ => _.Namespace).ToList();
+ var catalogs = projects.Select(_ => _.Specifications(namespaces, diagnostics)).ToList();
- diagnostics.Error(
- ScreenplayDiagnosticCodes.SourceDidNotCompile,
- $"The source did not compile - {errors.Count} error(s), the first being '{errors[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture)}'. Nothing recovered from it describes the application reliably",
- compilation.AssemblyName);
-
- return true;
+ return
+ [
+ .. slices.Select(slice => slice with
+ {
+ Specifications = SliceUnion.Specifications(
+ catalogs.SelectMany(_ => _.For(slice.Namespace)),
+ slice.Namespace,
+ diagnostics)
+ })
+ ];
}
///
@@ -113,118 +144,4 @@ static bool ReportCompilationErrors(Compilation compilation, ScreenplayDiagnosti
/// The authorizations.
static IEnumerable AuthorizationsIn(SliceModel slice) =>
slice.Commands.Select(_ => _.Authorization).Concat(slice.Queries.Select(_ => _.Authorization)).OfType();
-
- ///
- /// Reports every type the document had to refer to by a name that does not say what it is.
- ///
- /// The readers holding the types encountered.
- /// The diagnostics to report to.
- /// Where to report against.
- ///
- /// A Screenplay type reference is a single identifier, so a type the grammar cannot hold is written as whatever
- /// of its name survives - and the property then claims a type nothing declares. Saying which types those were is
- /// the difference between a document with a known gap and a document that is quietly wrong.
- ///
- static void ReportTypesTheDocumentCannotName(ArtifactReaders readers, ScreenplayDiagnostics diagnostics, string? location)
- {
- foreach (var type in readers.Types.Unmappable)
- {
- diagnostics.Warning(
- ScreenplayDiagnosticCodes.UnmappableTypeReference,
- $"'{type}' has no Screenplay counterpart, so it is referred to by a name the document never declares",
- location);
- }
-
- foreach (var type in readers.Types.Ambiguous)
- {
- diagnostics.Warning(
- ScreenplayDiagnosticCodes.AmbiguousConceptName,
- $"'{type}' shares its simple name with a concept the document already declares, so what it is is described by the first one instead",
- location);
- }
- }
-
- ///
- /// Reports every event the application refers to but does not declare.
- ///
- /// The slices to check.
- /// The diagnostics to report to.
- ///
- /// An event living in a referenced package is real, but nothing in the compilation declares it, so the document
- /// would refer to something it never introduces. Saying so is better than inventing a declaration for it.
- ///
- static void ReportEventsFromOutside(IReadOnlyList slices, ScreenplayDiagnostics diagnostics)
- {
- var declared = slices.SelectMany(_ => _.Events).Select(_ => _.Name).ToHashSet(StringComparer.Ordinal);
-
- foreach (var slice in slices)
- {
- foreach (var name in ReferencedEvents(slice).Where(_ => !declared.Contains(_)).Order(StringComparer.Ordinal))
- {
- diagnostics.Warning(
- ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation,
- $"'{name}' is referred to but declared outside the compilation, so the document refers to an event it never introduces",
- slice.Namespace);
- }
- }
- }
-
- ///
- /// Reports a namespace that carries nothing to arrange the document by.
- ///
- /// The slices to check.
- /// The diagnostics to report to.
- /// The number of leading namespace segments being skipped.
- ///
- /// Artifacts sitting in the root namespace leave the module, the feature and the slice with nothing to be named
- /// after but the assembly, so all of them end up saying the same word. Naming them anything else would be
- /// fiction - the source really does say nothing about where they belong - so this says what would fix it
- /// instead, which is either a namespace per slice or a leading segment skipped.
- ///
- static void ReportNamespacesWithoutStructure(
- IEnumerable slices,
- ScreenplayDiagnostics diagnostics,
- int segmentsToSkip)
- {
- foreach (var slice in slices.Where(_ => Segments(_.Namespace, segmentsToSkip) <= 1))
- {
- diagnostics.Information(
- ScreenplayDiagnosticCodes.NamespaceWithoutStructure,
- "The namespace carries no feature or slice to arrange by, so the module, the feature and the slice all take the same name - give the slice a namespace of its own, or skip a leading segment",
- slice.Namespace);
- }
- }
-
- ///
- /// Counts the namespace segments left to arrange a slice by.
- ///
- /// The namespace to count.
- /// The number of leading segments being skipped.
- /// The number of segments.
- static int Segments(string @namespace, int segmentsToSkip) =>
- @namespace.Split('.', StringSplitOptions.RemoveEmptyEntries).Length - segmentsToSkip;
-
- ///
- /// Gets the names of every event a slice refers to.
- ///
- /// The slice to read.
- /// The names, distinct.
- static IEnumerable ReferencedEvents(SliceModel slice) =>
- slice.Commands.SelectMany(_ => _.Produces).Select(_ => _.EventName)
- .Concat(slice.Reactors.SelectMany(_ => _.ObservedEvents))
- .Concat(slice.Constraints.SelectMany(EventsOf))
- .Concat(ProjectionEvents.In(slice.Projection))
- .Distinct(StringComparer.Ordinal);
-
- ///
- /// Gets the names of the events a constraint refers to.
- ///
- /// The constraint to read.
- /// The names.
- static IEnumerable EventsOf(ConstraintModel constraint) => constraint switch
- {
- UniquePropertyConstraintModel unique => [unique.EventName],
- UniqueEventConstraintModel unique => [unique.EventName],
- _ => []
- };
}
diff --git a/Source/DotNET/Screenplay/Analysis/ArtifactDeclaration.cs b/Source/DotNET/Screenplay/Analysis/ArtifactDeclaration.cs
new file mode 100644
index 000000000..81dfab5f7
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/ArtifactDeclaration.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Holds where the declaration that artifacts were recovered from is written.
+///
+///
+/// A type is written in as many places as it has partial declarations, and a source generator contributing one of
+/// them is exactly the case this exists to tell apart, so every place it is written is held rather than the first.
+///
+public class ArtifactDeclaration
+{
+ readonly (SyntaxTree Tree, TextSpan Span)[] _written;
+
+ ArtifactDeclaration(int artifacts, (SyntaxTree Tree, TextSpan Span)[] written)
+ {
+ Artifacts = artifacts;
+ _written = written;
+ }
+
+ ///
+ /// Gets the number of artifacts recovered from the declaration.
+ ///
+ public int Artifacts { get; }
+
+ ///
+ /// Holds where the type artifacts were recovered from is written.
+ ///
+ /// The type they were recovered from.
+ /// The number of artifacts recovered from it.
+ /// The .
+ public static ArtifactDeclaration For(INamedTypeSymbol type, int artifacts) =>
+ new(artifacts, [.. type.DeclaringSyntaxReferences.Select(_ => (_.SyntaxTree, _.Span))]);
+
+ ///
+ /// Answers whether something the compiler reported sits within the declaration.
+ ///
+ /// The diagnostic the compiler reported.
+ /// True when it sits within the declaration.
+ ///
+ /// An error somewhere else in the compilation says nothing about what was read here, while one written inside
+ /// the declaration itself is the compiler saying it could not make sense of the very source the artifact was
+ /// recovered from. Telling the two apart is the whole reason the spans are kept.
+ ///
+ public bool Encloses(Diagnostic diagnostic) =>
+ diagnostic.Location.SourceTree is { } tree &&
+ _written.Any(_ => ReferenceEquals(_.Tree, tree) && _.Span.Contains(diagnostic.Location.SourceSpan));
+}
diff --git a/Source/DotNET/Screenplay/Analysis/ArtifactReaders.cs b/Source/DotNET/Screenplay/Analysis/ArtifactReaders.cs
index 1068d70f7..7811a1559 100644
--- a/Source/DotNET/Screenplay/Analysis/ArtifactReaders.cs
+++ b/Source/DotNET/Screenplay/Analysis/ArtifactReaders.cs
@@ -91,18 +91,39 @@ public class ArtifactReaders
/// The catalogue of everything the compilation declares.
/// The anything unmappable is reported to.
/// The .
- public static ArtifactReaders For(Compilation compilation, ArtifactCatalog catalog, ScreenplayDiagnostics diagnostics)
+ public static ArtifactReaders For(Compilation compilation, ArtifactCatalog catalog, ScreenplayDiagnostics diagnostics) =>
+ For(compilation, catalog, SourcePaths.For(compilation, catalog), WholeApplication.Of(compilation, diagnostics));
+
+ ///
+ /// Composes every reader for one project of an application, sharing what belongs to the application as a whole.
+ ///
+ /// The compilation being analyzed.
+ /// The catalogue of everything the compilation declares.
+ /// The the paths of this project are written relative to.
+ /// What the application as a whole holds.
+ /// The .
+ ///
+ /// A concept is declared once at the top of a document however many projects refer to it, an aggregate root one
+ /// project declares is handed its work by a command another project may hold, and the body of that aggregate
+ /// root's behavior is read through the project it was written in rather than the one calling it. Everything else
+ /// here reads a declaration the project itself catalogued, which is why there is a set of readers per project at
+ /// all.
+ ///
+ public static ArtifactReaders For(
+ Compilation compilation,
+ ArtifactCatalog catalog,
+ SourcePaths paths,
+ WholeApplication whole)
{
- var types = new TypeRegistry();
+ var types = whole.Types;
+ var diagnostics = whole.Diagnostics;
var properties = new PropertyReader(types);
- var paths = SourcePaths.For(compilation, catalog);
- var aggregates = new AggregateRootCatalog();
- var produces = new ProducesReader(compilation, aggregates, diagnostics);
+ var produces = new ProducesReader(whole.Models, whole.AggregateRoots, diagnostics);
var validators = ValidatorCatalog.From(catalog, new(compilation, diagnostics));
return new(
types,
- aggregates,
+ whole.AggregateRoots,
validators,
new EventReader(properties, diagnostics),
new CommandReader(properties, produces, validators, paths),
diff --git a/Source/DotNET/Screenplay/Analysis/Commands/ProducedBySignature.cs b/Source/DotNET/Screenplay/Analysis/Commands/ProducedBySignature.cs
new file mode 100644
index 000000000..f670587b8
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Commands/ProducedBySignature.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Commands;
+
+///
+/// States what the signature of a handler promises, for a command whose body could not be read.
+///
+///
+/// Reading the body is what gives a production its mappings, so this is strictly the lesser answer - it says which
+/// events a command produces and nothing about what it puts in them. It is still much better than silence: a body
+/// that cannot be read is a partial documentation of the command rather than an argument for describing none of it,
+/// and what was lost is reported so the difference is visible.
+///
+public static class ProducedBySignature
+{
+ ///
+ /// Reads what the signatures of a command's handlers promise.
+ ///
+ /// The handler methods to read.
+ /// Where the command lives, for use in diagnostics.
+ /// The diagnostics to report to.
+ /// The productions, without mappings.
+ public static IEnumerable Of(
+ IReadOnlyList handlers,
+ string location,
+ ScreenplayDiagnostics diagnostics)
+ {
+ var events = handlers
+ .SelectMany(_ => HandlerBodies.EventTypesIn(_.ReturnType))
+ .Select(_ => _.Name)
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .ToList();
+
+ foreach (var name in events)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnmappableCommandProduction,
+ $"'{name}' is named by the handler's signature but never constructed in a body that could be read, so it is stated without mappings",
+ location);
+ }
+
+ return [.. events.Select(_ => new ProducesModel(_, null, []))];
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Commands/ProducesReader.cs b/Source/DotNET/Screenplay/Analysis/Commands/ProducesReader.cs
index 9cb5af359..1ee7eb53d 100644
--- a/Source/DotNET/Screenplay/Analysis/Commands/ProducesReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Commands/ProducesReader.cs
@@ -12,7 +12,7 @@ namespace Cratis.Arc.Screenplay.Analysis.Commands;
///
/// Reads the events a command produces, from the body of its handler.
///
-/// The compilation being analyzed.
+/// The every body is read through.
/// The recording which aggregate roots a command reaches.
/// The anything unmappable is reported to.
///
@@ -23,8 +23,13 @@ namespace Cratis.Arc.Screenplay.Analysis.Commands;
/// A handler that governs its change through an aggregate root constructs nothing itself, so the behaviors it calls
/// are read as well. Both ways of writing an Arc command then describe the same thing in the document.
///
+///
+/// That behavior is the one body here that need not belong to the project the command does - an aggregate root in a
+/// domain project called from the project above it is the ordinary layered arrangement - so which model reads it is
+/// asked rather than assumed.
+///
///
-public class ProducesReader(Compilation compilation, AggregateRootCatalog aggregates, ScreenplayDiagnostics diagnostics)
+public class ProducesReader(SemanticModels models, AggregateRootCatalog aggregates, ScreenplayDiagnostics diagnostics)
{
readonly ProducesMappingReader _mappings = new(diagnostics);
readonly ProducesConditionResolver _conditions = new(diagnostics);
@@ -46,17 +51,22 @@ public IEnumerable Read(INamedTypeSymbol command, IReadOnlyList 0 ? Deduplicate(produces) : FromSignatures(handlers, location);
+ return produces.Count > 0 ? Deduplicate(produces) : ProducedBySignature.Of(handlers, location, diagnostics);
}
///
@@ -95,17 +105,18 @@ static bool IsSame(ProducesModel left, ProducesModel right) =>
///
/// The type declaring the command.
/// The body to read.
+ /// The model the body is read through.
/// Where the command lives, for use in diagnostics.
/// The productions collected so far.
/// The behavior the body belongs to, when the handler reached it through an aggregate root.
void ReadBody(
INamedTypeSymbol command,
SyntaxNode body,
+ SemanticModel semanticModel,
string location,
List produces,
AggregateRootInvocation? behavior)
{
- var semanticModel = compilation.GetSemanticModel(body.SyntaxTree);
var scope = new ProducesScope(semanticModel, command, behavior?.Bindings, behavior?.AggregateRoot);
foreach (var creation in body.DescendantNodesAndSelf().OfType())
@@ -123,29 +134,34 @@ void ReadBody(
}
///
- /// Falls back to what the signatures of the handlers promise when no body could be read.
+ /// Reads what a behavior of an aggregate root the handler reached produces.
///
- /// The handler methods to read.
+ /// The type declaring the command.
+ /// The behavior the handler reached.
/// Where the command lives, for use in diagnostics.
- /// The productions, without mappings.
- IEnumerable FromSignatures(IReadOnlyList handlers, string location)
+ /// The productions collected so far.
+ ///
+ /// The behavior is written wherever the aggregate root is, and a project that was not handed over is one whose
+ /// source cannot be read at all. Saying so is the difference between a command stated as producing nothing and a
+ /// reader who knows a project is missing from what the document was generated from.
+ ///
+ void ReadBehavior(
+ INamedTypeSymbol command,
+ AggregateRootInvocation behavior,
+ string location,
+ List produces)
{
- var events = handlers
- .SelectMany(_ => HandlerBodies.EventTypesIn(_.ReturnType))
- .Select(_ => _.Name)
- .Distinct(StringComparer.Ordinal)
- .Order(StringComparer.Ordinal)
- .ToList();
-
- foreach (var name in events)
+ if (models.For(behavior.Body.SyntaxTree) is { } semanticModel)
{
- diagnostics.Warning(
- ScreenplayDiagnosticCodes.UnmappableCommandProduction,
- $"'{name}' is named by the handler's signature but never constructed in a body that could be read, so it is stated without mappings",
- location);
+ ReadBody(command, behavior.Body, semanticModel, location, produces, behavior);
+
+ return;
}
- return [.. events.Select(_ => new ProducesModel(_, null, []))];
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnmappableCommandProduction,
+ $"The command hands its work to '{behavior.AggregateRoot.Name}', which is written in a project the document was not generated from, so what it applies is not stated",
+ location);
}
///
diff --git a/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs b/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs
new file mode 100644
index 000000000..0b8dc1a4c
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs
@@ -0,0 +1,119 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Screens;
+using Cratis.Arc.Screenplay.Analysis.Slices;
+using Cratis.Arc.Screenplay.Analysis.Specifications;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Reads one project of an application into the slices it declares.
+///
+///
+/// Everything within this reads symbols and syntax of a single compilation, which is what makes it the unit an
+/// application of several projects is read in. What comes out is joined to what the other projects yielded, and the
+/// questions that can only be answered once every project has been read - what a validator declares about a concept
+/// another project holds, which slice of the whole application a scenario written here specifies, and how much the
+/// errors of this project left behind - are asked of this afterwards rather than kept inside it.
+///
+public class CompilationAnalysis
+{
+ readonly ArtifactCatalog _catalog;
+ readonly ArtifactReaders _readers;
+ readonly RecoveredArtifacts _recovered;
+
+ CompilationAnalysis(
+ Compilation compilation,
+ ArtifactCatalog catalog,
+ ArtifactReaders readers,
+ RecoveredArtifacts recovered,
+ IReadOnlyList slices)
+ {
+ Compilation = compilation;
+ Slices = slices;
+ _catalog = catalog;
+ _readers = readers;
+ _recovered = recovered;
+ }
+
+ ///
+ /// Gets the compilation that was read.
+ ///
+ public Compilation Compilation { get; }
+
+ ///
+ /// Gets the slices the compilation declares, ordered by namespace.
+ ///
+ public IReadOnlyList Slices { get; }
+
+ ///
+ /// Reads a compilation into the slices it declares.
+ ///
+ /// The compilation to read.
+ /// The catalogue of everything it declares.
+ /// The its paths are written relative to.
+ /// What the application as a whole holds.
+ /// The .
+ public static CompilationAnalysis Of(
+ Compilation compilation,
+ ArtifactCatalog catalog,
+ SourcePaths paths,
+ WholeApplication whole)
+ {
+ var diagnostics = whole.Diagnostics;
+ var readers = ArtifactReaders.For(compilation, catalog, paths, whole);
+ var screens = new ScreenReader(
+ whole.Files,
+ paths,
+ new(diagnostics),
+ new(whole.Files, diagnostics, whole.Elsewhere),
+ whole.Elsewhere);
+
+ var recovered = new RecoveredArtifacts();
+ var reader = new SliceReader(readers, diagnostics, screens, recovered);
+
+ var slices = catalog.Namespaces
+ .Select(@namespace => reader.Read(@namespace, catalog.In(@namespace)))
+ .OfType()
+ .ToList();
+
+ return new(compilation, catalog, readers, recovered, slices);
+ }
+
+ ///
+ /// Reads every scenario this project specifies a slice of the application by.
+ ///
+ /// The namespaces a slice was recovered from, across every project.
+ /// The diagnostics to report to.
+ /// The scenarios, arranged under the slice each belongs to.
+ ///
+ /// This waits until every project has been read, because a scenario is placed by the nearest namespace above it
+ /// that declares a slice - and the project declaring that slice need not be the one the scenario is written in.
+ ///
+ public SpecificationCatalog Specifications(IEnumerable slices, ScreenplayDiagnostics diagnostics) =>
+ SpecificationCatalog.Read(Compilation, _catalog, slices, diagnostics);
+
+ ///
+ /// Attaches the rules the validators of this project declare to the concepts they validate.
+ ///
+ ///
+ /// This waits until every project has been read, because the concept a validator here declares rules for may only
+ /// have been reached by an artifact in a project read after this one.
+ ///
+ public void LinkConceptValidations() => ConceptValidations.Link(_catalog, _readers);
+
+ ///
+ /// Reports the errors this compilation carries, at the severity what was recovered from it earns.
+ ///
+ /// The diagnostics to report to.
+ /// True when the source of this project did not compile.
+ ///
+ /// Each project answers for its own source. A project of an application that does not build says nothing about
+ /// the projects that do, and how much survived the errors is a count of what came out of this compilation.
+ ///
+ public bool ReportCompilationErrors(ScreenplayDiagnostics diagnostics) =>
+ CompilationErrors.Report(Compilation, _recovered, diagnostics);
+}
diff --git a/Source/DotNET/Screenplay/Analysis/CompilationErrors.cs b/Source/DotNET/Screenplay/Analysis/CompilationErrors.cs
new file mode 100644
index 000000000..6cdf80f46
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/CompilationErrors.cs
@@ -0,0 +1,93 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Reports source that did not compile, at the severity what was recovered from it earns.
+///
+///
+/// A compilation that does not build still yields symbols, and analyzing them can produce a document that looks like
+/// an answer while describing an application that does not exist - which is why this is reported at all. What it is
+/// not is a single outcome. A host that hands over a compilation assembled without the compile items a build
+/// generates leaves every reference to a strongly typed resource class unresolved, and hundreds of errors then sit
+/// in source that declares no artifact at all while every command, event and reactor is read exactly as written.
+/// Calling that an error says something untrue about thousands of correct lines and makes the host discard them.
+///
+/// So the severity follows one number: how many artifacts were recovered from a declaration no compilation error
+/// sits inside. When that number is zero - because nothing was recovered, or because every declaration something was
+/// recovered from is one the compiler could not make sense of - nothing in the document is worth trusting and this is
+/// an error, which is what makes a host exit non zero. Otherwise it is a warning stating how much was recovered
+/// anyway, because the artifacts read from source the compiler accepted are described exactly as that source states
+/// them and a document holding them is worth having.
+///
+///
+/// A count is used rather than a proportion deliberately. Any threshold - a tenth, a half - would make the same
+/// recovery pass for a large application and fail for a small one, and no number is defensible. Zero is the only one
+/// that means recovery was prevented rather than merely dented.
+///
+///
+public static class CompilationErrors
+{
+ ///
+ /// Reports the errors a compilation carries, if it carries any.
+ ///
+ /// The compilation that was analyzed.
+ /// What was recovered from it, and where each of it was written.
+ /// The diagnostics to report to.
+ /// True when the source did not compile, whatever was recovered from it.
+ public static bool Report(Compilation compilation, RecoveredArtifacts recovered, ScreenplayDiagnostics diagnostics)
+ {
+ var errors = compilation.GetDiagnostics().Where(_ => _.Severity == DiagnosticSeverity.Error).ToList();
+ if (errors.Count == 0)
+ {
+ return false;
+ }
+
+ var first = errors[0].GetMessage(CultureInfo.InvariantCulture);
+ var accepted = recovered.RecoveredFromAcceptedSource(errors);
+
+ if (accepted == 0)
+ {
+ diagnostics.Error(
+ ScreenplayDiagnosticCodes.SourceDidNotCompile,
+ PreventedRecovery(errors.Count, first, recovered.Count),
+ compilation.AssemblyName);
+ }
+ else
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.SourceDidNotCompile,
+ SurvivedRecovery(errors.Count, first, recovered.Count, accepted),
+ compilation.AssemblyName);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Says that the errors left nothing behind worth trusting.
+ ///
+ /// The number of errors the compiler reported.
+ /// The first thing the compiler said.
+ /// The number of artifacts recovered in all.
+ /// The message.
+ static string PreventedRecovery(int errors, string first, int recovered) =>
+ recovered == 0
+ ? $"The source did not compile - {errors} error(s), the first being '{first}'. Nothing at all was recovered from it, so nothing in the document describes the application reliably"
+ : $"The source did not compile - {errors} error(s), the first being '{first}'. {recovered} artifact(s) were recovered, and every declaration they were read from is one an error sits inside, so nothing recovered describes the application reliably";
+
+ ///
+ /// Says how much came through the errors intact.
+ ///
+ /// The number of errors the compiler reported.
+ /// The first thing the compiler said.
+ /// The number of artifacts recovered in all.
+ /// The number of them read from a declaration no error sits inside.
+ /// The message.
+ static string SurvivedRecovery(int errors, string first, int recovered, int accepted) =>
+ $"The source did not compile - {errors} error(s), the first being '{first}'. {recovered} artifact(s) were recovered anyway, {accepted} of them from a declaration no error sits inside, so the document describes those exactly as the source states them - a missing type named like 'SomethingMessages' or a designer class usually means the compilation was handed over without the compile items a build generates";
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Directories.cs b/Source/DotNET/Screenplay/Analysis/Directories.cs
new file mode 100644
index 000000000..6efd9081c
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Directories.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// The arithmetic of directories a document's paths are worked out with.
+///
+///
+/// A path in a document is written relative to a directory, and which directory that is comes from comparing the
+/// directories the source of a project - or of several - is written in. This is what the comparing is done with, held
+/// apart from the paths themselves because the same three questions are asked of one project's directories and of the
+/// roots of all of them.
+///
+public static class Directories
+{
+ ///
+ /// Normalizes a path onto forward slashes.
+ ///
+ /// The path to normalize.
+ /// The normalized path.
+ public static string Normalize(string path) => path.Replace('\\', '/');
+
+ ///
+ /// Finds the longest directory prefix every one of a set of directories shares.
+ ///
+ /// The directories to compare.
+ /// The shared prefix, ending in a separator, empty when they share none.
+ public static string SharedPrefixOf(IReadOnlyList directories)
+ {
+ if (directories.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ var prefix = directories[0];
+
+ foreach (var directory in directories.Skip(1))
+ {
+ var length = 0;
+ while (length < prefix.Length && length < directory.Length && prefix[length] == directory[length])
+ {
+ length++;
+ }
+
+ prefix = prefix[..length];
+ }
+
+ var separator = prefix.LastIndexOf('/');
+
+ return separator < 0 ? string.Empty : prefix[..(separator + 1)];
+ }
+
+ ///
+ /// Determines whether a directory is the root of a file system rather than a directory within one.
+ ///
+ /// The directory to check.
+ /// True when it is a file system root.
+ ///
+ /// Writing a path relative to / is not writing it relative to anything - it removes one character and
+ /// leaves the machine's own layout behind, looking like a relative path while being nothing of the sort.
+ ///
+ public static bool IsFileSystemRoot(string directory) =>
+ string.Equals(directory, "/", StringComparison.Ordinal) || (directory.Length == 3 && directory[1] == ':' && directory[2] == '/');
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs b/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs
new file mode 100644
index 000000000..18bef67e0
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs
@@ -0,0 +1,165 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Events;
+
+///
+/// Resolves the events an application refers to but does not declare itself.
+///
+///
+/// A reactor observing what a sibling bounded context publishes refers to an event living in a referenced package.
+/// The event is real and the document has to name it, but nothing in the compilation declares it, so a document
+/// stating only the reference refers to something it never introduces. Screenplay has the construct for exactly this
+/// situation - an import states the dependency outright and registers the name as an event that is known - so
+/// the one that can be found is imported and only the one that cannot is reported.
+///
+public static class ExternalEvents
+{
+ ///
+ /// Resolves the events the application refers to without declaring them, reporting every one nothing declares.
+ ///
+ /// The compilation being analyzed.
+ /// The slices to read.
+ /// The diagnostics to report to.
+ /// The fully qualified name of every event to import, ordered.
+ public static IReadOnlyList Resolve(
+ Compilation compilation,
+ IReadOnlyList slices,
+ ScreenplayDiagnostics diagnostics) =>
+ Resolve([compilation], slices, diagnostics);
+
+ ///
+ /// Resolves the events an application written as several projects refers to without declaring them.
+ ///
+ /// The compilations being analyzed, ordered.
+ /// The slices to read, joined across every project.
+ /// The diagnostics to report to.
+ /// The fully qualified name of every event to import, ordered.
+ ///
+ /// An import states a dependency on something outside the application, and a project of the application is not
+ /// outside it. One project referencing another puts the events they share in a referenced assembly, which is
+ /// exactly what an import looks in, so the assemblies being analyzed are left out of the search - an event a
+ /// sibling project declares is declared in the document rather than imported into it.
+ ///
+ public static IReadOnlyList Resolve(
+ IReadOnlyList compilations,
+ IReadOnlyList slices,
+ ScreenplayDiagnostics diagnostics)
+ {
+ var declared = slices.SelectMany(_ => _.Events).Select(_ => _.Name).ToHashSet(StringComparer.Ordinal);
+ var undeclared = slices.SelectMany(ReferredToBy).Where(_ => !declared.Contains(_)).ToHashSet(StringComparer.Ordinal);
+ var imported = DeclaredByAReference(compilations, undeclared);
+
+ foreach (var slice in slices)
+ {
+ foreach (var name in ReferredToBy(slice)
+ .Where(_ => !declared.Contains(_) && !imported.ContainsKey(_))
+ .Order(StringComparer.Ordinal))
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation,
+ $"'{name}' is referred to but nothing declares it, so the document refers to an event it never introduces",
+ slice.Namespace);
+ }
+ }
+
+ return [.. imported.Values.Order(StringComparer.Ordinal)];
+ }
+
+ ///
+ /// Gets the names of every event a slice refers to.
+ ///
+ /// The slice to read.
+ /// The names, distinct.
+ public static IEnumerable ReferredToBy(SliceModel slice) =>
+ slice.Commands.SelectMany(_ => _.Produces).Select(_ => _.EventName)
+ .Concat(slice.Reactors.SelectMany(_ => _.ObservedEvents))
+ .Concat(slice.Constraints.SelectMany(EventsOf))
+ .Concat(ProjectionEvents.In(slice.Projection))
+ .Distinct(StringComparer.Ordinal);
+
+ ///
+ /// Gets the names of the events a constraint refers to.
+ ///
+ /// The constraint to read.
+ /// The names.
+ static IEnumerable EventsOf(ConstraintModel constraint) => constraint switch
+ {
+ UniquePropertyConstraintModel unique => [unique.EventName],
+ UniqueEventConstraintModel unique => [unique.EventName],
+ _ => []
+ };
+
+ ///
+ /// Finds the event a referenced assembly declares under each of a set of names.
+ ///
+ /// The compilations being analyzed.
+ /// The names to look for.
+ /// The fully qualified name each one was found under, keyed by the name it is referred to by.
+ ///
+ /// Every assembly says which type names it holds without any of them being read, so the search only ever opens
+ /// the few that could answer. Assemblies and namespaces are both walked in name order and the first declaration
+ /// of a name wins, because two packages declaring an event under one name is a document that would otherwise
+ /// depend on the order the compiler happened to hand its references over.
+ ///
+ /// The references of every project are searched together, and ordering them by name across all of them rather
+ /// than project by project is what keeps the answer the same whichever project happens to be read first. The
+ /// assemblies the projects themselves build are excluded, because they are the application rather than something
+ /// it depends on.
+ ///
+ ///
+ static Dictionary DeclaredByAReference(IReadOnlyList compilations, HashSet names)
+ {
+ var found = new Dictionary(StringComparer.Ordinal);
+ if (names.Count == 0)
+ {
+ return found;
+ }
+
+ var analyzed = compilations.Select(_ => _.AssemblyName).OfType().ToHashSet(StringComparer.Ordinal);
+
+ 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);
+ }
+ }
+
+ return found;
+ }
+
+ ///
+ /// Collects every event declared under one of a set of names within a namespace and those nested in it.
+ ///
+ /// The namespace to walk.
+ /// The names to look for.
+ /// The declarations found so far.
+ static void Collect(INamespaceSymbol @namespace, HashSet names, Dictionary found)
+ {
+ foreach (var name in names.Where(_ => !found.ContainsKey(_)).Order(StringComparer.Ordinal))
+ {
+ var declaration = @namespace.GetTypeMembers(name)
+ .Where(EventReader.IsEvent)
+ .OrderBy(_ => _.ToDisplayString(), StringComparer.Ordinal)
+ .FirstOrDefault();
+
+ if (declaration is not null)
+ {
+ found[name] = declaration.ToDisplayString();
+ }
+ }
+
+ foreach (var nested in @namespace.GetNamespaceMembers().OrderBy(_ => _.Name, StringComparer.Ordinal))
+ {
+ Collect(nested, names, found);
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs b/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs
new file mode 100644
index 000000000..7e51b047d
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Recognizes the source a build wrote rather than a person.
+///
+///
+/// A compilation loaded from a project carries the output of every source generator that ran, emitted to disk under
+/// the intermediate folder. Those files declare real members of real types, so they belong in the model - but they
+/// say nothing whatsoever about where the application is written, and letting them answer that question puts a build
+/// folder among the folders a slice is said to live in. What follows from that is a slice reported as spread over
+/// two folders when it sits in one, and a screen scan widened to a tree no screen was ever written in.
+///
+public static class GeneratedSource
+{
+ ///
+ /// The folders a build writes its output to.
+ ///
+ public static readonly string[] OutputFolders = ["obj", "bin"];
+
+ ///
+ /// The suffixes a generated file is conventionally named with.
+ ///
+ public static readonly string[] Suffixes = [".g.cs", ".g.i.cs", ".generated.cs"];
+
+ ///
+ /// Determines whether a path names source a build wrote.
+ ///
+ /// The path to check.
+ /// True when the path names generated source.
+ public static bool Is(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return false;
+ }
+
+ var normalized = path.Replace('\\', '/');
+
+ return Array.Exists(Suffixes, _ => normalized.EndsWith(_, StringComparison.OrdinalIgnoreCase)) ||
+ normalized
+ .Split('/', StringSplitOptions.RemoveEmptyEntries)
+ .SkipLast(1)
+ .Any(segment => Array.Exists(OutputFolders, _ => string.Equals(segment, _, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ ///
+ /// Removes the paths naming source a build wrote, keeping every one of them when none is left otherwise.
+ ///
+ /// The paths to filter.
+ /// The paths a person wrote.
+ ///
+ /// Falling back to the whole set is what keeps a compilation of nothing but generated source answering at all.
+ /// Where a person wrote none of it there is no better answer available, and an empty one would leave every path
+ /// in the document written against the machine that generated it.
+ ///
+ public static IReadOnlyList Excluded(IEnumerable paths)
+ {
+ var all = paths.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _!).ToList();
+ var written = all.FindAll(_ => !Is(_));
+
+ return written.Count > 0 ? written : all;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/IApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/IApplicationModelAnalyzer.cs
index e1d4a74e4..de54653b4 100644
--- a/Source/DotNET/Screenplay/Analysis/IApplicationModelAnalyzer.cs
+++ b/Source/DotNET/Screenplay/Analysis/IApplicationModelAnalyzer.cs
@@ -22,4 +22,18 @@ public interface IApplicationModelAnalyzer
/// The options to analyze with, with every value already resolved.
/// The .
ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptions options);
+
+ ///
+ /// Analyzes the projects an application is written as and recovers the model they describe together.
+ ///
+ /// The compilations to analyze.
+ /// The options to analyze with, with every value already resolved.
+ /// The .
+ ///
+ /// Each compilation contributes what its own assembly declares and the results are joined into one model: a
+ /// namespace two projects declare into is one slice, a concept is declared once however many projects refer to
+ /// it, and an event a sibling project declares is one the application has rather than one it imports. The order
+ /// the list arrives in never reaches the model - the projects are put into assembly name order first.
+ ///
+ ApplicationModelAnalysis Analyze(IReadOnlyList compilations, ScreenplayOptions options);
}
diff --git a/Source/DotNET/Screenplay/Analysis/NamespacesWithoutStructure.cs b/Source/DotNET/Screenplay/Analysis/NamespacesWithoutStructure.cs
new file mode 100644
index 000000000..1b15417e8
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/NamespacesWithoutStructure.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Reports a namespace that carries nothing to arrange the document by.
+///
+///
+/// Artifacts sitting in the root namespace leave the module, the feature and the slice with nothing to be named after
+/// but the assembly, so all of them end up saying the same word. Naming them anything else would be fiction - the
+/// source really does say nothing about where they belong - so this says what would fix it instead, which is either a
+/// namespace per slice or a leading segment skipped.
+///
+public static class NamespacesWithoutStructure
+{
+ ///
+ /// Reports every slice whose namespace carries no feature or slice to arrange by.
+ ///
+ /// The slices to check.
+ /// The diagnostics to report to.
+ /// The number of leading namespace segments being skipped.
+ ///
+ /// The number of segments to skip is one number for the whole application rather than one per project. It says
+ /// how the namespaces of the application are shaped, and the projects of one application share the root of their
+ /// namespaces - a value per project would have to be keyed on assembly names the caller configuring it has no
+ /// reason to know.
+ ///
+ public static void Report(
+ IEnumerable slices,
+ ScreenplayDiagnostics diagnostics,
+ int segmentsToSkip)
+ {
+ foreach (var slice in slices.Where(_ => Segments(_.Namespace, segmentsToSkip) <= 1))
+ {
+ diagnostics.Information(
+ ScreenplayDiagnosticCodes.NamespaceWithoutStructure,
+ "The namespace carries no feature or slice to arrange by, so the module, the feature and the slice all take the same name - give the slice a namespace of its own, or skip a leading segment",
+ slice.Namespace);
+ }
+ }
+
+ ///
+ /// Counts the namespace segments left to arrange a slice by.
+ ///
+ /// The namespace to count.
+ /// The number of leading segments being skipped.
+ /// The number of segments.
+ static int Segments(string @namespace, int segmentsToSkip) =>
+ @namespace.Split('.', StringSplitOptions.RemoveEmptyEntries).Length - segmentsToSkip;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Policies/PolicyCatalog.cs b/Source/DotNET/Screenplay/Analysis/Policies/PolicyCatalog.cs
index 196920d55..26bc5149e 100644
--- a/Source/DotNET/Screenplay/Analysis/Policies/PolicyCatalog.cs
+++ b/Source/DotNET/Screenplay/Analysis/Policies/PolicyCatalog.cs
@@ -9,17 +9,33 @@ namespace Cratis.Arc.Screenplay.Analysis.Policies;
///
/// Declares a policy for everything the application asks of its callers.
///
-/// The compilation being analyzed.
+/// The compilations being analyzed, ordered.
+/// Where a policy nothing registers is reported against.
/// The anything unrecoverable is reported to.
///
/// Only what something refers to is declared. A role named by an artifact is a policy whose rule is the role itself;
/// a policy named by an artifact has its rule looked up where the application registers it. Declaring the ones that
/// nothing refers to would fill the document with rules it never uses.
+///
+/// Where the application registers it is any of its projects. An artifact naming a policy is written where the
+/// behavior is, and the registration where the host is composed, which is a different project as soon as an
+/// application has more than one.
+///
///
-public class PolicyCatalog(Compilation compilation, ScreenplayDiagnostics diagnostics)
+public class PolicyCatalog(IReadOnlyList compilations, string? location, ScreenplayDiagnostics diagnostics)
{
readonly PolicyRequirementReader _requirements = new(diagnostics);
+ ///
+ /// Initializes a new instance of the class for an application of a single project.
+ ///
+ /// The compilation being analyzed.
+ /// The anything unrecoverable is reported to.
+ public PolicyCatalog(Compilation compilation, ScreenplayDiagnostics diagnostics)
+ : this([compilation], compilation.AssemblyName, diagnostics)
+ {
+ }
+
///
/// Declares a policy for every role and every named policy the slices refer to.
///
@@ -28,7 +44,7 @@ public class PolicyCatalog(Compilation compilation, ScreenplayDiagnostics diagno
public IEnumerable Declare(IEnumerable authorizations)
{
var declared = authorizations as IReadOnlyCollection ?? [.. authorizations];
- var registrations = PolicyRegistrations.In(compilation);
+ var registrations = PolicyRegistrations.In(compilations);
var policies = new Dictionary(StringComparer.Ordinal);
foreach (var name in Names(declared, _ => _.Policies))
@@ -74,7 +90,7 @@ static IEnumerable Names(
diagnostics.Warning(
ScreenplayDiagnosticCodes.PolicyRequirementsUnrecoverable,
$"The policy '{name}' is referred to, but nothing in the compilation registers it, so what it requires is not stated",
- compilation.AssemblyName);
+ location);
return null;
}
diff --git a/Source/DotNET/Screenplay/Analysis/Policies/PolicyRegistrations.cs b/Source/DotNET/Screenplay/Analysis/Policies/PolicyRegistrations.cs
index 529ac351f..3e6b1af4f 100644
--- a/Source/DotNET/Screenplay/Analysis/Policies/PolicyRegistrations.cs
+++ b/Source/DotNET/Screenplay/Analysis/Policies/PolicyRegistrations.cs
@@ -31,13 +31,29 @@ public static class PolicyRegistrations
/// The first registration of a name wins, which is the framework's own behavior for the options form and is the
/// only choice that keeps the same compilation yielding the same document.
///
- public static IReadOnlyDictionary In(Compilation compilation)
+ public static IReadOnlyDictionary In(Compilation compilation) => In([compilation]);
+
+ ///
+ /// Finds every policy the projects of an application register.
+ ///
+ /// The compilations to read, ordered.
+ /// The registrations, keyed by the name each policy is registered under.
+ ///
+ /// An artifact naming a policy and the composition root registering it routinely sit in different projects - that
+ /// the host is not where the behavior lives is the whole point of layering an application - so every project is
+ /// read and the registrations of all of them form one set. The first registration of a name still wins, and the
+ /// projects arrive already ordered, so which one that is does not depend on how the caller listed them.
+ ///
+ public static IReadOnlyDictionary In(IReadOnlyList compilations)
{
var registrations = new Dictionary(StringComparer.Ordinal);
- foreach (var tree in compilation.SyntaxTrees.OrderBy(_ => _.FilePath, StringComparer.Ordinal))
+ foreach (var compilation in compilations)
{
- Collect(compilation.GetSemanticModel(tree), tree, registrations);
+ foreach (var tree in compilation.SyntaxTrees.OrderBy(_ => _.FilePath, StringComparer.Ordinal))
+ {
+ Collect(compilation.GetSemanticModel(tree), tree, registrations);
+ }
}
return registrations;
diff --git a/Source/DotNET/Screenplay/Analysis/PropertyReader.cs b/Source/DotNET/Screenplay/Analysis/PropertyReader.cs
index 9d393d2a5..8e1f0b39e 100644
--- a/Source/DotNET/Screenplay/Analysis/PropertyReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/PropertyReader.cs
@@ -39,7 +39,7 @@ public IEnumerable Read(ITypeSymbol type)
types.MarkAsPii(property.Type);
}
- properties.Add(new(property.Name, types.Resolve(property.Type)));
+ properties.Add(new(property.Name, types.ResolveCarried(property.Type)));
}
return properties;
diff --git a/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs b/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs
index cd562c1ef..3ad7ec40d 100644
--- a/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs
@@ -18,6 +18,17 @@ namespace Cratis.Arc.Screenplay.Analysis.Queries;
///
public class QueryReader(TypeRegistry types, ScreenplayDiagnostics diagnostics)
{
+ ///
+ /// The types a query is handed by the host it runs in rather than by the caller it answers.
+ ///
+ public static readonly string[] InfrastructureTypes =
+ [
+ WellKnownTypeNames.CancellationToken,
+ WellKnownTypeNames.QueryContext,
+ WellKnownTypeNames.Paging,
+ WellKnownTypeNames.Sorting
+ ];
+
///
/// Determines whether a type is a model-bound read model.
///
@@ -77,9 +88,14 @@ [.. parameters.Where(_ => !SymbolEqualityComparer.Default.Equals(_, required)).S
/// True when the parameter is input rather than infrastructure.
///
/// An interface parameter is a collaborator the host injects, never something a caller can send, so it is not
- /// part of the query's shape.
+ /// part of the query's shape. Being an interface is not the whole of that though: a cancellation token, the page
+ /// asked for and the order asked for are all filled in by the host from the request rather than sent as
+ /// arguments, and all three are values rather than interfaces. Stating one as caller input puts a parameter in
+ /// the document that no caller sends, typed by a name the document never declares.
///
- static bool IsInput(IParameterSymbol parameter) => parameter.Type.TypeKind != TypeKind.Interface;
+ static bool IsInput(IParameterSymbol parameter) =>
+ parameter.Type.TypeKind != TypeKind.Interface &&
+ !Array.Exists(InfrastructureTypes, parameter.Type.Is);
///
/// Determines whether a method returns the read model declaring it.
diff --git a/Source/DotNET/Screenplay/Analysis/RecoveredArtifacts.cs b/Source/DotNET/Screenplay/Analysis/RecoveredArtifacts.cs
new file mode 100644
index 000000000..03edc2d08
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/RecoveredArtifacts.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Counts what analysis recovered from a compilation, and remembers where each of it was written.
+///
+///
+/// "Nothing recovered from it describes the application reliably" is a claim about a compilation that did not
+/// compile, and for most of them it is false - a build handed over without the compile items it generates leaves
+/// hundreds of errors behind while every artifact is read exactly as written. This is what turns that claim into
+/// one that can be checked: how much was recovered, and how much of it came from source the compiler accepted.
+///
+public class RecoveredArtifacts
+{
+ readonly List _declarations = [];
+
+ ///
+ /// Gets the number of artifacts recovered in all.
+ ///
+ public int Count => _declarations.Sum(_ => _.Artifacts);
+
+ ///
+ /// Holds what one type contributed, ignoring a type that contributed nothing.
+ ///
+ /// The type that was read.
+ /// The number of artifacts recovered from it.
+ public void Declare(INamedTypeSymbol type, int artifacts)
+ {
+ if (artifacts <= 0)
+ {
+ return;
+ }
+
+ _declarations.Add(ArtifactDeclaration.For(type, artifacts));
+ }
+
+ ///
+ /// Counts the artifacts recovered from a declaration that none of the compilation errors sit inside.
+ ///
+ /// The errors the compiler reported.
+ /// The number of artifacts.
+ ///
+ /// This is the number that decides how serious a compilation that did not compile is. An artifact read from a
+ /// declaration the compiler accepted is described exactly as the source states it, whatever went wrong
+ /// elsewhere, so as long as there is one of them the document is not worthless.
+ ///
+ public int RecoveredFromAcceptedSource(IReadOnlyList errors) =>
+ _declarations.Where(declaration => !errors.Any(declaration.Encloses)).Sum(_ => _.Artifacts);
+}
diff --git a/Source/DotNET/Screenplay/Analysis/ResourceKeys.cs b/Source/DotNET/Screenplay/Analysis/ResourceKeys.cs
new file mode 100644
index 000000000..cc84d7f3a
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/ResourceKeys.cs
@@ -0,0 +1,128 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Reads the key a value is looked up in a resource by.
+///
+///
+/// An application that speaks more than one language declares its text once in a resource and names it from
+/// everywhere it is shown. What it names is a property a build generates from the resource, whose whole body is a
+/// lookup against the culture of the caller - so the compiler holds no value for it, and asking for one is how such
+/// a value comes back indistinguishable from text assembled while the request runs.
+///
+/// The key is there to be read even though the text is not, and it is the better of the two to take: text would fix
+/// one language into a document describing an application that has several. 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.
+///
+///
+public static class ResourceKeys
+{
+ ///
+ /// The lookup a generated resource property resolves its text through.
+ ///
+ public const string GetString = "GetString";
+
+ ///
+ /// The member a generated resource property looks its text up in.
+ ///
+ public const string ResourceManager = "ResourceManager";
+
+ ///
+ /// Reads the key an expression is looked up by, qualified by the resource declaring it.
+ ///
+ /// The expression to read.
+ /// The semantic model of the tree the expression lives in.
+ /// The key, or when the expression is no resource lookup.
+ ///
+ /// The key is qualified because a key is unique to the resource declaring it and to nothing wider. A real
+ /// application declares one resource per area of itself, and two of those areas naming the same rule the same
+ /// way - an organization number required both of a customer and of a partner - is ordinary rather than a
+ /// mistake. Bare, those two are one key that can carry only one text.
+ ///
+ public static string? Read(ExpressionSyntax? expression, SemanticModel semanticModel)
+ {
+ if (expression is null ||
+ semanticModel.GetSymbolInfo(expression).Symbol is not IPropertySymbol
+ {
+ IsStatic: true,
+ Type.SpecialType: SpecialType.System_String,
+ GetMethod: { } getter
+ } property)
+ {
+ return null;
+ }
+
+ return LookedUpBy(getter) is { } key ? $"{property.ContainingType.Name}.{key}" : null;
+ }
+
+ ///
+ /// Reads the key the getter of a property looks its value up by.
+ ///
+ /// The getter to read.
+ /// The key, or when the getter is no resource lookup.
+ ///
+ /// A getter is asked for exactly one lookup. None at all is a property computing its value some other way, and
+ /// more than one is a property choosing between them - a fallback, a composition - which is a value assembled
+ /// while the request runs however each part of it was reached.
+ ///
+ /// A property whose getter was compiled away into a referenced assembly has no body to read here and is left to
+ /// be reported, because the key would then have to be guessed from the name the property carries - and that name
+ /// is what a generator made of the key rather than the key itself.
+ ///
+ ///
+ static string? LookedUpBy(IMethodSymbol getter)
+ {
+ if (getter.DeclaringSyntaxReferences.Length != 1)
+ {
+ return null;
+ }
+
+ var lookups = getter.DeclaringSyntaxReferences[0].GetSyntax()
+ .DescendantNodes()
+ .OfType()
+ .Where(IsResourceLookup)
+ .ToList();
+
+ return lookups.Count == 1 ? KeyGivenTo(lookups[0]) : null;
+ }
+
+ ///
+ /// Determines whether a call looks a value up in a resource.
+ ///
+ /// The call to check.
+ /// True when the call is a resource lookup.
+ static bool IsResourceLookup(InvocationExpressionSyntax call) =>
+ call.Expression is MemberAccessExpressionSyntax lookup &&
+ string.Equals(lookup.Name.Identifier.ValueText, GetString, StringComparison.Ordinal) &&
+ string.Equals(NameOf(lookup.Expression), ResourceManager, StringComparison.Ordinal);
+
+ ///
+ /// Gets the name an expression ends in.
+ ///
+ /// The expression to name.
+ /// The name, or when the expression names nothing.
+ static string? NameOf(ExpressionSyntax expression) => expression switch
+ {
+ IdentifierNameSyntax identifier => identifier.Identifier.ValueText,
+ MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText,
+ _ => null
+ };
+
+ ///
+ /// Gets the key a lookup was given.
+ ///
+ /// The call to read.
+ /// The key, or when the lookup was given no key written down.
+ static string? KeyGivenTo(InvocationExpressionSyntax call) =>
+ call.ArgumentList.Arguments is [{ Expression: LiteralExpressionSyntax literal }, ..] &&
+ literal.IsKind(SyntaxKind.StringLiteralExpression)
+ ? literal.Token.ValueText
+ : null;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/CrossSliceQueries.cs b/Source/DotNET/Screenplay/Analysis/Screens/CrossSliceQueries.cs
new file mode 100644
index 000000000..2f25b02aa
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Screens/CrossSliceQueries.cs
@@ -0,0 +1,111 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.Analysis.Screens;
+
+///
+/// Reports the queries a screen reads through that a different slice of the application declares.
+///
+///
+/// A screen binds to what a slice declares, and an import naming something the slice does not declare says nothing -
+/// which is true of a component, a command and a package, and was taken to be true of everything. It is not true of a
+/// query another slice declares: a screen aggregating several read models is what Event Modeling screens routinely
+/// do, and dropping those left the document describing screens as reading almost nothing.
+///
+/// It still cannot be written down. A data directive names a query by the bare name its slice declares it
+/// under, and a real application declares All once per read model, so a name reaching across slices would say
+/// which query only by accident (Cratis/Screenplay#28). Where the import was written does say which one, and that is
+/// what is reported - which turns a silent drop into something a reader can act on today and a binding the moment a
+/// reference can carry the slice.
+///
+///
+public class CrossSliceQueries
+{
+ readonly Dictionary _owners = new(StringComparer.Ordinal);
+ readonly Dictionary> _declared = new(StringComparer.Ordinal);
+ readonly List _sites = [];
+
+ ///
+ /// Declares the queries a slice holds and the directories its source lives in.
+ ///
+ /// The namespace of the slice.
+ /// The directories the source of the slice lives in.
+ /// The queries the slice declares.
+ ///
+ /// A directory claimed by a second slice keeps the first, which is the same answer given wherever a folder holds
+ /// the source of more than one slice, and is already reported as the ambiguity it is.
+ ///
+ public void Declare(string @namespace, IReadOnlyList directories, IReadOnlyCollection queries)
+ {
+ _declared[@namespace] = [.. queries.Select(_ => _.Name)];
+
+ foreach (var directory in directories)
+ {
+ _owners.TryAdd(directory, @namespace);
+ }
+ }
+
+ ///
+ /// Records every name a screen imports from a module sitting alongside it.
+ ///
+ /// The namespace of the slice the screen belongs to.
+ /// The name of the screen.
+ /// The path of the file realizing the screen.
+ /// The names it imported.
+ ///
+ /// Every import is handed over rather than only the ones the slice declares no query under, because the name a
+ /// slice declares a query under is the same word twenty other slices declare one under - so a screen importing
+ /// All from another slice would be filtered out by the very name that makes it worth reporting.
+ ///
+ public void Record(string @namespace, string screen, string path, IEnumerable imports) =>
+ _sites.AddRange(imports.Select(_ => new ScreenImportSite(@namespace, screen, ScreenFiles.DirectoryOf(path), _)));
+
+ ///
+ /// Reports every import that named a query of another slice.
+ ///
+ /// The to report to.
+ ///
+ /// Reports are ordered by the slice, the screen and the query rather than by the order the screens happened to be
+ /// read, so the same source always reports the same way.
+ ///
+ public void Report(ScreenplayDiagnostics diagnostics)
+ {
+ var ordered = _sites
+ .OrderBy(_ => _.Namespace, StringComparer.Ordinal)
+ .ThenBy(_ => _.Screen, StringComparer.Ordinal)
+ .ThenBy(_ => _.Import.Name, StringComparer.Ordinal)
+ .ThenBy(_ => _.Import.Module, StringComparer.Ordinal);
+
+ foreach (var site in ordered)
+ {
+ if (DeclaringSlice(site) is not { } owner)
+ {
+ continue;
+ }
+
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.CrossSliceQueryBinding,
+ $"The screen '{site.Screen}' reads through the query '{site.Import.Name}' that '{owner}' declares, and a binding names a query by the bare name the screen's own slice declares it under, so it was left out",
+ site.Namespace);
+ }
+ }
+
+ ///
+ /// Gets the slice an import really names a query of, when it is not the one that wrote it.
+ ///
+ /// The import to resolve.
+ /// The namespace of the slice, or when the import names no query of another one.
+ string? DeclaringSlice(ScreenImportSite site)
+ {
+ if (ModulePaths.Resolve(site.Directory, site.Import.Module) is not { } resolved ||
+ !_owners.TryGetValue(ScreenFiles.DirectoryOf(resolved), out var owner) ||
+ string.Equals(owner, site.Namespace, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ return _declared.TryGetValue(owner, out var queries) && queries.Contains(site.Import.Name) ? owner : null;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ModulePaths.cs b/Source/DotNET/Screenplay/Analysis/Screens/ModulePaths.cs
new file mode 100644
index 000000000..08f810c3d
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ModulePaths.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Analysis.Screens;
+
+///
+/// Works out which file a relative module specifier names.
+///
+///
+/// A module specifier is a path written from the file doing the importing, and the folder it lands in is what says
+/// which slice the imported name belongs to. Resolving it is therefore the only way a name shared by twenty slices can
+/// be tied to one of them. Nothing here touches a disk - the extension is never appended and no file is looked for,
+/// because a slice is recognized by its folder rather than by the file within it.
+///
+public static class ModulePaths
+{
+ ///
+ /// Resolves the path a module specifier names.
+ ///
+ /// The directory of the file writing the import.
+ /// The module specifier.
+ /// The path without an extension, or when the specifier names nothing within.
+ ///
+ /// A specifier climbing past the root of the paths a compilation was built from names something outside the
+ /// application, which no slice of it can be.
+ ///
+ public static string? Resolve(string directory, string module)
+ {
+ var normalized = directory.Replace('\\', '/');
+ var rooted = normalized.StartsWith('/');
+ var segments = new List(normalized.Split('/', StringSplitOptions.RemoveEmptyEntries));
+
+ foreach (var segment in module.Replace('\\', '/').Split('/'))
+ {
+ if (segment.Length == 0 || string.Equals(segment, ".", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!string.Equals(segment, "..", StringComparison.Ordinal))
+ {
+ segments.Add(segment);
+ continue;
+ }
+
+ if (segments.Count == 0)
+ {
+ return null;
+ }
+
+ segments.RemoveAt(segments.Count - 1);
+ }
+
+ return segments.Count == 0 ? null : (rooted ? "/" : string.Empty) + string.Join('/', segments);
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ScreenDataReader.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenDataReader.cs
index d8df0be59..ed306daea 100644
--- a/Source/DotNET/Screenplay/Analysis/Screens/ScreenDataReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenDataReader.cs
@@ -10,18 +10,30 @@ namespace Cratis.Arc.Screenplay.Analysis.Screens;
///
/// The the text of a component is asked of.
/// The anything not inferred is reported to.
+/// The a name matching no query of the slice is held by.
///
/// Arc generates a proxy per query and a component imports it by name, so an import is a name the model can be held
-/// against rather than a reading of a user interface. A name matching a query the slice declares is a binding; a
-/// name matching nothing is dropped, whatever the component does with it. Nothing about the binding is taken from
-/// the component beyond the name - the type and the key come from the query, which is C#.
+/// against rather than a reading of a user interface. A name matching a query the slice declares is a binding.
+/// Nothing about the binding is taken from the component beyond the name - the type and the key come from the query,
+/// which is C#.
+///
+/// A name matching nothing the slice declares is handed on rather than dropped, because a query another slice
+/// declares is a binding too and the only reason it cannot be written down is that a reference has no way to say
+/// which slice it belongs to. What that name means depends on every slice the application has, so answering it waits
+/// until they have all been read.
+///
+///
+/// What a view model beside the component imports counts as what the component imports, because where components are
+/// written that way the query is named there and the component names only the view model - see
+/// for how far that is followed.
+///
///
/// Every screen also reports what stays out. The rest of the declarative form is JSX structure, and the cost of
/// guessing it wrong is a document that states something about the application that is not so - which is worse than
/// a document that says less. Saying so per screen is what turns that limit from a silence into an answer.
///
///
-public class ScreenDataReader(IUserInterfaceFiles files, ScreenplayDiagnostics diagnostics)
+public class ScreenDataReader(IUserInterfaceFiles files, ScreenplayDiagnostics diagnostics, CrossSliceQueries elsewhere)
{
///
/// Reads the bindings of one screen.
@@ -37,9 +49,12 @@ public IEnumerable Read(
string path,
IReadOnlyCollection queries)
{
- var imported = ScreenImports.In(files.Contents(path));
+ var written = ScreenImports.Statements(files.Contents(path));
+ var imports = written.Concat(ViewModelImports.Of(path, written, files)).Distinct().ToList();
+ var imported = new HashSet(imports.Select(_ => _.Name), StringComparer.Ordinal);
ReportUninferredStructure(@namespace, name);
+ elsewhere.Record(@namespace, name, path, imports);
return
[
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ScreenFiles.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenFiles.cs
index dd759fdcf..9f5f9a6ba 100644
--- a/Source/DotNET/Screenplay/Analysis/Screens/ScreenFiles.cs
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenFiles.cs
@@ -67,7 +67,7 @@ public static string DirectoryOf(string path)
///
/// The path to read.
/// The file name.
- static string FileNameOf(string path)
+ public static string FileNameOf(string path)
{
var normalized = Normalize(path);
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ScreenImport.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImport.cs
new file mode 100644
index 000000000..a8ff7aa3b
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImport.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Analysis.Screens;
+
+///
+/// Represents a name a user interface file imports, together with where it imported it from.
+///
+/// The name as the module exports it, rather than as the importing file renames it.
+/// The module specifier, exactly as it was written.
+///
+/// The name alone answers what a screen binds while the query is one its own slice declares. It stops answering the
+/// moment the query is declared elsewhere, because a real application declares All once per read model and the
+/// name says nothing about which of them was meant. Where the module was written is what says it, so the two travel
+/// together.
+///
+public record ScreenImport(string Name, string Module);
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ScreenImportSite.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImportSite.cs
new file mode 100644
index 000000000..56b9e820c
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImportSite.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Analysis.Screens;
+
+///
+/// Represents an import a screen wrote, together with where it was written from.
+///
+/// The namespace of the slice the screen belongs to.
+/// The name of the screen.
+/// The directory the file realizing the screen sits in, which the module is relative to.
+/// The name it imported and the module it came from.
+///
+/// Where the import was written is kept because a module specifier means nothing on its own - it is a path from the
+/// file that wrote it, and only that file says where it starts.
+///
+public record ScreenImportSite(string Namespace, string Screen, string Directory, ScreenImport Import);
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs
index bb0c9b9c5..fc269034c 100644
--- a/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs
@@ -27,31 +27,56 @@ public static partial class ScreenImports
/// slice, so that is the shape every real binding has, and the shapes that are left out - a default import, a
/// namespace import, an import of a package - cannot be tied to an exported name with any certainty.
///
- public static IReadOnlyCollection In(string? text)
+ public static IReadOnlyCollection In(string? text) =>
+ new HashSet(Statements(text).Select(_ => _.Name), StringComparer.Ordinal);
+
+ ///
+ /// Gets the names a file imports from a module sitting alongside it, together with the module each came from.
+ ///
+ /// The text of the file, or when it could not be read.
+ /// The imports, in the order the file writes them.
+ ///
+ /// Source order is kept rather than sorted, because it is the order the file was written in and nothing else is
+ /// any more meaningful - and keeping it is what makes the same file always read the same way.
+ ///
+ public static IReadOnlyList Statements(string? text)
{
- var names = new HashSet(StringComparer.Ordinal);
+ var imports = new List();
+ var seen = new HashSet();
if (string.IsNullOrWhiteSpace(text))
{
- return names;
+ return imports;
}
- foreach (var statement in StatementRegex().Matches(text).Cast())
+ foreach (var statement in StatementRegex().Matches(WithoutComments(text)).Cast())
{
var clause = statement.Groups["clause"].Value;
- if (!IsRelative(statement.Groups["module"].Value) || IsTypeOnly(clause))
+ var module = statement.Groups["module"].Value;
+ if (!IsRelative(module) || IsTypeOnly(clause))
{
continue;
}
- foreach (var name in NamedIn(clause))
- {
- names.Add(name);
- }
+ imports.AddRange(NamedIn(clause).Select(_ => new ScreenImport(_, module)).Where(seen.Add));
}
- return names;
+ return imports;
}
+ ///
+ /// Removes everything a comment holds, leaving the lines around it where they were.
+ ///
+ /// The text of the file.
+ /// The text with nothing commented out left in it.
+ ///
+ /// An import that has been commented out is an import the file does not make, and binding a screen to a query it
+ /// no longer calls is a plain untruth of exactly the kind this reader exists to avoid. The line breaks a comment
+ /// spans are kept, because a statement is recognized by starting a line and joining it to the line before would
+ /// hide a real import rather than a commented one.
+ ///
+ static string WithoutComments(string text) =>
+ CommentRegex().Replace(text, match => new string('\n', match.Value.Count(_ => _ == '\n')));
+
///
/// Determines whether a module specifier names a file sitting alongside the one importing it.
///
@@ -114,6 +139,13 @@ static IEnumerable NamedIn(string clause)
return exported is not null && IdentifierRegex().IsMatch(exported) ? exported : null;
}
+ ///
+ /// Gets the pattern a comment has to match.
+ ///
+ /// The compiled regular expression.
+ [GeneratedRegex(@"/\*[\s\S]*?\*/|//[^\r\n]*", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
+ private static partial Regex CommentRegex();
+
///
/// Gets the pattern an import statement naming a module has to match.
///
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ScreenReader.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenReader.cs
index 9528927ca..2a3e22cb5 100644
--- a/Source/DotNET/Screenplay/Analysis/Screens/ScreenReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenReader.cs
@@ -13,6 +13,7 @@ namespace Cratis.Arc.Screenplay.Analysis.Screens;
/// The rewriting the path of each file.
/// The anything uncertain is reported to.
/// The reading which of the slice's queries a screen binds.
+/// The told what each slice declares and where it lives.
///
/// Two things about a screen are recovered and no more: the file realizing it, which is what a reader opens, and the
/// queries it binds, which are names the model already holds and can be held against what the slice really declares.
@@ -23,7 +24,8 @@ public class ScreenReader(
IUserInterfaceFiles files,
SourcePaths paths,
AmbiguousScreens ambiguity,
- ScreenDataReader data)
+ ScreenDataReader data,
+ CrossSliceQueries elsewhere)
{
///
/// Reads the screens of a slice.
@@ -38,6 +40,8 @@ public IEnumerable Read(
IReadOnlyCollection queries)
{
var directories = SliceDirectories.Of(types);
+ elsewhere.Declare(@namespace, directories, queries);
+
if (directories.Count == 0)
{
return [];
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs
index 2d999286e..1fce9e9e6 100644
--- a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs
+++ b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs
@@ -12,11 +12,17 @@ namespace Cratis.Arc.Screenplay.Analysis.Screens;
/// This is what a syntax tree buys that metadata never could - a real path - and it is the whole reason a screen can
/// be recovered at all. The vertical slice convention puts the file realizing a screen next to the source of the
/// slice it belongs to, so where the source lives is where to look.
+///
+/// What a source generator emitted is not where the source lives. A logging generator writing partial members into
+/// the intermediate folder of the build contributes symbols to the slice and nothing at all to the question of where
+/// its screens are, so a slice sitting in one folder would otherwise be reported as spread over two and its screens
+/// looked for in a build folder.
+///
///
public static class SliceDirectories
{
///
- /// Gets the directories a set of types is declared across.
+ /// Gets the directories a set of types is written across.
///
/// The types to locate.
/// The directories, distinct and ordered.
@@ -25,7 +31,7 @@ public static IReadOnlyList Of(IEnumerable types) =>
.. types
.SelectMany(_ => _.DeclaringSyntaxReferences)
.Select(_ => _.SyntaxTree.FilePath)
- .Where(_ => !string.IsNullOrWhiteSpace(_))
+ .Where(_ => !string.IsNullOrWhiteSpace(_) && !GeneratedSource.Is(_))
.Select(ScreenFiles.DirectoryOf)
.Where(_ => _.Length > 0)
.Distinct(StringComparer.Ordinal)
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ViewModelImports.cs b/Source/DotNET/Screenplay/Analysis/Screens/ViewModelImports.cs
new file mode 100644
index 000000000..dad8dd9a6
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ViewModelImports.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Analysis.Screens;
+
+///
+/// Reads the imports a view model sitting beside a component writes on its behalf.
+///
+///
+/// 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 finds the name of a file and nothing about what the screen reads. That
+/// is not a variation worth guessing at in general, but this one is written down: the component names the module, the
+/// module 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 into the folder the screen itself sits in. A view model belongs to the slice that
+/// declares it, so following it says what that slice's own screen reads; following a chain of files across folders
+/// would be inferring an application's architecture, which is a guess rather than a reading.
+///
+///
+public static class ViewModelImports
+{
+ ///
+ /// The ending that marks a module as the view model of a component.
+ ///
+ public const string Suffix = "ViewModel";
+
+ static readonly string[] _extensions = [".ts", ".tsx"];
+
+ ///
+ /// Reads the imports the view models a screen names write.
+ ///
+ /// The path of the file realizing the screen.
+ /// What the screen itself imports.
+ /// The the text of a module is asked of.
+ /// The imports, in the order the view models write them.
+ public static IEnumerable Of(string path, IEnumerable imports, IUserInterfaceFiles files)
+ {
+ var directory = ScreenFiles.DirectoryOf(path);
+
+ return imports
+ .Select(_ => Beside(directory, _.Module))
+ .OfType()
+ .SelectMany(_ => ScreenImports.Statements(TextOf(_, files)));
+ }
+
+ ///
+ /// Gets the path of a view model a module specifier names in a directory.
+ ///
+ /// The directory the screen sits in.
+ /// The module specifier.
+ /// The path without an extension, or when the module is not one.
+ static string? Beside(string directory, string module) =>
+ ModulePaths.Resolve(directory, module) is { } resolved &&
+ string.Equals(ScreenFiles.DirectoryOf(resolved), directory, StringComparison.Ordinal) &&
+ ScreenFiles.FileNameOf(resolved).EndsWith(Suffix, StringComparison.Ordinal)
+ ? resolved
+ : null;
+
+ ///
+ /// Gets the text of a module, trying the endings a module is written with.
+ ///
+ /// The path of the module, without an extension.
+ /// The the text is asked of.
+ /// The text, or when nothing was found to read.
+ ///
+ /// A module specifier carries no extension, and a view model holding no markup is written as one file ending and
+ /// one holding some as another, so both are asked for in turn. Nothing found is not an error - it is a module
+ /// that is not a view model of this slice, or a file that cannot be read, and either way the screen is recovered
+ /// from what it says itself.
+ ///
+ static string? TextOf(string path, IUserInterfaceFiles files) =>
+ _extensions.Select(_ => files.Contents(path + _)).FirstOrDefault(_ => _ is not null);
+}
diff --git a/Source/DotNET/Screenplay/Analysis/SemanticModels.cs b/Source/DotNET/Screenplay/Analysis/SemanticModels.cs
new file mode 100644
index 000000000..61d3608ae
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/SemanticModels.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Resolves the semantic model of a syntax tree, whichever project of the application it was written in.
+///
+/// The compilations being analyzed, ordered.
+///
+/// A compilation answers only for the trees it was built from and throws for any other, so reading a declaration
+/// through the wrong one is not a wrong answer but a crash. Nearly every declaration is read through the compilation
+/// that catalogued it, and for those this is that compilation. The exception is a body reached from another body: a
+/// command handing its work to an aggregate root written in the project below it reads a behavior whose source
+/// belongs to that project, which is exactly the shape a layered application takes.
+///
+/// A tree belonging to none of them is a project the caller did not hand over. Nothing is returned rather than
+/// something being guessed, and the caller says what was lost.
+///
+///
+public class SemanticModels(IReadOnlyList compilations)
+{
+ ///
+ /// Resolves the semantic model a syntax tree is read through.
+ ///
+ /// The tree to read.
+ /// The , or when no project holds the tree.
+ public SemanticModel? For(SyntaxTree tree)
+ {
+ foreach (var compilation in compilations)
+ {
+ if (compilation.ContainsSyntaxTree(tree))
+ {
+ return compilation.GetSemanticModel(tree);
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/SliceKindInference.cs b/Source/DotNET/Screenplay/Analysis/SliceKindInference.cs
index 9640b184a..586b0cbb6 100644
--- a/Source/DotNET/Screenplay/Analysis/SliceKindInference.cs
+++ b/Source/DotNET/Screenplay/Analysis/SliceKindInference.cs
@@ -47,4 +47,33 @@ public static SliceKind Infer(
return commands.Any() || hasAggregateRoot ? SliceKind.StateChange : SliceKind.StateView;
}
+
+ ///
+ /// Infers the kind of a slice that several projects each declare a part of, from the kind of each part.
+ ///
+ /// The kind each part was inferred to be.
+ /// The inferred .
+ ///
+ /// This is applied to everything the parts hold together, said in terms of the kinds rather
+ /// than of the contents, and it answers identically. A part is a translation exactly when it holds a translating
+ /// reactor, an automation exactly when it holds a reactor and no translating one, and a state change exactly when
+ /// it holds a command or an aggregate root and no reactor at all - so taking the first of those that any part is
+ /// is the same first match the joined contents would have produced.
+ ///
+ public static SliceKind Combine(IEnumerable kinds)
+ {
+ var declared = kinds as IReadOnlyCollection ?? [.. kinds];
+
+ if (declared.Contains(SliceKind.Translate))
+ {
+ return SliceKind.Translate;
+ }
+
+ if (declared.Contains(SliceKind.Automation))
+ {
+ return SliceKind.Automation;
+ }
+
+ return declared.Contains(SliceKind.StateChange) ? SliceKind.StateChange : SliceKind.StateView;
+ }
}
diff --git a/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs b/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs
index 0b17f0409..73b6dc1ec 100644
--- a/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs
@@ -19,11 +19,12 @@ namespace Cratis.Arc.Screenplay.Analysis.Slices;
///
/// The reading each kind of artifact.
/// The anything unmappable is reported to.
+/// The holding what each declaration yielded.
///
/// A type can be more than one thing at once - a read model with queries is also the declaration of a projection -
/// so every recognizer is asked in turn rather than the first match winning.
///
-public class SliceArtifactReader(ArtifactReaders readers, ScreenplayDiagnostics diagnostics)
+public class SliceArtifactReader(ArtifactReaders readers, ScreenplayDiagnostics diagnostics, RecoveredArtifacts recovered)
{
///
/// Reads everything one type contributes.
@@ -31,7 +32,26 @@ public class SliceArtifactReader(ArtifactReaders readers, ScreenplayDiagnostics
/// The type to read.
/// The namespace the slice lives in.
/// The content collected so far.
+ ///
+ /// What the type yielded is taken as the difference it made to the content rather than recorded by each
+ /// recognizer, so a recognizer added later is tied to its declaration without anyone remembering to say so.
+ ///
public void Read(INamedTypeSymbol type, string @namespace, SliceContents content)
+ {
+ var before = content.Count;
+
+ ReadInto(type, @namespace, content);
+
+ recovered.Declare(type, content.Count - before);
+ }
+
+ ///
+ /// Asks every recognizer what the type is.
+ ///
+ /// The type to read.
+ /// The namespace the slice lives in.
+ /// The content collected so far.
+ void ReadInto(INamedTypeSymbol type, string @namespace, SliceContents content)
{
if (EventReader.IsEvent(type))
{
diff --git a/Source/DotNET/Screenplay/Analysis/Slices/SliceContents.cs b/Source/DotNET/Screenplay/Analysis/Slices/SliceContents.cs
index 4360669d3..f27e22e9c 100644
--- a/Source/DotNET/Screenplay/Analysis/Slices/SliceContents.cs
+++ b/Source/DotNET/Screenplay/Analysis/Slices/SliceContents.cs
@@ -49,14 +49,23 @@ public class SliceContents
///
public bool HasAggregateRoot { get; set; }
+ ///
+ /// Gets the number of artifacts collected so far.
+ ///
+ ///
+ /// What one type contributed is the difference between this before it was read and after, which is how a
+ /// declaration is tied to what came out of it without every recognizer having to say so itself.
+ ///
+ public int Count =>
+ Commands.Count +
+ Events.Count +
+ Queries.Count +
+ Reactors.Count +
+ Constraints.Count +
+ (Projection is null ? 0 : 1);
+
///
/// Gets a value indicating whether the slice declares nothing at all.
///
- public bool IsEmpty =>
- Commands.Count == 0 &&
- Events.Count == 0 &&
- Queries.Count == 0 &&
- Reactors.Count == 0 &&
- Constraints.Count == 0 &&
- Projection is null;
+ public bool IsEmpty => Count == 0;
}
diff --git a/Source/DotNET/Screenplay/Analysis/Slices/SliceReader.cs b/Source/DotNET/Screenplay/Analysis/Slices/SliceReader.cs
index 12a78e7fe..e8c164c01 100644
--- a/Source/DotNET/Screenplay/Analysis/Slices/SliceReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Slices/SliceReader.cs
@@ -13,14 +13,19 @@ namespace Cratis.Arc.Screenplay.Analysis.Slices;
/// The reading each kind of artifact.
/// The anything unmappable is reported to.
/// The reading what the slice ends in.
+/// The holding what each declaration yielded.
///
/// A namespace is the unit a slice is recovered from, because that is what the vertical slice convention makes it -
/// everything belonging to one behavior lives together, and nothing else does. The same convention puts the file
/// realizing a screen in that folder too, which is why screens are read here rather than discovered separately.
///
-public class SliceReader(ArtifactReaders readers, ScreenplayDiagnostics diagnostics, ScreenReader screens)
+public class SliceReader(
+ ArtifactReaders readers,
+ ScreenplayDiagnostics diagnostics,
+ ScreenReader screens,
+ RecoveredArtifacts recovered)
{
- readonly SliceArtifactReader _artifacts = new(readers, diagnostics);
+ readonly SliceArtifactReader _artifacts = new(readers, diagnostics, recovered);
///
/// Reads the slice a namespace declares.
diff --git a/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs b/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs
new file mode 100644
index 000000000..647890fbb
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs
@@ -0,0 +1,162 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.Analysis.Slices;
+
+///
+/// Joins what the projects of an application declare into one slice per namespace.
+///
+///
+/// A slice is recovered from a namespace, and nothing says a namespace belongs to one project. A bounded context that
+/// publishes its events from a contracts project and handles its commands from the project beside it declares one
+/// slice from two compilations, and a document holding both of them separately would say the slice twice and describe
+/// half of it in each. So the parts are joined: the events of one and the commands of the other end up in the slice
+/// they were always written for.
+///
+/// Everything within a slice is named once, though, so what two projects declare under one name cannot both be
+/// described. The parts arrive in the order the projects are read, which is their assembly name order and the only
+/// order there is to prefer by, and the first of a name is kept.
+///
+///
+public static class SliceUnion
+{
+ ///
+ /// Joins the slices of every project into the slices of the application.
+ ///
+ /// The slices each project declared, in the order the projects were read.
+ /// The diagnostics to report to.
+ /// The slices, one per namespace, ordered by namespace.
+ public static IReadOnlyList Of(IEnumerable slices, ScreenplayDiagnostics diagnostics) =>
+ [
+ .. slices
+ .GroupBy(_ => _.Namespace, StringComparer.Ordinal)
+ .OrderBy(_ => _.Key, StringComparer.Ordinal)
+ .Select(group => Join([.. group], diagnostics))
+ ];
+
+ ///
+ /// Joins the scenarios every project specifies one slice by.
+ ///
+ /// The scenarios each project placed under the slice, in the order they were read.
+ /// The namespace of the slice.
+ /// The diagnostics to report to.
+ /// The scenarios kept, ordered by name.
+ ///
+ /// Scenarios arrive apart from the rest of a slice, because which slice one belongs to is only known once every
+ /// project has been read, so they are joined here rather than in . What holds for every other
+ /// part of a slice holds for them: a document saying specification and_the_name_is_taken twice in one slice
+ /// says the same word twice and means it differently, so the first is kept.
+ ///
+ public static IReadOnlyList Specifications(
+ IEnumerable specifications,
+ string @namespace,
+ ScreenplayDiagnostics diagnostics) =>
+ Once(specifications, _ => _.Name, "specification", @namespace, diagnostics);
+
+ ///
+ /// Joins the parts of one slice.
+ ///
+ /// The parts, in the order the projects declaring them were read.
+ /// The diagnostics to report to.
+ /// The slice.
+ ///
+ /// A namespace declared by a single project is that project's slice exactly as it read it, which is what an
+ /// application of one project has always produced.
+ ///
+ static SliceModel Join(IReadOnlyList parts, ScreenplayDiagnostics diagnostics)
+ {
+ if (parts.Count == 1)
+ {
+ return parts[0];
+ }
+
+ var @namespace = parts[0].Namespace;
+
+ return new(
+ @namespace,
+ parts[0].Name,
+ SliceKindInference.Combine(parts.Select(_ => _.Kind)),
+ parts.Select(_ => _.Description).FirstOrDefault(_ => _ is not null),
+ Once(parts.SelectMany(_ => _.Commands), _ => _.Name, "command", @namespace, diagnostics),
+ Once(parts.SelectMany(_ => _.Events), _ => _.Name, "event", @namespace, diagnostics),
+ Once(parts.SelectMany(_ => _.Queries), _ => _.Name, "query", @namespace, diagnostics),
+ OneProjection(parts, @namespace, diagnostics),
+ Once(parts.SelectMany(_ => _.Reactors), _ => _.Name, "reactor", @namespace, diagnostics),
+ Once(parts.SelectMany(_ => _.Constraints), _ => _.Name, "constraint", @namespace, diagnostics))
+ {
+ Screens = Once(parts.SelectMany(_ => _.Screens), _ => _.Name, "screen", @namespace, diagnostics)
+ };
+ }
+
+ ///
+ /// Keeps the first artifact declared under each name, reporting every one that repeats a name.
+ ///
+ /// The kind of artifact.
+ /// Everything the projects declared, in the order they were read.
+ /// How an artifact is named.
+ /// What an artifact of this kind is called, for use in the report.
+ /// The namespace of the slice.
+ /// The diagnostics to report to.
+ /// The artifacts kept, ordered by name.
+ static IReadOnlyList Once(
+ IEnumerable declared,
+ Func name,
+ string kind,
+ string @namespace,
+ ScreenplayDiagnostics diagnostics)
+ {
+ var taken = new HashSet(StringComparer.Ordinal);
+ var kept = new List();
+
+ foreach (var artifact in declared)
+ {
+ if (taken.Add(name(artifact)))
+ {
+ kept.Add(artifact);
+
+ continue;
+ }
+
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.RepeatedDeclarationAcrossProjects,
+ $"'{name(artifact)}' is a {kind} a second project of the application declares in this slice, and a slice describes one of a name, so it was left out",
+ @namespace);
+ }
+
+ return [.. kept.OrderBy(name, StringComparer.Ordinal)];
+ }
+
+ ///
+ /// Keeps the single projection a slice may declare, reporting any beyond the first.
+ ///
+ /// The parts, in the order the projects declaring them were read.
+ /// The namespace of the slice.
+ /// The diagnostics to report to.
+ /// The projection, or when no part declares one.
+ static ProjectionModel? OneProjection(
+ IReadOnlyList parts,
+ string @namespace,
+ ScreenplayDiagnostics diagnostics)
+ {
+ ProjectionModel? kept = null;
+
+ foreach (var projection in parts.Select(_ => _.Projection).OfType())
+ {
+ if (kept is null)
+ {
+ kept = projection;
+
+ continue;
+ }
+
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnmappableProjectionConstruct,
+ $"'{projection.Identifier}' is a second projection in one slice, and a slice may declare at most one, so it was left out",
+ @namespace);
+ }
+
+ return kept;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/SourcePaths.cs b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
index 284e94565..cb72a759f 100644
--- a/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
+++ b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
@@ -34,15 +34,33 @@ public class SourcePaths(string root)
/// belongs to the project too and widens the root; a file sharing nothing with them but the file system root
/// belongs to somebody else and is left out of the question entirely.
///
+ ///
+ /// What a source generator emitted is left out of that vote. It is compiled from the intermediate folder of the
+ /// build rather than from anywhere the application is written, so counting it moves the answer towards a folder
+ /// nobody committed a line to.
+ ///
///
- public static SourcePaths For(Compilation compilation, ArtifactCatalog catalog)
+ public static SourcePaths For(Compilation compilation, ArtifactCatalog catalog) => new(RootOf(compilation, catalog));
+
+ ///
+ /// Resolves the directory the source of one project sits under.
+ ///
+ /// The compilation to read.
+ /// The catalogue of everything the compilation declares.
+ /// The directory, empty when there is none to write anything relative to.
+ ///
+ /// This is what resolves, said as a directory rather than as the
+ /// paths built from it, because an application written as several projects has one of these per project and a
+ /// single directory to write all of them relative to is worked out from them together.
+ ///
+ public static string RootOf(Compilation compilation, ArtifactCatalog catalog)
{
- var declared = DirectoriesOf(catalog.Types.Select(_ => _.SourceFilePath()));
+ var declared = DirectoriesOf(GeneratedSource.Excluded(catalog.Types.Select(_ => _.SourceFilePath())));
var anchor = DeepestSharedBy(declared);
var project = Rooted(declared, anchor);
var root = Rooted(DirectoriesOf(compilation.SyntaxTrees.Select(_ => _.FilePath)), project);
- return new(IsFileSystemRoot(root) ? string.Empty : root);
+ return Directories.IsFileSystemRoot(root) ? string.Empty : root;
}
///
@@ -57,7 +75,7 @@ public static SourcePaths For(Compilation compilation, ArtifactCatalog catalog)
return null;
}
- var normalized = Normalize(path);
+ var normalized = Directories.Normalize(path);
return root.Length > 0 && normalized.StartsWith(root, StringComparison.Ordinal)
? normalized[root.Length..]
@@ -106,7 +124,7 @@ static string Rooted(List directories, string anchor)
{
var onThePath = directories.Where(_ => OnTheSamePath(_, anchor)).ToList();
- return onThePath.Count == 0 ? string.Empty : CommonPrefix(onThePath);
+ return Directories.SharedPrefixOf(onThePath);
}
///
@@ -118,7 +136,7 @@ static List DirectoriesOf(IEnumerable paths) =>
[
.. paths
.Where(_ => !string.IsNullOrWhiteSpace(_))
- .Select(_ => Normalize(_!))
+ .Select(_ => Directories.Normalize(_!))
.Select(_ => _[..(_.LastIndexOf('/') + 1)])
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
@@ -134,48 +152,4 @@ static bool OnTheSamePath(string directory, string project) =>
project.Length == 0 ||
directory.StartsWith(project, StringComparison.Ordinal) ||
project.StartsWith(directory, StringComparison.Ordinal);
-
- ///
- /// Determines whether a directory is the root of a file system rather than a directory within one.
- ///
- /// The directory to check.
- /// True when it is a file system root.
- ///
- /// Writing a path relative to / is not writing it relative to anything - it removes one character and
- /// leaves the machine's own layout behind, looking like a relative path while being nothing of the sort.
- ///
- static bool IsFileSystemRoot(string directory) =>
- string.Equals(directory, "/", StringComparison.Ordinal) || (directory.Length == 3 && directory[1] == ':' && directory[2] == '/');
-
- ///
- /// Normalizes a path onto forward slashes.
- ///
- /// The path to normalize.
- /// The normalized path.
- static string Normalize(string path) => path.Replace('\\', '/');
-
- ///
- /// Finds the longest directory prefix every path shares.
- ///
- /// The directories to compare.
- /// The shared prefix, ending in a separator.
- static string CommonPrefix(List directories)
- {
- var prefix = directories[0];
-
- foreach (var directory in directories.Skip(1))
- {
- var length = 0;
- while (length < prefix.Length && length < directory.Length && prefix[length] == directory[length])
- {
- length++;
- }
-
- prefix = prefix[..length];
- }
-
- var separator = prefix.LastIndexOf('/');
-
- return separator < 0 ? string.Empty : prefix[..(separator + 1)];
- }
}
diff --git a/Source/DotNET/Screenplay/Analysis/SourceRoots.cs b/Source/DotNET/Screenplay/Analysis/SourceRoots.cs
new file mode 100644
index 000000000..1204a3fff
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/SourceRoots.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Resolves the directory every path in a document is written relative to, across the projects of an application.
+///
+///
+/// One project answers this on its own - the directory its own source sits under. Several projects each answer it for
+/// themselves, and writing each project's paths relative to its own root would leave a document where Ordering.cs
+/// is the path of two files in two projects and nothing says which. The directory the projects are all written under
+/// is the one that keeps a path both relative and unambiguous, so that is what is used and every path in the document
+/// then opens with the project it belongs to.
+///
+/// It is not always there. Projects checked out beside each other in unrelated places share nothing but the root of
+/// the file system, and writing a path relative to that leaves the machine's own layout behind while looking like a
+/// relative path. Each project's own root is used instead, which is the ambiguity above rather than a document nobody
+/// can commit, and it is reported so that the ambiguity is one the reader knows about.
+///
+///
+public static class SourceRoots
+{
+ ///
+ /// Resolves what the paths of each compilation are written relative to.
+ ///
+ /// The compilations being analyzed, ordered.
+ /// What each of them declares, in the same order.
+ /// The diagnostics to report to.
+ /// Where to report against.
+ /// The of each compilation, in the same order.
+ public static IReadOnlyList Across(
+ IReadOnlyList compilations,
+ IReadOnlyList catalogs,
+ ScreenplayDiagnostics diagnostics,
+ string? location)
+ {
+ var roots = compilations.Select((compilation, index) => SourcePaths.RootOf(compilation, catalogs[index])).ToList();
+ if (SharedBy(roots) is { } shared)
+ {
+ return [.. roots.Select(_ => new SourcePaths(shared))];
+ }
+
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.ProjectsWithoutASharedRoot,
+ $"The {roots.Count} projects of the application are written in directories that share none, so every path is written relative to the root of the project it belongs to and says nothing about which project that is",
+ location);
+
+ return [.. roots.Select(_ => new SourcePaths(_))];
+ }
+
+ ///
+ /// Gets the directory the source of every project sits under.
+ ///
+ /// The root of each project.
+ /// The directory, or when the projects share none.
+ ///
+ /// A single project is its own answer whatever that answer is, including the empty one it gives when there is
+ /// nothing to write its paths relative to - that is one project's situation rather than several projects failing
+ /// to agree, and it is what a document of one project has always said.
+ ///
+ static string? SharedBy(List roots)
+ {
+ if (roots.Count <= 1)
+ {
+ return roots.Count == 0 ? string.Empty : roots[0];
+ }
+
+ var shared = Directories.SharedPrefixOf(roots);
+
+ return shared.Length == 0 || Directories.IsFileSystemRoot(shared) ? null : shared;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs
new file mode 100644
index 000000000..f4bccd55f
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Gets the expressions a call gives to one of its parameters.
+///
+///
+/// What a specification states sits in one argument of a call, and which one it is has to be resolved by name rather
+/// than by position - the calls carrying it declare everything after it as optional, take it through a parameter
+/// array, or are extension methods whose first parameter is the receiver. A parameter array and a parameter taking a
+/// collection are both flattened, so that stating three events in one call and stating them in three reads the same
+/// way.
+///
+public static class CallArguments
+{
+ ///
+ /// Gets the expressions a call gives to a parameter.
+ ///
+ /// The call to read.
+ /// The method being called.
+ /// The name of the parameter to read.
+ /// The expressions, in the order the call declares them.
+ public static IEnumerable For(InvocationExpressionSyntax invocation, IMethodSymbol method, string parameter)
+ {
+ var arguments = invocation.ArgumentList.Arguments;
+ var given = new List();
+
+ for (var index = 0; index < arguments.Count; index++)
+ {
+ if (string.Equals(NameOf(arguments[index], method, index), parameter, StringComparison.Ordinal))
+ {
+ given.AddRange(Flatten(arguments[index].Expression));
+ }
+ }
+
+ return given;
+ }
+
+ ///
+ /// Gets the name of the parameter an argument fills in.
+ ///
+ /// The argument to name.
+ /// The method being called.
+ /// The position of the argument.
+ /// The parameter name, or when it cannot be resolved.
+ static string? NameOf(ArgumentSyntax argument, IMethodSymbol method, int index)
+ {
+ if (argument.NameColon is { } named)
+ {
+ return named.Name.Identifier.ValueText;
+ }
+
+ if (index < method.Parameters.Length)
+ {
+ return method.Parameters[index].Name;
+ }
+
+ return method.Parameters is [.., { IsParams: true } last] ? last.Name : null;
+ }
+
+ ///
+ /// Opens up an expression that holds several values into the values it holds.
+ ///
+ /// The expression to open up.
+ /// The values, or the expression itself when it holds one.
+ static IEnumerable Flatten(ExpressionSyntax expression) => expression switch
+ {
+ CollectionExpressionSyntax collection => collection.Elements.Select(ValueOf),
+ ImplicitArrayCreationExpressionSyntax implicitly => implicitly.Initializer.Expressions,
+ ArrayCreationExpressionSyntax array when array.Initializer is { } initializer => initializer.Expressions,
+ _ => [expression]
+ };
+
+ ///
+ /// Gets the expression one element of a collection stands for.
+ ///
+ /// The element to read.
+ /// The expression.
+ ///
+ /// An element spreading another collection into this one stands for however many values that collection holds,
+ /// which is not something a source text says. The expression being spread is returned as it is, so that whoever
+ /// asked for the values finds one it cannot read rather than a list quietly missing them.
+ ///
+ static ExpressionSyntax ValueOf(CollectionElementSyntax element) => element switch
+ {
+ ExpressionElementSyntax value => value.Expression,
+ SpreadElementSyntax spread => spread.Expression,
+ _ => SyntaxFactory.IdentifierName(element.ToString())
+ };
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs
new file mode 100644
index 000000000..16aaba1e7
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Recognizes what an assertion of a specification says the outcome was.
+///
+///
+/// Assertions are matched on the name of the helper alone. The same helper is declared several times over across the
+/// testing packages - once for the result of a command, once for the result of an append, once for the scenario
+/// itself - and a specification calls whichever of them its own shape reaches, so matching the declaring type would
+/// recognize the same sentence in one specification and not in the next.
+///
+/// Only the two outcomes Screenplay can hold are recognized: an event was appended, and the command was rejected.
+/// Everything else a specification asserts - how far the sequence got, that no exception was thrown, that the result
+/// was valid - says nothing the language has a place for and is passed over rather than reported, in the same way a
+/// unit level specification is.
+///
+///
+public static class SpecificationAssertions
+{
+ /// The assertion naming an event the command appended.
+ public const string AppendedEventAssertion = "ShouldHaveAppendedEvent";
+
+ /// The assertion saying a value is false, which is a rejection when the value is the success of a result.
+ public const string FalseAssertion = "ShouldBeFalse";
+
+ /// The value of a result saying whether the command was carried out.
+ public const string SuccessProperty = "IsSuccess";
+
+ /// The assertions saying the command was rejected, without naming why.
+ public static readonly string[] Rejections =
+ [
+ "ShouldHaveExceptions",
+ "ShouldHaveValidationErrors",
+ "ShouldNotBeAuthorized",
+ "ShouldNotBeSuccessful"
+ ];
+
+ /// The assertions saying the command was rejected and naming the reason.
+ public static readonly string[] NamedRejections =
+ [
+ "ShouldHaveConstraintViolation",
+ "ShouldHaveConstraintViolationFor",
+ "ShouldHaveValidationErrorFor"
+ ];
+
+ ///
+ /// Gets the event an assertion says was appended.
+ ///
+ /// The method being called.
+ /// The event type, or when the assertion is about something else.
+ ///
+ /// The event is the last type argument rather than the only one, because the helper taking the scenario also
+ /// takes the command it is a scenario for, which has to be named ahead of it.
+ ///
+ public static ITypeSymbol? AppendedEventOf(IMethodSymbol method) =>
+ string.Equals(method.Name, AppendedEventAssertion, StringComparison.Ordinal)
+ ? method.TypeArguments.LastOrDefault()
+ : null;
+
+ ///
+ /// Determines whether an assertion says the command was rejected.
+ ///
+ /// The assertion to read.
+ /// The method being called.
+ /// True when the assertion is a rejection.
+ public static bool IsRejection(InvocationExpressionSyntax invocation, IMethodSymbol method) =>
+ Array.Exists(Rejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal)) ||
+ IsNamedRejection(method) ||
+ IsUnsuccessful(invocation, method);
+
+ ///
+ /// Determines whether an assertion names the reason the command was rejected.
+ ///
+ /// The method being called.
+ /// True when the assertion names a reason.
+ public static bool IsNamedRejection(IMethodSymbol method) =>
+ Array.Exists(NamedRejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal));
+
+ ///
+ /// Determines whether an assertion says the result of the command was not a success.
+ ///
+ /// The assertion to read.
+ /// The method being called.
+ /// True when the assertion is about the success of a result.
+ static bool IsUnsuccessful(InvocationExpressionSyntax invocation, IMethodSymbol method) =>
+ string.Equals(method.Name, FalseAssertion, StringComparison.Ordinal) &&
+ invocation.Expression is MemberAccessExpressionSyntax { Expression: MemberAccessExpressionSyntax subject } &&
+ string.Equals(subject.Name.Identifier.ValueText, SuccessProperty, StringComparison.Ordinal);
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs
new file mode 100644
index 000000000..60cad5db5
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Recognizes the calls a Chronicle integration specification is written with.
+///
+///
+/// Two ways of writing one are recognized, because Arc documents both. A specification driving the command pipeline
+/// in process states what had happened through the scenario it holds and issues the command through the same
+/// scenario; one driving a running host states it against the event log and issues the command over HTTP. Everything
+/// is matched on the fully qualified metadata name of the type declaring the method, so neither testing package has
+/// to be referenced for either to be read.
+///
+public static class SpecificationCalls
+{
+ /// The method appending one event to a sequence.
+ public const string AppendMethod = "Append";
+
+ /// The method appending several events to a sequence as one transaction.
+ public const string AppendManyMethod = "AppendMany";
+
+ /// The method stating the events that had happened for one event source.
+ public const string EventsMethod = "Events";
+
+ /// The method pinning the read model one event source resolves to.
+ public const string ReadModelMethod = "ReadModel";
+
+ /// The method issuing a command through the in-process pipeline.
+ public const string ExecuteMethod = "Execute";
+
+ /// The method taking a command as far as the pipeline validates it.
+ public const string ValidateMethod = "Validate";
+
+ /// The method issuing a command over HTTP.
+ public const string ExecuteCommandMethod = "ExecuteCommand";
+
+ /// The parameter carrying the single event an append is given.
+ public const string EventParameter = "event";
+
+ /// The parameter carrying the events an append or a scenario is given.
+ public const string EventsParameter = "events";
+
+ /// The parameter carrying the read model a scenario is pinned with.
+ public const string ReadModelParameter = "readModel";
+
+ /// The parameter carrying the command issued over HTTP.
+ public const string CommandParameter = "command";
+
+ ///
+ /// Determines whether a call states the events a specification starts from.
+ ///
+ /// The method being called.
+ /// True when the call states events.
+ public static bool IsGivenEvents(IMethodSymbol method) =>
+ (IsOn(method, WellKnownTypeNames.EventSequence) && (Named(method, AppendMethod) || Named(method, AppendManyMethod))) ||
+ (IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, EventsMethod));
+
+ ///
+ /// Determines whether a call pins the read model a specification starts from.
+ ///
+ /// The method being called.
+ /// True when the call pins a read model.
+ public static bool IsGivenReadModel(IMethodSymbol method) =>
+ IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, ReadModelMethod);
+
+ ///
+ /// Determines whether a call issues the command a specification is about.
+ ///
+ /// The method being called.
+ /// True when the call issues a command.
+ public static bool IsExecution(IMethodSymbol method) =>
+ (IsOn(method, WellKnownTypeNames.CommandScenario) && (Named(method, ExecuteMethod) || Named(method, ValidateMethod))) ||
+ (IsOn(method, WellKnownTypeNames.HttpClientExtensions) && Named(method, ExecuteCommandMethod));
+
+ ///
+ /// Gets the name of the parameter carrying what a recognized call is given.
+ ///
+ /// The method being called.
+ /// The parameter name, or when the call carries nothing this reads.
+ public static string? PayloadParameterOf(IMethodSymbol method)
+ {
+ if (IsGivenReadModel(method))
+ {
+ return ReadModelParameter;
+ }
+
+ if (IsExecution(method))
+ {
+ return Named(method, ExecuteCommandMethod) ? CommandParameter : method.Parameters.FirstOrDefault()?.Name;
+ }
+
+ if (!IsGivenEvents(method))
+ {
+ return null;
+ }
+
+ return Named(method, AppendMethod) ? EventParameter : EventsParameter;
+ }
+
+ ///
+ /// Determines whether a method carries a name.
+ ///
+ /// The method to check.
+ /// The name to match.
+ /// True when the names are the same.
+ static bool Named(IMethodSymbol method, string name) => string.Equals(method.Name, name, StringComparison.Ordinal);
+
+ ///
+ /// Determines whether a method belongs to a named type, following the type it was reduced from.
+ ///
+ /// The method to check.
+ /// The fully qualified metadata name of the declaring type.
+ /// True when the method belongs to that type.
+ ///
+ /// A specialization of an interface inherits its members without redeclaring them, so the interface a member is
+ /// declared on is matched as well as the one it is reached through - appending to the event log is appending to a
+ /// sequence, whichever of the two the call site names.
+ ///
+ static bool IsOn(IMethodSymbol method, string fullMetadataName)
+ {
+ var declaring = (method.ReducedFrom ?? method).ContainingType;
+
+ return declaring.Is(fullMetadataName) || declaring.FindInterface(fullMetadataName) is not null;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs
new file mode 100644
index 000000000..64567b0d4
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Holds every scenario the application specifies its slices by, arranged under the slice each belongs to.
+///
+///
+/// Specifications are read once the slices are in, because which slice a scenario belongs to is answered by which
+/// namespaces turned out to declare one. Reading them alongside would mean deciding that against namespaces still
+/// being read, and a scenario would land under a different slice depending on the order the compilation was walked.
+///
+public class SpecificationCatalog
+{
+ readonly Dictionary> _bySlice;
+
+ SpecificationCatalog(Dictionary> bySlice) => _bySlice = bySlice;
+
+ ///
+ /// Reads every specification the compilation declares.
+ ///
+ /// The compilation being analyzed.
+ /// The catalogue of everything the compilation declares.
+ /// The namespaces a slice was recovered from.
+ /// The anything unreadable is reported to.
+ /// The .
+ public static SpecificationCatalog Read(
+ Compilation compilation,
+ ArtifactCatalog catalog,
+ IEnumerable slices,
+ ScreenplayDiagnostics diagnostics)
+ {
+ var reader = new SpecificationReader(compilation, diagnostics);
+ var placement = new SpecificationPlacement(slices);
+ var bySlice = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var type in catalog.Types.Where(reader.IsSpecification))
+ {
+ if (placement.SliceOf(type) is not { } slice)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnreadableSpecification,
+ $"The scenario '{type.Name}' was left out because no namespace above it declares a slice for it to specify",
+ type.ToDisplayString());
+
+ continue;
+ }
+
+ if (reader.Read(type, SpecificationPlacement.NameOf(type, slice)) is { } specification)
+ {
+ Add(bySlice, slice, specification);
+ }
+ }
+
+ return new(bySlice);
+ }
+
+ ///
+ /// Gets the specifications belonging to a slice.
+ ///
+ /// The namespace of the slice.
+ /// The specifications, ordered by name.
+ public IEnumerable For(string @namespace) =>
+ _bySlice.TryGetValue(@namespace, out var specifications)
+ ? specifications.OrderBy(_ => _.Name, StringComparer.Ordinal)
+ : [];
+
+ ///
+ /// Adds a specification under the slice it belongs to.
+ ///
+ /// The specifications collected so far.
+ /// The namespace of the slice.
+ /// The specification to add.
+ static void Add(Dictionary> bySlice, string slice, SpecificationModel specification)
+ {
+ if (!bySlice.TryGetValue(slice, out var specifications))
+ {
+ specifications = [];
+ bySlice[slice] = specifications;
+ }
+
+ specifications.Add(specification);
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs
new file mode 100644
index 000000000..c868c0bc1
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Collects a specification while the type declaring it is being read.
+///
+///
+/// A scenario is one example, so the first step of it that cannot be read decides the outcome for all of them. The
+/// reason is kept rather than the fact, because what made a scenario unreadable is the only part of it worth
+/// reporting - and the first reason is kept rather than the last, since every reason after it is read from a
+/// scenario already known to be incomplete.
+///
+public class SpecificationDraft
+{
+ ///
+ /// Gets what had already happened when the command was issued.
+ ///
+ public IList Given { get; } = [];
+
+ ///
+ /// Gets the events and read model states that followed.
+ ///
+ public IList Then { get; } = [];
+
+ ///
+ /// Gets the rejections that followed, each named by the reason the source gives for it.
+ ///
+ public IList Errors { get; } = [];
+
+ ///
+ /// Gets or sets the command that was issued.
+ ///
+ public SpecificationStateModel? When { get; set; }
+
+ ///
+ /// Gets why the scenario could not be read, or while all of it still can be.
+ ///
+ public string? Unreadable { get; private set; }
+
+ ///
+ /// Records why the scenario cannot be read.
+ ///
+ /// What made it unreadable.
+ public void CannotRead(string reason) => Unreadable ??= reason;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs
new file mode 100644
index 000000000..56f761fc4
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs
@@ -0,0 +1,114 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Finds the parts of a specification within the chain of types it is written across.
+///
+///
+/// A specification inherits the world it starts from. Every type in the chain may set part of it up, and the
+/// specification framework calls each of those in turn from the base down, so the chain is walked in that same order
+/// and what it states comes out in the order it really happens in.
+///
+/// Where the steps are written differs between the two shapes Arc documents. A specification driving the pipeline in
+/// process writes them on itself; one driving a running host writes them on a nested type the fixture is handed to,
+/// and keeps only its assertions outside. Which of the two a specification is follows from where the steps are, not
+/// from what it derives from, so a chain no package declares reads exactly the same way.
+///
+///
+public static class SpecificationMembers
+{
+ /// The method setting up the world a specification starts from.
+ public const string EstablishMethod = "Establish";
+
+ /// The method performing the single action a specification is about.
+ public const string BecauseMethod = "Because";
+
+ /// The nested type the steps of a specification against a running host are written on.
+ public const string ContextType = "context";
+
+ ///
+ /// Gets the type the steps of a specification are written on.
+ ///
+ /// The type declaring the specification.
+ /// The nested context when the specification has one, otherwise the type itself.
+ public static INamedTypeSymbol StepsOf(INamedTypeSymbol type) =>
+ type.GetTypeMembers(ContextType).FirstOrDefault(_ => Declares(_, BecauseMethod) || Declares(_, EstablishMethod)) ?? type;
+
+ ///
+ /// Gets the chain of types a type is written across, from the base down.
+ ///
+ /// The type to walk.
+ /// The chain, base first.
+ public static IEnumerable ChainOf(INamedTypeSymbol type)
+ {
+ var chain = new List();
+ for (var current = type; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType)
+ {
+ chain.Insert(0, current);
+ }
+
+ return chain;
+ }
+
+ ///
+ /// Gets every declaration of a method in a chain of types.
+ ///
+ /// The type to walk from.
+ /// The name of the method.
+ /// The methods, from the base down.
+ public static IEnumerable MethodsIn(INamedTypeSymbol type, string name) =>
+ ChainOf(type).SelectMany(_ => _.GetMembers(name)).OfType().Where(_ => _.MethodKind == MethodKind.Ordinary);
+
+ ///
+ /// Gets every assertion a specification makes.
+ ///
+ /// The type declaring the specification.
+ /// The assertions, ordered by name so that the same source always reads the same way.
+ public static IEnumerable AssertionsIn(INamedTypeSymbol type) =>
+ ChainOf(type)
+ .SelectMany(_ => _.GetMembers())
+ .OfType()
+ .Where(_ => _.HasAttribute(WellKnownTypeNames.FactAttribute))
+ .OrderBy(_ => _.Name, StringComparer.Ordinal)
+ .ThenBy(_ => _.ToDisplayString(), StringComparer.Ordinal);
+
+ ///
+ /// Determines whether a type holds a scenario the command pipeline is driven through.
+ ///
+ /// The type to check.
+ /// True when the type or one of its bases holds a scenario.
+ ///
+ /// Holding one is what makes a specification an integration specification of the slice rather than a unit level
+ /// one about a collaborator, and it holds whether or not the command is issued somewhere this can read. That is
+ /// exactly why it is asked: a specification that holds a scenario and issues its command through a helper is one
+ /// this cannot read, and saying so is the difference between a known gap and a silent one.
+ ///
+ public static bool HoldsAScenario(INamedTypeSymbol type) =>
+ ChainOf(type)
+ .SelectMany(_ => _.GetMembers())
+ .Any(member => TypeOf(member).Is(WellKnownTypeNames.CommandScenario));
+
+ ///
+ /// Determines whether a type declares a method itself.
+ ///
+ /// The type to check.
+ /// The name of the method.
+ /// True when the type declares it.
+ static bool Declares(INamedTypeSymbol type, string name) => type.GetMembers(name).OfType().Any();
+
+ ///
+ /// Gets the type a member holds a value of.
+ ///
+ /// The member to read.
+ /// The type, or when the member holds no value.
+ static ITypeSymbol? TypeOf(ISymbol member) => member switch
+ {
+ IFieldSymbol field => field.Type,
+ IPropertySymbol property => property.Type,
+ _ => null
+ };
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs
new file mode 100644
index 000000000..a41fcc5da
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs
@@ -0,0 +1,151 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Analysis.Events;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads what a specification says followed from the command it issued.
+///
+/// The compilation being analyzed.
+/// The anything unreadable is reported to.
+///
+/// Each assertion is one sentence about the outcome, and several of them routinely say the same sentence about a
+/// different part of the same event - once for each value it carries. Screenplay says an event followed once, so a
+/// sentence already said is passed over rather than repeated.
+///
+public class SpecificationOutcomeReader(Compilation compilation, ScreenplayDiagnostics diagnostics)
+{
+ ///
+ /// Reads what a specification says followed.
+ ///
+ /// The type declaring the specification.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives, for use in diagnostics.
+ public void Read(INamedTypeSymbol type, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var assertion in SpecificationMembers.AssertionsIn(type))
+ {
+ foreach (var body in HandlerBodies.Of(assertion))
+ {
+ ReadBody(body, compilation.GetSemanticModel(body.SyntaxTree), draft, name, location);
+ }
+ }
+ }
+
+ ///
+ /// Adds an event a specification says followed, or records that it cannot be read.
+ ///
+ /// The type the assertion names.
+ /// The scenario collected so far.
+ static void AddEvent(ITypeSymbol appended, SpecificationDraft draft)
+ {
+ if (!EventReader.IsEvent(appended))
+ {
+ draft.CannotRead($"it expects '{appended.Name}' to follow, which is not declared as an event");
+ return;
+ }
+
+ if (!draft.Then.Any(_ => string.Equals(_.Name, appended.Name, StringComparison.Ordinal)))
+ {
+ draft.Then.Add(new(appended.Name, SpecificationStateKind.Event, []));
+ }
+ }
+
+ ///
+ /// Reads what one assertion says followed.
+ ///
+ /// The body of the assertion.
+ /// The semantic model of the tree the body lives in.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void ReadBody(SyntaxNode body, SemanticModel semanticModel, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var invocation in body.DescendantNodesAndSelf().OfType())
+ {
+ if (semanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method)
+ {
+ continue;
+ }
+
+ var appended = SpecificationAssertions.AppendedEventOf(method);
+ var rejection = SpecificationAssertions.IsRejection(invocation, method);
+ if (appended is null && !rejection)
+ {
+ continue;
+ }
+
+ if (!StepsTaken.Always(invocation, body))
+ {
+ draft.CannotRead("what it expects to follow is only expected under a condition, and a scenario says what followed");
+ return;
+ }
+
+ if (appended is not null)
+ {
+ AddEvent(appended, draft);
+ continue;
+ }
+
+ AddError(invocation, method, semanticModel, draft, name, location);
+ }
+ }
+
+ ///
+ /// Adds a rejection a specification says followed, named by the reason the source gives for it.
+ ///
+ /// The assertion to read.
+ /// The method being called.
+ /// The semantic model of the tree the assertion lives in.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void AddError(
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method,
+ SemanticModel semanticModel,
+ SpecificationDraft draft,
+ string name,
+ string location)
+ {
+ var reason = SpecificationAssertions.IsNamedRejection(method)
+ ? ReasonOf(invocation, semanticModel, name, location)
+ : string.Empty;
+
+ if (!draft.Errors.Contains(reason, StringComparer.Ordinal))
+ {
+ draft.Errors.Add(reason);
+ }
+ }
+
+ ///
+ /// Gets the reason an assertion names for a rejection.
+ ///
+ /// The assertion to read.
+ /// The semantic model of the tree the assertion lives in.
+ /// The name of the specification.
+ /// Where the specification lives.
+ /// The reason, empty when the source names one this cannot read.
+ string ReasonOf(InvocationExpressionSyntax invocation, SemanticModel semanticModel, string name, string location)
+ {
+ var named = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
+ if (named is not null && semanticModel.GetConstantValue(named) is { HasValue: true, Value: { } value })
+ {
+ return value.ToString() ?? string.Empty;
+ }
+
+ diagnostics.Information(
+ ScreenplayDiagnosticCodes.UnreadableSpecificationValue,
+ $"The reason '{name}' gives for a rejection is code rather than a constant, so the rejection is stated without one",
+ location);
+
+ return string.Empty;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs
new file mode 100644
index 000000000..8663601f3
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Decides which slice a specification belongs to and what it is called there.
+///
+/// The namespaces a slice was recovered from.
+///
+/// A specification sits in a folder beneath the slice it is about, which is a namespace beneath the slice's own. The
+/// slice it belongs to is therefore the nearest namespace above it that declares one - nearest rather than any,
+/// because a slice within a slice is exactly what a feature holding both a behavior and the behaviors beneath it
+/// looks like.
+///
+/// The name is every word between the slice and the specification, in the order the source wrote them. That is what
+/// the convention was built to read as - a folder saying when something happens and a file saying what else was true
+/// at the time - so keeping all of it is what makes the specification say in the document what it says in the source.
+///
+///
+public class SpecificationPlacement(IEnumerable slices)
+{
+ /// The namespace segment holding the world several specifications share.
+ public const string SharedContextSegment = "given";
+
+ readonly HashSet _slices = [.. slices];
+
+ ///
+ /// Gets the name a specification is declared under.
+ ///
+ /// The type declaring the specification.
+ /// The namespace of the slice it belongs to.
+ /// The name, as the words the source wrote joined together.
+ public static string NameOf(INamedTypeSymbol type, string slice)
+ {
+ var below = Segments(type)[SegmentsIn(slice)..];
+
+ return string.Join(
+ '_',
+ below.Where(_ => !string.Equals(_, SharedContextSegment, StringComparison.Ordinal)).Append(type.Name));
+ }
+
+ ///
+ /// Gets the namespace of the slice a specification belongs to.
+ ///
+ /// The type declaring the specification.
+ /// The namespace, or when no slice above it declares anything.
+ public string? SliceOf(INamedTypeSymbol type)
+ {
+ var segments = Segments(type);
+
+ for (var depth = segments.Length - 1; depth > 0; depth--)
+ {
+ var candidate = string.Join('.', segments[..depth]);
+ if (_slices.Contains(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the segments of the namespace a type lives in, with the type left out.
+ ///
+ /// The type to read.
+ /// The segments.
+ static string[] Segments(INamedTypeSymbol type) => type.Namespace().Split('.', StringSplitOptions.RemoveEmptyEntries);
+
+ ///
+ /// Counts the segments of a namespace.
+ ///
+ /// The namespace to count.
+ /// The number of segments.
+ static int SegmentsIn(string @namespace) => @namespace.Split('.', StringSplitOptions.RemoveEmptyEntries).Length;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs
new file mode 100644
index 000000000..efb5b396d
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs
@@ -0,0 +1,133 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads the scenario one type specifies a slice by.
+///
+/// The compilation being analyzed.
+/// The anything unreadable is reported to.
+///
+/// Only a specification driving a command through the real pipeline is read. A unit level one stands a collaborator
+/// up behind a substitute and says what that collaborator was asked to do, which is a statement about the inside of
+/// the slice rather than about its behavior, and Screenplay has nowhere to put it. Whether a specification is one or
+/// the other is decided by what it touches: holding a scenario the pipeline runs in, or reaching the event log, is
+/// what an integration specification does and nothing else does.
+///
+public class SpecificationReader(Compilation compilation, ScreenplayDiagnostics diagnostics)
+{
+ ///
+ /// Determines whether a type specifies a slice by driving a command through the pipeline.
+ ///
+ /// The type to check.
+ /// True when the type is a specification of the kind this reads.
+ public bool IsSpecification(INamedTypeSymbol type)
+ {
+ if (type is not { TypeKind: TypeKind.Class, IsAbstract: false, ContainingType: null })
+ {
+ return false;
+ }
+
+ var steps = SpecificationMembers.StepsOf(type);
+
+ return SpecificationMembers.MethodsIn(steps, SpecificationMembers.BecauseMethod).Any() &&
+ (SpecificationMembers.HoldsAScenario(steps) || DrivesASlice(steps));
+ }
+
+ ///
+ /// Reads the scenario a type specifies a slice by.
+ ///
+ /// The type declaring the specification.
+ /// The name the specification is declared under.
+ /// The , or when the scenario cannot be read.
+ ///
+ /// What was left out of a scenario is held back until the scenario is known to survive. Saying that a value was
+ /// left out of a scenario that was itself left out describes a document that says something it does not say, and
+ /// there are far more values than scenarios - so the noise would be the loudest thing in the report.
+ ///
+ public SpecificationModel? Read(INamedTypeSymbol type, string name)
+ {
+ var location = type.ToDisplayString();
+ var steps = SpecificationMembers.StepsOf(type);
+ var draft = new SpecificationDraft();
+ var stated = new ScreenplayDiagnostics();
+
+ var reader = new SpecificationStepReader(compilation, new(stated));
+ reader.ReadGiven(steps, draft, name, location);
+ reader.ReadWhen(steps, draft, name, location);
+ new SpecificationOutcomeReader(compilation, stated).Read(type, draft, name, location);
+
+ if (draft.When is null)
+ {
+ draft.CannotRead("the command it issues is put together somewhere this cannot read");
+ }
+ else if (draft.Then.Count == 0 && draft.Errors.Count == 0)
+ {
+ draft.CannotRead("it expects no event and no rejection, and those are the outcomes the language holds");
+ }
+
+ if (draft.Unreadable is not null)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnreadableSpecification,
+ $"The scenario '{name}' was left out because {draft.Unreadable}",
+ location);
+
+ return null;
+ }
+
+ diagnostics.AddRange(stated.All);
+
+ return new(name, [.. draft.Given], draft.When, [.. draft.Then], [.. draft.Errors]);
+ }
+
+ ///
+ /// Determines whether the steps of a specification drive a slice rather than exercise a part of one.
+ ///
+ /// The type the steps are written on.
+ /// True when the specification issues a command, or says what the event store already held.
+ ///
+ /// A specification against a running host holds no scenario of its own - the host is the scenario - so what makes
+ /// it one is that it hands a command to the host, or says what the event log already held before doing so.
+ ///
+ /// Where the two are looked for differs, and deliberately. Issuing a command is what a slice does, wherever it is
+ /// written. Appending to a sequence is only the world a scenario starts in when it happens before the action, and
+ /// a specification appending as its action is one about the event store itself - a constraint holding, an append
+ /// being rejected - which is a part of a slice rather than the behavior of one.
+ ///
+ ///
+ bool DrivesASlice(INamedTypeSymbol steps) =>
+ Calls(steps, SpecificationMembers.EstablishMethod)
+ .Any(_ => SpecificationCalls.IsGivenEvents(_) || SpecificationCalls.IsGivenReadModel(_) || SpecificationCalls.IsExecution(_)) ||
+ Calls(steps, SpecificationMembers.BecauseMethod).Any(SpecificationCalls.IsExecution);
+
+ ///
+ /// Gets every call a method of a chain resolves to.
+ ///
+ /// The type the steps are written on.
+ /// The name of the method to read.
+ /// The methods called.
+ IEnumerable Calls(INamedTypeSymbol steps, string method) =>
+ SpecificationMembers.MethodsIn(steps, method).SelectMany(HandlerBodies.Of).SelectMany(CallsIn);
+
+ ///
+ /// Gets every call a body resolves to.
+ ///
+ /// The body to walk.
+ /// The methods called.
+ IEnumerable CallsIn(SyntaxNode body)
+ {
+ var semanticModel = compilation.GetSemanticModel(body.SyntaxTree);
+
+ return body.DescendantNodesAndSelf()
+ .OfType()
+ .Select(invocation => semanticModel.GetSymbolInfo(invocation).Symbol)
+ .OfType();
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs
new file mode 100644
index 000000000..cf516032b
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs
@@ -0,0 +1,154 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Analysis.Events;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads what a specification starts from and the command it issues, from the bodies stating them.
+///
+/// The compilation being analyzed.
+/// The reading the values each step states.
+public class SpecificationStepReader(Compilation compilation, SpecificationValues values)
+{
+ ///
+ /// Reads what a specification had already seen when it issued its command.
+ ///
+ /// The type the steps are written on.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives, for use in diagnostics.
+ public void ReadGiven(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.EstablishMethod))
+ {
+ if (!SpecificationCalls.IsGivenEvents(method) && !SpecificationCalls.IsGivenReadModel(method))
+ {
+ continue;
+ }
+
+ if (!always)
+ {
+ draft.CannotRead("what it starts from only happens under a condition, and a scenario says what happened");
+ return;
+ }
+
+ var kind = SpecificationCalls.IsGivenReadModel(method) ? SpecificationStateKind.ReadModel : SpecificationStateKind.Event;
+
+ foreach (var stated in CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty))
+ {
+ Add(draft.Given, stated, kind, semanticModel, draft, name, location);
+ }
+ }
+ }
+
+ ///
+ /// Reads the command a specification issues.
+ ///
+ /// The type the steps are written on.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives, for use in diagnostics.
+ public void ReadWhen(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.BecauseMethod))
+ {
+ if (!SpecificationCalls.IsExecution(method))
+ {
+ continue;
+ }
+
+ if (!always)
+ {
+ draft.CannotRead("the command it issues is only issued under a condition, and a scenario says what happened");
+ return;
+ }
+
+ if (draft.When is not null)
+ {
+ draft.CannotRead("it issues more than one command, and a scenario is about one");
+ return;
+ }
+
+ var stated = CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty).ToList();
+ if (stated is not [BaseObjectCreationExpressionSyntax creation] ||
+ semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol command)
+ {
+ draft.CannotRead("the command it issues is put together somewhere this cannot read");
+ return;
+ }
+
+ if (!CommandReader.IsCommand(command))
+ {
+ draft.CannotRead($"'{command.Name}' is not a command the document declares");
+ return;
+ }
+
+ draft.When = new(command.Name, SpecificationStateKind.Command, values.Read(creation, semanticModel, command, name, location));
+ }
+ }
+
+ ///
+ /// Gets every call a body makes that resolves to a method.
+ ///
+ /// The body to walk.
+ /// The semantic model of the tree the body lives in.
+ /// The calls, in the order the body makes them.
+ static IEnumerable Calls(SyntaxNode body, SemanticModel semanticModel) =>
+ body.DescendantNodesAndSelf()
+ .OfType()
+ .Select(invocation => (invocation, Method: semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol, semanticModel, body))
+ .Where(call => call.Method is not null)
+ .Select(call => new Step(call.invocation, call.Method!, call.semanticModel, StepsTaken.Always(call.invocation, call.body)));
+
+ ///
+ /// Gets every call a method of a chain makes, with the model the tree it lives in is read through.
+ ///
+ /// The type to walk from.
+ /// The name of the method to read.
+ /// The calls, from the base down and in the order each body makes them.
+ IEnumerable CallsIn(INamedTypeSymbol type, string method) =>
+ SpecificationMembers.MethodsIn(type, method)
+ .SelectMany(HandlerBodies.Of)
+ .SelectMany(body => Calls(body, compilation.GetSemanticModel(body.SyntaxTree)));
+
+ ///
+ /// Adds one state a specification starts from, or records that it cannot be read.
+ ///
+ /// The states collected so far.
+ /// The expression stating it.
+ /// What the state is.
+ /// The semantic model of the tree the expression lives in.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void Add(
+ IList states,
+ ExpressionSyntax stated,
+ SpecificationStateKind kind,
+ SemanticModel semanticModel,
+ SpecificationDraft draft,
+ string name,
+ string location)
+ {
+ if (stated is not BaseObjectCreationExpressionSyntax creation ||
+ semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol type)
+ {
+ draft.CannotRead("what it starts from is put together somewhere this cannot read");
+ return;
+ }
+
+ if (kind == SpecificationStateKind.Event && !EventReader.IsEvent(type))
+ {
+ draft.CannotRead($"it starts from '{type.Name}', which is not declared as an event");
+ return;
+ }
+
+ states.Add(new(type.Name, kind, values.Read(creation, semanticModel, type, name, location)));
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs
new file mode 100644
index 000000000..18a383d8c
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads the values a specification states for one artifact, from the construction stating them.
+///
+/// The anything unreadable is reported to.
+///
+/// A specification is closed - it refers to nothing outside itself - so a constant is the only source of a value
+/// there is. That is the same discipline a produces mapping is read with, one source shorter: a produces mapping may
+/// also name the input of the command being handled, and a scenario has no input to name.
+///
+public class SpecificationValues(ScreenplayDiagnostics diagnostics)
+{
+ readonly MappingSourceReader _sources = new(diagnostics);
+
+ ///
+ /// Reads the values one construction states.
+ ///
+ /// The construction to read.
+ /// The semantic model of the tree the construction lives in.
+ /// The type being constructed.
+ /// The name of the specification, for use in diagnostics.
+ /// Where the specification lives, for use in diagnostics.
+ /// The values, in the order the source declares them.
+ public IEnumerable Read(
+ BaseObjectCreationExpressionSyntax creation,
+ SemanticModel semanticModel,
+ ITypeSymbol type,
+ string specification,
+ string location)
+ {
+ var values = new List();
+ var constructor = semanticModel.GetSymbolInfo(creation).Symbol as IMethodSymbol;
+
+ foreach (var (name, expression) in Stated(creation, constructor))
+ {
+ Add(values, PropertyOf(type, name), expression, semanticModel, type, specification, location);
+ }
+
+ return values;
+ }
+
+ ///
+ /// Gets every value a construction states, from its arguments and from its initializer.
+ ///
+ /// The construction to read.
+ /// The constructor being called.
+ /// The values, each with the name it fills in, in the order the source declares them.
+ static IEnumerable<(string Name, ExpressionSyntax Expression)> Stated(
+ BaseObjectCreationExpressionSyntax creation,
+ IMethodSymbol? constructor)
+ {
+ var arguments = creation.ArgumentList?.Arguments ?? [];
+ for (var index = 0; index < arguments.Count; index++)
+ {
+ if (NameOf(arguments[index], constructor, index) is { } name)
+ {
+ yield return (name, arguments[index].Expression);
+ }
+ }
+
+ foreach (var assignment in creation.Initializer?.Expressions.OfType() ?? [])
+ {
+ if (assignment.Left is IdentifierNameSyntax identifier)
+ {
+ yield return (identifier.Identifier.ValueText, assignment.Right);
+ }
+ }
+ }
+
+ ///
+ /// Gets the name of the property an argument fills in.
+ ///
+ /// The argument to name.
+ /// The constructor being called.
+ /// The position of the argument.
+ /// The parameter name, or when it cannot be resolved.
+ static string? NameOf(ArgumentSyntax argument, IMethodSymbol? constructor, int index)
+ {
+ if (argument.NameColon is { } named)
+ {
+ return named.Name.Identifier.ValueText;
+ }
+
+ return constructor is not null && index < constructor.Parameters.Length ? constructor.Parameters[index].Name : null;
+ }
+
+ ///
+ /// Resolves the declared casing of a property from the name an argument used.
+ ///
+ /// The type being constructed.
+ /// The name to resolve.
+ /// The property name.
+ static string PropertyOf(ITypeSymbol type, string name) =>
+ type.DeclaredProperties().FirstOrDefault(_ => string.Equals(_.Name, name, StringComparison.OrdinalIgnoreCase))?.Name ?? name;
+
+ ///
+ /// Adds a value, reporting one that is code rather than stating it as something it is not.
+ ///
+ /// The values collected so far.
+ /// The property being filled in.
+ /// The expression filling it in.
+ /// The semantic model of the tree the expression lives in.
+ /// The type being constructed.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void Add(
+ List values,
+ string property,
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ ITypeSymbol type,
+ string specification,
+ string location)
+ {
+ if (_sources.Read(expression, semanticModel, type, location) is LiteralSource literal)
+ {
+ values.Add(new(property, literal));
+ return;
+ }
+
+ diagnostics.Information(
+ ScreenplayDiagnosticCodes.UnreadableSpecificationValue,
+ $"The value '{specification}' states for '{type.Name}.{property}' is code rather than a constant, so the scenario states everything but that value",
+ location);
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs
new file mode 100644
index 000000000..ce9a3b423
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Represents one call a specification makes, with everything needed to read what it says.
+///
+/// The call as it is written.
+/// The method it resolves to.
+/// The semantic model of the tree it lives in.
+/// Whether the body it is written in always makes the call, exactly once.
+public record Step(
+ InvocationExpressionSyntax Invocation,
+ IMethodSymbol Method,
+ SemanticModel SemanticModel,
+ bool Always);
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs
new file mode 100644
index 000000000..e0c312396
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Answers whether a step written in a body is one the body always takes.
+///
+///
+/// A scenario says what happened, once, in order. A step written inside a branch happened in some runs of the
+/// specification and not in others, and one written inside a loop happened as many times as the loop went round -
+/// neither of which the source text says. Reading such a step as if it always happened states a world the
+/// application was never specified against, which is worse than saying nothing: it is the one failure mode a reader
+/// has no way of catching.
+///
+/// Which condition a branch stood on is routinely knowable to the compiler - a virtual property a derived
+/// specification overrides with a constant is the usual shape - but knowing it means following values through
+/// dispatch rather than reading what was written, which is a different discipline from the one everything else here
+/// keeps to. So a conditional step is left unread and said so, and a scenario that has one is left out whole.
+///
+///
+public static class StepsTaken
+{
+ ///
+ /// Determines whether a body always takes a step, exactly once.
+ ///
+ /// The call the step is written as.
+ /// The body the step is written in.
+ /// True when nothing between the two makes the step conditional or repeated.
+ public static bool Always(SyntaxNode call, SyntaxNode body)
+ {
+ for (var current = call.Parent; current is not null && current != body; current = current.Parent)
+ {
+ if (IsConditional(current))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Determines whether a construct makes what is written inside it conditional, repeated or deferred.
+ ///
+ /// The construct to check.
+ /// True when it does.
+ static bool IsConditional(SyntaxNode node) => node switch
+ {
+ IfStatementSyntax or SwitchStatementSyntax or SwitchExpressionSyntax => true,
+ ForStatementSyntax or ForEachStatementSyntax or WhileStatementSyntax or DoStatementSyntax => true,
+ ConditionalExpressionSyntax or ConditionalAccessExpressionSyntax => true,
+ CatchClauseSyntax or FinallyClauseSyntax => true,
+ AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax => true,
+ BinaryExpressionSyntax binary => binary.IsKind(SyntaxKind.LogicalAndExpression) ||
+ binary.IsKind(SyntaxKind.LogicalOrExpression) ||
+ binary.IsKind(SyntaxKind.CoalesceExpression),
+ _ => false
+ };
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/CarriedTypes.cs b/Source/DotNET/Screenplay/Analysis/Types/CarriedTypes.cs
new file mode 100644
index 000000000..23cfe8b47
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Types/CarriedTypes.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission.Types;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Types;
+
+///
+/// Finds every type a record carries, however far down it is carried.
+///
+///
+/// A concept is declared once at the top of a document and referred to by name, and a concept nothing refers to has no
+/// reason to be declared - which is why only the types artifacts really name are collected. Reaching only the outermost
+/// position took that too far: a name carried inside a line of an approved timesheet is referred to by the application
+/// just as much as one written straight onto an event, and a document leaving it out understates what the application
+/// holds. Where that value is marked as personal data, the document then understates something the reader is answerable
+/// for, which is the opposite of what declaring concepts is for.
+///
+public static class CarriedTypes
+{
+ ///
+ /// Determines whether a type is a record carrying values rather than being one.
+ ///
+ /// The type to check.
+ /// True when the type is a record whose members are worth walking.
+ ///
+ /// A record is what a value carrying several values is written as, and stopping at that is what keeps the walk
+ /// finite and about the application. Following every class a property mentions would descend into the framework
+ /// types the application merely touches, and a constructed generic is a type the document cannot name at all.
+ ///
+ public static bool IsRecord(ITypeSymbol type) =>
+ type is INamedTypeSymbol { IsRecord: true, TypeArguments.Length: 0 } record &&
+ !ScreenplayPrimitiveTypes.TryResolve(record.FullMetadataName(), out _) &&
+ record.FindBase(WellKnownTypeNames.ConceptAs) is null;
+
+ ///
+ /// Gets every type reachable through the members of a record.
+ ///
+ /// The type to walk.
+ /// The types, ordered so that the same source always reads the same way.
+ ///
+ /// A record referring to itself, directly or around a loop, is walked once - the second time round would say
+ /// nothing new and never end. What comes back is ordered by name rather than by the order the walk happened to
+ /// reach it, so that two records naming the same concept differently still leave the same document.
+ ///
+ public static IReadOnlyList Within(ITypeSymbol type)
+ {
+ var found = new Dictionary(StringComparer.Ordinal);
+
+ if (IsRecord(type))
+ {
+ Walk(type, found, new HashSet(StringComparer.Ordinal) { type.ToDisplayString() });
+ }
+
+ return [.. found.OrderBy(_ => _.Key, StringComparer.Ordinal).Select(_ => _.Value)];
+ }
+
+ ///
+ /// Collects the types the members of one record carry, descending into the records among them.
+ ///
+ /// The record to walk.
+ /// Everything found so far, keyed by the name it is told apart by.
+ /// The records already walked.
+ static void Walk(ITypeSymbol type, Dictionary found, HashSet walked)
+ {
+ 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);
+ }
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/ConceptRegistry.cs b/Source/DotNET/Screenplay/Analysis/Types/ConceptRegistry.cs
new file mode 100644
index 000000000..27f668e42
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Types/ConceptRegistry.cs
@@ -0,0 +1,163 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission.Types;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Types;
+
+///
+/// Collects the concepts an application refers to, keeping one declaration per name.
+///
+///
+/// A concept is declared once at the top of the document and referenced by its simple name from there on, so which
+/// concepts a document declares has nothing to do with which ones the application defines and everything to do with
+/// which ones were reached while a type was being resolved. They are gathered as they are encountered rather than
+/// found up front, which keeps the document to what the application actually uses.
+///
+/// What a concept says about itself arrives from more than one place and at different moments - the values of an
+/// enumeration come from the type, the mark saying it carries personal data comes from the property referring to it,
+/// and the rules it holds its own value to come from a validator read later still. Only the declaration is kept as
+/// the concept; the rest is kept beside it and folded in when the concepts are read back, so that a mark or a rule
+/// arriving after the concept was first seen still lands on it.
+///
+///
+public class ConceptRegistry
+{
+ readonly Dictionary _concepts = new(StringComparer.Ordinal);
+ readonly Dictionary> _validations = new(StringComparer.Ordinal);
+ readonly HashSet _pii = new(StringComparer.Ordinal);
+ readonly HashSet _ambiguous = new(StringComparer.Ordinal);
+
+ ///
+ /// Gets the full name of every type whose simple name a concept was already declared under.
+ ///
+ public IEnumerable Ambiguous => _ambiguous.Order(StringComparer.Ordinal);
+
+ ///
+ /// Gets every concept referenced by the application, ordered by name.
+ ///
+ public IEnumerable Concepts =>
+ [
+ .. _concepts.Values
+ .Select(_ => _ with
+ {
+ IsPii = _.IsPii || _pii.Contains(_.Name),
+ Validations = _validations.TryGetValue(_.Name, out var rules) ? rules : []
+ })
+ .OrderBy(_ => _.Name, StringComparer.Ordinal)
+ ];
+
+ ///
+ /// Registers a type as a concept when it is one.
+ ///
+ /// The type to register.
+ /// True when the type is a concept and was registered.
+ ///
+ /// An enumeration and a type backed by ConceptAs are both one value with a name, which is what a concept
+ /// is, and both are therefore declared. Anything else is a type referred to by name and never declared, which is
+ /// what the false answer says.
+ ///
+ public bool TryRegister(ITypeSymbol type)
+ {
+ if (type.TypeKind == TypeKind.Enum)
+ {
+ Register(type, new(type.Name, ScreenplayPrimitive.Enum, false, ValuesOf(type), []));
+
+ return true;
+ }
+
+ if (type.FindBase(WellKnownTypeNames.ConceptAs) is { } concept)
+ {
+ Register(type, ToConcept(type, concept.TypeArguments[0]));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Records that a value of a concept carries personally identifiable information.
+ ///
+ /// The type of the value.
+ ///
+ /// The concept a value is marked under has to be the one it is referenced under, or the mark lands on a name no
+ /// concept is declared with and the document says a value is not sensitive while the runtime encrypts it. Both
+ /// therefore strip the same wrappers - a collection of an optional concept says one thing about the value and
+ /// three things about how many there are and whether it may be absent.
+ ///
+ public void MarkAsPii(ITypeSymbol type) => _pii.Add(UnderlyingTypes.Of(type).Name);
+
+ ///
+ /// Records the validation rules a concept declares for itself.
+ ///
+ /// The name of the concept.
+ /// The rules to record.
+ public void AddValidations(string conceptName, IEnumerable rules)
+ {
+ if (!_validations.TryGetValue(conceptName, out var declared))
+ {
+ declared = [];
+ _validations[conceptName] = declared;
+ }
+
+ declared.AddRange(rules);
+ }
+
+ ///
+ /// Gets the values of an enumeration, in declaration order.
+ ///
+ /// The enumeration to read.
+ /// The value names.
+ static IEnumerable ValuesOf(ITypeSymbol type) =>
+ [.. type.GetMembers().OfType().Where(_ => _.HasConstantValue).Select(_ => _.Name)];
+
+ ///
+ /// Builds the concept a type backed by ConceptAs declares.
+ ///
+ /// The concept type.
+ /// The type the concept is backed by.
+ /// The .
+ ConceptModel ToConcept(ITypeSymbol type, ITypeSymbol backing)
+ {
+ var pii = type.HasAttribute(WellKnownTypeNames.PiiAttribute);
+
+ if (backing.TypeKind == TypeKind.Enum)
+ {
+ return new(type.Name, ScreenplayPrimitive.Enum, pii, ValuesOf(backing), []);
+ }
+
+ var resolved = backing is INamedTypeSymbol named && ScreenplayPrimitiveTypes.TryResolve(named.FullMetadataName(), out var primitive)
+ ? primitive
+ : ScreenplayPrimitive.String;
+
+ return new(type.Name, resolved, pii, [], []);
+ }
+
+ ///
+ /// Registers a concept, keeping the first declaration of a given name.
+ ///
+ /// The type the concept was read from.
+ /// The concept to register.
+ ///
+ /// A concept is declared once at the top of the document and referenced by its simple name, so two types sharing
+ /// that name cannot both be described. Keeping the first is the only choice left, and saying so is what stops the
+ /// document from quietly claiming the second one is something it is not.
+ ///
+ void Register(ITypeSymbol type, ConceptModel concept)
+ {
+ if (!_concepts.TryGetValue(concept.Name, out var existing))
+ {
+ _concepts[concept.Name] = concept;
+
+ return;
+ }
+
+ if (existing.Primitive != concept.Primitive || !existing.EnumValues.SequenceEqual(concept.EnumValues, StringComparer.Ordinal))
+ {
+ _ambiguous.Add(type.ToDisplayString());
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
index 7c64c2bbe..bd864d5e7 100644
--- a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
+++ b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
@@ -11,41 +11,41 @@ namespace Cratis.Arc.Screenplay.Analysis.Types;
/// Resolves the type of a property, a parameter or a return value, collecting the concepts encountered along the way.
///
///
-/// A concept is declared once at the top of the document and referenced by name from there on, so every concept an
-/// artifact refers to has to be registered while its type is resolved. Only concepts that are actually referenced
-/// are declared, which keeps the document to what the application uses.
+/// Resolving a type is answering two questions at once. The first is what to write - a single identifier, whether
+/// there is one of it or many, and whether it may be absent. The second is what naming it commits the document to,
+/// because every concept reached on the way has to be declared before it can be referenced, and every name that says
+/// less than the type does has to be reported rather than passed off as a description.
+///
+/// The first question is answered here. The second is split: what a name loses and which shapes no declaration can
+/// hold are kept here because they are consequences of writing the name, while the concepts themselves are kept by a
+/// , which decides what a concept is and what happens when two of them share a name.
+///
///
public class TypeRegistry
{
- readonly Dictionary _concepts = new(StringComparer.Ordinal);
- readonly Dictionary> _validations = new(StringComparer.Ordinal);
- readonly HashSet _pii = new(StringComparer.Ordinal);
+ readonly ConceptRegistry _concepts = new();
readonly HashSet _unmappable = new(StringComparer.Ordinal);
- readonly HashSet _ambiguous = new(StringComparer.Ordinal);
+ readonly HashSet _shapes = new(StringComparer.Ordinal);
///
/// Gets the full name of every type that had to be referred to by a name that does not say what it is.
///
public IEnumerable Unmappable => _unmappable.Order(StringComparer.Ordinal);
+ ///
+ /// Gets the full name of every record a property carries whose shape no declaration can hold.
+ ///
+ public IEnumerable Shapes => _shapes.Order(StringComparer.Ordinal);
+
///
/// Gets the full name of every type whose simple name a concept was already declared under.
///
- public IEnumerable Ambiguous => _ambiguous.Order(StringComparer.Ordinal);
+ public IEnumerable Ambiguous => _concepts.Ambiguous;
///
/// Gets every concept referenced by the application, ordered by name.
///
- public IEnumerable Concepts =>
- [
- .. _concepts.Values
- .Select(_ => _ with
- {
- IsPii = _.IsPii || _pii.Contains(_.Name),
- Validations = _validations.TryGetValue(_.Name, out var rules) ? rules : []
- })
- .OrderBy(_ => _.Name, StringComparer.Ordinal)
- ];
+ public IEnumerable Concepts => _concepts.Concepts;
///
/// Resolves the Screenplay type reference a symbol corresponds to.
@@ -55,75 +55,49 @@ .. _concepts.Values
public TypeReferenceModel Resolve(ITypeSymbol type)
{
var optional = false;
- var current = Unwrap(type, ref optional);
- var collection = CollectionElements.ElementOf(current);
- if (collection is not null)
- {
- current = Unwrap(collection, ref optional);
- }
+ var collection = false;
- return new(NameOf(current), collection is not null, optional);
+ return new(NameOf(UnderlyingTypes.Of(type, ref optional, ref collection)), collection, optional);
}
///
- /// Records that a value of a concept carries personally identifiable information.
+ /// Resolves the Screenplay type reference of a value an artifact carries.
///
- /// The type of the value.
- public void MarkAsPii(ITypeSymbol type)
+ /// The type to resolve.
+ /// The .
+ ///
+ /// A property is where a record the document has no way to declare is really referred to - the line carrying it
+ /// names a shape nothing in the document introduces. That is asked here rather than everywhere a type is resolved,
+ /// because a query returning a read model refers to something the slice around it already describes, while a
+ /// property carrying a record refers to a shape stated nowhere at all.
+ ///
+ public TypeReferenceModel ResolveCarried(ITypeSymbol type)
{
var optional = false;
- var current = Unwrap(type, ref optional);
- var collection = CollectionElements.ElementOf(current);
+ var collection = false;
+ var carried = UnderlyingTypes.Of(type, ref optional, ref collection);
- _pii.Add((collection ?? current).Name);
- }
-
- ///
- /// Records the validation rules a concept declares for itself.
- ///
- /// The name of the concept.
- /// The rules to record.
- public void AddValidations(string conceptName, IEnumerable rules)
- {
- if (!_validations.TryGetValue(conceptName, out var declared))
+ if (CarriedTypes.IsRecord(carried))
{
- declared = [];
- _validations[conceptName] = declared;
+ _shapes.Add(carried.ToDisplayString());
}
- declared.AddRange(rules);
+ return new(NameOf(carried), collection, optional);
}
///
- /// Strips the wrappers that only say whether a value may be absent.
+ /// Records that a value of a concept carries personally identifiable information.
///
- /// The type to strip.
- /// Set when a wrapper said the value may be absent.
- /// The wrapped type.
- static ITypeSymbol Unwrap(ITypeSymbol type, ref bool optional)
- {
- if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable)
- {
- optional = true;
-
- return nullable.TypeArguments[0];
- }
-
- if (type.NullableAnnotation == NullableAnnotation.Annotated && type.IsReferenceType)
- {
- optional = true;
- }
-
- return type;
- }
+ /// The type of the value.
+ public void MarkAsPii(ITypeSymbol type) => _concepts.MarkAsPii(type);
///
- /// Gets the values of an enumeration, in declaration order.
+ /// Records the validation rules a concept declares for itself.
///
- /// The enumeration to read.
- /// The value names.
- static IEnumerable ValuesOf(ITypeSymbol type) =>
- [.. type.GetMembers().OfType().Where(_ => _.HasConstantValue).Select(_ => _.Name)];
+ /// The name of the concept.
+ /// The rules to record.
+ public void AddValidations(string conceptName, IEnumerable rules) =>
+ _concepts.AddValidations(conceptName, rules);
///
/// Resolves the name a type is referenced by, registering it as a concept when it is one.
@@ -137,87 +111,50 @@ string NameOf(ITypeSymbol type)
return ScreenplayPrimitiveTypes.GetName(primitive);
}
- if (type.TypeKind == TypeKind.Enum)
- {
- Register(type, new(type.Name, ScreenplayPrimitive.Enum, false, ValuesOf(type), []));
-
- return type.Name;
- }
-
- if (type.FindBase(WellKnownTypeNames.ConceptAs) is { } concept)
+ if (_concepts.TryRegister(type))
{
- Register(type, ToConcept(type, concept.TypeArguments[0]));
-
return type.Name;
}
+ RegisterWhatItCarries(type);
ReportWhatTheNameLoses(type);
return type.Name;
}
///
- /// Records a type whose simple name says less than the type does.
+ /// Registers every concept a record carries, however far down it is carried.
///
/// The type being named.
///
- /// A read model or a nested object referred to by its own name is exactly right. A constructed generic is not -
- /// writing IDictionary<string, string> as the single identifier the grammar allows leaves the word
- /// KeyValuePair behind, which says nothing and which the document never declares. Same for a type
- /// parameter, whose name is a placeholder rather than a type.
+ /// A record is referred to by name and never declared, so nothing inside it is ever named on its own - which left
+ /// every concept reached only through a line of a timesheet or a property of a read model out of the document
+ /// entirely. A concept can be declared wherever it was reached from, so it is, and the shape carrying it waits on
+ /// the language.
///
- void ReportWhatTheNameLoses(ITypeSymbol type)
+ void RegisterWhatItCarries(ITypeSymbol type)
{
- if (type is INamedTypeSymbol { TypeArguments.Length: > 0 } or { TypeKind: TypeKind.TypeParameter })
+ foreach (var carried in CarriedTypes.Within(type))
{
- _unmappable.Add(type.ToDisplayString());
+ _concepts.TryRegister(carried);
}
}
///
- /// Builds the concept a type backed by ConceptAs declares.
- ///
- /// The concept type.
- /// The type the concept is backed by.
- /// The .
- ConceptModel ToConcept(ITypeSymbol type, ITypeSymbol backing)
- {
- var pii = type.HasAttribute(WellKnownTypeNames.PiiAttribute);
-
- if (backing.TypeKind == TypeKind.Enum)
- {
- return new(type.Name, ScreenplayPrimitive.Enum, pii, ValuesOf(backing), []);
- }
-
- var resolved = backing is INamedTypeSymbol named && ScreenplayPrimitiveTypes.TryResolve(named.FullMetadataName(), out var primitive)
- ? primitive
- : ScreenplayPrimitive.String;
-
- return new(type.Name, resolved, pii, [], []);
- }
-
- ///
- /// Registers a concept, keeping the first declaration of a given name.
+ /// Records a type whose simple name says less than the type does.
///
- /// The type the concept was read from.
- /// The concept to register.
+ /// The type being named.
///
- /// A concept is declared once at the top of the document and referenced by its simple name, so two types sharing
- /// that name cannot both be described. Keeping the first is the only choice left, and saying so is what stops the
- /// document from quietly claiming the second one is something it is not.
+ /// A read model or a nested object referred to by its own name is exactly right. A constructed generic is not -
+ /// writing IDictionary<string, string> as the single identifier the grammar allows leaves the word
+ /// KeyValuePair behind, which says nothing and which the document never declares. Same for a type
+ /// parameter, whose name is a placeholder rather than a type.
///
- void Register(ITypeSymbol type, ConceptModel concept)
+ void ReportWhatTheNameLoses(ITypeSymbol type)
{
- if (!_concepts.TryGetValue(concept.Name, out var existing))
- {
- _concepts[concept.Name] = concept;
-
- return;
- }
-
- if (existing.Primitive != concept.Primitive || !existing.EnumValues.SequenceEqual(concept.EnumValues, StringComparer.Ordinal))
+ if (type is INamedTypeSymbol { TypeArguments.Length: > 0 } or { TypeKind: TypeKind.TypeParameter })
{
- _ambiguous.Add(type.ToDisplayString());
+ _unmappable.Add(type.ToDisplayString());
}
}
}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/TypesTheDocumentCannotName.cs b/Source/DotNET/Screenplay/Analysis/Types/TypesTheDocumentCannotName.cs
new file mode 100644
index 000000000..953afa463
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Types/TypesTheDocumentCannotName.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Analysis.Types;
+
+///
+/// Reports every type the document had to refer to by a name that does not say what it is.
+///
+///
+/// A Screenplay type reference is a single identifier, so a type the grammar cannot hold is written as whatever of its
+/// name survives - and the property then claims a type nothing declares. Saying which types those were is the
+/// difference between a document with a known gap and a document that is quietly wrong.
+///
+public static class TypesTheDocumentCannotName
+{
+ ///
+ /// Reports everything the registry collected that the document could not say properly.
+ ///
+ /// The registry holding the types encountered.
+ /// The diagnostics to report to.
+ /// Where to report against.
+ ///
+ /// These are reported against the application rather than against the project a type was reached from. One
+ /// registry holds the concepts of every project, because a concept is declared once and referred to by name from
+ /// there on, so which project first reached a type is an accident of the order the projects are read in and would
+ /// be a misleading thing to point at.
+ ///
+ public static void Report(TypeRegistry types, ScreenplayDiagnostics diagnostics, string? location)
+ {
+ foreach (var type in types.Unmappable)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnmappableTypeReference,
+ $"'{type}' has no Screenplay counterpart, so it is referred to by a name the document never declares",
+ location);
+ }
+
+ foreach (var type in types.Ambiguous)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.AmbiguousConceptName,
+ $"'{type}' shares its simple name with a concept the document already declares, so what it is is described by the first one instead",
+ location);
+ }
+
+ foreach (var shape in types.Shapes)
+ {
+ diagnostics.Information(
+ ScreenplayDiagnosticCodes.UndeclarableShape,
+ $"'{shape}' is a record an artifact carries, and there is no way to declare what a record holds, so the document names it without saying what is in it - the concepts it carries are declared, the shape itself is not",
+ location);
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/UnderlyingTypes.cs b/Source/DotNET/Screenplay/Analysis/Types/UnderlyingTypes.cs
new file mode 100644
index 000000000..ba2fc65fa
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Types/UnderlyingTypes.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Types;
+
+///
+/// Strips everything a value is wrapped in that says how many there are or whether it may be absent.
+///
+///
+/// The value a type carries and the wrappers around it answer different questions, and every reader that asks either
+/// one has to strip the same wrappers to get the same answer. A collection of an optional concept says one thing about
+/// the value and three things about how many there are and whether it may be absent - so a reader marking that value
+/// as personal data and a reader naming it have to arrive at the same type, or the mark lands on a name nothing is
+/// declared with.
+///
+public static class UnderlyingTypes
+{
+ ///
+ /// Strips a type down to the value it carries.
+ ///
+ /// The type to strip.
+ /// Set when a wrapper said the value may be absent.
+ /// Set when the value is a collection of what is left.
+ /// The type of the value itself.
+ public static ITypeSymbol Of(ITypeSymbol type, ref bool optional, ref bool collection)
+ {
+ var current = Unwrap(type, ref optional);
+ var element = CollectionElements.ElementOf(current);
+ if (element is null)
+ {
+ return current;
+ }
+
+ collection = true;
+
+ return Unwrap(element, ref optional);
+ }
+
+ ///
+ /// Strips a type down to the value it carries, when nothing is asked about the wrappers.
+ ///
+ /// The type to strip.
+ /// The type of the value itself.
+ public static ITypeSymbol Of(ITypeSymbol type)
+ {
+ var optional = false;
+ var collection = false;
+
+ return Of(type, ref optional, ref collection);
+ }
+
+ ///
+ /// Strips the wrappers that only say whether a value may be absent.
+ ///
+ /// The type to strip.
+ /// Set when a wrapper said the value may be absent.
+ /// The wrapped type.
+ static ITypeSymbol Unwrap(ITypeSymbol type, ref bool optional)
+ {
+ if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable)
+ {
+ optional = true;
+
+ return nullable.TypeArguments[0];
+ }
+
+ if (type.NullableAnnotation == NullableAnnotation.Annotated && type.IsReferenceType)
+ {
+ optional = true;
+ }
+
+ return type;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs b/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs
index 60568f7a7..15294baaa 100644
--- a/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs
@@ -28,9 +28,16 @@ public class ValidationChainReader(ScreenplayDiagnostics diagnostics)
public const string Length = "Length";
///
- /// The call constraining a value to look like an email address.
+ /// The call holding the rules before it to a condition.
///
- public const string EmailAddress = "EmailAddress";
+ public const string When = "When";
+
+ ///
+ /// The call holding the rules before it to a condition being false.
+ ///
+ public const string Unless = "Unless";
+
+ readonly ValidationOperands _operands = new(diagnostics);
///
/// Reads one rule chain.
@@ -70,59 +77,6 @@ public void Read(
}
}
- ///
- /// Reads the operand a rule compares against.
- ///
- /// The call declaring the rule.
- /// The name of the rule builder.
- /// The semantic model of the tree the call lives in.
- /// Where the validator lives, for use in diagnostics.
- /// The operand, or when the rule takes none.
- object? OperandOf(InvocationExpressionSyntax call, string name, SemanticModel semanticModel, string location) =>
- string.Equals(name, EmailAddress, StringComparison.Ordinal)
- ? ValidationRuleKinds.EmailPattern
- : Constant(call, 0, semanticModel, location);
-
- ///
- /// Reads the constant value of an argument.
- ///
- /// The call to read.
- /// The position of the argument.
- /// The semantic model of the tree the call lives in.
- /// Where the validator lives, for use in diagnostics.
- /// The value, or when the argument is not a constant.
- ///
- /// A rule comparing against a member of an enumeration is given the number behind the member, which would have
- /// the document compare a concept declaring names against a number. Naming the member is what keeps the rule
- /// readable against the concept it is written about.
- ///
- object? Constant(InvocationExpressionSyntax call, int index, SemanticModel semanticModel, string location)
- {
- var argument = InvocationChain.ArgumentOf(call, index);
- if (argument is null)
- {
- return null;
- }
-
- var value = semanticModel.GetConstantValue(argument).Value;
- if (EnumConstants.EnumerationOf(argument, semanticModel) is not { } enumeration)
- {
- return value;
- }
-
- if (EnumConstants.TryResolve(enumeration, value, out var member))
- {
- return member;
- }
-
- diagnostics.Warning(
- ScreenplayDiagnosticCodes.UnnamedEnumerationValue,
- $"'{enumeration.Name}' declares no member with the value '{value}', so it is written as that number rather than as a name the concept declares",
- location);
-
- return value;
- }
-
///
/// Reads one call of a rule chain.
///
@@ -152,10 +106,17 @@ int ReadCall(
return 0;
}
+ if (string.Equals(name, When, StringComparison.Ordinal) || string.Equals(name, Unless, StringComparison.Ordinal))
+ {
+ ReportCondition(call, name, property, location);
+
+ return 0;
+ }
+
if (!forEach && string.Equals(name, Length, StringComparison.Ordinal) && call.ArgumentList.Arguments.Count == 2)
{
- rules.Add(new(property, ValidationRuleKind.Min, Constant(call, 0, semanticModel, location), null));
- rules.Add(new(property, ValidationRuleKind.Max, Constant(call, 1, semanticModel, location), null));
+ rules.Add(new(property, ValidationRuleKind.Min, _operands.Constant(call, 0, semanticModel, location), null));
+ rules.Add(new(property, ValidationRuleKind.Max, _operands.Constant(call, 1, semanticModel, location), null));
return 2;
}
@@ -170,11 +131,31 @@ int ReadCall(
return 0;
}
- rules.Add(new(property, kind, OperandOf(call, name, semanticModel, location), null));
+ rules.Add(new(property, kind, _operands.Read(call, name, semanticModel, location), null));
return 1;
}
+ ///
+ /// Reports the condition the rules declared before it are held to.
+ ///
+ /// The call carrying the condition.
+ /// The name of the call.
+ /// The property the chain declares rules for.
+ /// Where the validator lives, for use in diagnostics.
+ ///
+ /// A rule that only holds sometimes is stated as though it always holds, because a rule carries no condition of
+ /// its own yet (Cratis/Screenplay#32). That is a real difference between the document and the application, so the
+ /// condition is written into the report rather than only its absence - a reader who can see what the rule was
+ /// held to can tell one that hardly ever applies from one that nearly always does, and a report naming only the
+ /// call says neither.
+ ///
+ void ReportCondition(InvocationExpressionSyntax call, string name, string property, string location) =>
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnmappableValidationRule,
+ $"The rules on '{property}' are held to '{name}{call.ArgumentList}', and a rule carries no condition, so they are stated as though nothing held them",
+ location);
+
///
/// Applies a message to every rule the call before it declared.
///
@@ -195,12 +176,24 @@ void ApplyMessage(
int preceding,
string location)
{
- var message = InvocationChain.ArgumentOf(call) is { } argument ? semanticModel.GetConstantValue(argument).Value as string : null;
- if (message is null || preceding == 0)
+ var argument = InvocationChain.ArgumentOf(call);
+ var message = ValidationMessages.Read(argument, semanticModel);
+
+ if (message is null)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnmappableValidationRule,
+ $"The message '{argument}' is put together while the request runs rather than written down, so the rule it belongs to states none",
+ location);
+
+ return;
+ }
+
+ if (preceding == 0)
{
diagnostics.Warning(
ScreenplayDiagnosticCodes.UnmappableValidationRule,
- "A message was declared without a constant value or without a rule preceding it, and was left out",
+ $"The message '{message}' follows nothing the document states a rule for, so there is nothing to attach it to and it was left out",
location);
return;
diff --git a/Source/DotNET/Screenplay/Analysis/Validation/ValidationMessages.cs b/Source/DotNET/Screenplay/Analysis/Validation/ValidationMessages.cs
new file mode 100644
index 000000000..7d486248c
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Validation/ValidationMessages.cs
@@ -0,0 +1,106 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission.Naming;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Validation;
+
+///
+/// Reads what a WithMessage call says.
+///
+///
+/// A message is written in two shapes that say the same thing. Given directly it is text the compiler already holds.
+/// Given as a lambda it is usually a reference to a class the application declares its messages in once and names from
+/// every validator, which is how a message is written wherever messages are shared rather than repeated - and the
+/// lambda there computes nothing at all, so refusing it left the document without a single message it could have
+/// stated.
+///
+/// The lambda form exists so that a message can be built from the value being validated, and a message built while
+/// the request runs has no text to write down. So the compiler is asked for the text, and where it holds none the
+/// source is asked for a key: an application showing its messages in more than one language writes each of them as a
+/// lookup rather than as a value, and the key it looks up is the one thing about that message which is the same in
+/// every language. The key is what the document states, because stating text would settle it on a language the
+/// application itself never settled on.
+///
+///
+/// What neither answers for is a message genuinely put together while the request runs, which stays reported.
+///
+///
+public static class ValidationMessages
+{
+ ///
+ /// Reads the message an argument carries.
+ ///
+ /// The argument to read.
+ /// The semantic model of the tree the argument lives in.
+ /// The message, or when the argument says nothing that can be written down.
+ public static string? Read(ExpressionSyntax? argument, SemanticModel semanticModel) =>
+ argument is null ? null : MessageOf(argument, semanticModel) ?? MessageOf(BodyOf(argument), semanticModel);
+
+ ///
+ /// Gets what an expression says - the text when the compiler holds it, and the key it is looked up by when not.
+ ///
+ /// The expression to read.
+ /// The semantic model of the tree the expression lives in.
+ /// The message, or when the expression says nothing that can be written down.
+ static string? MessageOf(ExpressionSyntax? expression, SemanticModel semanticModel) =>
+ TextOf(expression, semanticModel) ?? KeyOf(expression, semanticModel);
+
+ ///
+ /// Gets the key an expression is looked up by, in the form the document references a localized string in.
+ ///
+ /// The expression to read.
+ /// The semantic model of the tree the expression lives in.
+ /// The reference, or when there is no key the document can write.
+ ///
+ /// A key the document has no way of writing is left to be reported rather than written some other way. The
+ /// language references a localized string by a path of bare words while a resource key is free of that - it is a
+ /// name in the resource and nowhere else, which is why a build turning one into a property already has to rename
+ /// it. Making that same alteration here would name a key the resource does not hold, and a rule stating a message
+ /// that resolves to nothing is worse than one stating no message at all.
+ ///
+ static string? KeyOf(ExpressionSyntax? expression, SemanticModel semanticModel) =>
+ ResourceKeys.Read(expression, semanticModel) is { } key && IsWritable(key)
+ ? $"{ScreenplayIdentifier.LocalizationPrefix}{key}"
+ : null;
+
+ ///
+ /// Determines whether every part of a key can be written to the document as a bare word.
+ ///
+ /// The key to check.
+ /// True when the key can be written.
+ static bool IsWritable(string key) =>
+ Array.TrueForAll(key.Split('.'), ScreenplayIdentifier.IsBareIdentifier);
+
+ ///
+ /// Gets the expression a lambda returns.
+ ///
+ /// The argument to read.
+ /// The expression, or when the argument is not a lambda returning one.
+ ///
+ /// A lambda with a block body runs statements to arrive at its message, which is the definition of a message
+ /// there is no text for.
+ ///
+ static ExpressionSyntax? BodyOf(ExpressionSyntax argument) => argument switch
+ {
+ SimpleLambdaExpressionSyntax simple => simple.ExpressionBody,
+ ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ExpressionBody,
+ _ => null
+ };
+
+ ///
+ /// Gets the text an expression holds, when the compiler holds it.
+ ///
+ /// The expression to read.
+ /// The semantic model of the tree the expression lives in.
+ /// The text, or when the expression is not text known while compiling.
+ ///
+ /// A reference to something declared as a constant is answered here just as a literal is, which is what makes the
+ /// shared message class work - the compiler substituted the text at the point of use before anything was asked of
+ /// it. A field holding text it was only assigned once is not that, and neither is a property returning one.
+ ///
+ static string? TextOf(ExpressionSyntax? expression, SemanticModel semanticModel) =>
+ expression is null ? null : semanticModel.GetConstantValue(expression).Value as string;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Validation/ValidationOperands.cs b/Source/DotNET/Screenplay/Analysis/Validation/ValidationOperands.cs
new file mode 100644
index 000000000..77fd4a9d5
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Validation/ValidationOperands.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Validation;
+
+///
+/// Reads the value a validation rule compares against.
+///
+/// The anything unmappable is reported to.
+///
+/// A rule compares against one of two things, and both are written down in full. A value the compiler holds is the
+/// obvious one. The other is another property of the same command - an end date is on or after the start date it was
+/// sent with - which is a lambda naming that property and nothing more, and which a rule operand carries as the path
+/// it names.
+///
+public class ValidationOperands(ScreenplayDiagnostics diagnostics)
+{
+ ///
+ /// The call constraining a value to look like an email address.
+ ///
+ public const string EmailAddress = "EmailAddress";
+
+ ///
+ /// Reads the operand a rule compares against.
+ ///
+ /// The call declaring the rule.
+ /// The name of the rule builder.
+ /// The semantic model of the tree the call lives in.
+ /// Where the validator lives, for use in diagnostics.
+ /// The operand, or when the rule takes none that can be read.
+ ///
+ /// A lambda is asked about before a constant is, because a lambda holds no constant and the question would only
+ /// ever be answered with nothing. The other way round would drop every comparison written against a sibling
+ /// property as though the rule had been given no operand at all.
+ ///
+ public object? Read(InvocationExpressionSyntax call, string name, SemanticModel semanticModel, string location)
+ {
+ if (string.Equals(name, EmailAddress, StringComparison.Ordinal))
+ {
+ return ValidationRuleKinds.EmailPattern;
+ }
+
+ return LambdaPaths.Read(InvocationChain.ArgumentOf(call)) is { } path
+ ? new PropertyPathSource(path)
+ : Constant(call, 0, semanticModel, location);
+ }
+
+ ///
+ /// Reads the constant value of an argument.
+ ///
+ /// The call to read.
+ /// The position of the argument.
+ /// The semantic model of the tree the call lives in.
+ /// Where the validator lives, for use in diagnostics.
+ /// The value, or when the argument is not a constant.
+ ///
+ /// A rule comparing against a member of an enumeration is given the number behind the member, which would have
+ /// the document compare a concept declaring names against a number. Naming the member is what keeps the rule
+ /// readable against the concept it is written about.
+ ///
+ public object? Constant(InvocationExpressionSyntax call, int index, SemanticModel semanticModel, string location)
+ {
+ var argument = InvocationChain.ArgumentOf(call, index);
+ if (argument is null)
+ {
+ return null;
+ }
+
+ var value = semanticModel.GetConstantValue(argument).Value;
+ if (EnumConstants.EnumerationOf(argument, semanticModel) is not { } enumeration)
+ {
+ return value;
+ }
+
+ if (EnumConstants.TryResolve(enumeration, value, out var member))
+ {
+ return member;
+ }
+
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnnamedEnumerationValue,
+ $"'{enumeration.Name}' declares no member with the value '{value}', so it is written as that number rather than as a name the concept declares",
+ location);
+
+ return value;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
index 4c66b0be2..4bd288e83 100644
--- a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
+++ b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
@@ -116,4 +116,31 @@ public static class WellKnownTypeNames
/// The transport level result carrying the value a controller method really returns.
public const string ActionResultOfT = "Microsoft.AspNetCore.Mvc.ActionResult`1";
+
+ /// The token a query is handed to observe cancellation through.
+ public const string CancellationToken = "System.Threading.CancellationToken";
+
+ /// The everything a query is performed with, which the host fills in.
+ public const string QueryContext = "Cratis.Arc.Queries.QueryContext";
+
+ /// The sequence a specification appends the events it starts from to.
+ public const string EventSequence = "Cratis.Chronicle.EventSequences.IEventSequence";
+
+ /// The scenario a specification issues a command through in process.
+ public const string CommandScenario = "Cratis.Arc.Testing.Commands.CommandScenario`1";
+
+ /// The builder a specification states the state of one event source with.
+ public const string CommandScenarioSourceGivenBuilder = "Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioSourceGivenBuilder`1";
+
+ /// The extensions a specification issues a command through over HTTP.
+ public const string HttpClientExtensions = "Cratis.Chronicle.XUnit.Integration.HttpClientExtensions";
+
+ /// The attribute marking a method as one assertion of a specification.
+ public const string FactAttribute = "Xunit.FactAttribute";
+
+ /// The page of a result a query is performed for, which the host fills in from the request.
+ public const string Paging = "Cratis.Arc.Queries.Paging";
+
+ /// The order a result is returned in, which the host fills in from the request.
+ public const string Sorting = "Cratis.Arc.Queries.Sorting";
}
diff --git a/Source/DotNET/Screenplay/Analysis/WholeApplication.cs b/Source/DotNET/Screenplay/Analysis/WholeApplication.cs
new file mode 100644
index 000000000..85589a2d4
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/WholeApplication.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Aggregates;
+using Cratis.Arc.Screenplay.Analysis.Screens;
+using Cratis.Arc.Screenplay.Analysis.Types;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis;
+
+///
+/// Holds everything an analysis keeps for the whole application rather than for one of its projects.
+///
+/// The compilations the application is analyzed from, ordered.
+/// The everything is reported to.
+///
+/// An application written as one project made this distinction invisible: everything a compilation was read into was
+/// the application, because the compilation was. With several, each of these has to be one thing across all of them
+/// or the document says something twice or misses it entirely. A concept is declared once and referred to by name
+/// from there on; an aggregate root one project declares is handed its work by a command another project holds; a
+/// screen imports the query of a slice that may live in a different project; a body reached from a handler may be
+/// written in the project below it; and diagnostics are one sequence in one order.
+///
+/// Everything not here is per project, because it reads a declaration the project itself catalogued and a symbol
+/// belongs to the compilation it came from.
+///
+///
+public record WholeApplication(IReadOnlyList Compilations, ScreenplayDiagnostics Diagnostics)
+{
+ ///
+ /// Gets the the screens of a slice are found through.
+ ///
+ public IUserInterfaceFiles Files { get; init; } = new UserInterfaceFiles();
+
+ ///
+ /// Gets the models every syntax tree of the application is read through.
+ ///
+ public SemanticModels Models { get; } = new(Compilations);
+
+ ///
+ /// Gets the registry collecting every concept the application refers to.
+ ///
+ public TypeRegistry Types { get; } = new();
+
+ ///
+ /// Gets the aggregate roots the application declares, and which of them a command reaches.
+ ///
+ public AggregateRootCatalog AggregateRoots { get; } = new();
+
+ ///
+ /// Gets the queries screens read through that a slice other than their own declares.
+ ///
+ public CrossSliceQueries Elsewhere { get; } = new();
+
+ ///
+ /// Creates what an application of a single project holds.
+ ///
+ /// The compilation being analyzed.
+ /// The everything is reported to.
+ /// The .
+ public static WholeApplication Of(Compilation compilation, ScreenplayDiagnostics diagnostics) =>
+ new([compilation], diagnostics);
+}
diff --git a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
index a85102ec3..f8d9986ae 100644
--- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
+++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
@@ -12,6 +12,7 @@
using Cratis.Arc.Screenplay.Emission.Reactors;
using Cratis.Arc.Screenplay.Emission.Screens;
using Cratis.Arc.Screenplay.Emission.Slices;
+using Cratis.Arc.Screenplay.Emission.Specifications;
using Cratis.Arc.Screenplay.Emission.Types;
using Cratis.Arc.Screenplay.Emission.Validation;
using Cratis.Arc.Screenplay.Model;
@@ -41,20 +42,25 @@ public class ApplicationSyntaxBuilder(IScreenplayNaming naming, ScreenplayDiagno
/// Builds the document.
///
/// The model to build from.
- /// The options to build with.
+ /// The options to build with, already resolved.
/// The .
+ ///
+ /// The options arrive resolved rather than being resolved here. What a name falls back to depends on how the
+ /// document was asked for - the assembly being analyzed when a generation asked for it, the domain of the model
+ /// when a host emitted one it already had - so resolving where neither of those is known meant resolving a
+ /// second time against a different answer and letting one of them quietly win.
+ ///
public ApplicationSyntax Build(ApplicationModel model, ScreenplayOptions options)
{
- var resolved = options.WithDefaults(model.Domain);
- var domain = ToName(model.Domain, resolved.Domain);
- var module = ToName(model.Module, resolved.Module);
+ var domain = ToName(model.Domain, options.Domain);
+ var module = ToName(model.Module, options.Module);
- var modules = BuildModules(model, module, resolved.SegmentsToSkip ?? 0);
+ var modules = BuildModules(model, module, options.SegmentsToSkip ?? 0);
var concepts = new ConceptSyntaxBuilder(naming, _validations, diagnostics, _names).Build(model.Concepts);
var policies = new PolicySyntaxBuilder(naming).Build(model.Policies, _authorize.Referenced);
return new(
- [],
+ [.. BuildImports(model)],
[.. concepts],
[.. policies],
[.. modules],
@@ -62,6 +68,32 @@ public ApplicationSyntax Build(ApplicationModel model, ScreenplayOptions options
new DomainSyntax(domain, SourceLocation.Start));
}
+ ///
+ /// Builds the imports naming every event the application refers to without declaring it.
+ ///
+ /// The model to build from.
+ /// The imports, ordered.
+ ///
+ /// The Screenplay compiler reads the last segment of an import as the name of an event that is known, so the
+ /// segment naming the event is written exactly as every reference to it is written - through the same conversion
+ /// - or the document would import one name and refer to another.
+ ///
+ IEnumerable BuildImports(ApplicationModel model) =>
+ model.Imports
+ .Select(ToQualifiedName)
+ .Where(_ => _.Length > 0)
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .Select(_ => new ImportSyntax(_, SourceLocation.Start));
+
+ ///
+ /// Sanitizes every segment of a dotted name.
+ ///
+ /// The name to sanitize.
+ /// The sanitized name.
+ string ToQualifiedName(string name) =>
+ string.Join('.', name.Split('.', StringSplitOptions.RemoveEmptyEntries).Select(naming.ToDeclarationName));
+
///
/// Builds the modules holding every slice that declares something.
///
@@ -114,7 +146,8 @@ SliceSyntaxBuilder CreateSliceBuilder() =>
new ConstraintSyntaxBuilder(naming),
new ReactorSyntaxBuilder(naming, diagnostics),
new ProjectionSyntaxBuilder(naming, diagnostics, _names),
- new ScreenSyntaxBuilder(naming, _types));
+ new ScreenSyntaxBuilder(naming, _types),
+ new SpecificationSyntaxBuilder(naming));
///
/// Sanitizes a document level name, falling back when it yields nothing usable.
diff --git a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs
index f46ecbd06..d856dad01 100644
--- a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs
+++ b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs
@@ -127,10 +127,25 @@ static string OnOneLine(string value)
}
///
- /// Strips everything that is not a valid identifier character, including generic type arity suffixes.
+ /// Reduces a name to the identifier the Screenplay grammar accepts, including generic type arity suffixes.
///
/// The name to sanitize.
/// The sanitized name.
+ ///
+ /// An identifier is [A-Za-z_]\w*, so a name carrying separators has to be transformed - and a runtime name
+ /// is routinely written with them, kebab case being idiomatic for a Chronicle constraint. Deleting them is the
+ /// least readable answer available: unique-timesheet-start becomes one run-together word and the word
+ /// boundaries the source stated are thrown away. Each separator is therefore read as the boundary it is and the
+ /// words either side of it are joined in Pascal case, which is what the grammar accepts and what a reader would
+ /// have written by hand. A name carrying no separator at all is left exactly as it is, because its casing is
+ /// already whatever the application chose.
+ ///
+ /// Composing the name is the first thing done to it rather than the last. An accented letter can be written as
+ /// one character or as a letter followed by a combining mark, and the two are canonically the same name - but a
+ /// combining mark is not a letter, so stripping before composing keeps one of them and quietly unaccents the
+ /// other. Two source files saying the same thing would then produce two different documents.
+ ///
+ ///
static string Sanitize(string name)
{
if (string.IsNullOrWhiteSpace(name))
@@ -138,18 +153,59 @@ static string Sanitize(string name)
return string.Empty;
}
- var backTick = name.IndexOf('`', StringComparison.Ordinal);
- var candidate = backTick > 0 ? name[..backTick] : name;
- var builder = new StringBuilder(candidate.Length);
+ var composed = name.Normalize(NormalizationForm.FormC);
+ var backTick = composed.IndexOf('`', StringComparison.Ordinal);
+ var words = WordsIn(backTick > 0 ? composed[..backTick] : composed);
+
+ return words.Count switch
+ {
+ 0 => string.Empty,
+ 1 => words[0],
+ _ => string.Concat(words.Select(Capitalized))
+ };
+ }
+
+ ///
+ /// Splits a name into the words the characters that cannot be written in an identifier separate.
+ ///
+ /// The name to split.
+ /// The words, in order, none of them empty.
+ ///
+ /// An underscore separates words as surely as a hyphen does, and is treated as one even though the grammar would
+ /// accept it, so that every way of writing a name apart comes out the same way.
+ ///
+ static List WordsIn(string name)
+ {
+ var words = new List();
+ var word = new StringBuilder(name.Length);
- foreach (var character in candidate)
+ foreach (var character in name)
{
- if (char.IsLetterOrDigit(character) || character == '_')
+ if (char.IsLetterOrDigit(character))
{
- builder.Append(character);
+ word.Append(character);
+ continue;
}
+
+ if (word.Length > 0)
+ {
+ words.Add(word.ToString());
+ word.Clear();
+ }
+ }
+
+ if (word.Length > 0)
+ {
+ words.Add(word.ToString());
}
- return builder.ToString().Normalize(NormalizationForm.FormC);
+ return words;
}
+
+ ///
+ /// Raises the first character of a word, leaving the rest of it as the application wrote it.
+ ///
+ /// The word to raise.
+ /// The raised word.
+ static string Capitalized(string word) => $"{char.ToUpperInvariant(word[0])}{word[1..]}";
}
diff --git a/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs b/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs
index 7b2691c12..0c2c85ed0 100644
--- a/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs
+++ b/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs
@@ -23,10 +23,16 @@ public ScreenplayEmitter()
}
///
+ ///
+ /// This is where the options an emission runs with are resolved, and the only place. What a name falls back to
+ /// is a question only an entry point can answer - a host that emits a model it already holds has nothing to fall
+ /// back on but the domain of that model - so resolving deeper down meant resolving twice on the way through a
+ /// generation, against two answers that only happen to agree.
+ ///
public ScreenplayEmission Emit(ApplicationModel model, ScreenplayOptions options)
{
var diagnostics = new ScreenplayDiagnostics();
- var application = new ApplicationSyntaxBuilder(naming, diagnostics).Build(model, options);
+ var application = new ApplicationSyntaxBuilder(naming, diagnostics).Build(model, options.WithDefaults(model.Domain));
return new(printer.Print(application), application, diagnostics.All);
}
diff --git a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs
index e5ab049d2..585d50edc 100644
--- a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs
+++ b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs
@@ -9,6 +9,7 @@
using Cratis.Arc.Screenplay.Emission.Queries;
using Cratis.Arc.Screenplay.Emission.Reactors;
using Cratis.Arc.Screenplay.Emission.Screens;
+using Cratis.Arc.Screenplay.Emission.Specifications;
using Cratis.Arc.Screenplay.Model;
using Cratis.Screenplay.Diagnostics;
using Cratis.Screenplay.Syntax;
@@ -27,6 +28,7 @@ namespace Cratis.Arc.Screenplay.Emission.Slices;
/// The for the reactors of the slice.
/// The for the projection of the slice.
/// The for the screens of the slice.
+/// The for the scenarios the slice is specified by.
public class SliceSyntaxBuilder(
IScreenplayNaming naming,
CommandSyntaxBuilder commands,
@@ -35,7 +37,8 @@ public class SliceSyntaxBuilder(
ConstraintSyntaxBuilder constraints,
ReactorSyntaxBuilder reactors,
ProjectionSyntaxBuilder projections,
- ScreenSyntaxBuilder screens)
+ ScreenSyntaxBuilder screens,
+ SpecificationSyntaxBuilder specifications)
{
///
/// The name given to a slice whose own name yields nothing usable.
@@ -73,7 +76,7 @@ .. slice.Constraints
.Select(_ => constraints.Build(_, slice.Namespace))
.OrderBy(_ => _.Name, StringComparer.Ordinal)
],
- [],
+ [.. specifications.Build(slice.Specifications)],
SourceLocation.Start,
naming.ToStringLiteral(slice.Description));
diff --git a/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs
new file mode 100644
index 000000000..89b941387
--- /dev/null
+++ b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs
@@ -0,0 +1,84 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission.Expressions;
+using Cratis.Arc.Screenplay.Emission.Naming;
+using Cratis.Arc.Screenplay.Model;
+using Cratis.Screenplay.Diagnostics;
+using Cratis.Screenplay.Syntax;
+using Cratis.Screenplay.Syntax.Specifications;
+
+namespace Cratis.Arc.Screenplay.Emission.Specifications;
+
+///
+/// Builds the Screenplay specification blocks of a slice.
+///
+/// The used for name conversion.
+///
+/// Nothing a step states is ever left out over its name. Every other block decides what a line is from its first
+/// word, so a property called 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.
+///
+public class SpecificationSyntaxBuilder(IScreenplayNaming naming)
+{
+ readonly MappingSourceConverter _sources = new(naming);
+
+ ///
+ /// Builds the specifications of a slice.
+ ///
+ /// The scenarios the slice is specified by.
+ /// The specifications, ordered by name.
+ public IEnumerable Build(IEnumerable specifications) =>
+ [
+ .. specifications
+ .Select(Build)
+ .OrderBy(_ => _.Name, StringComparer.Ordinal)
+ ];
+
+ ///
+ /// Builds one specification.
+ ///
+ /// The scenario to build for.
+ /// The .
+ SpecificationSyntax Build(SpecificationModel specification) =>
+ new(
+ naming.ToDeclarationName(specification.Name),
+ [.. Events(specification.Given)],
+ new SpecificationCommandSyntax(
+ naming.ToDeclarationName(specification.When.Name),
+ [.. Values(specification.When)],
+ SourceLocation.Start),
+ [.. Events(specification.Then)],
+ [.. specification.Errors.Select(_ => new SpecificationErrorSyntax(naming.ToStringLiteral(_) ?? string.Empty, SourceLocation.Start))],
+ SourceLocation.Start,
+ [.. ReadModels(specification.Given)],
+ [.. ReadModels(specification.Then)]);
+
+ ///
+ /// Builds the states of a step that name an event.
+ ///
+ /// The states to build from.
+ /// The events.
+ IEnumerable Events(IEnumerable states) =>
+ states
+ .Where(_ => _.Kind == SpecificationStateKind.Event)
+ .Select(_ => new SpecificationEventSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start));
+
+ ///
+ /// Builds the states of a step that name a read model.
+ ///
+ /// The states to build from.
+ /// The read models.
+ IEnumerable ReadModels(IEnumerable states) =>
+ states
+ .Where(_ => _.Kind == SpecificationStateKind.ReadModel)
+ .Select(_ => new SpecificationReadModelSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start));
+
+ ///
+ /// Builds the values a step states.
+ ///
+ /// The state to build from.
+ /// The values.
+ IEnumerable Values(SpecificationStateModel state) =>
+ state.Values.Select(_ => new PropertyMappingSyntax(naming.ToPropertyName(_.Property), _sources.Convert(_.Source), SourceLocation.Start));
+}
diff --git a/Source/DotNET/Screenplay/Emission/Validation/ValidationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Validation/ValidationSyntaxBuilder.cs
index 5bd113de5..b59f0b8d4 100644
--- a/Source/DotNET/Screenplay/Emission/Validation/ValidationSyntaxBuilder.cs
+++ b/Source/DotNET/Screenplay/Emission/Validation/ValidationSyntaxBuilder.cs
@@ -86,6 +86,11 @@ public IEnumerable Build(IEnumerable rules,
///
/// A pattern that is a bare word - email is the one the grammar knows - is written unquoted, because that
/// is how the named patterns are referenced. Everything else is a string literal.
+ ///
+ /// A rule comparing against another property of the same command carries the path it names rather than a value,
+ /// and a rule operand is a host expression, so the path is written as one - endDate >= startDate reads
+ /// as what the developer wrote, where a literal would read as a comparison against the word.
+ ///
///
ExpressionSyntax? ToOperand(ValidationRuleModel rule)
{
@@ -94,6 +99,11 @@ public IEnumerable Build(IEnumerable rules,
return null;
}
+ if (rule.Value is PropertyPathSource property)
+ {
+ return new PathExpressionSyntax(naming.ToPropertyPath(property.Path), SourceLocation.Start);
+ }
+
if (rule.Kind == ModelRuleKind.Matches && rule.Value is string pattern && ScreenplayIdentifier.IsBareIdentifier(pattern))
{
return new PathExpressionSyntax(pattern, SourceLocation.Start);
diff --git a/Source/DotNET/Screenplay/IScreenplayGenerator.cs b/Source/DotNET/Screenplay/IScreenplayGenerator.cs
index d9b78a58f..40b63bbb2 100644
--- a/Source/DotNET/Screenplay/IScreenplayGenerator.cs
+++ b/Source/DotNET/Screenplay/IScreenplayGenerator.cs
@@ -22,4 +22,24 @@ public interface IScreenplayGenerator
/// The options to generate with.
/// The .
ScreenplayGenerationResult Generate(Compilation compilation, ScreenplayOptions options);
+
+ ///
+ /// Generates the Screenplay document describing an application written as several projects.
+ ///
+ /// The compilations to generate from.
+ /// The options to generate with.
+ /// The .
+ ///
+ /// A layered application - domain, application and api, or a host beside the bounded contexts it serves - is not
+ /// described by any one of its projects. Each compilation contributes what its own assembly declares and the
+ /// document holds all of it: a namespace two projects declare into is one slice, a concept referred to from three
+ /// of them is declared once, and an event a sibling project declares is one the application has rather than one
+ /// it imports.
+ ///
+ /// The order the list arrives in does not reach the document. 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 and the same
+ /// projects always print the same bytes.
+ ///
+ ///
+ ScreenplayGenerationResult Generate(IReadOnlyList compilations, ScreenplayOptions options);
}
diff --git a/Source/DotNET/Screenplay/Model/ApplicationModel.cs b/Source/DotNET/Screenplay/Model/ApplicationModel.cs
index 7b7f4d8f9..146ad63a2 100644
--- a/Source/DotNET/Screenplay/Model/ApplicationModel.cs
+++ b/Source/DotNET/Screenplay/Model/ApplicationModel.cs
@@ -22,4 +22,13 @@ public record ApplicationModel(
/// Represents an application that declares nothing at all.
///
public static readonly ApplicationModel Empty = new(string.Empty, string.Empty, [], [], []);
+
+ ///
+ /// Gets the fully qualified name of every event the application refers to that something it references declares.
+ ///
+ ///
+ /// An event a sibling bounded context publishes is real, but nothing here declares it, so the document states the
+ /// dependency outright rather than referring to a name it never introduces.
+ ///
+ public IEnumerable Imports { get; init; } = [];
}
diff --git a/Source/DotNET/Screenplay/Model/SliceModel.cs b/Source/DotNET/Screenplay/Model/SliceModel.cs
index 9aa5a0838..aeb944390 100644
--- a/Source/DotNET/Screenplay/Model/SliceModel.cs
+++ b/Source/DotNET/Screenplay/Model/SliceModel.cs
@@ -37,6 +37,16 @@ public record SliceModel(
///
public IEnumerable Screens { get; init; } = [];
+ ///
+ /// Gets the scenarios the slice is specified by.
+ ///
+ ///
+ /// A scenario is recovered from a namespace beneath the slice's own rather than from the slice's, so it arrives
+ /// once every namespace has been read and which of them declare a slice is known - which is after the slice
+ /// itself exists.
+ ///
+ public IEnumerable Specifications { get; init; } = [];
+
///
/// Creates a slice that declares nothing, for use as a starting point.
///
diff --git a/Source/DotNET/Screenplay/Model/SpecificationModel.cs b/Source/DotNET/Screenplay/Model/SpecificationModel.cs
new file mode 100644
index 000000000..fa68980f1
--- /dev/null
+++ b/Source/DotNET/Screenplay/Model/SpecificationModel.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Model;
+
+///
+/// Represents one scenario a slice is specified by - what had happened, the command that was issued and what
+/// followed.
+///
+/// The name of the specification.
+/// What had already happened when the command was issued.
+/// The command that was issued.
+/// The events and read model states that followed.
+/// The rejections that followed, each named by the reason the source gives for it.
+///
+/// A rejection the source names no reason for carries an empty one. The scenario is named after the words the
+/// source itself uses for it, so what the rejection was about is already said by the name and inventing a sentence
+/// to repeat it would be describing an application nobody wrote.
+///
+public record SpecificationModel(
+ string Name,
+ IEnumerable Given,
+ SpecificationStateModel When,
+ IEnumerable Then,
+ IEnumerable Errors);
diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs
new file mode 100644
index 000000000..6bf316bd5
--- /dev/null
+++ b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Model;
+
+///
+/// Represents what a state a specification names actually is.
+///
+public enum SpecificationStateKind
+{
+ ///
+ /// An event, which is what a given and a then name by default.
+ ///
+ Event = 0,
+
+ ///
+ /// A read model, which a given and a then name behind the readmodel keyword.
+ ///
+ ReadModel = 1,
+
+ ///
+ /// A command, which is what the single when of a specification names.
+ ///
+ Command = 2
+}
diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs
new file mode 100644
index 000000000..0bdaf3dec
--- /dev/null
+++ b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Model;
+
+///
+/// Represents one artifact a specification names, together with the values it states for it.
+///
+/// The name of the event, read model or command being named.
+/// What the named artifact is.
+/// The values the specification states, in the order the source declares them.
+///
+/// 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 belongs to says which of them it is.
+///
+public record SpecificationStateModel(string Name, SpecificationStateKind Kind, IEnumerable Values);
diff --git a/Source/DotNET/Screenplay/Model/ValidationRuleModel.cs b/Source/DotNET/Screenplay/Model/ValidationRuleModel.cs
index 2a3561fa2..4de1095fc 100644
--- a/Source/DotNET/Screenplay/Model/ValidationRuleModel.cs
+++ b/Source/DotNET/Screenplay/Model/ValidationRuleModel.cs
@@ -12,5 +12,10 @@ namespace Cratis.Arc.Screenplay.Model;
/// The message shown when the rule is broken, or for the default.
///
/// A message starting with $strings. is a localization key and is emitted unquoted.
+///
+/// An operand is a value the compiler held, except when the rule compares against another property of the same
+/// command - an end date on or after the start date it was sent with - where it is a
+/// naming that property and is written as the path rather than as text.
+///
///
public record ValidationRuleModel(string Property, ValidationRuleKind Kind, object? Value, string? Message);
diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
index f7dc7eddc..19e68754c 100644
--- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
+++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
@@ -74,6 +74,11 @@ public static class ScreenplayDiagnosticCodes
///
/// A command handler yields the identifier of the event source it appends to, which Screenplay cannot express.
///
+ ///
+ /// A produces line names the event and says nothing about where it lands, so a handler returning the event
+ /// source alongside it is stating exactly what that line cannot carry (Cratis/Screenplay#33). The production is
+ /// written as it stands, because what the handler produces is right even while where it produces it is unsaid.
+ ///
public const string UnmappableEventSourceIdResult = "SP0013";
///
@@ -84,11 +89,26 @@ public static class ScreenplayDiagnosticCodes
///
/// A projection declares something the projection definition language has no counterpart for.
///
+ ///
+ /// Most of what this reports is a construct the language has no word for. One case is not: a slice holding a
+ /// second projection has that projection turned away because a slice declares at most one, which drops a read
+ /// model the application really builds (Cratis/Screenplay#30). Reporting it stays the right thing to do until a
+ /// slice can hold more than one - the projection is left out either way, and a reader counting read models
+ /// against the application otherwise has no way of seeing which one went missing.
+ ///
public const string UnmappableProjectionConstruct = "SP0015";
///
/// A validator declares a rule that could not be expressed declaratively.
///
+ ///
+ /// A rule living in code, a chain rooted in something that does not name a property, and a message either put
+ /// together while the request runs or following no rule to attach it to are all read as far as they go and then
+ /// left out. A rule held to a When or an Unless is different: it is written down, but as though
+ /// nothing held it, because a rule carries no condition of its own (Cratis/Screenplay#32). That is a difference
+ /// between the document and the application rather than an omission from it, which is why the report names the
+ /// condition the rule was held to rather than only the call carrying it.
+ ///
public const string UnmappableValidationRule = "SP0016";
///
@@ -109,11 +129,23 @@ public static class ScreenplayDiagnosticCodes
///
/// A reducer folds events into a read model, which Screenplay has no counterpart for.
///
+ ///
+ /// A projection says what each event does to the read model it builds. A reducer says the same thing as code,
+ /// and the language has no construct to fold one value into another (Cratis/Screenplay#39). The events it
+ /// observes are read from its signatures and are real, so the document states which events reach the read model
+ /// while leaving unsaid what they do to it.
+ ///
public const string ReducerWithoutCounterpart = "SP0020";
///
- /// An event referenced by the application is declared outside the compilation being analyzed.
+ /// An event the application refers to is declared neither by it nor by anything it references.
///
+ ///
+ /// An event a referenced package declares is real and can be stated - an import names it and the compiler
+ /// then reads it as an event that is known - so that case is written rather than reported. This is what is left:
+ /// a name nothing at all resolves to, where inventing a declaration would describe an event the application does
+ /// not have and staying silent would leave a document referring to something it never introduces.
+ ///
public const string EventDeclaredOutsideCompilation = "SP0021";
///
@@ -127,8 +159,31 @@ public static class ScreenplayDiagnosticCodes
public const string NamespaceWithoutStructure = "SP0023";
///
- /// The source did not compile, so nothing recovered from it can be relied on.
+ /// The source did not compile, and how far what was recovered from it can be relied on is what the severity says.
///
+ ///
+ /// This is the one code whose severity is decided rather than fixed, because "the source did not compile" covers
+ /// two outcomes that could not be further apart. A host handing over a compilation assembled without the compile
+ /// items a build generates leaves every reference to a generated type unresolved - hundreds of errors, none of
+ /// them anywhere near an artifact - while every command, event and reactor is read exactly as written. Calling
+ /// that an error says something untrue about a document that is entirely correct, and makes the host throw it
+ /// away.
+ ///
+ /// So the severity follows how many artifacts were recovered from a declaration no compilation error sits inside.
+ /// None - because nothing was recovered at all, or because every declaration something came out of is one the
+ /// compiler could not make sense of - is an error, and nothing in the document is worth trusting. Any at all is a
+ /// warning saying how many came through, because an artifact read from source the compiler accepted describes
+ /// what that source states regardless of what failed elsewhere.
+ ///
+ ///
+ /// As an error it suppresses and , both for
+ /// the same reason: a model recovered from symbols the compiler never accepted describes an application that does
+ /// not exist, so an empty document and a rejected one are consequences of the broken build rather than defects of
+ /// their own. As a warning it suppresses neither - it can never coincide with the first, since a warning is only
+ /// reached when something was recovered, and suppressing the second would hand back a document the language
+ /// rejects with nothing wrong reported.
+ ///
+ ///
public const string SourceDidNotCompile = "SP0024";
///
@@ -174,6 +229,11 @@ public static class ScreenplayDiagnosticCodes
///
public const string ScreenStructureNotInferred = "SP0028";
+ // SP0029 is deliberately unused. It was assigned to a code that was retired before the first release, and the
+ // sequence is left with the gap rather than closed up: a code is what a consumer suppresses and groups on, so
+ // handing this number to something else would silently change what an existing suppression means. Nothing is to
+ // be declared with it.
+
///
/// A type is referred to by a name that does not say what it is, because Screenplay cannot express it.
///
@@ -224,4 +284,87 @@ public static class ScreenplayDiagnosticCodes
/// as it stands so the line that was rejected can be read.
///
public const string DocumentDidNotCompile = "SP0034";
+
+ ///
+ /// A value an artifact carries is a record, whose shape no declaration in the language can hold.
+ ///
+ ///
+ /// A concept is one value with a name, and every concept the application refers to is declared. A record carrying
+ /// several values is a different thing: an event property written as days ApprovedDayLine[] names a shape
+ /// the document has no construct to introduce, so what that line holds is stated nowhere - including anything
+ /// within it the application marks as personal data. The concepts inside it are recovered and declared, because a
+ /// concept can be declared wherever it was reached from; the shape itself waits on the language
+ /// (Cratis/Screenplay#29). This is reported rather than left unsaid because a reader counting what the document
+ /// declares against what the application holds otherwise has no way of knowing where the difference went.
+ ///
+ public const string UndeclarableShape = "SP0035";
+
+ ///
+ /// A screen reads through a query a different slice of the application declares.
+ ///
+ ///
+ /// A screen aggregating several read models is what an Event Modeling screen routinely is, and an import naming a
+ /// query the model really holds is a binding rather than the noise every other unmatched import is. A data
+ /// directive names a query by the bare name its slice declares it under, though, and an application declares
+ /// All once per read model, so writing one down would say which query only by accident
+ /// (Cratis/Screenplay#28). Naming the screen, the query and the slice declaring it is what a reader needs to see
+ /// the binding the document is missing, and what turns it into one the moment a reference can carry the slice.
+ ///
+ public const string CrossSliceQueryBinding = "SP0036";
+
+ ///
+ /// Two projects of one application declare an artifact of the same name in the same slice.
+ ///
+ ///
+ /// A slice is recovered from a namespace, and a namespace an application declares from two projects is one slice
+ /// written in two places - the contracts of a bounded context sitting beside the handlers acting on them is the
+ /// ordinary case. Everything both projects contribute belongs to that one slice, and everything within a slice is
+ /// named once: a document declaring two commands called PlaceOrder in one slice says the same word twice
+ /// and means it differently. The first is kept, because the projects are read in assembly name order and no other
+ /// order is available to prefer by, and the second is reported rather than dropped where nobody would see it.
+ ///
+ public const string RepeatedDeclarationAcrossProjects = "SP0037";
+
+ ///
+ /// The projects of an application are written in directories that share none above the root of the file system.
+ ///
+ ///
+ /// Every path in a document is written relative to a directory, because a document carrying the absolute layout of
+ /// the machine that generated it is one nobody can commit or diff. With several projects that directory is the one
+ /// they are all written under - a Source folder holding a project each - and a path relative to it says
+ /// which project a file belongs to as well as where it sits within that project.
+ ///
+ /// Projects checked out beside each other in unrelated places share nothing but the root of the file system, which
+ /// is not a directory to write anything relative to. Each project's paths are therefore written relative to its own
+ /// root, which keeps every one of them relative and still says where a file sits within its project - and says
+ /// nothing about which project that is, so two files can come out as the same path. That is what this reports.
+ ///
+ ///
+ public const string ProjectsWithoutASharedRoot = "SP0038";
+
+ ///
+ /// A scenario a slice is specified by states a step that could not be read, so the whole scenario was left out.
+ ///
+ ///
+ /// A specification is one concrete example, and an example missing the command it issues, the state it started
+ /// from or the outcome it expects is not that example - it is a different one nobody wrote. So unlike a mapping,
+ /// which stands on its own and can be left out while the rest of the block still says something true, a step that
+ /// cannot be read takes the scenario with it. What made it unreadable is said, because the difference between a
+ /// scenario resting on a value computed at run time and one resting on a helper the reader could inline is the
+ /// difference between a gap that will always be there and one an afternoon closes.
+ ///
+ public const string UnreadableSpecification = "SP0039";
+
+ ///
+ /// A value a scenario states is code rather than a constant, so the scenario states everything but that value.
+ ///
+ ///
+ /// A scenario is written in the host language, where the identity two steps agree on is routinely a fresh
+ /// identifier held in a field rather than something written down twice. Screenplay states values and has no way
+ /// to name one, so such a value is left out and the rest of the scenario stands: which events had happened, which
+ /// command was issued and what followed are all still exactly what the source says. This is reported per value
+ /// rather than per scenario for the same reason a produces mapping is - a reader counting what a scenario states
+ /// against what the source states otherwise has no way of knowing which of the two it is looking at.
+ ///
+ public const string UnreadableSpecificationValue = "SP0040";
}
diff --git a/Source/DotNET/Screenplay/ScreenplayGenerator.cs b/Source/DotNET/Screenplay/ScreenplayGenerator.cs
index 2140740c1..bca2b892a 100644
--- a/Source/DotNET/Screenplay/ScreenplayGenerator.cs
+++ b/Source/DotNET/Screenplay/ScreenplayGenerator.cs
@@ -37,16 +37,21 @@ public ScreenplayGenerator()
}
///
- public ScreenplayGenerationResult Generate(Compilation compilation, ScreenplayOptions options)
+ public ScreenplayGenerationResult Generate(Compilation compilation, ScreenplayOptions options) =>
+ Generate([compilation], options);
+
+ ///
+ public ScreenplayGenerationResult Generate(IReadOnlyList compilations, ScreenplayOptions options)
{
- var resolved = options.WithDefaults(compilation.AssemblyName);
- var analysis = analyzer.Analyze(compilation, resolved);
+ var ordered = AnalyzedCompilations.Ordered(compilations);
+ var resolved = options.WithDefaults(AnalyzedCompilations.NameOf(ordered));
+ var analysis = analyzer.Analyze(ordered, resolved);
var emission = emitter.Emit(analysis.Model, resolved);
var diagnostics = new ScreenplayDiagnostics();
diagnostics.AddRange(analysis.Diagnostics);
diagnostics.AddRange(emission.Diagnostics);
- ReportDocumentThatDoesNotCompile(emission.Source, analysis.Diagnostics, diagnostics, compilation.AssemblyName);
+ ReportDocumentThatDoesNotCompile(emission.Source, analysis.Diagnostics, diagnostics, resolved.Domain);
return new(emission.Source, analysis.Model, diagnostics.All);
}
@@ -68,6 +73,13 @@ public ScreenplayGenerationResult Generate(Compilation compilation, ScreenplayOp
/// way it suppresses : a model recovered from symbols
/// the compiler never accepted describes an application that does not exist, so a poor document made from it is
/// the consequence already reported rather than a second defect.
+ ///
+ /// The suppression follows the severity that code was reported at rather than the code alone. As an error it
+ /// says nothing recovered can be trusted, which is the whole reason a document built from it says nothing
+ /// either. As a warning it says the opposite - that what was recovered stands - and a document built from a
+ /// model that stands is exactly what this check is for. Suppressing it there would hand back a document the
+ /// language rejects with nothing wrong reported, which is the one outcome this exists to make impossible.
+ ///
///
void ReportDocumentThatDoesNotCompile(
string source,
@@ -75,7 +87,7 @@ void ReportDocumentThatDoesNotCompile(
ScreenplayDiagnostics diagnostics,
string? location)
{
- if (analyzed.Any(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile))
+ if (analyzed.Any(_ => _.Code == ScreenplayDiagnosticCodes.SourceDidNotCompile && _.Severity == ScreenplayDiagnosticSeverity.Error))
{
return;
}