From 4cc4175e54b097ae614f5b32016f9bae84dbb6f8 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 21 Aug 2026 03:26:25 +0200 Subject: [PATCH 1/7] Add Marten and Critter Stack Screenplay providers Route source generation through built-in Arc, Marten, or combined Critter Stack packages while reusing one MSBuild workspace. Align Screenplay, Stage, Prologue, and Arc package generations to avoid runtime syntax-tree incompatibilities. --- Directory.Packages.props | 21 +++-- .../a_marten_application_built_from_source.cs | 65 +++++++++++++++ .../when_generating/from_marten_source.cs | 20 +++++ .../and_generation_options_are_given.cs | 6 ++ Source/Cli/Cli.csproj | 3 + .../CritterStackScreenplayGeneration.cs | 83 +++++++++++++++++++ .../Screenplay/GenerateScreenplayCommand.cs | 9 +- .../Screenplay/GenerateScreenplaySettings.cs | 9 +- .../Screenplay/IScreenplayGeneration.cs | 6 +- .../ProviderScreenplayGeneration.cs | 72 ++++++++++++++++ .../Screenplay/ScreenplayCompilationLoader.cs | 29 +++++-- .../Screenplay/ScreenplayDiagnosticCodes.cs | 5 ++ .../Screenplay/ScreenplayGenerationOptions.cs | 10 ++- .../Screenplay/ScreenplayGenerations.cs | 2 +- .../Screenplay/ScreenplayProviders.cs | 39 +++++++++ 15 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 Source/Cli.Specs/for_CritterStackScreenplayGeneration/given/a_marten_application_built_from_source.cs create mode 100644 Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/from_marten_source.cs create mode 100644 Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs create mode 100644 Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayProviders.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 6d5f641..cb7f40f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,19 +5,22 @@ - + + + + - - - - - - - - + + + + + + + + diff --git a/Source/Cli.Specs/for_CritterStackScreenplayGeneration/given/a_marten_application_built_from_source.cs b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/given/a_marten_application_built_from_source.cs new file mode 100644 index 0000000..fcdf88a --- /dev/null +++ b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/given/a_marten_application_built_from_source.cs @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_CritterStackScreenplayGeneration.given; + +public class a_marten_application_built_from_source : Specification +{ + protected const string ProjectName = "Banking"; + + static readonly string Source = string.Join( + '\n', + [ + "namespace Marten", + "{", + " public interface IDocumentStore;", + " public class StoreOptions", + " {", + " public Marten.Events.Projections.ProjectionOptions Projections { get; } = new();", + " }", + "}", + "namespace Marten.Events.Projections", + "{", + " public enum SnapshotLifecycle { Inline }", + " public class ProjectionOptions", + " {", + " public void Snapshot(SnapshotLifecycle lifecycle) { }", + " }", + "}", + "namespace Banking", + "{", + " public record AccountOpened(System.Guid AccountId);", + " public class Account", + " {", + " public System.Guid Id { get; set; }", + " public void Apply(AccountOpened opened) { }", + " }", + " public static class Configuration", + " {", + " public static void Configure(Marten.StoreOptions options) =>", + " options.Projections.Snapshot(Marten.Events.Projections.SnapshotLifecycle.Inline);", + " }", + "}" + ]); + + protected LoadedCompilation Loaded { get; private set; } = null!; + + void Establish() => Loaded = new( + [ + CSharpCompilation.Create( + ProjectName, + [CSharpSyntaxTree.ParseText(Source, path: "/workspace/Banking/Account.cs")], + References(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + ], + [ProjectName], + []); + + static IEnumerable References() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(_ => MetadataReference.CreateFromFile(_)); +} diff --git a/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/from_marten_source.cs b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/from_marten_source.cs new file mode 100644 index 0000000..cb600bd --- /dev/null +++ b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/from_marten_source.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_CritterStackScreenplayGeneration.when_generating; + +public class from_marten_source : given.a_marten_application_built_from_source +{ + GeneratedScreenplay _result = null!; + + void Because() => _result = CritterStackScreenplayGeneration.GenerateFrom( + Loaded, + "/workspace/Banking/Banking.csproj", + ScreenplayGenerationOptions.Default with { Provider = ScreenplayProviders.Marten }); + + [Fact] void should_report_the_project() => _result.Projects.ShouldContainOnly(ProjectName); + [Fact] void should_generate_the_read_model() => _result.Source.ShouldContain("readmodel Account"); + [Fact] void should_generate_the_event() => _result.Source.ShouldContain("event AccountOpened"); + [Fact] void should_generate_the_reducer() => _result.Source.ShouldContain("reducer AccountSnapshot => Account"); + [Fact] void should_report_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs index 135b77c..1aa747a 100644 --- a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs @@ -11,6 +11,7 @@ void Establish() _settings.Domain = "Library"; _settings.Module = "Lending"; _settings.SkipSegments = 2; + _settings.Provider = ScreenplayProviders.CritterStack; } async Task Because() => await Execute(); @@ -20,6 +21,11 @@ [Fact] void should_pass_them_to_the_generation() => _generation.Received(1).Gene Arg.Is(options => options.Domain == "Library" && options.Module == "Lending" && options.SegmentsToSkip == 2), Arg.Any()); + [Fact] void should_pass_the_provider_to_the_generation() => _generation.Received(1).Generate( + Arg.Any(), + Arg.Is(options => options.Provider == ScreenplayProviders.CritterStack), + Arg.Any()); + [Fact] void should_leave_the_modules_named_by_one_name() => _generation.Received(1).Generate( Arg.Any(), Arg.Is(options => !options.ModulesFromNamespaceRoots), diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 19704da..29ac5f2 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -30,6 +30,9 @@ + + + diff --git a/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs new file mode 100644 index 0000000..1e486a3 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs @@ -0,0 +1,83 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.CritterStack.Screenplay; +using Cratis.Screenplay.Generation; +using Cratis.Screenplay.Generation.DotNet; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Generates Screenplay documents from Marten and Wolverine source code. +/// +public sealed class CritterStackScreenplayGeneration : IScreenplayGeneration +{ + /// + public async Task Generate( + string targetPath, + ScreenplayGenerationOptions options, + CancellationToken cancellationToken) => + GenerateFrom( + await ScreenplayCompilationLoader.Load(targetPath, includeAllProjects: true, cancellationToken), + targetPath, + options); + + /// + /// Generates from compilations that have already been loaded. + /// + /// The loaded project compilations. + /// The solution or project path. + /// Generation options. + /// The generated Screenplay. + internal static GeneratedScreenplay GenerateFrom( + LoadedCompilation loaded, + string targetPath, + ScreenplayGenerationOptions options) + { + if (loaded.Compilations.Count == 0) + { + return new GeneratedScreenplay(string.Empty, loaded.Diagnostics); + } + + var sourceRoot = Path.GetDirectoryName(targetPath); + var projects = loaded.Compilations + .Select((compilation, index) => new DotNetProjectCompilation + { + Name = loaded.ProjectNames[index], + ProjectPath = targetPath, + SourceRoot = sourceRoot, + Compilation = compilation + }) + .ToArray(); + var result = new CritterStackScreenplayGenerator().Generate( + projects, + new CritterStackScreenplayOptions + { + Domain = options.Domain ?? DomainFrom(targetPath, loaded), + Module = options.Module, + NamespaceSegmentsToSkip = options.SegmentsToSkip ?? 0 + }); + + return new GeneratedScreenplay( + result.Source, + [.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)]) + { + Projects = loaded.ProjectNames + }; + } + + static string? DomainFrom(string targetPath, LoadedCompilation loaded) => + loaded.Compilations.Count > 1 ? Path.GetFileNameWithoutExtension(targetPath) : loaded.ProjectNames[0]; + + static ScreenplayDiagnostic Map(GenerationDiagnostic diagnostic) => new( + diagnostic.Severity switch + { + GenerationDiagnosticSeverity.Information => ScreenplayDiagnosticSeverity.Information, + GenerationDiagnosticSeverity.Warning => ScreenplayDiagnosticSeverity.Warning, + GenerationDiagnosticSeverity.Error => ScreenplayDiagnosticSeverity.Error, + _ => ScreenplayDiagnosticSeverity.Error + }, + diagnostic.Code, + diagnostic.Message, + diagnostic.Source?.Path); +} diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs index 7b81c98..16f38c2 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs @@ -4,17 +4,18 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Generates a Cratis Screenplay (.play) file from the source code of a Cratis Arc application — reads the -/// solution or project with Roslyn, hands the compilation to the Screenplay generator, and writes the result. +/// Generates a Cratis Screenplay (.play) file from Arc, Marten, or Critter Stack application source — reads +/// the solution or project with Roslyn, hands the compilation to the selected generator, and writes the result. /// -[LlmDescription("Generates a Cratis Screenplay (.play) file from Cratis Arc SOURCE CODE. Reads a solution or project with Roslyn — it never connects to a running application, so nothing needs to be started first. Writes the .play source to standard output unless --file is given. Diagnostics for anything that could not be expressed go to standard error, grouped by severity; the command exits with a validation error when any of them is an error.")] -[CliCommand("generate", "Generate a Screenplay from Arc source code", Branch = typeof(ScreenplayBranch))] +[LlmDescription("Generates a Cratis Screenplay (.play) file from Arc, Marten, or Critter Stack SOURCE CODE. Reads a solution or project with Roslyn — it never connects to a running application, so nothing needs to be started first. Writes the .play source to standard output unless --file is given. Diagnostics for anything that could not be expressed go to standard error, grouped by severity; the command exits with a validation error when any of them is an error.")] +[CliCommand("generate", "Generate a Screenplay from application source code", Branch = typeof(ScreenplayBranch))] [CliExample("screenplay", "generate")] [CliExample("screenplay", "generate", "./MyApp.slnx", "--file", "MyApp.play")] [CliExample("screenplay", "generate", "./Source/MyApp/MyApp.csproj")] [CliExample("screenplay", "generate", "--modules-from-namespace-roots", "--skip-segments", "1")] [LlmOption("[PATH]", "string", "Solution (.slnx, .sln, .slnf), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] [LlmOption("--file", "string", "File to write the generated Screenplay to. Writes to standard output when not given.")] +[LlmOption("--provider", "string", "Source framework provider: auto, arc, marten, or critter-stack.")] [LlmOption("--domain", "string", "Name of the domain the generated document belongs to.")] [LlmOption("--module", "string", "Name of the module every discovered feature is placed within.")] [LlmOption("--skip-segments", "int", "Number of leading namespace segments to skip when inferring features and slices.")] diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs index cc3dd1c..1e2c148 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs @@ -25,6 +25,13 @@ public class GenerateScreenplaySettings : GlobalSettings [Description("File to write the generated Screenplay to. Writes to standard output when not given.")] public string? File { get; set; } + /// + /// Gets or sets the source framework provider used for generation. + /// + [CommandOption("--provider ")] + [Description("Source framework provider: auto, arc, marten, or critter-stack. Defaults to auto detection.")] + public string Provider { get; set; } = ScreenplayProviders.Auto; + /// /// Gets or sets the domain the generated document belongs to. /// @@ -67,5 +74,5 @@ public class GenerateScreenplaySettings : GlobalSettings /// Gets the generation options these settings describe. /// /// The . - public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments, ModulesFromNamespaceRoots); + public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments, ModulesFromNamespaceRoots, Provider); } diff --git a/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs index 40239f1..b43fdce 100644 --- a/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs @@ -4,12 +4,12 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Defines a system that generates a Screenplay document from the source code of a Cratis Arc application. +/// Defines a system that generates a Screenplay document from application source code. /// /// -/// This is the seam between the CLI and the Cratis.Arc.Screenplay generator. Everything the CLI does around +/// This is the seam between the CLI and source framework generator packages. Everything the CLI does around /// generation — resolving the target, writing the document, reporting diagnostics — is expressed against this -/// interface so that it stays independent of how a compilation is obtained. +/// interface so that it stays independent of how framework semantics are recovered. /// public interface IScreenplayGeneration { diff --git a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs new file mode 100644 index 0000000..d23a74b --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Loads application source once and delegates generation to the selected framework provider. +/// +public sealed class ProviderScreenplayGeneration : IScreenplayGeneration +{ + /// + public async Task Generate( + string targetPath, + ScreenplayGenerationOptions options, + CancellationToken cancellationToken) + { + var requested = options.Provider.ToLowerInvariant(); + if (!ScreenplayProviders.IsKnown(requested)) + { + return InvalidProvider(requested, targetPath); + } + + var loaded = await ScreenplayCompilationLoader.Load( + targetPath, + includeAllProjects: !string.Equals(requested, ScreenplayProviders.Arc, StringComparison.Ordinal), + cancellationToken); + var provider = string.Equals(requested, ScreenplayProviders.Auto, StringComparison.Ordinal) + ? Detect(loaded) + : requested; + + return provider switch + { + ScreenplayProviders.Arc => ArcScreenplayGeneration.GenerateFrom(NarrowToArc(loaded), targetPath, options), + ScreenplayProviders.Marten or ScreenplayProviders.CritterStack => + CritterStackScreenplayGeneration.GenerateFrom(loaded, targetPath, options), + _ => InvalidProvider(provider, targetPath) + }; + } + + static string Detect(LoadedCompilation loaded) => loaded.Compilations.Any(IsCritterStack) + ? ScreenplayProviders.CritterStack + : ScreenplayProviders.Arc; + + static bool IsCritterStack(Microsoft.CodeAnalysis.Compilation compilation) => + compilation.GetTypeByMetadataName("Marten.StoreOptions") is not null || + compilation.GetTypeByMetadataName("Marten.IDocumentStore") is not null || + compilation.GetTypeByMetadataName("Wolverine.WolverineOptions") is not null; + + static LoadedCompilation NarrowToArc(LoadedCompilation loaded) + { + var selected = loaded.Compilations + .Select((compilation, index) => new { Compilation = compilation, Name = loaded.ProjectNames[index] }) + .Where(_ => ScreenplayProjectSelection.CanDeclareAnArtifact(_.Compilation)) + .ToArray(); + return selected.Length == loaded.Compilations.Count + ? loaded + : new LoadedCompilation( + [.. selected.Select(_ => _.Compilation)], + [.. selected.Select(_ => _.Name)], + loaded.Diagnostics); + } + + static GeneratedScreenplay InvalidProvider(string provider, string targetPath) => new( + string.Empty, + [ + new ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity.Error, + ScreenplayDiagnosticCodes.InvalidProvider, + $"Unknown Screenplay provider '{provider}'. Use auto, arc, marten, or critter-stack", + targetPath) + ]); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs index dce1456..8f744f5 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -46,14 +46,31 @@ public static void RegisterMSBuild() /// Cancellation token. /// The describing the outcome. [MethodImpl(MethodImplOptions.NoInlining)] - public static async Task Load(string targetPath, CancellationToken cancellationToken) + public static Task Load(string targetPath, CancellationToken cancellationToken) => + Load(targetPath, includeAllProjects: false, cancellationToken); + + /// + /// Loads the given solution or project and optionally retains every non-spec C# project for provider analysis. + /// + /// The full path of the solution or project file. + /// Whether solution projects should bypass Arc-specific artifact filtering. + /// Cancellation token. + /// The describing the outcome. + [MethodImpl(MethodImplOptions.NoInlining)] + public static async Task Load( + string targetPath, + bool includeAllProjects, + CancellationToken cancellationToken) { RegisterMSBuild(); - return await LoadWithWorkspace(targetPath, cancellationToken); + return await LoadWithWorkspace(targetPath, includeAllProjects, cancellationToken); } [MethodImpl(MethodImplOptions.NoInlining)] - static async Task LoadWithWorkspace(string targetPath, CancellationToken cancellationToken) + static async Task LoadWithWorkspace( + string targetPath, + bool includeAllProjects, + CancellationToken cancellationToken) { var failures = new List(); var failureLock = new Lock(); @@ -112,7 +129,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel failures); } - return await CompilationsOf(selected, isSolution, targetPath, failures, cancellationToken); + return await CompilationsOf(selected, isSolution, includeAllProjects, targetPath, failures, cancellationToken); } /// @@ -120,6 +137,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel /// /// The projects that take part, ordered by name. /// Whether a solution was opened rather than a single project. + /// Whether all selected projects should bypass Arc-specific artifact filtering. /// The full path of the solution or project file. /// Everything the workspace reported while loading. /// Cancellation token. @@ -134,6 +152,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel static async Task CompilationsOf( IReadOnlyList selected, bool isSolution, + bool includeAllProjects, string targetPath, IReadOnlyList failures, CancellationToken cancellationToken) @@ -161,7 +180,7 @@ static async Task CompilationsOf( continue; } - if (isSolution && !ScreenplayProjectSelection.CanDeclareAnArtifact(compilation)) + if (isSolution && !includeAllProjects && !ScreenplayProjectSelection.CanDeclareAnArtifact(compilation)) { continue; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs index 93c9850..05ae63c 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -40,4 +40,9 @@ public static class ScreenplayDiagnosticCodes /// Every project loaded, and none of them can declare anything a Screenplay document is made of. /// public const string NoArtifacts = "CLI0006"; + + /// + /// The requested source framework provider is not recognized. + /// + public const string InvalidProvider = "CLI0007"; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs index 272ece6..8b0880b 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs @@ -10,10 +10,16 @@ namespace Cratis.Cli.Commands.Screenplay; /// The module every discovered feature is placed within; falls back to the domain. /// The number of leading namespace segments to skip when inferring features and slices; uses the generator default. /// Whether each feature is placed in a module named after the outermost segment of its namespace rather than all of them in one module. -public record ScreenplayGenerationOptions(string? Domain, string? Module, int? SegmentsToSkip, bool ModulesFromNamespaceRoots = false) +/// The source framework provider to use or auto-detect. +public record ScreenplayGenerationOptions( + string? Domain, + string? Module, + int? SegmentsToSkip, + bool ModulesFromNamespaceRoots = false, + string Provider = ScreenplayProviders.Auto) { /// /// Gets the options that leave every choice to the generator. /// - public static ScreenplayGenerationOptions Default { get; } = new(null, null, null); + public static ScreenplayGenerationOptions Default { get; } = new(null, null, null, Provider: ScreenplayProviders.Auto); } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs index c698c6b..9fd4eb0 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs @@ -12,5 +12,5 @@ public static class ScreenplayGenerations /// Creates the generation to use. /// /// The to generate with. - public static IScreenplayGeneration Create() => new ArcScreenplayGeneration(); + public static IScreenplayGeneration Create() => new ProviderScreenplayGeneration(); } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProviders.cs b/Source/Cli/Commands/Screenplay/ScreenplayProviders.cs new file mode 100644 index 0000000..db6dc84 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayProviders.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Defines the source-framework providers available for Screenplay generation. +/// +public static class ScreenplayProviders +{ + /// + /// Detect the provider from loaded project compilations. + /// + public const string Auto = "auto"; + + /// + /// Generate from Arc and Chronicle conventions. + /// + public const string Arc = "arc"; + + /// + /// Generate from Marten conventions without requiring Wolverine. + /// + public const string Marten = "marten"; + + /// + /// Generate from combined Marten and Wolverine conventions. + /// + public const string CritterStack = "critter-stack"; + + static readonly HashSet _known = [Auto, Arc, Marten, CritterStack]; + + /// + /// Gets whether a provider name is recognized. + /// + /// The provider name. + /// when recognized; otherwise, . + public static bool IsKnown(string provider) => _known.Contains(provider); +} From 7d085a5bb2b34c368663cc502b3fcf33ef91dcc5 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 21 Aug 2026 03:47:29 +0200 Subject: [PATCH 2/7] Document Screenplay source providers --- Documentation/reference/screenplay.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md index 6be48c6..8c37bfe 100644 --- a/Documentation/reference/screenplay.md +++ b/Documentation/reference/screenplay.md @@ -1,6 +1,6 @@ # Screenplay -`cratis screenplay` works with Cratis Screenplay (`.play`) documents. It generates one from the source code of a Cratis Arc application — so the event model your team reads is derived from the code that actually runs rather than maintained alongside it — and it compiles the documents you already have. +`cratis screenplay` works with Cratis Screenplay (`.play`) documents. It generates one from Arc, Marten, or Marten + Wolverine application source — so the event model your team reads is derived from the code that actually runs rather than maintained alongside it — and it compiles the documents you already have. ```bash cratis screenplay generate [PATH] @@ -9,11 +9,11 @@ cratis screenplay validate [PATH] **Nothing needs to be running.** This is what separates `cratis screenplay` from [`cratis arc`](../arc/index.md): every `arc` command talks to a *running* application over HTTP, while `screenplay` only ever reads files. The result is reproducible from a checkout — commit it, diff it, and run it in CI, on a machine where the application was never started. -Fetching a `.play` document from a running Arc application over its introspection endpoint is a separate, complementary route: it trades the SDK requirement for the requirement that the application be running. That route does not exist yet — neither the Arc endpoint nor a CLI command for it — so generating from source is today the only way to derive a Screenplay from a Cratis Arc application. +Fetching a `.play` document from a running application over an introspection endpoint is a separate, complementary route: it trades the SDK requirement for the requirement that the application be running. Source generation remains reproducible from a restored checkout and does not execute application startup or connect to Chronicle/PostgreSQL. ## `cratis screenplay generate [PATH]` -Reads a solution or project, derives the event model from the Arc artifacts it finds — commands, events, read models, projections, reactors, constraints, and the concepts they are built from — and writes a Screenplay document. +Reads a solution or project, selects the Arc, Marten, or Critter Stack source provider, derives the event model from the framework artifacts and conventions it finds, and writes a Screenplay document. By default the document goes to standard output, so it composes with the shell: @@ -34,6 +34,7 @@ Pass `--file` to write it directly instead. The output is written as raw UTF-8, | Option | Description | |---|---| | `--file ` | File to write the generated Screenplay to. Writes to standard output when not given. | +| `--provider ` | Source provider: `auto`, `arc`, `marten`, or `critter-stack`. Defaults to auto detection. | | `--domain ` | Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project, and to the solution name when several projects are read. | | `--module ` | Name of the module every discovered feature is placed within. Defaults to the domain. | | `--skip-segments ` | Number of leading namespace segments to skip when inferring features and slices. | @@ -45,6 +46,8 @@ The output file uses `--file` rather than `-o`, because `-o/--output` is the glo cratis screenplay generate cratis screenplay generate ./MyApp.slnx --file MyApp.play cratis screenplay generate ./Source/MyApp/MyApp.csproj +cratis screenplay generate ./Banking.csproj --provider marten --file Banking.play +cratis screenplay generate ./Helpdesk.csproj --provider critter-stack --file Helpdesk.play cratis screenplay generate --domain Library --module Lending --file Library.play ``` @@ -76,7 +79,7 @@ A solution filter (`.slnf`) is read as the solution it filters, which is how a r A Screenplay describes one application, and an application is regularly split across several projects — an executable alongside the libraries holding its slices. Every project of a solution therefore takes part in the same document, except: -- **Projects that cannot declare anything the document is made of.** Every artifact is declared with an attribute the framework ships, so a project resolving neither the Arc nor the Chronicle one — a Roslyn analyzer, a build-time tool, a code-generation project — is left out. This is asked of what the project can *see*, not of what it is called. +- **With the Arc provider, projects that cannot declare an Arc/Chronicle artifact.** A Roslyn analyzer, build-time tool, or code-generation project resolving neither framework is left out. Marten/Wolverine contracts are frequently markerless and may live in referenced projects without a direct package reference, so Critter Stack analysis retains non-spec C# projects and lets the provider contribute only evidence it recognizes. - **Spec projects**, by name: the ones called, or ending in, `.Specs`, `.Specifications`, `.Tests`, `.Test`, `.IntegrationTests`, or `.Specs.AppHost`. Nothing about what a spec project can see tells it apart — it references the same framework the application does — so the name is what decides. `.Specs.AppHost` covers the host integration specs start the application in. A project that targets several frameworks is read once. The workspace opens it once per target framework and names the results `MyApp(net10.0)`, `MyApp(net9.0)`; they all hold the same application, so one of them takes part. @@ -131,7 +134,8 @@ The project does **not** have to have been built first. Sources MSBuild generate | No solution or project found in `PATH` or any parent folder | Not-found error. | | The solution holds no project that is not specs | Validation error (`CLI0001`). | | A project has not been restored | Validation error (`CLI0005`) naming it; nothing is generated. | -| No project of the solution can declare a command or an event type | Validation error (`CLI0006`). | +| No Arc project of the solution can declare a command or event type | Validation error (`CLI0006`). | +| `--provider` is not `auto`, `arc`, `marten`, or `critter-stack` | Validation error (`CLI0007`). | | A project cannot be read into a compilation | Validation error (`CLI0004`) naming it; the remaining projects are still described. | | Generation reports one or more errors, with `--file` | Validation error; the document is written anyway. | | Generation reports one or more errors, writing to standard output | Validation error; nothing is written. | @@ -181,7 +185,7 @@ With `-o json` or `-o json-compact` the same diagnostics are written to standard Generating from source is one of three ways to arrive at a `.play` file, and they meet in the same place: -- **From source** — `screenplay generate`, for an application that already exists in Cratis Arc. Needs the .NET SDK and a checkout; needs nothing running. +- **From source** — `screenplay generate`, for an Arc, Marten, or Critter Stack application. Needs the .NET SDK and a restored checkout; needs nothing running. - **From a running system** — [`cratis prologue`](prologue.md) captures what a system does and interprets it into a Screenplay, for systems built without Cratis. - **By hand** — write the `.play` file as the design, before any code exists. From 1a13aeaf9bc06f1630025f3f26fb1fa59f80824c Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 21 Aug 2026 03:50:11 +0200 Subject: [PATCH 3/7] Bootstrap source-provider packages in CLI CI --- .github/workflows/build.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cba6922..cf8dc8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,8 +29,20 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + # Bootstrap the first source-provider packages while their new package IDs await nuget.org trusted-publisher + # policies. Remove this step once both 0.1.0 releases are available from nuget.org. + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore --property:Configuration=Release --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Build - run: dotnet build --configuration Release + run: dotnet build --no-restore --configuration Release # The integration fixture starts a Chronicle container whose image is named after the Chronicle # package version, so a version bump always faces a cold ~400MB pull. Testcontainers times the first @@ -45,4 +57,4 @@ jobs: docker pull "cratis/chronicle:$VERSION-development" - name: Test - run: dotnet test --configuration Release + run: dotnet test --no-restore --configuration Release From d4a0985f7514fd0cd0f6db6c11a1252d95527d40 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 21 Aug 2026 03:51:36 +0200 Subject: [PATCH 4/7] Preserve empty Arc provider diagnostics --- .../Commands/Screenplay/ProviderScreenplayGeneration.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs index d23a74b..c6ae714 100644 --- a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs @@ -52,6 +52,15 @@ static LoadedCompilation NarrowToArc(LoadedCompilation loaded) .Select((compilation, index) => new { Compilation = compilation, Name = loaded.ProjectNames[index] }) .Where(_ => ScreenplayProjectSelection.CanDeclareAnArtifact(_.Compilation)) .ToArray(); + if (selected.Length == 0 && loaded.Compilations.Count > 0) + { + return LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.NoArtifacts, + "No loaded project declares Arc commands or Chronicle events, so there is nothing for the Arc Screenplay provider to generate", + null, + loaded.Diagnostics); + } + return selected.Length == loaded.Compilations.Count ? loaded : new LoadedCompilation( From e0da95c2ca29aeee0ceb0b16811d204073a04dc7 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 21 Aug 2026 23:10:51 +0200 Subject: [PATCH 5/7] Repair framework references in loaded projects MSBuildWorkspace can omit .NET reference-pack assemblies when the CLI analyzes projects targeting an earlier installed framework. Add the matching reference packs before generation so net7/net9 source resolves real framework symbols instead of silently omitting artifacts or inventing error-symbol events. --- .../Screenplay/ScreenplayCompilationLoader.cs | 2 + .../ScreenplayFrameworkReferences.cs | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayFrameworkReferences.cs diff --git a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs index 8f744f5..db8939c 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -180,6 +180,8 @@ static async Task CompilationsOf( continue; } + compilation = ScreenplayFrameworkReferences.AddMissingTo(project, compilation); + if (isSolution && !includeAllProjects && !ScreenplayProjectSelection.CanDeclareAnArtifact(compilation)) { continue; diff --git a/Source/Cli/Commands/Screenplay/ScreenplayFrameworkReferences.cs b/Source/Cli/Commands/Screenplay/ScreenplayFrameworkReferences.cs new file mode 100644 index 0000000..9eb27f2 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayFrameworkReferences.cs @@ -0,0 +1,90 @@ +// 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.InteropServices; +using Microsoft.CodeAnalysis; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Restores target-framework reference assemblies that an MSBuild workspace can omit for projects targeting an +/// earlier installed .NET version than the CLI process. +/// +static class ScreenplayFrameworkReferences +{ + static readonly string[] _packs = ["Microsoft.NETCore.App.Ref", "Microsoft.AspNetCore.App.Ref"]; + + /// + /// Adds missing target-framework references to a project compilation. + /// + /// The workspace project. + /// The compilation produced by the workspace. + /// The original compilation when its framework is complete; otherwise, a compilation with references. + public static Compilation AddMissingTo(Project project, Compilation compilation) + { + if (compilation.GetSpecialType(SpecialType.System_Object).TypeKind != TypeKind.Error) + { + return compilation; + } + + var targetFramework = TargetFrameworkOf(project); + if (targetFramework is null) + { + return compilation; + } + + var existing = compilation.References + .Select(_ => Path.GetFileNameWithoutExtension(_.Display)) + .Where(_ => !string.IsNullOrEmpty(_)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var references = PackRoots() + .SelectMany(root => _packs.Select(pack => Path.Combine(root, pack))) + .Where(Directory.Exists) + .Select(pack => ReferenceDirectory(pack, targetFramework)) + .Where(_ => _ is not null) + .SelectMany(_ => Directory.EnumerateFiles(_!, "*.dll", SearchOption.TopDirectoryOnly)) + .Where(_ => existing.Add(Path.GetFileNameWithoutExtension(_))) + .Select(_ => MetadataReference.CreateFromFile(_)) + .ToArray(); + + return references.Length == 0 ? compilation : compilation.AddReferences(references); + } + + static string? TargetFrameworkOf(Project project) + { + var assemblyPath = project.CompilationOutputInfo.AssemblyPath; + if (!string.IsNullOrWhiteSpace(assemblyPath)) + { + var framework = new DirectoryInfo(Path.GetDirectoryName(assemblyPath)!).Name; + if (framework.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + { + return framework; + } + } + + var start = project.Name.LastIndexOf('('); + return start > 0 && project.Name.EndsWith(')') ? project.Name[(start + 1)..^1] : null; + } + + static IEnumerable PackRoots() + { + var runtime = new DirectoryInfo(RuntimeEnvironment.GetRuntimeDirectory()); + var dotnetRoot = runtime.Parent?.Parent?.Parent; + if (dotnetRoot is not null) + { + yield return Path.Combine(dotnetRoot.FullName, "packs"); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + { + yield return Path.Combine(home, ".nuget", "packages"); + } + } + + static string? ReferenceDirectory(string pack, string targetFramework) => Directory.EnumerateDirectories(pack) + .Select(version => Path.Combine(version, "ref", targetFramework)) + .Where(Directory.Exists) + .OrderDescending(StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); +} From 4ccded12d67edb3860ec5eb7525a6b807ccb09f4 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 21 Aug 2026 23:44:41 +0200 Subject: [PATCH 6/7] Reject unsafe source generation inputs Fail Critter Stack generation when source does not compile and reject solutions with multiple deployable hosts unless the user targets one project. This prevents apparently valid partial models and accidental application merges. --- .../and_source_does_not_compile.cs | 24 +++++++++++ ...olution_has_several_critter_stack_hosts.cs | 40 +++++++++++++++++++ .../CritterStackScreenplayGeneration.cs | 21 ++++++++++ .../ProviderScreenplayGeneration.cs | 33 ++++++++++++++- .../Screenplay/ScreenplayDiagnosticCodes.cs | 10 +++++ 5 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/and_source_does_not_compile.cs create mode 100644 Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs diff --git a/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/and_source_does_not_compile.cs b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/and_source_does_not_compile.cs new file mode 100644 index 0000000..b704b21 --- /dev/null +++ b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/and_source_does_not_compile.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 Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_CritterStackScreenplayGeneration.when_generating; + +public class and_source_does_not_compile : given.a_marten_application_built_from_source +{ + GeneratedScreenplay _result = null!; + + void Because() + { + var broken = Loaded.Compilations[0].AddSyntaxTrees(CSharpSyntaxTree.ParseText("public class Broken { MissingType Value; }")); + var loaded = Loaded with { Compilations = [broken] }; + _result = CritterStackScreenplayGeneration.GenerateFrom( + loaded, + "/workspace/Banking/Banking.csproj", + ScreenplayGenerationOptions.Default with { Provider = ScreenplayProviders.Marten }); + } + + [Fact] void should_generate_no_source() => _result.Source.ShouldBeEmpty(); + [Fact] void should_report_the_compilation_error() => _result.Diagnostics.Select(_ => _.Code).ShouldContainOnly(ScreenplayDiagnosticCodes.SourceDidNotCompile); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs new file mode 100644 index 0000000..333bf71 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_a_solution_has_several_critter_stack_hosts : Specification +{ + GeneratedScreenplay? _result; + + void Because() + { + var loaded = new LoadedCompilation( + [Host("Api"), Host("Worker")], + ["Api", "Worker"], + []); + _result = ProviderScreenplayGeneration.AmbiguousHosts( + loaded, + "/workspace/Applications.slnx", + ScreenplayProviders.CritterStack); + } + + [Fact] void should_report_an_outcome() => _result.ShouldNotBeNull(); + [Fact] void should_report_both_hosts() => _result!.Diagnostics.Single().Message.ShouldContain("Api, Worker"); + [Fact] void should_report_the_ambiguity_code() => _result!.Diagnostics.Single().Code.ShouldEqual(ScreenplayDiagnosticCodes.AmbiguousApplicationHosts); + [Fact] void should_generate_no_source() => _result!.Source.ShouldBeEmpty(); + + static CSharpCompilation Host(string name) => CSharpCompilation.Create( + name, + [CSharpSyntaxTree.ParseText("public static class Program { public static void Main() { } }")], + References(), + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + + static IEnumerable References() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(_ => MetadataReference.CreateFromFile(_)); +} diff --git a/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs index 1e486a3..516454a 100644 --- a/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs @@ -39,6 +39,15 @@ internal static GeneratedScreenplay GenerateFrom( return new GeneratedScreenplay(string.Empty, loaded.Diagnostics); } + var sourceErrors = SourceErrors(loaded); + if (sourceErrors.Count > 0) + { + return new GeneratedScreenplay(string.Empty, [.. loaded.Diagnostics, .. sourceErrors]) + { + Projects = loaded.ProjectNames + }; + } + var sourceRoot = Path.GetDirectoryName(targetPath); var projects = loaded.Compilations .Select((compilation, index) => new DotNetProjectCompilation @@ -66,6 +75,18 @@ internal static GeneratedScreenplay GenerateFrom( }; } + static IReadOnlyList SourceErrors(LoadedCompilation loaded) => + [ + .. loaded.Compilations.SelectMany((compilation, index) => compilation.GetDiagnostics() + .Where(_ => _.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .Take(1) + .Select(_ => new ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity.Error, + ScreenplayDiagnosticCodes.SourceDidNotCompile, + $"Source project '{loaded.ProjectNames[index]}' did not compile: {_.Id} {_.GetMessage()}", + _.Location.GetLineSpan().Path))) + ]; + static string? DomainFrom(string targetPath, LoadedCompilation loaded) => loaded.Compilations.Count > 1 ? Path.GetFileNameWithoutExtension(targetPath) : loaded.ProjectNames[0]; diff --git a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs index c6ae714..e4bdae2 100644 --- a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs @@ -27,8 +27,7 @@ public async Task Generate( var provider = string.Equals(requested, ScreenplayProviders.Auto, StringComparison.Ordinal) ? Detect(loaded) : requested; - - return provider switch + return AmbiguousHosts(loaded, targetPath, provider) ?? provider switch { ScreenplayProviders.Arc => ArcScreenplayGeneration.GenerateFrom(NarrowToArc(loaded), targetPath, options), ScreenplayProviders.Marten or ScreenplayProviders.CritterStack => @@ -37,6 +36,36 @@ public async Task Generate( }; } + internal static GeneratedScreenplay? AmbiguousHosts( + LoadedCompilation loaded, + string targetPath, + string provider) + { + if (!ScreenplayTargetResolver.IsSolution(targetPath) || + (provider != ScreenplayProviders.Marten && provider != ScreenplayProviders.CritterStack)) + { + return null; + } + + var hosts = loaded.Compilations + .Select((compilation, index) => new { EntryPoint = compilation.GetEntryPoint(CancellationToken.None), Name = loaded.ProjectNames[index] }) + .Where(_ => _.EntryPoint is not null) + .Select(_ => _.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + return hosts.Length <= 1 + ? null + : new GeneratedScreenplay( + string.Empty, + [ + new ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity.Error, + ScreenplayDiagnosticCodes.AmbiguousApplicationHosts, + $"Solution contains several deployable application hosts: {string.Join(", ", hosts)}. Target one .csproj explicitly", + targetPath) + ]); + } + static string Detect(LoadedCompilation loaded) => loaded.Compilations.Any(IsCritterStack) ? ScreenplayProviders.CritterStack : ScreenplayProviders.Arc; diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs index 05ae63c..c5972bc 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -45,4 +45,14 @@ public static class ScreenplayDiagnosticCodes /// The requested source framework provider is not recognized. /// public const string InvalidProvider = "CLI0007"; + + /// + /// Source compilation errors make the recovered semantic model untrustworthy. + /// + public const string SourceDidNotCompile = "CLI0008"; + + /// + /// A solution contains several deployable hosts and therefore does not identify one application. + /// + public const string AmbiguousApplicationHosts = "CLI0009"; } From 3c99e29f1531876e728bcdbb6588397b48c90c67 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 21 Aug 2026 23:53:44 +0200 Subject: [PATCH 7/7] Discover bundled Screenplay source providers Replace the hardcoded framework switch with an allowlisted provider registry. Providers self-describe matching, specificity, host constraints, and generation; auto mode selects Critter Stack over its Marten base and reports unrelated ambiguity. Bootstrap release builds from verified package assets until NuGet publication is available. --- .github/workflows/publish-native.yml | 48 ++++++- .github/workflows/publish.yml | 13 +- .../given/provider_compilations.cs | 24 ++++ ...olution_has_several_critter_stack_hosts.cs | 4 +- .../and_arc_and_critter_stack_are_present.cs | 20 +++ .../and_marten_and_wolverine_are_present.cs | 18 +++ .../when_selecting/and_no_provider_matches.cs | 17 +++ .../Screenplay/IScreenplaySourceProvider.cs | 44 ++++++ .../ProviderScreenplayGeneration.cs | 130 +++++++++--------- .../Screenplay/ScreenplayDiagnosticCodes.cs | 10 ++ .../Screenplay/ScreenplaySourceProviders.cs | 72 ++++++++++ 11 files changed, 328 insertions(+), 72 deletions(-) create mode 100644 Source/Cli.Specs/for_ProviderScreenplayGeneration/given/provider_compilations.cs create mode 100644 Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_arc_and_critter_stack_are_present.cs create mode 100644 Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_marten_and_wolverine_are_present.cs create mode 100644 Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_no_provider_matches.cs create mode 100644 Source/Cli/Commands/Screenplay/IScreenplaySourceProvider.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplaySourceProviders.cs diff --git a/.github/workflows/publish-native.yml b/.github/workflows/publish-native.yml index dd3edd0..078f782 100644 --- a/.github/workflows/publish-native.yml +++ b/.github/workflows/publish-native.yml @@ -28,8 +28,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r osx-arm64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r osx-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-arm64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r osx-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-arm64 - name: Package run: | @@ -55,8 +65,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r osx-x64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r osx-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-x64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r osx-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-x64 - name: Package run: | @@ -82,8 +102,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r linux-x64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r linux-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-x64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r linux-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-x64 - name: Package run: | @@ -109,8 +139,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r linux-arm64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r linux-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-arm64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r linux-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-arm64 - name: Package run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e31d0f..354242e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -101,8 +101,19 @@ jobs: - name: Remove any existing artifacts run: rm -rf ${{ env.NUGET_OUTPUT }} + # Bootstrap until the new package IDs are available from nuget.org. + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore --property:Configuration=Release --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Build - run: dotnet build --configuration Release -p:Version=${{ needs.release.outputs.version }} + run: dotnet build --no-restore --configuration Release -p:Version=${{ needs.release.outputs.version }} - name: Create NuGet packages run: dotnet pack Source/Cli/Cli.csproj --no-build --configuration Release -o ${{ env.NUGET_OUTPUT }} -p:PackageVersion=${{ needs.release.outputs.version }} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/given/provider_compilations.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/given/provider_compilations.cs new file mode 100644 index 0000000..1137fb0 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/given/provider_compilations.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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.given; + +public class provider_compilations : Specification +{ + protected static LoadedCompilation LoadedFrom(string source) => new( + [CSharpCompilation.Create( + "Application", + [CSharpSyntaxTree.ParseText(source)], + References(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))], + ["Application"], + []); + + static IEnumerable References() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(_ => MetadataReference.CreateFromFile(_)); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs index 333bf71..fdb9ed9 100644 --- a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs @@ -16,10 +16,10 @@ void Because() [Host("Api"), Host("Worker")], ["Api", "Worker"], []); - _result = ProviderScreenplayGeneration.AmbiguousHosts( + _result = new ProviderScreenplayGeneration().AmbiguousHosts( loaded, "/workspace/Applications.slnx", - ScreenplayProviders.CritterStack); + new CritterStackSourceProvider()); } [Fact] void should_report_an_outcome() => _result.ShouldNotBeNull(); diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_arc_and_critter_stack_are_present.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_arc_and_critter_stack_are_present.cs new file mode 100644 index 0000000..9e1713d --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_arc_and_critter_stack_are_present.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_arc_and_critter_stack_are_present : given.provider_compilations +{ + ProviderSelection _selection = null!; + + void Because() => _selection = new ProviderScreenplayGeneration().Discover( + LoadedFrom( + "namespace Cratis.Arc.Commands.ModelBound { public class CommandAttribute : System.Attribute; } " + + "namespace Marten { public class StoreOptions; } " + + "namespace Wolverine { public class WolverineOptions; }"), + "/workspace/Application.csproj"); + + [Fact] void should_select_no_provider() => _selection.Provider.ShouldBeNull(); + [Fact] void should_report_ambiguity() => _selection.Error!.Diagnostics.Single().Code.ShouldEqual(ScreenplayDiagnosticCodes.AmbiguousProviders); + [Fact] void should_name_both_candidates() => _selection.Error!.Diagnostics.Single().Message.ShouldContain("arc, critter-stack"); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_marten_and_wolverine_are_present.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_marten_and_wolverine_are_present.cs new file mode 100644 index 0000000..72197b6 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_marten_and_wolverine_are_present.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_marten_and_wolverine_are_present : given.provider_compilations +{ + ProviderSelection _selection = null!; + + void Because() => _selection = new ProviderScreenplayGeneration().Discover( + LoadedFrom( + "namespace Marten { public class StoreOptions; } " + + "namespace Wolverine { public class WolverineOptions; }"), + "/workspace/Application.csproj"); + + [Fact] void should_select_the_more_specific_critter_stack_provider() => _selection.Provider!.Name.ShouldEqual(ScreenplayProviders.CritterStack); + [Fact] void should_report_no_error() => _selection.Error.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_no_provider_matches.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_no_provider_matches.cs new file mode 100644 index 0000000..196aec2 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_no_provider_matches.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_no_provider_matches : given.provider_compilations +{ + ProviderSelection _selection = null!; + + void Because() => _selection = new ProviderScreenplayGeneration().Discover( + LoadedFrom("public class Application;"), + "/workspace/Application.csproj"); + + [Fact] void should_select_no_provider() => _selection.Provider.ShouldBeNull(); + [Fact] void should_report_no_match() => _selection.Error!.Diagnostics.Single().Code.ShouldEqual(ScreenplayDiagnosticCodes.NoMatchingProvider); + [Fact] void should_list_available_providers() => _selection.Error!.Diagnostics.Single().Message.ShouldContain("arc, critter-stack, marten"); +} diff --git a/Source/Cli/Commands/Screenplay/IScreenplaySourceProvider.cs b/Source/Cli/Commands/Screenplay/IScreenplaySourceProvider.cs new file mode 100644 index 0000000..611db8f --- /dev/null +++ b/Source/Cli/Commands/Screenplay/IScreenplaySourceProvider.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Defines one bundled source-framework provider for Screenplay generation. +/// +interface IScreenplaySourceProvider +{ + /// + /// Gets the stable provider name used by --provider. + /// + string Name { get; } + + /// + /// Gets provider names this more-specific provider replaces when both match. + /// + IReadOnlyList Supersedes { get; } + + /// + /// Gets whether a solution with several deployable hosts is ambiguous for this provider. + /// + bool RequiresSingleHost { get; } + + /// + /// Gets whether source evidence in the loaded compilations matches this provider. + /// + /// The loaded source compilations. + /// when the provider matches. + bool Matches(LoadedCompilation loaded); + + /// + /// Generates the Screenplay from already loaded source. + /// + /// The loaded source compilations. + /// The source target path. + /// Generation options. + /// The generated Screenplay. + GeneratedScreenplay GenerateFrom( + LoadedCompilation loaded, + string targetPath, + ScreenplayGenerationOptions options); +} diff --git a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs index e4bdae2..a30f414 100644 --- a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs @@ -4,10 +4,25 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Loads application source once and delegates generation to the selected framework provider. +/// Loads application source once, discovers bundled provider capabilities, and delegates generation. /// public sealed class ProviderScreenplayGeneration : IScreenplayGeneration { + readonly IReadOnlyList _providers; + + /// + /// Initializes generation with every allowlisted provider bundled into this CLI build. + /// + public ProviderScreenplayGeneration() + : this(ScreenplaySourceProviders.Default) + { + } + + internal ProviderScreenplayGeneration(IReadOnlyList providers) + { + _providers = providers; + } + /// public async Task Generate( string targetPath, @@ -15,34 +30,51 @@ public async Task Generate( CancellationToken cancellationToken) { var requested = options.Provider.ToLowerInvariant(); - if (!ScreenplayProviders.IsKnown(requested)) + var explicitProvider = string.Equals(requested, ScreenplayProviders.Auto, StringComparison.Ordinal) + ? null + : _providers.FirstOrDefault(_ => string.Equals(_.Name, requested, StringComparison.Ordinal)); + if (requested != ScreenplayProviders.Auto && explicitProvider is null) { return InvalidProvider(requested, targetPath); } - var loaded = await ScreenplayCompilationLoader.Load( - targetPath, - includeAllProjects: !string.Equals(requested, ScreenplayProviders.Arc, StringComparison.Ordinal), - cancellationToken); - var provider = string.Equals(requested, ScreenplayProviders.Auto, StringComparison.Ordinal) - ? Detect(loaded) - : requested; - return AmbiguousHosts(loaded, targetPath, provider) ?? provider switch + var loaded = await ScreenplayCompilationLoader.Load(targetPath, includeAllProjects: true, cancellationToken); + var selection = explicitProvider is null ? Discover(loaded, targetPath) : new ProviderSelection(explicitProvider, null); + if (selection.Error is not null) { - ScreenplayProviders.Arc => ArcScreenplayGeneration.GenerateFrom(NarrowToArc(loaded), targetPath, options), - ScreenplayProviders.Marten or ScreenplayProviders.CritterStack => - CritterStackScreenplayGeneration.GenerateFrom(loaded, targetPath, options), - _ => InvalidProvider(provider, targetPath) + return selection.Error; + } + + var provider = selection.Provider!; + return AmbiguousHosts(loaded, targetPath, provider) ?? provider.GenerateFrom(loaded, targetPath, options); + } + + internal ProviderSelection Discover(LoadedCompilation loaded, string targetPath) + { + var matches = _providers.Where(_ => _.Matches(loaded)).ToArray(); + var superseded = matches.SelectMany(_ => _.Supersedes).ToHashSet(StringComparer.Ordinal); + matches = [.. matches.Where(_ => !superseded.Contains(_.Name))]; + + return matches.Length switch + { + 1 => new(matches[0], null), + 0 => new(null, ProviderError( + ScreenplayDiagnosticCodes.NoMatchingProvider, + $"No bundled Screenplay provider recognizes the loaded source. Available providers: {ProviderNames()}", + targetPath)), + _ => new(null, ProviderError( + ScreenplayDiagnosticCodes.AmbiguousProviders, + $"Several Screenplay providers recognize the loaded source: {string.Join(", ", matches.Select(_ => _.Name).Order(StringComparer.Ordinal))}. Select one with --provider", + targetPath)) }; } - internal static GeneratedScreenplay? AmbiguousHosts( + internal GeneratedScreenplay? AmbiguousHosts( LoadedCompilation loaded, string targetPath, - string provider) + IScreenplaySourceProvider provider) { - if (!ScreenplayTargetResolver.IsSolution(targetPath) || - (provider != ScreenplayProviders.Marten && provider != ScreenplayProviders.CritterStack)) + if (!provider.RequiresSingleHost || !ScreenplayTargetResolver.IsSolution(targetPath)) { return null; } @@ -55,56 +87,24 @@ public async Task Generate( .ToArray(); return hosts.Length <= 1 ? null - : new GeneratedScreenplay( - string.Empty, - [ - new ScreenplayDiagnostic( - ScreenplayDiagnosticSeverity.Error, - ScreenplayDiagnosticCodes.AmbiguousApplicationHosts, - $"Solution contains several deployable application hosts: {string.Join(", ", hosts)}. Target one .csproj explicitly", - targetPath) - ]); + : ProviderError( + ScreenplayDiagnosticCodes.AmbiguousApplicationHosts, + $"Solution contains several deployable application hosts: {string.Join(", ", hosts)}. Target one .csproj explicitly", + targetPath); } - static string Detect(LoadedCompilation loaded) => loaded.Compilations.Any(IsCritterStack) - ? ScreenplayProviders.CritterStack - : ScreenplayProviders.Arc; + string ProviderNames() => string.Join(", ", _providers.Select(_ => _.Name).Order(StringComparer.Ordinal)); - static bool IsCritterStack(Microsoft.CodeAnalysis.Compilation compilation) => - compilation.GetTypeByMetadataName("Marten.StoreOptions") is not null || - compilation.GetTypeByMetadataName("Marten.IDocumentStore") is not null || - compilation.GetTypeByMetadataName("Wolverine.WolverineOptions") is not null; + GeneratedScreenplay InvalidProvider(string provider, string targetPath) => ProviderError( + ScreenplayDiagnosticCodes.InvalidProvider, + $"Unknown Screenplay provider '{provider}'. Available providers: {ProviderNames()}", + targetPath); - static LoadedCompilation NarrowToArc(LoadedCompilation loaded) - { - var selected = loaded.Compilations - .Select((compilation, index) => new { Compilation = compilation, Name = loaded.ProjectNames[index] }) - .Where(_ => ScreenplayProjectSelection.CanDeclareAnArtifact(_.Compilation)) - .ToArray(); - if (selected.Length == 0 && loaded.Compilations.Count > 0) - { - return LoadedCompilation.Failed( - ScreenplayDiagnosticCodes.NoArtifacts, - "No loaded project declares Arc commands or Chronicle events, so there is nothing for the Arc Screenplay provider to generate", - null, - loaded.Diagnostics); - } - - return selected.Length == loaded.Compilations.Count - ? loaded - : new LoadedCompilation( - [.. selected.Select(_ => _.Compilation)], - [.. selected.Select(_ => _.Name)], - loaded.Diagnostics); - } - - static GeneratedScreenplay InvalidProvider(string provider, string targetPath) => new( + GeneratedScreenplay ProviderError(string code, string message, string targetPath) => new( string.Empty, - [ - new ScreenplayDiagnostic( - ScreenplayDiagnosticSeverity.Error, - ScreenplayDiagnosticCodes.InvalidProvider, - $"Unknown Screenplay provider '{provider}'. Use auto, arc, marten, or critter-stack", - targetPath) - ]); + [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, code, message, targetPath)]); } + +internal sealed record ProviderSelection( + IScreenplaySourceProvider? Provider, + GeneratedScreenplay? Error); diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs index c5972bc..9866b1e 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -55,4 +55,14 @@ public static class ScreenplayDiagnosticCodes /// A solution contains several deployable hosts and therefore does not identify one application. /// public const string AmbiguousApplicationHosts = "CLI0009"; + + /// + /// No bundled source provider recognizes the loaded application. + /// + public const string NoMatchingProvider = "CLI0010"; + + /// + /// More than one unrelated source provider recognizes the loaded application. + /// + public const string AmbiguousProviders = "CLI0011"; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplaySourceProviders.cs b/Source/Cli/Commands/Screenplay/ScreenplaySourceProviders.cs new file mode 100644 index 0000000..af30a52 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplaySourceProviders.cs @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Provides the allowlisted source-framework providers bundled with this CLI build. +/// +static class ScreenplaySourceProviders +{ + public static readonly IReadOnlyList Default = + [ + new ArcSourceProvider(), + new MartenSourceProvider(), + new CritterStackSourceProvider() + ]; +} + +static class ProviderEvidence +{ + public static bool HasMarten(Microsoft.CodeAnalysis.Compilation compilation) => + compilation.GetTypeByMetadataName("Marten.StoreOptions") is not null || + compilation.GetTypeByMetadataName("Marten.IDocumentStore") is not null; + + public static bool HasWolverine(Microsoft.CodeAnalysis.Compilation compilation) => + compilation.GetTypeByMetadataName("Wolverine.WolverineOptions") is not null; +} + +sealed class ArcSourceProvider : IScreenplaySourceProvider +{ + public string Name => ScreenplayProviders.Arc; + public IReadOnlyList Supersedes => []; + public bool RequiresSingleHost => false; + public bool Matches(LoadedCompilation loaded) => loaded.Compilations.Any(ScreenplayProjectSelection.CanDeclareAnArtifact); + public GeneratedScreenplay GenerateFrom(LoadedCompilation loaded, string targetPath, ScreenplayGenerationOptions options) => + ArcScreenplayGeneration.GenerateFrom(Narrow(loaded), targetPath, options); + + static LoadedCompilation Narrow(LoadedCompilation loaded) + { + var selected = loaded.Compilations + .Select((compilation, index) => new { Compilation = compilation, Name = loaded.ProjectNames[index] }) + .Where(_ => ScreenplayProjectSelection.CanDeclareAnArtifact(_.Compilation)) + .ToArray(); + return selected.Length == loaded.Compilations.Count + ? loaded + : new LoadedCompilation( + [.. selected.Select(_ => _.Compilation)], + [.. selected.Select(_ => _.Name)], + loaded.Diagnostics); + } +} + +sealed class MartenSourceProvider : IScreenplaySourceProvider +{ + public string Name => ScreenplayProviders.Marten; + public IReadOnlyList Supersedes => []; + public bool RequiresSingleHost => true; + public bool Matches(LoadedCompilation loaded) => loaded.Compilations.Any(ProviderEvidence.HasMarten); + public GeneratedScreenplay GenerateFrom(LoadedCompilation loaded, string targetPath, ScreenplayGenerationOptions options) => + CritterStackScreenplayGeneration.GenerateFrom(loaded, targetPath, options); +} + +sealed class CritterStackSourceProvider : IScreenplaySourceProvider +{ + public string Name => ScreenplayProviders.CritterStack; + public IReadOnlyList Supersedes => [ScreenplayProviders.Marten]; + public bool RequiresSingleHost => true; + public bool Matches(LoadedCompilation loaded) => loaded.Compilations.Any(_ => + ProviderEvidence.HasMarten(_) && ProviderEvidence.HasWolverine(_)); + public GeneratedScreenplay GenerateFrom(LoadedCompilation loaded, string targetPath, ScreenplayGenerationOptions options) => + CritterStackScreenplayGeneration.GenerateFrom(loaded, targetPath, options); +}