diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 9db62aeaa..763239ee4 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -176,6 +176,57 @@ jobs: - name: Test (${{ matrix.project }}) run: dotnet test ${{ matrix.project }} --no-build --configuration Release --framework net10.0 + # Every Screenplay specification builds its compilation from source strings, which is what makes them hermetic - + # and what puts a whole class of defect out of their reach by construction. A compilation built that way has no + # intermediate folder, no source generator output on disk and no referenced package declaring an event, so a + # generator that mis-attributes one of those produces a wrong document that every specification still passes. Two + # shipped that way. This reads a real project through MSBuild and holds the document it generates to the compiler + # the language ships - it has to come back with nothing to say, warnings included. + screenplay-end-to-end: + needs: [dotnet-build] + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }} + + - name: Setup .Net + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + ${{ env.DOTNET8_VERSION }} + ${{ env.DOTNET9_VERSION }} + ${{ env.DOTNET10_VERSION }} + + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.*.props', '**/Directory.*.targets') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + + - name: Extract build output + run: tar -xzf build-output.tar.gz + + # The build output artifact carries bin folders only, and reading a project through MSBuild needs the assets + # file that restore writes to obj. + - name: Restore the application to generate from + run: dotnet restore TestApps/AspNetCore/AspNetCore.csproj + + - name: Generate and read back the document of a real application + run: | + dotnet Source/DotNET/Screenplay.EndToEnd/bin/Release/net10.0/Cratis.Arc.Screenplay.EndToEnd.dll \ + TestApps/AspNetCore/AspNetCore.csproj \ + "${RUNNER_TEMP}/AspNetCore.play" + proxy-specs: needs: [dotnet-build] runs-on: ubuntu-latest diff --git a/Arc.slnx b/Arc.slnx index 67d6a64e7..5bdfa91d1 100644 --- a/Arc.slnx +++ b/Arc.slnx @@ -31,6 +31,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index b62c03507..690fc5ebb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,6 +67,12 @@ + + + + diff --git a/Source/DotNET/Screenplay.EndToEnd/Program.cs b/Source/DotNET/Screenplay.EndToEnd/Program.cs new file mode 100644 index 000000000..d28378d94 --- /dev/null +++ b/Source/DotNET/Screenplay.EndToEnd/Program.cs @@ -0,0 +1,80 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay; +using Cratis.Arc.Screenplay.EndToEnd; +using Cratis.Screenplay; + +if (args.Length < 2) +{ + Console.WriteLine("Usage:"); + Console.WriteLine(" Cratis.Arc.Screenplay.EndToEnd "); + + return 2; +} + +var project = Path.GetFullPath(args[0]); +var output = Path.GetFullPath(args[1]); + +Console.WriteLine($"Generating the Screenplay document of '{project}'"); + +var failures = new List(); +var compilation = await ProjectCompilation.Of(project, failures); + +foreach (var failure in failures) +{ + Console.WriteLine($" workspace: {failure}"); +} + +if (compilation is null) +{ + Console.WriteLine($"'{project}' yielded no compilation, so there is nothing to generate from"); + + return 1; +} + +var generated = new ScreenplayGenerator().Generate(compilation, new ScreenplayOptions()); + +Directory.CreateDirectory(Path.GetDirectoryName(output)!); +await File.WriteAllTextAsync(output, generated.Source); + +Console.WriteLine($"Wrote {generated.Source.Split('\n').Length} lines to '{output}'"); + +foreach (var group in generated.Diagnostics.GroupBy(_ => _.Code).OrderBy(_ => _.Key, StringComparer.Ordinal)) +{ + Console.WriteLine($" {group.Key} x{group.Count()}"); +} + +var errors = generated.Diagnostics.Where(_ => _.Severity == ScreenplayDiagnosticSeverity.Error).ToList(); +foreach (var error in errors) +{ + Console.WriteLine($" generation error {error.Code}: {error.Message}"); +} + +var compiled = new ScreenplayCompiler().Compile(generated.Source); +var rejected = compiled.Diagnostics.ToList(); +foreach (var diagnostic in rejected) +{ + Console.WriteLine($" document {diagnostic.Severity} on line {diagnostic.Location.Line}: {diagnostic.Message}"); +} + +// The document has to compile clean, warnings included. A warning the language reports is the generator writing a +// document that refers to something it never introduces, which is a defect here rather than in the application - and +// it is precisely the class of defect no specification built from source strings can reach. +if (rejected.Count > 0) +{ + Console.WriteLine($"The generated document did not read back clean - {rejected.Count} diagnostic(s)"); + + return 1; +} + +if (errors.Count > 0) +{ + Console.WriteLine($"Generation reported {errors.Count} error(s)"); + + return 1; +} + +Console.WriteLine("The generated document reads back clean"); + +return 0; diff --git a/Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.cs b/Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.cs new file mode 100644 index 000000000..115cf78f0 --- /dev/null +++ b/Source/DotNET/Screenplay.EndToEnd/ProjectCompilation.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.MSBuild; + +namespace Cratis.Arc.Screenplay.EndToEnd; + +/// +/// Reads a real project file into the compilation the generator is handed. +/// +/// +/// The generator itself never loads a workspace - it takes a and nothing else - which is +/// what lets every specification build one from source strings. That seam is exactly what this check exists to get +/// behind: a compilation built from strings has no intermediate folder, no source generator output on disk and no +/// referenced package that declares an event, so a defect that only shows in one of those is invisible to a +/// specification by construction. Two shipped that way. +/// +public static class ProjectCompilation +{ + /// + /// Loads a project and everything it references. + /// + /// The full path of the project file. + /// Everything the workspace reported while loading, in the order it reported them. + /// The , or when the project yielded none. + /// + /// Registering the SDK has to happen before any MSBuild type is touched, which is why the member touching one is + /// marked as not inlinable - the JIT would otherwise resolve those types while registration is still running. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + public static async Task Of(string path, ICollection failures) + { + if (!MSBuildLocator.IsRegistered) + { + MSBuildLocator.RegisterDefaults(); + } + + return await Load(path, failures); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task Load(string path, ICollection failures) + { + var reported = new Lock(); + using var workspace = MSBuildWorkspace.Create(); + using var subscription = workspace.RegisterWorkspaceFailedHandler(args => + { + lock (reported) + { + failures.Add(args.Diagnostic.Message); + } + }); + + var project = await workspace.OpenProjectAsync(path); + + return await project.GetCompilationAsync(); + } +} diff --git a/Source/DotNET/Screenplay.EndToEnd/Screenplay.EndToEnd.csproj b/Source/DotNET/Screenplay.EndToEnd/Screenplay.EndToEnd.csproj new file mode 100644 index 000000000..b4a640c85 --- /dev/null +++ b/Source/DotNET/Screenplay.EndToEnd/Screenplay.EndToEnd.csproj @@ -0,0 +1,28 @@ + + + + Exe + Cratis.Arc.Screenplay.EndToEnd + Cratis.Arc.Screenplay.EndToEnd + + net10.0 + false + + + + + + + + + + + + + + + + + diff --git a/Source/DotNET/Screenplay.Specs/Analyzed.cs b/Source/DotNET/Screenplay.Specs/Analyzed.cs index 60ca2afa2..f60b08383 100644 --- a/Source/DotNET/Screenplay.Specs/Analyzed.cs +++ b/Source/DotNET/Screenplay.Specs/Analyzed.cs @@ -54,6 +54,46 @@ public static class Analyzed public static ApplicationModelAnalysis Source(params (string Path, string Text)[] sources) => Source(DeclaredUserInterfaceFiles.None, sources); + /// + /// Compiles source referencing a package and recovers the model it describes. + /// + /// The package the source references. + /// The source files, keyed by the path each one is compiled as. + /// The . + public static ApplicationModelAnalysis SourceReferencing(MetadataReference package, params (string Path, string Text)[] sources) => + new ApplicationModelAnalyzer(DeclaredUserInterfaceFiles.None) + .Analyze(Compile([package], sources), new ScreenplayOptions().WithDefaults(AssemblyName)); + + /// + /// Compiles source into the assembly image a referenced package really is. + /// + /// The name of the assembly. + /// The source. + /// The . + /// Thrown when the source of the package does not compile. + /// + /// The image is emitted rather than referenced as a compilation, because a package the application depends on is + /// metadata with no syntax tree behind it - which is the whole reason nothing in the compilation declares what it + /// holds. + /// + public static MetadataReference Package(string name, string text) + { + var compilation = CSharpCompilation.Create( + name, + [CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(documentationMode: DocumentationMode.Parse))], + _references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + using var stream = new MemoryStream(); + var emitted = compilation.Emit(stream); + if (!emitted.Success) + { + throw new InvalidOperationException($"The package '{name}' did not compile - {emitted.Diagnostics.First(_ => _.Severity == DiagnosticSeverity.Error)}"); + } + + return MetadataReference.CreateFromImage(stream.ToArray()); + } + /// /// Compiles source and recovers the model it describes. /// @@ -87,14 +127,22 @@ public static ApplicationModelAnalysis Source(IUserInterfaceFiles files, params /// /// The source files, keyed by the path each one is compiled as. /// The . - public static Compilation Compile(params (string Path, string Text)[] sources) => + public static Compilation Compile(params (string Path, string Text)[] sources) => Compile([], sources); + + /// + /// Compiles source referencing further packages into a compilation. + /// + /// The packages the source references beyond the platform. + /// The source files, keyed by the path each one is compiled as. + /// The . + public static Compilation Compile(IEnumerable packages, params (string Path, string Text)[] sources) => CSharpCompilation.Create( AssemblyName, sources.Append(Root).Select(_ => CSharpSyntaxTree.ParseText( _.Text, new CSharpParseOptions(documentationMode: DocumentationMode.Parse), path: _.Path)), - _references, + _references.Concat(packages), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); /// @@ -102,8 +150,25 @@ public static Compilation Compile(params (string Path, string Text)[] sources) = /// /// The source files, keyed by the path each one is compiled as. /// The errors, empty when the source compiles. - public static IEnumerable ErrorsIn(params (string Path, string Text)[] sources) => - Compile(sources) + public static IEnumerable ErrorsIn(params (string Path, string Text)[] sources) => ErrorsIn([], sources); + + /// + /// Gets everything the compiler itself reported for source referencing a package. + /// + /// The package the source references. + /// The source files, keyed by the path each one is compiled as. + /// The errors, empty when the source compiles. + public static IEnumerable ErrorsIn(MetadataReference package, params (string Path, string Text)[] sources) => + ErrorsIn([package], sources); + + /// + /// Gets everything the compiler itself reported for source referencing further packages. + /// + /// The packages the source references beyond the platform. + /// The source files, keyed by the path each one is compiled as. + /// The errors, empty when the source compiles. + public static IEnumerable ErrorsIn(IEnumerable packages, params (string Path, string Text)[] sources) => + Compile(packages, sources) .GetDiagnostics() .Where(_ => _.Severity == DiagnosticSeverity.Error) .Select(_ => _.ToString()); diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs new file mode 100644 index 000000000..b8a1f9e57 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// What a caller sends is what the document describes a query by. A cancellation token, the page asked for and the +/// order asked for are all filled in by the host from the request rather than sent as arguments, and none of them is +/// an interface - so stating them as caller input puts parameters in the document that no caller sends, typed by +/// names the document never declares. +/// +public class a_query_the_host_hands_more_than_arguments : Specification +{ + const string Source = """ + using System.Collections.Generic; + using System.Threading; + using Cratis.Arc.Queries; + using Cratis.Arc.Queries.ModelBound; + + namespace Library.Authors.Listing; + + [ReadModel] + public record Author + { + public string Id { get; init; } = string.Empty; + + public static IEnumerable AuthorsByName( + string name, + CancellationToken cancellationToken, + Paging paging, + Sorting sorting, + QueryContext context) => []; + } + """; + + ApplicationModelAnalysis _analysis; + QueryModel _query; + + void Establish() + { + _analysis = Analyzed.Source(("Library/Authors/Listing/Listing.cs", Source)); + _query = _analysis.Slice().Queries.Single(); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty(); + [Fact] void should_key_the_query_by_what_the_caller_sends() => _query.By!.Name.ShouldEqual("name"); + [Fact] void should_leave_out_everything_the_host_fills_in() => _query.Filters.ShouldBeEmpty(); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_whose_imports_are_commented_out.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_whose_imports_are_commented_out.cs new file mode 100644 index 000000000..7ec98ca3b --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_screen_whose_imports_are_commented_out.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// An import that has been commented out is an import the component does not make, so a binding recovered from one +/// says the screen reads through a query it never calls. A commented import is also the most ordinary thing to find +/// in a component under change, which makes it exactly the case the reader has to be right about. +/// +public class a_screen_whose_imports_are_commented_out : Specification +{ + const string Source = """ + using System.Collections.Generic; + using Cratis.Arc.Queries.ModelBound; + + namespace Library.Authors.Listing; + + [ReadModel] + public record Author + { + public string Id { get; init; } = string.Empty; + + public static IEnumerable AllAuthors() => []; + + public static IEnumerable RetiredAuthors() => []; + + public static IEnumerable HonoraryAuthors() => []; + } + """; + + const string Component = """ + import { DataTable } from 'primereact/datatable'; + // import { RetiredAuthors } from './RetiredAuthors'; + /* + import { HonoraryAuthors } from './HonoraryAuthors'; + */ + import { AllAuthors } from './AllAuthors'; + + export const AuthorList = () => ; + """; + + static readonly DeclaredUserInterfaceFiles _files = DeclaredUserInterfaceFiles.Holding( + ("Library/Authors/Listing/AuthorList.tsx", Component)); + + ApplicationModelAnalysis _analysis; + IEnumerable _data; + + void Establish() + { + _analysis = Analyzed.Source(_files, ("Library/Authors/Listing/Listing.cs", Source)); + _data = _analysis.Slice().Screens.Single().Data; + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty(); + [Fact] void should_bind_only_the_query_the_component_really_imports() => _data.Select(_ => _.Query).ShouldContainOnly(["AllAuthors"]); + [Fact] void should_report_only_what_no_screen_states() => _analysis.Diagnostics.Select(_ => _.Code).ShouldContainOnly([ScreenplayDiagnosticCodes.ScreenStructureNotInferred]); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_a_source_generator_contributed_to.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_a_source_generator_contributed_to.cs new file mode 100644 index 000000000..637888ebd --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_a_source_generator_contributed_to.cs @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A compilation loaded from a project carries what every source generator emitted to the intermediate folder of the +/// build. Those files declare real members of the slice and say nothing at all about where it is written, so counting +/// them reports a slice sitting in one folder as spread over two and looks for its screens in a build folder. +/// +public class a_slice_a_source_generator_contributed_to : Specification +{ + const string Written = """ + using System.Threading.Tasks; + using Cratis.Chronicle.Events; + using Cratis.Chronicle.Reactors; + + namespace Library.Onboarding.Registry; + + [EventType] + public record CompanyRegistered(string Name); + + public partial class RegistryNotifier : IReactor + { + public Task CompanyRegistered(CompanyRegistered @event, EventContext context) => Task.CompletedTask; + } + """; + + const string Emitted = """ + namespace Library.Onboarding.Registry; + + public partial class RegistryNotifier + { + public string Describe() => nameof(RegistryNotifier); + } + """; + + static readonly (string Path, string Text)[] _sources = + [ + ("Library/Onboarding/Registry/Registry.cs", Written), + ("Library/obj/Debug/net10.0/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Registry.Logging.g.cs", Emitted) + ]; + + static readonly DeclaredUserInterfaceFiles _files = new( + "Library/Onboarding/Registry/RegistryPage.tsx", + "Library/obj/Debug/net10.0/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Emitted.tsx"); + + ApplicationModelAnalysis _analysis; + IEnumerable _screens; + + void Establish() + { + _analysis = Analyzed.Source(_files, _sources); + _screens = _analysis.Slice().Screens; + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_not_report_the_slice_as_spread_over_folders() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.AmbiguousScreenFile).ShouldBeFalse(); + [Fact] void should_take_its_screens_only_from_the_folder_it_is_written_in() => _screens.Select(_ => _.Name).ShouldContainOnly(["RegistryPage"]); + [Fact] void should_report_nothing_beyond_what_no_screen_states() => _analysis.Diagnostics.Select(_ => _.Code).Distinct().ShouldContainOnly([ScreenplayDiagnosticCodes.ScreenStructureNotInferred]); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_a_referenced_package_declares.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_a_referenced_package_declares.cs new file mode 100644 index 000000000..7d863611b --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_a_referenced_package_declares.cs @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A reactor observing what a sibling bounded context publishes refers to an event a package declares. The event is +/// real, so a document saying nothing about it refers to a name it never introduces - and Screenplay has the +/// construct for exactly this, which is what an import is for. +/// +public class an_event_a_referenced_package_declares : Specification +{ + const string Contracts = """ + using Cratis.Chronicle.Events; + + namespace Partners.Contracts; + + [EventType] + public record InvitationToJoinAdaAccepted(string Email); + """; + + const string Slice = """ + using System.Threading.Tasks; + using Cratis.Chronicle.Events; + using Cratis.Chronicle.Reactors; + using Partners.Contracts; + + namespace Library.Admin.Invitations; + + public class AcceptedInvitationProvisioner : IReactor + { + public Task Provision(InvitationToJoinAdaAccepted @event, EventContext context) => Task.CompletedTask; + } + """; + + static readonly (string Path, string Text)[] _sources = [("Library/Admin/Invitations/Provision.cs", Slice)]; + + MetadataReference _package; + ApplicationModelAnalysis _analysis; + + void Establish() => _package = Analyzed.Package("Partners.Contracts", Contracts); + + void Because() => _analysis = Analyzed.SourceReferencing(_package, _sources); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_package, _sources).ShouldBeEmpty(); + [Fact] void should_import_the_event_by_its_qualified_name() => _analysis.Model.Imports.ShouldContainOnly(["Partners.Contracts.InvitationToJoinAdaAccepted"]); + [Fact] void should_still_observe_it_from_the_reactor() => _analysis.Slice().Reactors.Single().ObservedEvents.ShouldContainOnly(["InvitationToJoinAdaAccepted"]); + [Fact] void should_not_report_it_as_undeclared() => _analysis.Diagnostics.Any(_ => _.Code == ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation).ShouldBeFalse(); + [Fact] void should_report_nothing_at_all() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_marking_a_collection_of_optional_values_as_personal_data.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_marking_a_collection_of_optional_values_as_personal_data.cs new file mode 100644 index 000000000..043895dcc --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_marking_a_collection_of_optional_values_as_personal_data.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A concept is declared once and referenced by name, so a value marked as personal data has to be marked under the +/// name it is referenced under. A collection of an optional value says one thing about the value and two about how +/// many there are and whether it may be absent - strip fewer of them than the reference does and the mark lands on +/// the name of the wrapper, leaving a document that says a value is not sensitive while the runtime encrypts it. +/// +public class an_event_marking_a_collection_of_optional_values_as_personal_data : Specification +{ + const string Source = """ + using System.Collections.Generic; + using Cratis.Chronicle.Compliance.GDPR; + using Cratis.Chronicle.Events; + + namespace Library.Authors.Registration; + + public enum AuthorStanding + { + Member, + Honorary + } + + public enum AuthorTier + { + Standard, + Premium + } + + [EventType] + public record AuthorRegistered([PII] IEnumerable Standings, IEnumerable Tiers); + """; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(Source); + + bool IsPii(string name) => _analysis.Model.Concepts.Single(_ => _.Name == name).IsPii; + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty(); + [Fact] void should_mark_the_value_the_collection_holds() => IsPii("AuthorStanding").ShouldBeTrue(); + [Fact] void should_leave_the_unmarked_value_alone() => IsPii("AuthorTier").ShouldBeFalse(); + [Fact] void should_declare_a_concept_for_nothing_the_reference_stripped() => _analysis.Model.Concepts.Select(_ => _.Name).ShouldContainOnly(["AuthorStanding", "AuthorTier"]); + [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_nothing_declares.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_nothing_declares.cs new file mode 100644 index 000000000..2a0dbb732 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/an_event_nothing_declares.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A constraint can name any type at all, and a type nothing declares as an event is a name no import can introduce - +/// importing it would state that a package declares an event it does not. This is what is left for the report to say. +/// +public class an_event_nothing_declares : Specification +{ + const string Contracts = """ + namespace Partners.Contracts; + + public record CustomerRegistered(string Name); + """; + + const string Slice = """ + using Cratis.Chronicle.Events.Constraints; + using Partners.Contracts; + + namespace Library.Customers.Registration; + + public class UniqueCustomerConstraint : IConstraint + { + public void Define(IConstraintBuilder builder) => builder.Unique(); + } + """; + + static readonly (string Path, string Text)[] _sources = [("Library/Customers/Registration/Registration.cs", Slice)]; + + MetadataReference _package; + ApplicationModelAnalysis _analysis; + ScreenplayDiagnostic _reported; + + void Establish() => _package = Analyzed.Package("Partners.Contracts", Contracts); + + void Because() + { + _analysis = Analyzed.SourceReferencing(_package, _sources); + _reported = _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation); + } + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_package, _sources).ShouldBeEmpty(); + [Fact] void should_import_nothing() => _analysis.Model.Imports.ShouldBeEmpty(); + [Fact] void should_name_the_event_in_the_report() => _reported.Message.Contains("CustomerRegistered", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_locate_the_report_at_the_slice() => _reported.Location.ShouldEqual("Library.Customers.Registration"); + [Fact] void should_report_it_as_a_loss() => _reported.Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning); +} diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_build_wrote_all_of_it.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_build_wrote_all_of_it.cs new file mode 100644 index 000000000..5b7598155 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_build_wrote_all_of_it.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_GeneratedSource.when_excluding_generated_source; + +/// +/// Where a build wrote every last file, leaving nothing is not an improvement on leaving all of it - the question of +/// where the application sits still has to be answered, and answering it with nothing writes every path in the +/// document against the machine that generated it. +/// +public class and_a_build_wrote_all_of_it : Specification +{ + static readonly string[] _paths = + [ + "Library/obj/Debug/net10.0/Some.Generator/Registration.g.cs", + "Library/obj/Debug/net10.0/Some.Generator/Listing.g.cs" + ]; + + IReadOnlyList _result; + + void Because() => _result = GeneratedSource.Excluded(_paths); + + [Fact] void should_fall_back_to_all_of_it() => _result.ShouldContainOnly(_paths); +} diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_person_wrote_some_of_it.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_person_wrote_some_of_it.cs new file mode 100644 index 000000000..9befe37ab --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_excluding_generated_source/and_a_person_wrote_some_of_it.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_GeneratedSource.when_excluding_generated_source; + +/// +/// Where a person wrote some of the source, that is the whole of the answer to where the application is written. +/// +public class and_a_person_wrote_some_of_it : Specification +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedSource.Excluded( + [ + "Library/Authors/Registration/Registration.cs", + "Library/obj/Debug/net10.0/Some.Generator/Registration.g.cs", + null, + " " + ]); + + [Fact] void should_keep_only_what_a_person_wrote() => _result.ShouldContainOnly(["Library/Authors/Registration/Registration.cs"]); +} diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_recognizing_a_path.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_recognizing_a_path.cs new file mode 100644 index 000000000..7733907ba --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_recognizing_a_path.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_GeneratedSource; + +/// +/// Source a build wrote is told from source a person wrote by where it sits and by what it is called, because those +/// are the only two things a path says. Both halves matter - a generator emits under the intermediate folder, and a +/// designer writes a companion file next to the source it belongs to. +/// +public class when_recognizing_a_path : Specification +{ + bool _underTheIntermediateFolder; + bool _underTheOutputFolder; + bool _windowsSeparated; + bool _namedAsGenerated; + bool _writtenByAPerson; + bool _namedAfterTheOutputFolder; + bool _nothingAtAll; + + void Because() + { + _underTheIntermediateFolder = GeneratedSource.Is("Library/obj/Debug/net10.0/Some.Generator/Slice.g.cs"); + _underTheOutputFolder = GeneratedSource.Is("Library/bin/Release/net10.0/Slice.cs"); + _windowsSeparated = GeneratedSource.Is(@"C:\Work\Library\obj\Debug\Slice.cs"); + _namedAsGenerated = GeneratedSource.Is("Library/Authors/Registration/Registration.generated.cs"); + _writtenByAPerson = GeneratedSource.Is("Library/Authors/Registration/Registration.cs"); + _namedAfterTheOutputFolder = GeneratedSource.Is("Library/Authors/obj.cs"); + _nothingAtAll = GeneratedSource.Is(null); + } + + [Fact] void should_recognize_source_under_the_intermediate_folder() => _underTheIntermediateFolder.ShouldBeTrue(); + [Fact] void should_recognize_source_under_the_output_folder() => _underTheOutputFolder.ShouldBeTrue(); + [Fact] void should_recognize_a_path_separated_the_way_windows_separates_one() => _windowsSeparated.ShouldBeTrue(); + [Fact] void should_recognize_a_file_named_as_generated() => _namedAsGenerated.ShouldBeTrue(); + [Fact] void should_leave_source_a_person_wrote_alone() => _writtenByAPerson.ShouldBeFalse(); + [Fact] void should_not_read_a_file_name_as_a_folder() => _namedAfterTheOutputFolder.ShouldBeFalse(); + [Fact] void should_answer_for_a_path_that_is_not_there() => _nothingAtAll.ShouldBeFalse(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenImports/when_reading_imports/and_some_of_them_are_commented_out.cs b/Source/DotNET/Screenplay.Specs/for_ScreenImports/when_reading_imports/and_some_of_them_are_commented_out.cs new file mode 100644 index 000000000..fc14c3832 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenImports/when_reading_imports/and_some_of_them_are_commented_out.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis.Screens; + +namespace Cratis.Arc.Screenplay.for_ScreenImports.when_reading_imports; + +/// +/// A statement is recognized by starting a line, which a commented one still does. Removing what a comment holds is +/// what tells them apart, and the line breaks the comment spanned have to survive it - joining the line after a block +/// comment to the line before would hide the real import that follows one. +/// +public class and_some_of_them_are_commented_out : Specification +{ + const string Component = """ + // import { CommentedOnItsOwnLine } from './One'; + //import { IndentedAndCommented } from './Two'; + import { Kept } from './Three'; // import { AfterAStatement } from './Four'; + /* import { CommentedInABlock } from './Five'; */ + /* + import { CommentedOverSeveralLines } from './Six'; + */ + import { KeptAfterABlock } from './Seven'; + """; + + IReadOnlyCollection _names; + + void Because() => _names = ScreenImports.In(Component); + + [Fact] void should_keep_every_import_the_file_really_makes() => _names.ShouldContainOnly(["Kept", "KeptAfterABlock"]); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_constraint_named_the_way_chronicle_names_one.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_constraint_named_the_way_chronicle_names_one.cs new file mode 100644 index 000000000..491b07653 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_constraint_named_the_way_chronicle_names_one.cs @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Emission; +using Cratis.Arc.Screenplay.Library; +using Cratis.Arc.Screenplay.Model; +using Cratis.Arc.Screenplay.Verification; + +namespace Cratis.Arc.Screenplay.for_ScreenplayEmitter.when_emitting; + +/// +/// Kebab case is idiomatic for the runtime name of a Chronicle constraint, and a Screenplay identifier cannot carry a +/// hyphen. Deleting the hyphens leaves one run-together word that reads as nothing, so the words either side of each +/// one are joined the way a reader would have written them. +/// +public class a_constraint_named_the_way_chronicle_names_one : given.an_emitter +{ + ApplicationModel _model; + ScreenplayEmission _emission; + RoundTripResult _roundTrip; + + void Establish() => + _model = LibraryApplication.Build() with + { + Slices = + [ + .. LibraryApplication.Slices(), + SliceModel.Empty("Library.Lending.Reserving.Rules", "Rules", SliceKind.StateChange) with + { + Constraints = [new UniqueEventConstraintModel("unique-book-reservation", "BookReserved")] + } + ] + }; + + void Because() + { + _emission = _emitter.Emit(_model, _options); + _roundTrip = RoundTrip.For(_emission.Application); + } + + [Fact] void should_name_the_constraint_by_the_words_the_source_stated() => _emission.Source.Contains("constraint UniqueBookReservation", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_not_run_the_words_together() => _emission.Source.Contains("uniquebookreservation", StringComparison.Ordinal).ShouldBeFalse(); + [Fact] void should_compile_without_errors() => _roundTrip.Errors.ShouldBeEmpty(); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundTrip.Reprinted.ShouldEqual(_roundTrip.Printed); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_model_with_nothing_configured_alongside_it.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_model_with_nothing_configured_alongside_it.cs new file mode 100644 index 000000000..12b016e42 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/a_model_with_nothing_configured_alongside_it.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Emission; +using Cratis.Arc.Screenplay.Library; +using Cratis.Arc.Screenplay.Model; + +namespace Cratis.Arc.Screenplay.for_ScreenplayEmitter.when_emitting; + +/// +/// A host emitting a model it already has configures nothing, and there is no compilation to name the document +/// after. The domain the model carries is the only answer available, so the emitter is where the options an emission +/// runs with are resolved - moving that any deeper meant it happened twice on the way through a generation. +/// +public class a_model_with_nothing_configured_alongside_it : given.an_emitter +{ + ApplicationModel _model; + ScreenplayEmission _emission; + + void Establish() => _model = LibraryApplication.Build() with { Domain = "Lending", Module = string.Empty }; + + void Because() => _emission = _emitter.Emit(_model, new ScreenplayOptions()); + + [Fact] void should_name_the_document_after_the_domain_the_model_carries() => _emission.Source.StartsWith("domain Lending", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_fall_back_to_the_domain_for_the_module() => _emission.Source.Contains("module Lending", StringComparison.Ordinal).ShouldBeTrue(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/an_application_importing_an_event.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/an_application_importing_an_event.cs new file mode 100644 index 000000000..c8ca10fd3 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayEmitter/when_emitting/an_application_importing_an_event.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Emission; +using Cratis.Arc.Screenplay.Library; +using Cratis.Arc.Screenplay.Model; +using Cratis.Arc.Screenplay.Verification; + +namespace Cratis.Arc.Screenplay.for_ScreenplayEmitter.when_emitting; + +/// +/// The Screenplay compiler reads the last segment of an import as the name of an event that is known, which is +/// exactly what a reactor observing an event of another bounded context needs. Compiling the document back is what +/// proves it - without the import the language reports the trigger as an event nothing declares. +/// +public class an_application_importing_an_event : given.an_emitter +{ + ApplicationModel _model; + ScreenplayEmission _emission; + RoundTripResult _roundTrip; + + void Establish() => + _model = LibraryApplication.Build() with + { + Imports = ["Partners.Contracts.InvitationToJoinAdaAccepted"], + Slices = + [ + .. LibraryApplication.Slices(), + SliceModel.Empty("Library.Admin.Invitations", "Invitations", SliceKind.Automation) with + { + Reactors = + [ + new ReactorModel( + "AcceptedInvitationProvisioner", + ["InvitationToJoinAdaAccepted"], + false, + "Admin/Invitations/Provision.cs") + ] + } + ] + }; + + void Because() + { + _emission = _emitter.Emit(_model, _options); + _roundTrip = RoundTrip.For(_emission.Application); + } + + [Fact] void should_declare_the_import() => _emission.Source.Contains("import Partners.Contracts.InvitationToJoinAdaAccepted", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_still_observe_it_from_the_reactor() => _emission.Source.Contains("on InvitationToJoinAdaAccepted", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_compile_without_errors() => _roundTrip.Errors.ShouldBeEmpty(); + [Fact] void should_leave_the_language_nothing_to_warn_about() => _roundTrip.Diagnostics.ShouldBeEmpty(); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundTrip.Reprinted.ShouldEqual(_roundTrip.Printed); + [Fact] void should_report_nothing_as_unmappable() => _emission.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_declaring_values_that_may_be_absent.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_declaring_values_that_may_be_absent.cs new file mode 100644 index 000000000..3166baf49 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_declaring_values_that_may_be_absent.cs @@ -0,0 +1,81 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay; + +namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating; + +/// +/// Whether a value may be absent is written in two different ways in C# - a reference type annotated as nullable and +/// a value type wrapped in Nullable - and in one way in Screenplay, a trailing question mark. Stripping the +/// wrapper without carrying what it said across leaves a document claiming every value is always there, which is a +/// shape the application does not have; carrying it across but naming the wrapper leaves a type nothing declares. +/// This asks the whole way through, from source to printed text, in every position a type reference is written in. +/// +public class from_source_declaring_values_that_may_be_absent : Specification +{ + const string Invoicing = """ + using System; + using System.Collections.Generic; + using Cratis.Arc.Commands.ModelBound; + using Cratis.Arc.Queries.ModelBound; + using Cratis.Chronicle.Events; + using Cratis.Concepts; + + namespace Library.Invoicing.Grouping; + + public record InvoiceGroupKey(string Value) : ConceptAs(Value); + + public record InvoiceNumber(int Value) : ConceptAs(Value); + + public enum InvoiceStanding + { + Draft, + Issued + } + + [EventType] + public record InvoiceGrouped(InvoiceGroupKey? Grouping, InvoiceStanding? Standing, IEnumerable Numbers); + + [Command] + public record GroupInvoice(InvoiceGroupKey? Reference, InvoiceStanding? Standing, IEnumerable Numbers) + { + public InvoiceGrouped Handle() => new(Reference, Standing, Numbers); + } + + [ReadModel] + public record InvoiceGroup + { + public string Id { get; init; } = string.Empty; + + public static IEnumerable GroupsByKey(InvoiceGroupKey? groupKey) => []; + } + """; + + static readonly (string Path, string Text)[] _sources = [("Library/Invoicing/Grouping/Grouping.cs", Invoicing)]; + + ScreenplayGenerationResult _result; + CompilationResult _compiled; + string _reprinted; + + void Because() + { + _result = new ScreenplayGenerator().Generate(Analyzed.Compile(_sources), new ScreenplayOptions()); + _compiled = new ScreenplayCompiler().Compile(_result.Source); + _reprinted = _compiled.Value is null ? string.Empty : new Cratis.Screenplay.Printing.ScreenplayPrinter().Print(_compiled.Value); + } + + bool Says(string text) => _result.Source.Contains(text, StringComparison.Ordinal); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty(); + [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue(); + [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty(); + [Fact] void should_print_the_same_text_on_a_second_pass() => _reprinted.ShouldEqual(_result.Source); + [Fact] void should_mark_an_optional_concept_on_a_command() => Says("reference InvoiceGroupKey?").ShouldBeTrue(); + [Fact] void should_mark_an_optional_concept_on_an_event() => Says("grouping InvoiceGroupKey?").ShouldBeTrue(); + [Fact] void should_mark_an_optional_enumeration() => Says("standing InvoiceStanding?").ShouldBeTrue(); + [Fact] void should_mark_a_collection_of_optional_values() => Says("numbers InvoiceNumber[]?").ShouldBeTrue(); + [Fact] void should_mark_an_optional_parameter_of_a_query() => Says("by groupKey InvoiceGroupKey?").ShouldBeTrue(); + [Fact] void should_never_name_the_wrapper_a_value_may_be_absent_behind() => Says("Nullable").ShouldBeFalse(); + [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs new file mode 100644 index 000000000..84393c044 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.for_ScreenplayNaming.when_making_a_declaration_name; + +/// +/// A runtime name is routinely written with separators - kebab case is idiomatic for a Chronicle constraint - and the +/// Screenplay identifier rules force some transformation of it. Deleting the separators throws away the word +/// boundaries the source stated, so each one is read as the boundary it is instead. +/// +public class from_names_carrying_separators : given.a_naming +{ + string _kebabCased; + string _snakeCased; + string _spaced; + string _dotted; + string _mixed; + string _leadingSeparator; + string _withoutASeparator; + string _alreadyCamelCased; + string _nothingButSeparators; + + void Because() + { + _kebabCased = _naming.ToDeclarationName("unique-timesheet-start"); + _snakeCased = _naming.ToDeclarationName("unique_invitation_email"); + _spaced = _naming.ToDeclarationName("Only one retirement"); + _dotted = _naming.ToDeclarationName("library.authors.registered"); + _mixed = _naming.ToDeclarationName("unique invitation-email.address"); + _leadingSeparator = _naming.ToDeclarationName("-overdue"); + _withoutASeparator = _naming.ToDeclarationName("AuthorRegistered"); + _alreadyCamelCased = _naming.ToDeclarationName("authorRegistered"); + _nothingButSeparators = _naming.ToDeclarationName("---"); + } + + [Fact] void should_pascal_case_a_kebab_cased_name() => _kebabCased.ShouldEqual("UniqueTimesheetStart"); + [Fact] void should_pascal_case_a_snake_cased_name() => _snakeCased.ShouldEqual("UniqueInvitationEmail"); + [Fact] void should_pascal_case_a_name_written_as_words() => _spaced.ShouldEqual("OnlyOneRetirement"); + [Fact] void should_pascal_case_a_dotted_name() => _dotted.ShouldEqual("LibraryAuthorsRegistered"); + [Fact] void should_read_every_kind_of_separator_in_one_name() => _mixed.ShouldEqual("UniqueInvitationEmailAddress"); + [Fact] void should_leave_the_casing_of_one_word_alone() => _leadingSeparator.ShouldEqual("overdue"); + [Fact] void should_leave_a_name_carrying_no_separator_exactly_as_it_is() => _withoutASeparator.ShouldEqual("AuthorRegistered"); + [Fact] void should_not_raise_a_name_that_carries_no_separator() => _alreadyCamelCased.ShouldEqual("authorRegistered"); + [Fact] void should_fall_back_for_a_name_with_no_word_in_it() => _nothingButSeparators.ShouldEqual("_"); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_written_two_ways_that_mean_one_thing.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_written_two_ways_that_mean_one_thing.cs new file mode 100644 index 000000000..7b7876b6b --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_written_two_ways_that_mean_one_thing.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.for_ScreenplayNaming.when_making_a_declaration_name; + +/// +/// An accented letter can be written as one character or as a letter followed by a combining mark, and Unicode calls +/// the two canonically equal - a compiler does too, and an editor is free to save either. A combining mark is not a +/// letter though, so composing the name has to happen before anything is stripped from it or one spelling keeps its +/// accent and the other quietly loses it, which is two documents for one application. +/// +public class from_names_written_two_ways_that_mean_one_thing : given.a_naming +{ + const string Composed = "Andr\u00E9Registered"; + const string Decomposed = "Andre\u0301Registered"; + + string _fromTheComposedName; + string _fromTheDecomposedName; + + void Because() + { + _fromTheComposedName = _naming.ToDeclarationName(Composed); + _fromTheDecomposedName = _naming.ToDeclarationName(Decomposed); + } + + [Fact] void should_spell_the_two_names_apart_to_begin_with() => Decomposed.ShouldNotEqual(Composed); + [Fact] void should_read_the_two_spellings_as_one_name() => _fromTheDecomposedName.ShouldEqual(_fromTheComposedName); + [Fact] void should_keep_the_accent_of_the_composed_spelling() => _fromTheComposedName.ShouldEqual(Composed); + [Fact] void should_keep_the_accent_of_the_decomposed_spelling() => _fromTheDecomposedName.ShouldEqual(Composed); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_property_name/from_names_carrying_separators.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_property_name/from_names_carrying_separators.cs new file mode 100644 index 000000000..ea68ac6ed --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_property_name/from_names_carrying_separators.cs @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.for_ScreenplayNaming.when_making_a_property_name; + +/// +/// Reading separators as word boundaries is what every identifier position gets, not only the ones a constraint is +/// named in. A property line is lower camel cased on top of that, which is the same transformation a name written +/// with words would be given by hand. +/// +public class from_names_carrying_separators : given.a_naming +{ + string _kebabCased; + string _snakeCased; + string _leadingUnderscore; + string _startingWithADigitAfterASeparator; + string _nothingButSeparators; + + void Because() + { + _kebabCased = _naming.ToPropertyName("unique-timesheet-start"); + _snakeCased = _naming.ToPropertyName("first_name"); + _leadingUnderscore = _naming.ToPropertyName("_isbn"); + _startingWithADigitAfterASeparator = _naming.ToPropertyName("first-1st"); + _nothingButSeparators = _naming.ToPropertyName("---"); + } + + [Fact] void should_lower_camel_a_kebab_cased_name() => _kebabCased.ShouldEqual("uniqueTimesheetStart"); + [Fact] void should_lower_camel_a_snake_cased_name() => _snakeCased.ShouldEqual("firstName"); + [Fact] void should_drop_a_leading_underscore() => _leadingUnderscore.ShouldEqual("isbn"); + [Fact] void should_join_a_word_starting_with_a_digit() => _startingWithADigitAfterASeparator.ShouldEqual("first1st"); + [Fact] void should_fall_back_for_a_name_with_no_word_in_it() => _nothingButSeparators.ShouldEqual("value"); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayOptions/when_resolving_defaults/and_they_are_already_resolved.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayOptions/when_resolving_defaults/and_they_are_already_resolved.cs new file mode 100644 index 000000000..53859f95b --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayOptions/when_resolving_defaults/and_they_are_already_resolved.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.for_ScreenplayOptions.when_resolving_defaults; + +/// +/// A generation resolves for the analysis half against the assembly it is reading, and the emission half resolves +/// against the domain of the model it is handed. Both run on the way through one generation, so resolving what is +/// already resolved has to answer the same thing however a second fallback would have answered - otherwise which +/// entry point was used decides what the document is called. +/// +public class and_they_are_already_resolved : Specification +{ + ScreenplayOptions _resolved; + ScreenplayOptions _resolvedAgain; + + void Because() + { + _resolved = new ScreenplayOptions().WithDefaults("Library"); + _resolvedAgain = _resolved.WithDefaults("SomethingElse"); + } + + [Fact] void should_answer_the_same_thing_a_second_time() => _resolvedAgain.ShouldEqual(_resolved); + [Fact] void should_not_take_the_second_fallback_for_the_domain() => _resolvedAgain.Domain.ShouldEqual("Library"); + [Fact] void should_not_take_the_second_fallback_for_the_module() => _resolvedAgain.Module.ShouldEqual("Library"); +} diff --git a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs index b3bdf0f82..d33d66f6a 100644 --- a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs +++ b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Arc.Screenplay.Analysis.Events; using Cratis.Arc.Screenplay.Analysis.Policies; using Cratis.Arc.Screenplay.Analysis.Screens; using Cratis.Arc.Screenplay.Analysis.Slices; @@ -56,7 +57,7 @@ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptio ConceptValidations.Link(catalog, readers); readers.AggregateRoots.Report(diagnostics); ReportTypesTheDocumentCannotName(readers, diagnostics, compilation.AssemblyName); - ReportEventsFromOutside(slices, diagnostics); + var imports = ExternalEvents.Resolve(compilation, slices, diagnostics); ReportNamespacesWithoutStructure(slices, diagnostics, options.SegmentsToSkip ?? 0); if (slices.Count == 0 && !failedToCompile) @@ -73,7 +74,10 @@ public ApplicationModelAnalysis Analyze(Compilation compilation, ScreenplayOptio options.Module ?? options.Domain ?? ScreenplayOptions.DefaultName, readers.Types.Concepts, new PolicyCatalog(compilation, diagnostics).Declare(slices.SelectMany(AuthorizationsIn)), - slices), + slices) + { + Imports = imports + }, diagnostics.All); } @@ -144,31 +148,6 @@ static void ReportTypesTheDocumentCannotName(ArtifactReaders readers, Screenplay } } - /// - /// Reports every event the application refers to but does not declare. - /// - /// The slices to check. - /// The diagnostics to report to. - /// - /// An event living in a referenced package is real, but nothing in the compilation declares it, so the document - /// would refer to something it never introduces. Saying so is better than inventing a declaration for it. - /// - static void ReportEventsFromOutside(IReadOnlyList slices, ScreenplayDiagnostics diagnostics) - { - var declared = slices.SelectMany(_ => _.Events).Select(_ => _.Name).ToHashSet(StringComparer.Ordinal); - - foreach (var slice in slices) - { - foreach (var name in ReferencedEvents(slice).Where(_ => !declared.Contains(_)).Order(StringComparer.Ordinal)) - { - diagnostics.Warning( - ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation, - $"'{name}' is referred to but declared outside the compilation, so the document refers to an event it never introduces", - slice.Namespace); - } - } - } - /// /// Reports a namespace that carries nothing to arrange the document by. /// @@ -203,28 +182,4 @@ static void ReportNamespacesWithoutStructure( /// The number of segments. static int Segments(string @namespace, int segmentsToSkip) => @namespace.Split('.', StringSplitOptions.RemoveEmptyEntries).Length - segmentsToSkip; - - /// - /// Gets the names of every event a slice refers to. - /// - /// The slice to read. - /// The names, distinct. - static IEnumerable ReferencedEvents(SliceModel slice) => - slice.Commands.SelectMany(_ => _.Produces).Select(_ => _.EventName) - .Concat(slice.Reactors.SelectMany(_ => _.ObservedEvents)) - .Concat(slice.Constraints.SelectMany(EventsOf)) - .Concat(ProjectionEvents.In(slice.Projection)) - .Distinct(StringComparer.Ordinal); - - /// - /// Gets the names of the events a constraint refers to. - /// - /// The constraint to read. - /// The names. - static IEnumerable EventsOf(ConstraintModel constraint) => constraint switch - { - UniquePropertyConstraintModel unique => [unique.EventName], - UniqueEventConstraintModel unique => [unique.EventName], - _ => [] - }; } diff --git a/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs b/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs new file mode 100644 index 000000000..5618d27ca --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Events/ExternalEvents.cs @@ -0,0 +1,135 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Model; +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis.Events; + +/// +/// Resolves the events an application refers to but does not declare itself. +/// +/// +/// A reactor observing what a sibling bounded context publishes refers to an event living in a referenced package. +/// The event is real and the document has to name it, but nothing in the compilation declares it, so a document +/// stating only the reference refers to something it never introduces. Screenplay has the construct for exactly this +/// situation - an import states the dependency outright and registers the name as an event that is known - so +/// the one that can be found is imported and only the one that cannot is reported. +/// +public static class ExternalEvents +{ + /// + /// Resolves the events the application refers to without declaring them, reporting every one nothing declares. + /// + /// The compilation being analyzed. + /// The slices to read. + /// The diagnostics to report to. + /// The fully qualified name of every event to import, ordered. + public static IReadOnlyList Resolve( + Compilation compilation, + IReadOnlyList slices, + ScreenplayDiagnostics diagnostics) + { + var declared = slices.SelectMany(_ => _.Events).Select(_ => _.Name).ToHashSet(StringComparer.Ordinal); + var undeclared = slices.SelectMany(ReferredToBy).Where(_ => !declared.Contains(_)).ToHashSet(StringComparer.Ordinal); + var imported = DeclaredByAReference(compilation, undeclared); + + foreach (var slice in slices) + { + foreach (var name in ReferredToBy(slice) + .Where(_ => !declared.Contains(_) && !imported.ContainsKey(_)) + .Order(StringComparer.Ordinal)) + { + diagnostics.Warning( + ScreenplayDiagnosticCodes.EventDeclaredOutsideCompilation, + $"'{name}' is referred to but nothing declares it, so the document refers to an event it never introduces", + slice.Namespace); + } + } + + return [.. imported.Values.Order(StringComparer.Ordinal)]; + } + + /// + /// Gets the names of every event a slice refers to. + /// + /// The slice to read. + /// The names, distinct. + public static IEnumerable ReferredToBy(SliceModel slice) => + slice.Commands.SelectMany(_ => _.Produces).Select(_ => _.EventName) + .Concat(slice.Reactors.SelectMany(_ => _.ObservedEvents)) + .Concat(slice.Constraints.SelectMany(EventsOf)) + .Concat(ProjectionEvents.In(slice.Projection)) + .Distinct(StringComparer.Ordinal); + + /// + /// Gets the names of the events a constraint refers to. + /// + /// The constraint to read. + /// The names. + static IEnumerable EventsOf(ConstraintModel constraint) => constraint switch + { + UniquePropertyConstraintModel unique => [unique.EventName], + UniqueEventConstraintModel unique => [unique.EventName], + _ => [] + }; + + /// + /// Finds the event a referenced assembly declares under each of a set of names. + /// + /// The compilation being analyzed. + /// The names to look for. + /// The fully qualified name each one was found under, keyed by the name it is referred to by. + /// + /// Every assembly says which type names it holds without any of them being read, so the search only ever opens + /// the few that could answer. Assemblies and namespaces are both walked in name order and the first declaration + /// of a name wins, because two packages declaring an event under one name is a document that would otherwise + /// depend on the order the compiler happened to hand its references over. + /// + static Dictionary DeclaredByAReference(Compilation compilation, HashSet names) + { + var found = new Dictionary(StringComparer.Ordinal); + if (names.Count == 0) + { + return found; + } + + foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols + .OrderBy(_ => _.Identity.GetDisplayName(), StringComparer.Ordinal)) + { + if (names.Any(assembly.TypeNames.Contains)) + { + Collect(assembly.GlobalNamespace, names, found); + } + } + + return found; + } + + /// + /// Collects every event declared under one of a set of names within a namespace and those nested in it. + /// + /// The namespace to walk. + /// The names to look for. + /// The declarations found so far. + static void Collect(INamespaceSymbol @namespace, HashSet names, Dictionary found) + { + foreach (var name in names.Where(_ => !found.ContainsKey(_)).Order(StringComparer.Ordinal)) + { + var declaration = @namespace.GetTypeMembers(name) + .Where(EventReader.IsEvent) + .OrderBy(_ => _.ToDisplayString(), StringComparer.Ordinal) + .FirstOrDefault(); + + if (declaration is not null) + { + found[name] = declaration.ToDisplayString(); + } + } + + foreach (var nested in @namespace.GetNamespaceMembers().OrderBy(_ => _.Name, StringComparer.Ordinal)) + { + Collect(nested, names, found); + } + } +} diff --git a/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs b/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs new file mode 100644 index 000000000..7e51b047d --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Screenplay.Analysis; + +/// +/// Recognizes the source a build wrote rather than a person. +/// +/// +/// A compilation loaded from a project carries the output of every source generator that ran, emitted to disk under +/// the intermediate folder. Those files declare real members of real types, so they belong in the model - but they +/// say nothing whatsoever about where the application is written, and letting them answer that question puts a build +/// folder among the folders a slice is said to live in. What follows from that is a slice reported as spread over +/// two folders when it sits in one, and a screen scan widened to a tree no screen was ever written in. +/// +public static class GeneratedSource +{ + /// + /// The folders a build writes its output to. + /// + public static readonly string[] OutputFolders = ["obj", "bin"]; + + /// + /// The suffixes a generated file is conventionally named with. + /// + public static readonly string[] Suffixes = [".g.cs", ".g.i.cs", ".generated.cs"]; + + /// + /// Determines whether a path names source a build wrote. + /// + /// The path to check. + /// True when the path names generated source. + public static bool Is(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var normalized = path.Replace('\\', '/'); + + return Array.Exists(Suffixes, _ => normalized.EndsWith(_, StringComparison.OrdinalIgnoreCase)) || + normalized + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .SkipLast(1) + .Any(segment => Array.Exists(OutputFolders, _ => string.Equals(segment, _, StringComparison.OrdinalIgnoreCase))); + } + + /// + /// Removes the paths naming source a build wrote, keeping every one of them when none is left otherwise. + /// + /// The paths to filter. + /// The paths a person wrote. + /// + /// Falling back to the whole set is what keeps a compilation of nothing but generated source answering at all. + /// Where a person wrote none of it there is no better answer available, and an empty one would leave every path + /// in the document written against the machine that generated it. + /// + public static IReadOnlyList Excluded(IEnumerable paths) + { + var all = paths.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _!).ToList(); + var written = all.FindAll(_ => !Is(_)); + + return written.Count > 0 ? written : all; + } +} diff --git a/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs b/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs index cd562c1ef..3ad7ec40d 100644 --- a/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs +++ b/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs @@ -18,6 +18,17 @@ namespace Cratis.Arc.Screenplay.Analysis.Queries; /// public class QueryReader(TypeRegistry types, ScreenplayDiagnostics diagnostics) { + /// + /// The types a query is handed by the host it runs in rather than by the caller it answers. + /// + public static readonly string[] InfrastructureTypes = + [ + WellKnownTypeNames.CancellationToken, + WellKnownTypeNames.QueryContext, + WellKnownTypeNames.Paging, + WellKnownTypeNames.Sorting + ]; + /// /// Determines whether a type is a model-bound read model. /// @@ -77,9 +88,14 @@ [.. parameters.Where(_ => !SymbolEqualityComparer.Default.Equals(_, required)).S /// True when the parameter is input rather than infrastructure. /// /// An interface parameter is a collaborator the host injects, never something a caller can send, so it is not - /// part of the query's shape. + /// part of the query's shape. Being an interface is not the whole of that though: a cancellation token, the page + /// asked for and the order asked for are all filled in by the host from the request rather than sent as + /// arguments, and all three are values rather than interfaces. Stating one as caller input puts a parameter in + /// the document that no caller sends, typed by a name the document never declares. /// - static bool IsInput(IParameterSymbol parameter) => parameter.Type.TypeKind != TypeKind.Interface; + static bool IsInput(IParameterSymbol parameter) => + parameter.Type.TypeKind != TypeKind.Interface && + !Array.Exists(InfrastructureTypes, parameter.Type.Is); /// /// Determines whether a method returns the read model declaring it. diff --git a/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs index bb0c9b9c5..84f6718d8 100644 --- a/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs +++ b/Source/DotNET/Screenplay/Analysis/Screens/ScreenImports.cs @@ -35,7 +35,7 @@ public static IReadOnlyCollection In(string? text) return names; } - foreach (var statement in StatementRegex().Matches(text).Cast()) + foreach (var statement in StatementRegex().Matches(WithoutComments(text)).Cast()) { var clause = statement.Groups["clause"].Value; if (!IsRelative(statement.Groups["module"].Value) || IsTypeOnly(clause)) @@ -52,6 +52,20 @@ public static IReadOnlyCollection In(string? text) return names; } + /// + /// Removes everything a comment holds, leaving the lines around it where they were. + /// + /// The text of the file. + /// The text with nothing commented out left in it. + /// + /// An import that has been commented out is an import the file does not make, and binding a screen to a query it + /// no longer calls is a plain untruth of exactly the kind this reader exists to avoid. The line breaks a comment + /// spans are kept, because a statement is recognized by starting a line and joining it to the line before would + /// hide a real import rather than a commented one. + /// + static string WithoutComments(string text) => + CommentRegex().Replace(text, match => new string('\n', match.Value.Count(_ => _ == '\n'))); + /// /// Determines whether a module specifier names a file sitting alongside the one importing it. /// @@ -114,6 +128,13 @@ static IEnumerable NamedIn(string clause) return exported is not null && IdentifierRegex().IsMatch(exported) ? exported : null; } + /// + /// Gets the pattern a comment has to match. + /// + /// The compiled regular expression. + [GeneratedRegex(@"/\*[\s\S]*?\*/|//[^\r\n]*", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + private static partial Regex CommentRegex(); + /// /// Gets the pattern an import statement naming a module has to match. /// diff --git a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs index 2d999286e..1fce9e9e6 100644 --- a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs +++ b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs @@ -12,11 +12,17 @@ namespace Cratis.Arc.Screenplay.Analysis.Screens; /// This is what a syntax tree buys that metadata never could - a real path - and it is the whole reason a screen can /// be recovered at all. The vertical slice convention puts the file realizing a screen next to the source of the /// slice it belongs to, so where the source lives is where to look. +/// +/// What a source generator emitted is not where the source lives. A logging generator writing partial members into +/// the intermediate folder of the build contributes symbols to the slice and nothing at all to the question of where +/// its screens are, so a slice sitting in one folder would otherwise be reported as spread over two and its screens +/// looked for in a build folder. +/// /// public static class SliceDirectories { /// - /// Gets the directories a set of types is declared across. + /// Gets the directories a set of types is written across. /// /// The types to locate. /// The directories, distinct and ordered. @@ -25,7 +31,7 @@ public static IReadOnlyList Of(IEnumerable types) => .. types .SelectMany(_ => _.DeclaringSyntaxReferences) .Select(_ => _.SyntaxTree.FilePath) - .Where(_ => !string.IsNullOrWhiteSpace(_)) + .Where(_ => !string.IsNullOrWhiteSpace(_) && !GeneratedSource.Is(_)) .Select(ScreenFiles.DirectoryOf) .Where(_ => _.Length > 0) .Distinct(StringComparer.Ordinal) diff --git a/Source/DotNET/Screenplay/Analysis/SourcePaths.cs b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs index 284e94565..774887918 100644 --- a/Source/DotNET/Screenplay/Analysis/SourcePaths.cs +++ b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs @@ -34,10 +34,15 @@ public class SourcePaths(string root) /// belongs to the project too and widens the root; a file sharing nothing with them but the file system root /// belongs to somebody else and is left out of the question entirely. /// + /// + /// What a source generator emitted is left out of that vote. It is compiled from the intermediate folder of the + /// build rather than from anywhere the application is written, so counting it moves the answer towards a folder + /// nobody committed a line to. + /// /// public static SourcePaths For(Compilation compilation, ArtifactCatalog catalog) { - var declared = DirectoriesOf(catalog.Types.Select(_ => _.SourceFilePath())); + var declared = DirectoriesOf(GeneratedSource.Excluded(catalog.Types.Select(_ => _.SourceFilePath()))); var anchor = DeepestSharedBy(declared); var project = Rooted(declared, anchor); var root = Rooted(DirectoriesOf(compilation.SyntaxTrees.Select(_ => _.FilePath)), project); diff --git a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs index 7c64c2bbe..d2ac2f324 100644 --- a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs +++ b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs @@ -55,27 +55,27 @@ .. _concepts.Values public TypeReferenceModel Resolve(ITypeSymbol type) { var optional = false; - var current = Unwrap(type, ref optional); - var collection = CollectionElements.ElementOf(current); - if (collection is not null) - { - current = Unwrap(collection, ref optional); - } + var collection = false; - return new(NameOf(current), collection is not null, optional); + return new(NameOf(Underlying(type, ref optional, ref collection)), collection, optional); } /// /// Records that a value of a concept carries personally identifiable information. /// /// The type of the value. + /// + /// The concept a value is marked under has to be the one it is referenced under, or the mark lands on a name no + /// concept is declared with and the document says a value is not sensitive while the runtime encrypts it. Both + /// therefore strip the same wrappers - a collection of an optional concept says one thing about the value and + /// three things about how many there are and whether it may be absent. + /// public void MarkAsPii(ITypeSymbol type) { var optional = false; - var current = Unwrap(type, ref optional); - var collection = CollectionElements.ElementOf(current); + var collection = false; - _pii.Add((collection ?? current).Name); + _pii.Add(Underlying(type, ref optional, ref collection).Name); } /// @@ -94,6 +94,27 @@ public void AddValidations(string conceptName, IEnumerable declared.AddRange(rules); } + /// + /// Strips everything a value is wrapped in that says how many there are or whether it may be absent. + /// + /// The type to strip. + /// Set when a wrapper said the value may be absent. + /// Set when the value is a collection of what is left. + /// The type of the value itself. + static ITypeSymbol Underlying(ITypeSymbol type, ref bool optional, ref bool collection) + { + var current = Unwrap(type, ref optional); + var element = CollectionElements.ElementOf(current); + if (element is null) + { + return current; + } + + collection = true; + + return Unwrap(element, ref optional); + } + /// /// Strips the wrappers that only say whether a value may be absent. /// diff --git a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs index 4c66b0be2..62fc50fb4 100644 --- a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs +++ b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs @@ -116,4 +116,16 @@ public static class WellKnownTypeNames /// The transport level result carrying the value a controller method really returns. public const string ActionResultOfT = "Microsoft.AspNetCore.Mvc.ActionResult`1"; + + /// The token a query is handed to observe cancellation through. + public const string CancellationToken = "System.Threading.CancellationToken"; + + /// The everything a query is performed with, which the host fills in. + public const string QueryContext = "Cratis.Arc.Queries.QueryContext"; + + /// The page of a result a query is performed for, which the host fills in from the request. + public const string Paging = "Cratis.Arc.Queries.Paging"; + + /// The order a result is returned in, which the host fills in from the request. + public const string Sorting = "Cratis.Arc.Queries.Sorting"; } diff --git a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs index a85102ec3..403ca5534 100644 --- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs +++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs @@ -41,20 +41,25 @@ public class ApplicationSyntaxBuilder(IScreenplayNaming naming, ScreenplayDiagno /// Builds the document. /// /// The model to build from. - /// The options to build with. + /// The options to build with, already resolved. /// The . + /// + /// The options arrive resolved rather than being resolved here. What a name falls back to depends on how the + /// document was asked for - the assembly being analyzed when a generation asked for it, the domain of the model + /// when a host emitted one it already had - so resolving where neither of those is known meant resolving a + /// second time against a different answer and letting one of them quietly win. + /// public ApplicationSyntax Build(ApplicationModel model, ScreenplayOptions options) { - var resolved = options.WithDefaults(model.Domain); - var domain = ToName(model.Domain, resolved.Domain); - var module = ToName(model.Module, resolved.Module); + var domain = ToName(model.Domain, options.Domain); + var module = ToName(model.Module, options.Module); - var modules = BuildModules(model, module, resolved.SegmentsToSkip ?? 0); + var modules = BuildModules(model, module, options.SegmentsToSkip ?? 0); var concepts = new ConceptSyntaxBuilder(naming, _validations, diagnostics, _names).Build(model.Concepts); var policies = new PolicySyntaxBuilder(naming).Build(model.Policies, _authorize.Referenced); return new( - [], + [.. BuildImports(model)], [.. concepts], [.. policies], [.. modules], @@ -62,6 +67,32 @@ public ApplicationSyntax Build(ApplicationModel model, ScreenplayOptions options new DomainSyntax(domain, SourceLocation.Start)); } + /// + /// Builds the imports naming every event the application refers to without declaring it. + /// + /// The model to build from. + /// The imports, ordered. + /// + /// The Screenplay compiler reads the last segment of an import as the name of an event that is known, so the + /// segment naming the event is written exactly as every reference to it is written - through the same conversion + /// - or the document would import one name and refer to another. + /// + IEnumerable BuildImports(ApplicationModel model) => + model.Imports + .Select(ToQualifiedName) + .Where(_ => _.Length > 0) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Select(_ => new ImportSyntax(_, SourceLocation.Start)); + + /// + /// Sanitizes every segment of a dotted name. + /// + /// The name to sanitize. + /// The sanitized name. + string ToQualifiedName(string name) => + string.Join('.', name.Split('.', StringSplitOptions.RemoveEmptyEntries).Select(naming.ToDeclarationName)); + /// /// Builds the modules holding every slice that declares something. /// diff --git a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs index f46ecbd06..d856dad01 100644 --- a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs +++ b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs @@ -127,10 +127,25 @@ static string OnOneLine(string value) } /// - /// Strips everything that is not a valid identifier character, including generic type arity suffixes. + /// Reduces a name to the identifier the Screenplay grammar accepts, including generic type arity suffixes. /// /// The name to sanitize. /// The sanitized name. + /// + /// An identifier is [A-Za-z_]\w*, so a name carrying separators has to be transformed - and a runtime name + /// is routinely written with them, kebab case being idiomatic for a Chronicle constraint. Deleting them is the + /// least readable answer available: unique-timesheet-start becomes one run-together word and the word + /// boundaries the source stated are thrown away. Each separator is therefore read as the boundary it is and the + /// words either side of it are joined in Pascal case, which is what the grammar accepts and what a reader would + /// have written by hand. A name carrying no separator at all is left exactly as it is, because its casing is + /// already whatever the application chose. + /// + /// Composing the name is the first thing done to it rather than the last. An accented letter can be written as + /// one character or as a letter followed by a combining mark, and the two are canonically the same name - but a + /// combining mark is not a letter, so stripping before composing keeps one of them and quietly unaccents the + /// other. Two source files saying the same thing would then produce two different documents. + /// + /// static string Sanitize(string name) { if (string.IsNullOrWhiteSpace(name)) @@ -138,18 +153,59 @@ static string Sanitize(string name) return string.Empty; } - var backTick = name.IndexOf('`', StringComparison.Ordinal); - var candidate = backTick > 0 ? name[..backTick] : name; - var builder = new StringBuilder(candidate.Length); + var composed = name.Normalize(NormalizationForm.FormC); + var backTick = composed.IndexOf('`', StringComparison.Ordinal); + var words = WordsIn(backTick > 0 ? composed[..backTick] : composed); + + return words.Count switch + { + 0 => string.Empty, + 1 => words[0], + _ => string.Concat(words.Select(Capitalized)) + }; + } + + /// + /// Splits a name into the words the characters that cannot be written in an identifier separate. + /// + /// The name to split. + /// The words, in order, none of them empty. + /// + /// An underscore separates words as surely as a hyphen does, and is treated as one even though the grammar would + /// accept it, so that every way of writing a name apart comes out the same way. + /// + static List WordsIn(string name) + { + var words = new List(); + var word = new StringBuilder(name.Length); - foreach (var character in candidate) + foreach (var character in name) { - if (char.IsLetterOrDigit(character) || character == '_') + if (char.IsLetterOrDigit(character)) { - builder.Append(character); + word.Append(character); + continue; } + + if (word.Length > 0) + { + words.Add(word.ToString()); + word.Clear(); + } + } + + if (word.Length > 0) + { + words.Add(word.ToString()); } - return builder.ToString().Normalize(NormalizationForm.FormC); + return words; } + + /// + /// Raises the first character of a word, leaving the rest of it as the application wrote it. + /// + /// The word to raise. + /// The raised word. + static string Capitalized(string word) => $"{char.ToUpperInvariant(word[0])}{word[1..]}"; } diff --git a/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs b/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs index 7b2691c12..0c2c85ed0 100644 --- a/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs +++ b/Source/DotNET/Screenplay/Emission/ScreenplayEmitter.cs @@ -23,10 +23,16 @@ public ScreenplayEmitter() } /// + /// + /// This is where the options an emission runs with are resolved, and the only place. What a name falls back to + /// is a question only an entry point can answer - a host that emits a model it already holds has nothing to fall + /// back on but the domain of that model - so resolving deeper down meant resolving twice on the way through a + /// generation, against two answers that only happen to agree. + /// public ScreenplayEmission Emit(ApplicationModel model, ScreenplayOptions options) { var diagnostics = new ScreenplayDiagnostics(); - var application = new ApplicationSyntaxBuilder(naming, diagnostics).Build(model, options); + var application = new ApplicationSyntaxBuilder(naming, diagnostics).Build(model, options.WithDefaults(model.Domain)); return new(printer.Print(application), application, diagnostics.All); } diff --git a/Source/DotNET/Screenplay/Model/ApplicationModel.cs b/Source/DotNET/Screenplay/Model/ApplicationModel.cs index 7b7f4d8f9..146ad63a2 100644 --- a/Source/DotNET/Screenplay/Model/ApplicationModel.cs +++ b/Source/DotNET/Screenplay/Model/ApplicationModel.cs @@ -22,4 +22,13 @@ public record ApplicationModel( /// Represents an application that declares nothing at all. /// public static readonly ApplicationModel Empty = new(string.Empty, string.Empty, [], [], []); + + /// + /// Gets the fully qualified name of every event the application refers to that something it references declares. + /// + /// + /// An event a sibling bounded context publishes is real, but nothing here declares it, so the document states the + /// dependency outright rather than referring to a name it never introduces. + /// + public IEnumerable Imports { get; init; } = []; } diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs index f7dc7eddc..f206ea2ab 100644 --- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs @@ -112,8 +112,14 @@ public static class ScreenplayDiagnosticCodes public const string ReducerWithoutCounterpart = "SP0020"; /// - /// An event referenced by the application is declared outside the compilation being analyzed. + /// An event the application refers to is declared neither by it nor by anything it references. /// + /// + /// An event a referenced package declares is real and can be stated - an import names it and the compiler + /// then reads it as an event that is known - so that case is written rather than reported. This is what is left: + /// a name nothing at all resolves to, where inventing a declaration would describe an event the application does + /// not have and staying silent would leave a document referring to something it never introduces. + /// public const string EventDeclaredOutsideCompilation = "SP0021"; /// @@ -174,6 +180,11 @@ public static class ScreenplayDiagnosticCodes /// public const string ScreenStructureNotInferred = "SP0028"; + // SP0029 is deliberately unused. It was assigned to a code that was retired before the first release, and the + // sequence is left with the gap rather than closed up: a code is what a consumer suppresses and groups on, so + // handing this number to something else would silently change what an existing suppression means. Nothing is to + // be declared with it. + /// /// A type is referred to by a name that does not say what it is, because Screenplay cannot express it. ///