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/Source/DotNET/Screenplay.EndToEnd/Program.cs b/Source/DotNET/Screenplay.EndToEnd/Program.cs
new file mode 100644
index 000000000..d28378d94
--- /dev/null
+++ b/Source/DotNET/Screenplay.EndToEnd/Program.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;
+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 compilation = await ProjectCompilation.Of(project, failures);
+
+foreach (var failure in failures)
+{
+ Console.WriteLine($" workspace: {failure}");
+}
+
+if (compilation is null)
+{
+ Console.WriteLine($"'{project}' yielded no compilation, so there is nothing to generate from");
+
+ return 1;
+}
+
+var generated = new ScreenplayGenerator().Generate(compilation, 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..115cf78f0
--- /dev/null
+++ b/Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.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 System.Runtime.CompilerServices;
+using Microsoft.Build.Locator;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.MSBuild;
+
+namespace Cratis.Arc.Screenplay.EndToEnd;
+
+///
+/// Reads a real project file into the compilation the generator is handed.
+///
+///
+/// The generator itself never loads a workspace - it takes a and nothing else - which is
+/// what lets every specification build one 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.
+///
+public static class ProjectCompilation
+{
+ ///
+ /// Loads a project and everything it references.
+ ///
+ /// The full path of the project file.
+ /// Everything the workspace reported while loading, in the order it reported them.
+ /// The , or when the project yielded none.
+ ///
+ /// Registering the SDK has to happen before any MSBuild type is touched, which is why the member touching one is
+ /// 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 await Load(path, failures);
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static async Task Load(string path, ICollection failures)
+ {
+ var reported = new Lock();
+ using var workspace = MSBuildWorkspace.Create();
+ using var subscription = workspace.RegisterWorkspaceFailedHandler(args =>
+ {
+ lock (reported)
+ {
+ failures.Add(args.Diagnostic.Message);
+ }
+ });
+
+ var project = await workspace.OpenProjectAsync(path);
+
+ return await project.GetCompilationAsync();
+ }
+}
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..f60b08383 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,14 +127,22 @@ 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));
///
@@ -102,8 +150,25 @@ public static Compilation Compile(params (string Path, string Text)[] sources) =
///
/// 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/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_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/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_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/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_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/Analysis/ApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
index b3bdf0f82..d33d66f6a 100644
--- a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
+++ b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
@@ -1,6 +1,7 @@
// 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;
@@ -56,7 +57,7 @@ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptio
ConceptValidations.Link(catalog, readers);
readers.AggregateRoots.Report(diagnostics);
ReportTypesTheDocumentCannotName(readers, diagnostics, compilation.AssemblyName);
- ReportEventsFromOutside(slices, diagnostics);
+ var imports = ExternalEvents.Resolve(compilation, slices, diagnostics);
ReportNamespacesWithoutStructure(slices, diagnostics, options.SegmentsToSkip ?? 0);
if (slices.Count == 0 && !failedToCompile)
@@ -73,7 +74,10 @@ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptio
options.Module ?? options.Domain ?? ScreenplayOptions.DefaultName,
readers.Types.Concepts,
new PolicyCatalog(compilation, diagnostics).Declare(slices.SelectMany(AuthorizationsIn)),
- slices),
+ slices)
+ {
+ Imports = imports
+ },
diagnostics.All);
}
@@ -144,31 +148,6 @@ static void ReportTypesTheDocumentCannotName(ArtifactReaders readers, Screenplay
}
}
- ///
- /// 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.
///
@@ -203,28 +182,4 @@ static void ReportNamespacesWithoutStructure(
/// 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/Events/ExternalEvents.cs b/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs
new file mode 100644
index 000000000..5618d27ca
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.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.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)
+ {
+ var declared = slices.SelectMany(_ => _.Events).Select(_ => _.Name).ToHashSet(StringComparer.Ordinal);
+ var undeclared = slices.SelectMany(ReferredToBy).Where(_ => !declared.Contains(_)).ToHashSet(StringComparer.Ordinal);
+ var imported = DeclaredByAReference(compilation, 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 compilation 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.
+ ///
+ static Dictionary DeclaredByAReference(Compilation compilation, HashSet names)
+ {
+ var found = new Dictionary(StringComparer.Ordinal);
+ if (names.Count == 0)
+ {
+ return found;
+ }
+
+ foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols
+ .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/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/Screens/ScreenImports.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs
index bb0c9b9c5..84f6718d8 100644
--- a/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs
+++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs
@@ -35,7 +35,7 @@ public static IReadOnlyCollection In(string? text)
return names;
}
- 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))
@@ -52,6 +52,20 @@ public static IReadOnlyCollection In(string? text)
return names;
}
+ ///
+ /// 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 +128,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/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/SourcePaths.cs b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
index 284e94565..774887918 100644
--- a/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
+++ b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
@@ -34,10 +34,15 @@ 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)
{
- 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);
diff --git a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
index 7c64c2bbe..d2ac2f324 100644
--- a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
+++ b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
@@ -55,27 +55,27 @@ .. _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(Underlying(type, ref optional, ref collection)), collection, optional);
}
///
/// 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)
{
var optional = false;
- var current = Unwrap(type, ref optional);
- var collection = CollectionElements.ElementOf(current);
+ var collection = false;
- _pii.Add((collection ?? current).Name);
+ _pii.Add(Underlying(type, ref optional, ref collection).Name);
}
///
@@ -94,6 +94,27 @@ public void AddValidations(string conceptName, IEnumerable
declared.AddRange(rules);
}
+ ///
+ /// Strips everything a value is wrapped in that says how many there are or whether it may be absent.
+ ///
+ /// 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.
+ static ITypeSymbol Underlying(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 the wrappers that only say whether a value may be absent.
///
diff --git a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
index 4c66b0be2..62fc50fb4 100644
--- a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
+++ b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
@@ -116,4 +116,16 @@ 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 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/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
index a85102ec3..403ca5534 100644
--- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
+++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
@@ -41,20 +41,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 +67,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.
///
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/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/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
index f7dc7eddc..f206ea2ab 100644
--- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
+++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
@@ -112,8 +112,14 @@ public static class ScreenplayDiagnosticCodes
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";
///
@@ -174,6 +180,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.
///