From 634b7f1c43507b48ef6ff68f0ba2d63c776979e0 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 14:55:20 +0200 Subject: [PATCH 1/2] Describe every project of a solution rather than asking which one to read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An application is regularly split across several projects, and the generator has taken a list of compilations since 20.65.2 — the CLI still reported a solution holding more than one candidate project as ambiguous and generated nothing at all. - Every project that is not specs takes part in the same document, so a project holding only the events of a slice no longer has to be named by hand. - A project named exactly `Specs` or `Tests` is recognized as specs. Only a dotted suffix was, which put test-only artifacts in the document. - A project that yields no compilation is reported and left out rather than ending the run, so the projects that did load are still described. - The domain defaults to the solution name; several projects have no single assembly for the generator to name the document after. - The result names the projects the document was generated from — which ones took part is the difference between describing an application and half of it. CLI0002 is retired rather than reused: a solution holding several projects stopped being a question. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p --- Directory.Packages.props | 2 +- .../and_one_project_is_an_executable.cs | 18 ------ .../and_spec_projects_are_present.cs | 12 ++-- ...he_application_is_split_across_projects.cs | 21 +++++++ ...specs_project_is_named_without_a_prefix.cs | 18 ++++++ .../and_there_are_only_spec_projects.cs | 6 +- .../and_two_libraries_remain.cs | 26 -------- .../Screenplay/ArcScreenplayGeneration.cs | 27 +++++++-- .../Screenplay/GenerateScreenplayCommand.cs | 4 ++ .../Screenplay/GenerateScreenplaySettings.cs | 2 +- .../Screenplay/GeneratedScreenplay.cs | 9 +++ .../Commands/Screenplay/LoadedCompilation.cs | 12 ++-- .../Screenplay/ScreenplayCompilationLoader.cs | 60 +++++++++++-------- .../Screenplay/ScreenplayDiagnosticCodes.cs | 11 ++-- .../Screenplay/ScreenplayProjectCandidate.cs | 11 ---- .../Screenplay/ScreenplayProjectSelection.cs | 52 +++++++--------- 16 files changed, 154 insertions(+), 137 deletions(-) delete mode 100644 Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.cs create mode 100644 Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_application_is_split_across_projects.cs create mode 100644 Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_specs_project_is_named_without_a_prefix.cs delete mode 100644 Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs delete mode 100644 Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d5d675..93052e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ - + diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.cs deleted file mode 100644 index 4786163..0000000 --- a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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_ScreenplayProjectSelection.when_narrowing; - -public class and_one_project_is_an_executable : Specification -{ - string? _result; - - void Because() => _result = ScreenplayProjectSelection.Select( - [ - new ScreenplayProjectCandidate("MyApp.Domain", false), - new ScreenplayProjectCandidate("MyApp.Api", true), - new ScreenplayProjectCandidate("MyApp.Read", false) - ]); - - [Fact] void should_select_the_executable() => _result.ShouldEqual("MyApp.Api"); -} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_are_present.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_are_present.cs index bd25617..b2985c8 100644 --- a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_are_present.cs +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_are_present.cs @@ -5,14 +5,14 @@ namespace Cratis.Cli.for_ScreenplayProjectSelection.when_narrowing; public class and_spec_projects_are_present : Specification { - string? _result; + IReadOnlyList _result; - void Because() => _result = ScreenplayProjectSelection.Select( + void Because() => _result = ScreenplayProjectSelection.Narrow( [ - new ScreenplayProjectCandidate("MyApp", false), - new ScreenplayProjectCandidate("MyApp.Specs", false), - new ScreenplayProjectCandidate("MyApp.Tests", false) + "MyApp", + "MyApp.Specs", + "MyApp.Tests" ]); - [Fact] void should_select_the_only_project_that_is_not_specs() => _result.ShouldEqual("MyApp"); + [Fact] void should_keep_only_the_project_that_is_not_specs() => _result.ShouldContainOnly(["MyApp"]); } diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_application_is_split_across_projects.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_application_is_split_across_projects.cs new file mode 100644 index 0000000..e3a5613 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_application_is_split_across_projects.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_ScreenplayProjectSelection.when_narrowing; + +public class and_the_application_is_split_across_projects : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayProjectSelection.Narrow( + [ + "MyApp.Domain", + "MyApp.Api", + "MyApp.Read" + ]); + + [Fact] void should_keep_every_project() => _result.Count.ShouldEqual(3); + [Fact] void should_order_the_projects_by_name() => _result[0].ShouldEqual("MyApp.Api"); + [Fact] void should_keep_the_libraries_alongside_the_executable() => _result[1].ShouldEqual("MyApp.Domain"); + [Fact] void should_keep_the_last_project_too() => _result[2].ShouldEqual("MyApp.Read"); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_specs_project_is_named_without_a_prefix.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_specs_project_is_named_without_a_prefix.cs new file mode 100644 index 0000000..684c0d7 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_specs_project_is_named_without_a_prefix.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_ScreenplayProjectSelection.when_narrowing; + +public class and_the_specs_project_is_named_without_a_prefix : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayProjectSelection.Narrow( + [ + "Core", + "Specs", + "Tests" + ]); + + [Fact] void should_keep_only_the_project_that_is_not_specs() => _result.ShouldContainOnly(["Core"]); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.cs index f33d59e..7cc85ed 100644 --- a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.cs +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.cs @@ -5,12 +5,12 @@ namespace Cratis.Cli.for_ScreenplayProjectSelection.when_narrowing; public class and_there_are_only_spec_projects : Specification { - IReadOnlyList _result; + IReadOnlyList _result; void Because() => _result = ScreenplayProjectSelection.Narrow( [ - new ScreenplayProjectCandidate("MyApp.Specs", false), - new ScreenplayProjectCandidate("MyApp.IntegrationTests", true) + "MyApp.Specs", + "MyApp.IntegrationTests" ]); [Fact] void should_leave_nothing_to_generate_from() => _result.ShouldBeEmpty(); diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs deleted file mode 100644 index e8ebd20..0000000 --- a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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_ScreenplayProjectSelection.when_narrowing; - -public class and_two_libraries_remain : Specification -{ - string? _result; - IReadOnlyList _narrowed; - - void Because() - { - ScreenplayProjectCandidate[] candidates = - [ - new("MyApp.Ordering", false), - new("MyApp.Billing", false) - ]; - - _narrowed = ScreenplayProjectSelection.Narrow(candidates); - _result = ScreenplayProjectSelection.Select(candidates); - } - - [Fact] void should_not_select_any_of_them() => _result.ShouldBeNull(); - [Fact] void should_keep_both_as_candidates() => _narrowed.Count.ShouldEqual(2); - [Fact] void should_order_the_candidates_by_name() => _narrowed[0].Name.ShouldEqual("MyApp.Billing"); -} diff --git a/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs index d360d72..95399ab 100644 --- a/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs @@ -6,7 +6,7 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Generates a Screenplay document by loading the target into a Roslyn compilation and handing it to the +/// Generates a Screenplay document by loading the target into Roslyn compilations and handing them to the /// Cratis.Arc.Screenplay generator. /// /// @@ -19,25 +19,42 @@ public sealed class ArcScreenplayGeneration : IScreenplayGeneration public async Task Generate(string targetPath, ScreenplayGenerationOptions options, CancellationToken cancellationToken) { var loaded = await ScreenplayCompilationLoader.Load(targetPath, cancellationToken); - if (loaded.Compilation is null) + if (loaded.Compilations.Count == 0) { return new GeneratedScreenplay(string.Empty, loaded.Diagnostics); } var result = new ScreenplayGenerator().Generate( - loaded.Compilation, + loaded.Compilations, new ScreenplayOptions { - Domain = options.Domain, + Domain = options.Domain ?? DomainFrom(targetPath, loaded), Module = options.Module, SegmentsToSkip = options.SegmentsToSkip }); return new GeneratedScreenplay( result.Source, - [.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)]); + [.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)]) + { + Projects = loaded.ProjectNames + }; } + /// + /// Gets the domain to use when none was given. + /// + /// The solution or project that was read. + /// What was loaded from it. + /// The domain name, or to leave the choice to the generator. + /// + /// The generator names the domain after the assembly, which it can only do when it read exactly one — several + /// projects have no single assembly to name, and the fallback name describes nobody's application. The solution + /// is the name the application already goes by, and --domain still overrides it. + /// + static string? DomainFrom(string targetPath, LoadedCompilation loaded) => + loaded.Compilations.Count > 1 ? Path.GetFileNameWithoutExtension(targetPath) : null; + static ScreenplayDiagnostic Map(Cratis.Arc.Screenplay.ScreenplayDiagnostic diagnostic) => new( (ScreenplayDiagnosticSeverity)(int)diagnostic.Severity, diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs index 32cb055..cfcd779 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs @@ -145,14 +145,18 @@ static void WriteResult(string format, string outputPath, string targetPath, Gen { Path = outputPath, Source = targetPath, + generated.Projects, Lines = CountLines(generated.Source), Diagnostics = generated.Diagnostics.Count }, result => { + // Which projects took part is the difference between a document describing the whole application + // and one describing part of it, so the panel says so rather than only naming what was read. var content = new Markup( $"[bold]{result.Path.EscapeMarkup()}[/]\n" + $"Source: {result.Source.EscapeMarkup()}\n" + + $"Projects: {string.Join(", ", result.Projects).EscapeMarkup()}\n" + $"Lines: {result.Lines}\n" + $"Diagnostics: {result.Diagnostics}"); var panel = new Panel(content) diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs index f9f0cc0..7ee3c8d 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs @@ -29,7 +29,7 @@ public class GenerateScreenplaySettings : GlobalSettings /// Gets or sets the domain the generated document belongs to. /// [CommandOption("--domain ")] - [Description("Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project.")] + [Description("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.")] public string? Domain { get; set; } /// diff --git a/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs b/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs index 82353ba..3ace866 100644 --- a/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs +++ b/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs @@ -10,6 +10,15 @@ namespace Cratis.Cli.Commands.Screenplay; /// Everything the generator could not fully express, in the order it was reported. public record GeneratedScreenplay(string Source, IReadOnlyList Diagnostics) { + /// + /// Gets the names of the projects the document was generated from. + /// + /// + /// An application is regularly split across several projects, so which ones took part is the difference between + /// a document that describes all of it and one that describes half of it. + /// + public IReadOnlyList Projects { get; init; } = []; + /// /// Gets an outcome carrying no source and a single error diagnostic. /// diff --git a/Source/Cli/Commands/Screenplay/LoadedCompilation.cs b/Source/Cli/Commands/Screenplay/LoadedCompilation.cs index 067973c..2b0cb37 100644 --- a/Source/Cli/Commands/Screenplay/LoadedCompilation.cs +++ b/Source/Cli/Commands/Screenplay/LoadedCompilation.cs @@ -6,12 +6,12 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Represents the outcome of loading a solution or project into a Roslyn compilation. +/// Represents the outcome of loading a solution or project into the Roslyn compilations to generate from. /// -/// The compilation to generate from; when loading failed. -/// The name of the project the compilation came from; empty when loading failed. +/// The compilations to generate from; empty when nothing could be loaded. +/// The names of the projects the compilations came from, in the same order. /// Anything worth reporting about the load itself. -public record LoadedCompilation(Compilation? Compilation, string ProjectName, IReadOnlyList Diagnostics) +public record LoadedCompilation(IReadOnlyList Compilations, IReadOnlyList ProjectNames, IReadOnlyList Diagnostics) { /// /// Gets a failed outcome carrying a single error diagnostic. @@ -23,7 +23,7 @@ public record LoadedCompilation(Compilation? Compilation, string ProjectName, IR /// The failed . public static LoadedCompilation Failed(string code, string message, string? location, IEnumerable? warnings = null) => new( - null, - string.Empty, + [], + [], [.. warnings ?? [], new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, code, message, location)]); } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs index 5420ee5..ca5e95b 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -9,12 +9,13 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Loads a solution or project into the Roslyn compilation the Screenplay generator reads. +/// Loads a solution or project into the Roslyn compilations the Screenplay generator reads. /// /// -/// The Cratis.Arc.Screenplay generator deliberately never loads an MSBuild workspace — it takes a -/// and nothing else. Doing the workspace work here keeps that seam intact and makes the -/// generator equally usable from an MSBuild task, an analyzer, or a spec that builds a compilation from strings. +/// The Cratis.Arc.Screenplay generator deliberately never loads an MSBuild workspace — it takes +/// instances and nothing else. Doing the workspace work here keeps that seam intact and +/// makes the generator equally usable from an MSBuild task, an analyzer, or a spec that builds a compilation from +/// strings. /// public static class ScreenplayCompilationLoader { @@ -79,11 +80,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel .GroupBy(project => project.Name, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); - var candidates = byName.Values - .Select(project => new ScreenplayProjectCandidate(project.Name, IsExecutable(project))) - .ToArray(); - - var narrowed = ScreenplayProjectSelection.Narrow(candidates); + var narrowed = ScreenplayProjectSelection.Narrow(byName.Keys); if (narrowed.Count == 0) { return LoadedCompilation.Failed( @@ -93,26 +90,41 @@ static async Task LoadWithWorkspace(string targetPath, Cancel failures); } - if (narrowed.Count > 1) + var compilations = new List(); + var names = new List(); + + // A project that yields no compilation is left out of the document rather than ending the run, so that a + // solution still describes the projects that did load - and is reported as an error, because a document + // missing part of the application it names is exactly what nobody notices on their own. + var unloadable = new List(); + + foreach (var name in narrowed) + { + var project = GeneratedResourceSources.AddMissingTo(byName[name]); + var compilation = await project.GetCompilationAsync(cancellationToken); + if (compilation is null) + { + unloadable.Add(new ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity.Error, + ScreenplayDiagnosticCodes.NoCompilation, + $"No compilation could be created for '{project.Name}', which is therefore not part of the document", + project.FilePath ?? targetPath)); + continue; + } + + compilations.Add(compilation); + names.Add(project.Name); + } + + if (compilations.Count == 0) { return LoadedCompilation.Failed( - ScreenplayDiagnosticCodes.AmbiguousProject, - $"'{targetPath}' holds {narrowed.Count} candidate projects ({string.Join(", ", narrowed.Select(candidate => candidate.Name))}) — pass the project to generate from", + ScreenplayDiagnosticCodes.NoCompilation, + $"No compilation could be created for any project in '{targetPath}'", targetPath, failures); } - var selected = GeneratedResourceSources.AddMissingTo(byName[narrowed[0].Name]); - var compilation = await selected.GetCompilationAsync(cancellationToken); - return compilation is null - ? LoadedCompilation.Failed( - ScreenplayDiagnosticCodes.NoCompilation, - $"No compilation could be created for '{selected.Name}'", - selected.FilePath ?? targetPath, - failures) - : new LoadedCompilation(compilation, selected.Name, failures); + return new LoadedCompilation(compilations, names, [.. failures, .. unloadable]); } - - static bool IsExecutable(Project project) => - project.CompilationOptions?.OutputKind is OutputKind.ConsoleApplication or OutputKind.WindowsApplication; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs index 9b77be5..1b202a7 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -9,6 +9,10 @@ namespace Cratis.Cli.Commands.Screenplay; /// /// These sit alongside the codes the Cratis.Arc.Screenplay generator reports and use a distinct prefix so the /// two can never be confused for one another. +/// +/// CLI0002 is retired and must not be reused. It reported a solution holding more than one candidate project +/// as ambiguous, which stopped being a question once several projects could describe one application together. +/// /// public static class ScreenplayDiagnosticCodes { @@ -17,18 +21,13 @@ public static class ScreenplayDiagnosticCodes /// public const string NoProject = "CLI0001"; - /// - /// The solution holds more than one candidate project and the choice is ambiguous. - /// - public const string AmbiguousProject = "CLI0002"; - /// /// MSBuild reported a problem while loading the solution or project. /// public const string WorkspaceFailure = "CLI0003"; /// - /// The project loaded but Roslyn could not produce a compilation for it. + /// A project loaded but Roslyn could not produce a compilation for it, so it is not part of the document. /// public const string NoCompilation = "CLI0004"; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs b/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs deleted file mode 100644 index 69ca5dc..0000000 --- a/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs +++ /dev/null @@ -1,11 +0,0 @@ -// 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; - -/// -/// Represents a project in a loaded solution that a Screenplay could be generated from. -/// -/// The project name. -/// Whether the project produces an executable rather than a library. -public record ScreenplayProjectCandidate(string Name, bool IsExecutable); diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs b/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs index 8be725d..43d6227 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs @@ -4,49 +4,41 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Selects which project of a loaded solution the Screenplay is generated from. +/// Selects which projects of a loaded solution the Screenplay is generated from. /// /// -/// A Screenplay describes one application, so a solution has to narrow down to a single project. Spec projects are -/// dropped, and when any project produces an executable the libraries are dropped too — an Arc application is the -/// executable. Anything still ambiguous is reported rather than guessed at. +/// A Screenplay describes one application, and an application is regularly split across several projects, so every +/// project that remains takes part in the same document. Spec projects are dropped — they describe the application +/// rather than being part of it. /// public static class ScreenplayProjectSelection { - static readonly string[] _specSuffixes = [".Specs", ".Specifications", ".Tests", ".Test", ".IntegrationTests"]; + static readonly string[] _specNames = ["Specs", "Specifications", "Tests", "Test", "IntegrationTests"]; /// - /// Selects the single project to generate from. + /// Narrows the projects down to the ones the Screenplay is generated from. /// - /// The projects found in the solution. - /// The name of the selected project, or when the choice is ambiguous or there is nothing to choose from. - public static string? Select(IEnumerable candidates) - { - var remaining = Narrow(candidates); - return remaining.Count == 1 ? remaining[0].Name : null; - } - - /// - /// Narrows the projects down to the ones a Screenplay could reasonably be generated from. - /// - /// The projects found in the solution. - /// The remaining projects, ordered by name. - public static IReadOnlyList Narrow(IEnumerable candidates) - { - var withoutSpecs = candidates - .Where(candidate => !IsSpecProject(candidate.Name)) - .OrderBy(candidate => candidate.Name, StringComparer.Ordinal) - .ToArray(); - - var executables = withoutSpecs.Where(candidate => candidate.IsExecutable).ToArray(); - return executables.Length > 0 ? executables : withoutSpecs; - } + /// The names of the projects found in the solution. + /// The remaining project names, ordered by name. + public static IReadOnlyList Narrow(IEnumerable projectNames) => + [.. projectNames + .Where(name => !IsSpecProject(name)) + .Order(StringComparer.Ordinal)]; /// /// Determines whether the given project name identifies a spec or test project. /// /// The project name. /// when the project holds specs or tests. + /// + /// The last segment of the name decides, so a project called Specs is recognized as readily as + /// MyApp.Specs — a solution that groups its integration specs in a folder regularly names the project + /// just that, and taking it for part of the application puts test-only artifacts in the document. + /// public static bool IsSpecProject(string name) => - Array.Exists(_specSuffixes, suffix => name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); + Array.Exists( + _specNames, + spec => + name.Equals(spec, StringComparison.OrdinalIgnoreCase) || + name.EndsWith($".{spec}", StringComparison.OrdinalIgnoreCase)); } From 9fa40cb84fff67afe91bc1deec01c939da5f0879 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 14:55:26 +0200 Subject: [PATCH 2/2] Document how a solution is read into one Screenplay Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VRa2Z1cA6D3Fuw9TAo6m2p --- Documentation/reference/screenplay.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md index 8c7c324..947d634 100644 --- a/Documentation/reference/screenplay.md +++ b/Documentation/reference/screenplay.md @@ -34,7 +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. | -| `--domain ` | Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project. | +| `--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. | @@ -51,11 +51,15 @@ cratis screenplay generate --domain Library --module Lending --file Library.play When `PATH` is a solution or project file, that file is read. When it is a folder — or is omitted entirely — the CLI looks in that folder and then in each parent folder in turn, stopping at the first one that holds a match. Within a folder it prefers `.slnx`, then `.sln`, then `.csproj`. Two candidates of the same kind in one folder is reported rather than guessed at. -A Screenplay describes one application, so a solution has to narrow down to a single project: +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 the ones whose name ends in `.Specs`, `.Specifications`, `.Tests`, `.Test`, or `.IntegrationTests`. -1. Projects whose name ends in `.Specs`, `.Specifications`, `.Tests`, `.Test`, or `.IntegrationTests` are dropped. -2. If any project that remains produces an executable, the libraries are dropped — an Arc application is the executable. -3. Exactly one project must remain. Anything else is reported, listing the candidates, so you can pass the project you meant. +The projects that were read are named in the result, so you can see what the document covers: + +```text +Projects: Library.Api, Library.Domain, Library.ReadModels +``` + +Pass a `.csproj` instead of the solution to describe a single project. ### Diagnostics @@ -89,7 +93,8 @@ The project does **not** have to have been built first. Sources MSBuild generate | `PATH` does not exist | Not-found error. | | `PATH` is a file that is not a solution or project | Not-found error. | | No solution or project found in `PATH` or any parent folder | Not-found error. | -| The solution holds more than one candidate project | Validation error listing the candidates. | +| The solution holds no project that is not specs | Validation error. | +| A project cannot be read into a compilation | Validation error 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. |