From 4c1036a9a4a9389b1327d396fb4e303a7b57de56 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:40:24 +0200 Subject: [PATCH 01/11] Import events a referenced package declares instead of leaving them dangling A reactor observing what a sibling bounded context publishes refers to an event a package declares. The document had nothing to say about it beyond SP0021, and then referred to a name it never introduced - which the official Screenplay compiler reports as an unknown event. Screenplay already has the construct for this: the compiler reads the last segment of an 'import' as the name of an event that is known. Every undeclared name is now looked for in the assemblies the compilation references, and the one that is found is imported rather than reported. SP0021 is left for what is really unresolvable - a name nothing at all declares an event under, where importing it would state that a package declares something it does not. Co-Authored-By: Claude Opus 5 --- Source/DotNET/Screenplay.Specs/Analyzed.cs | 73 +++++++++- .../an_event_a_referenced_package_declares.cs | 53 +++++++ .../an_event_nothing_declares.cs | 52 +++++++ .../an_application_importing_an_event.cs | 55 +++++++ .../Analysis/ApplicationModelAnalyzer.cs | 57 +------- .../Analysis/Events/ExternalEvents.cs | 135 ++++++++++++++++++ .../Emission/ApplicationSyntaxBuilder.cs | 28 +++- .../Screenplay/Model/ApplicationModel.cs | 9 ++ .../Screenplay/ScreenplayDiagnosticCodes.cs | 8 +- 9 files changed, 413 insertions(+), 57 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_a_referenced_package_declares.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_nothing_declares.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/an_application_importing_an_event.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs 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/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_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_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/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/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs index a85102ec3..320f6a52c 100644 --- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs +++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs @@ -54,7 +54,7 @@ public ApplicationSyntax Build(ApplicationModel model, ScreenplayOptions options var policies = new PolicySyntaxBuilder(naming).Build(model.Policies, _authorize.Referenced); return new( - [], + [.. BuildImports(model)], [.. concepts], [.. policies], [.. modules], @@ -62,6 +62,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/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..aeeb42c0b 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"; /// From 2a23238f6d44863ad99d0b50d00caae6a55ab479 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:43:40 +0200 Subject: [PATCH 02/11] Leave source a build wrote out of where a slice is written A compilation loaded from a project carries what every source generator emitted to the intermediate folder. Those files declare real members of the slice, so the folders they sit in were counted among the folders the slice is written across - which reported a slice sitting in one folder as spread over two, claimed the folder was shared with the next slice to be handed the same generator output, and widened the screen scan to a build folder. Generated source now answers neither where a slice is written nor where the project is. Where a build wrote every last file the whole set is kept, since answering with nothing writes every path against the machine that generated it. Co-Authored-By: Claude Opus 5 --- ...slice_a_source_generator_contributed_to.cs | 64 ++++++++++++++++++ .../and_a_build_wrote_all_of_it.cs | 26 ++++++++ .../and_a_person_wrote_some_of_it.cs | 24 +++++++ .../when_recognizing_a_path.cs | 41 ++++++++++++ .../Screenplay/Analysis/GeneratedSource.cs | 66 +++++++++++++++++++ .../Analysis/Screens/SliceDirectories.cs | 10 ++- .../DotNET/Screenplay/Analysis/SourcePaths.cs | 7 +- 7 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_a_source_generator_contributed_to.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_build_wrote_all_of_it.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_person_wrote_some_of_it.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_recognizing_a_path.cs create mode 100644 Source/DotNET/Screenplay/Analysis/GeneratedSource.cs 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_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/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/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); From fd22de878067883f8f8685f3ab8c04ddb419ef49 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:46:37 +0200 Subject: [PATCH 03/11] Read a separator in a name as the word boundary it is Kebab case is idiomatic for the runtime name of a Chronicle constraint and a Screenplay identifier cannot carry a hyphen, so a transformation is forced. Deleting the separators was the least readable one available - every one of Ada's thirty one constraints came out as a run-together lower case word, and the word boundaries the source stated were thrown away with them. Each character an identifier cannot hold now ends a word instead, and the words are joined in Pascal case: 'unique-timesheet-start' reads as 'UniqueTimesheetStart'. This is every identifier position rather than only a constraint. A name carrying no separator is left exactly as it is, so nothing the application already writes as one word changes. Co-Authored-By: Claude Opus 5 --- ...raint_named_the_way_chronicle_names_one.cs | 45 +++++++++++++ .../from_names_carrying_separators.cs | 45 +++++++++++++ .../from_names_carrying_separators.cs | 33 ++++++++++ .../Emission/Naming/ScreenplayNaming.cs | 65 +++++++++++++++++-- 4 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_constraint_named_the_way_chronicle_names_one.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_property_name/from_names_carrying_separators.cs 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_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_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/Emission/Naming/ScreenplayNaming.cs b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs index f46ecbd06..91f574481 100644 --- a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs +++ b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs @@ -127,10 +127,19 @@ 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. + /// static string Sanitize(string name) { if (string.IsNullOrWhiteSpace(name)) @@ -139,17 +148,59 @@ static string Sanitize(string name) } var backTick = name.IndexOf('`', StringComparison.Ordinal); - var candidate = backTick > 0 ? name[..backTick] : name; - var builder = new StringBuilder(candidate.Length); + var words = WordsIn(backTick > 0 ? name[..backTick] : name); + + var identifier = words.Count switch + { + 0 => string.Empty, + 1 => words[0], + _ => string.Concat(words.Select(Capitalized)) + }; + + return identifier.Normalize(NormalizationForm.FormC); + } + + /// + /// 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)) + { + word.Append(character); + continue; + } + + if (word.Length > 0) { - builder.Append(character); + words.Add(word.ToString()); + word.Clear(); } } - return builder.ToString().Normalize(NormalizationForm.FormC); + if (word.Length > 0) + { + words.Add(word.ToString()); + } + + 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..]}"; } From be8440b25b13f521903509d376422d211e9655d7 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:49:24 +0200 Subject: [PATCH 04/11] Mark personal data under the name the value is referenced by Resolving a type strips the collection it sits in and then strips the optionality of the element, while marking one as personal data stripped only the collection. A collection of an optional value therefore registered the mark under 'Nullable' - a name no concept is declared with - so the document said the value was not sensitive while the runtime encrypted it. Both now strip the same wrappers through one path. Co-Authored-By: Claude Opus 5 --- ...ion_of_optional_values_as_personal_data.cs | 50 +++++++++++++++++++ .../Screenplay/Analysis/Types/TypeRegistry.cs | 41 +++++++++++---- 2 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_marking_a_collection_of_optional_values_as_personal_data.cs 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/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. /// From a3321cac8917077770450f9390573662a16e70e8 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:50:56 +0200 Subject: [PATCH 05/11] Read a commented out import as the import a component does not make The statement pattern anchors on a line starting with 'import', which a commented out one still does, so a component carrying the leftovers of a change bound its screen to queries it never calls. Comments are now removed before statements are read, and the line breaks a block comment spanned are kept - joining the line after one to the line before would hide the real import that follows it. Co-Authored-By: Claude Opus 5 --- ..._screen_whose_imports_are_commented_out.cs | 61 +++++++++++++++++++ .../and_some_of_them_are_commented_out.cs | 31 ++++++++++ .../Analysis/Screens/ScreenImports.cs | 23 ++++++- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_whose_imports_are_commented_out.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenImports/when_reading_imports/and_some_of_them_are_commented_out.cs 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_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/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. /// From e2f1f257fc2bb03575252b26890bb8557b12ad8e Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:53:08 +0200 Subject: [PATCH 06/11] Leave what the host fills in out of what a query asks a caller for Only an interface parameter was read as a collaborator rather than as input, which is not the whole of what a host hands a query. A cancellation token, the page asked for and the order asked for are all filled in from the request and none of them is an interface, so each was stated as caller input - a parameter no caller sends, typed by a name the document never declares. They are named explicitly, because there is no property of a type that tells infrastructure from a value a caller really sends. Co-Authored-By: Claude Opus 5 --- ...uery_the_host_hands_more_than_arguments.cs | 52 +++++++++++++++++++ .../Analysis/Queries/QueryReader.cs | 20 ++++++- .../Screenplay/Analysis/WellKnownTypeNames.cs | 12 +++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs 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/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/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"; } From 748dc3743654e54f9462a72dc373a6e590af0681 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:53:24 +0200 Subject: [PATCH 07/11] Reserve SP0029 so nothing is ever declared with it The sequence jumped from SP0028 to SP0030 with nothing saying why. A code is what a consumer suppresses and groups on, so a number handed to something else later would silently change what an existing suppression means. The gap is now stated as deliberate. Co-Authored-By: Claude Opus 5 --- Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs index aeeb42c0b..f206ea2ab 100644 --- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs @@ -180,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. /// From f30c1b646b1d60e59b5376c4d58eb557da5f06c5 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:55:05 +0200 Subject: [PATCH 08/11] Compose a name before stripping what an identifier cannot hold An accented letter can be written as one character or as a letter followed by a combining mark, and Unicode calls the two canonically equal. A combining mark is not a letter, so stripping first kept the accent of one spelling and quietly removed it from the other - two documents for one application, decided by how an editor happened to save the file. Co-Authored-By: Claude Opus 5 --- ...es_written_two_ways_that_mean_one_thing.cs | 30 +++++++++++++++++++ .../Emission/Naming/ScreenplayNaming.cs | 15 ++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_written_two_ways_that_mean_one_thing.cs 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/Emission/Naming/ScreenplayNaming.cs b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs index 91f574481..d856dad01 100644 --- a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs +++ b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs @@ -139,6 +139,12 @@ static string OnOneLine(string value) /// 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) { @@ -147,17 +153,16 @@ static string Sanitize(string name) return string.Empty; } - var backTick = name.IndexOf('`', StringComparison.Ordinal); - var words = WordsIn(backTick > 0 ? name[..backTick] : name); + var composed = name.Normalize(NormalizationForm.FormC); + var backTick = composed.IndexOf('`', StringComparison.Ordinal); + var words = WordsIn(backTick > 0 ? composed[..backTick] : composed); - var identifier = words.Count switch + return words.Count switch { 0 => string.Empty, 1 => words[0], _ => string.Concat(words.Select(Capitalized)) }; - - return identifier.Normalize(NormalizationForm.FormC); } /// From da2b5711b821d5166e7848d1556d2d86fe343d87 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:57:20 +0200 Subject: [PATCH 09/11] Specify that a value which may be absent stays that way in the document C# says a value may be absent in two ways - a nullable annotation on a reference type and a Nullable wrapper on a value type - and Screenplay says it in one, a trailing question mark. Unwrapping distinguished the two all along and nothing asserted that either survived as far as the printed text, where getting it wrong describes a shape the application does not have or names a type the document never declares. The specification is end to end and covers a command property, an event property, an enumeration, a collection of optional values and the parameter of a query. Co-Authored-By: Claude Opus 5 --- ...rce_declaring_values_that_may_be_absent.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_declaring_values_that_may_be_absent.cs 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(); +} From c5e1a574b5eb22878a917cee8a6d137f343f1184 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 21:00:23 +0200 Subject: [PATCH 10/11] Resolve the options of an emission in one place The generator resolved them against the assembly it was reading and the syntax builder resolved them again against the domain of the model, so one generation ran two resolutions with two different fallbacks and let whichever came last decide. They agree today, which is the only reason nothing showed. The emitter is now the single place the emission half resolves, and the builder is handed options that are already resolved. Co-Authored-By: Claude Opus 5 --- ...el_with_nothing_configured_alongside_it.cs | 26 +++++++++++++++++++ .../and_they_are_already_resolved.cs | 26 +++++++++++++++++++ .../Emission/ApplicationSyntaxBuilder.cs | 15 +++++++---- .../Screenplay/Emission/ScreenplayEmitter.cs | 8 +++++- 4 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_model_with_nothing_configured_alongside_it.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayOptions/when_resolving_defaults/and_they_are_already_resolved.cs 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_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/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs index 320f6a52c..403ca5534 100644 --- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs +++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs @@ -41,15 +41,20 @@ 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); 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); } From 5d75a6c55aedb544f46d725c3607ce3265f36b86 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 21:07:47 +0200 Subject: [PATCH 11/11] Hold the document of a real application to the compiler in CI Every Screenplay specification builds its compilation from source strings, which is what makes them hermetic and what puts a class of defect out of their reach by construction: a compilation built that way has no intermediate folder, no source generator output on disk and no referenced package declaring an event, so a generator that mis-attributes one of those produces a wrong document that every specification still passes. Two shipped that way. A console project reads a real project through MSBuild, generates from it and reads the document back with the compiler the language ships. It has to come back with nothing to say, warnings included - a warning there is the document referring to something it never introduces, which is this generator being wrong rather than the application. Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnet-build.yml | 51 ++++++++++++ Arc.slnx | 1 + Directory.Packages.props | 6 ++ Source/DotNET/Screenplay.EndToEnd/Program.cs | 80 +++++++++++++++++++ .../Screenplay.EndToEnd/ProjectCompilation.cs | 61 ++++++++++++++ .../Screenplay.EndToEnd.csproj | 28 +++++++ 6 files changed, 227 insertions(+) create mode 100644 Source/DotNET/Screenplay.EndToEnd/Program.cs create mode 100644 Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.cs create mode 100644 Source/DotNET/Screenplay.EndToEnd/Screenplay.EndToEnd.csproj 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 + + + + + + + + + + + + + + + + +