From 1fd5e51223e54d28c7bc6616abb4d8ae4e35f8aa Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 18:40:28 +0200 Subject: [PATCH 01/10] Add screenplay generate command and opt-in generator dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `cratis screenplay generate`, which loads a target project or solution into a Roslyn compilation and hands it to the Cratis.Arc.Screenplay generator to produce a `.play` document. Cratis.Arc.Screenplay is not published yet, so the CLI depends on it only when the CratisArcScreenplayProject MSBuild property points at a local checkout. Without it, the CLI still builds and the command reports CLI0005 instead of generating — this keeps the CLI buildable ahead of the generator's release. Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 8 +- Source/Cli/Cli.csproj | 26 +++ .../Screenplay/ArcScreenplayGeneration.cs | 47 ++++++ .../Screenplay/GenerateScreenplayCommand.cs | 155 ++++++++++++++++++ .../Screenplay/GenerateScreenplaySettings.cs | 54 ++++++ .../Screenplay/GeneratedScreenplay.cs | 21 +++ .../Screenplay/IScreenplayGeneration.cs | 24 +++ .../Commands/Screenplay/LoadedCompilation.cs | 29 ++++ .../Screenplay/ScreenplayCompilationLoader.cs | 117 +++++++++++++ .../Screenplay/ScreenplayDiagnostic.cs | 17 ++ .../Screenplay/ScreenplayDiagnosticCodes.cs | 39 +++++ .../ScreenplayDiagnosticSeverity.cs | 25 +++ .../Screenplay/ScreenplayDiagnostics.cs | 46 ++++++ .../Screenplay/ScreenplayDiagnosticsWriter.cs | 102 ++++++++++++ .../Commands/Screenplay/ScreenplayDocument.cs | 62 +++++++ .../Screenplay/ScreenplayGenerationOptions.cs | 18 ++ .../Screenplay/ScreenplayGenerations.cs | 21 +++ .../Screenplay/ScreenplayProjectCandidate.cs | 11 ++ .../Screenplay/ScreenplayProjectSelection.cs | 52 ++++++ .../Commands/Screenplay/ScreenplayTarget.cs | 33 ++++ .../Screenplay/ScreenplayTargetResolver.cs | 114 +++++++++++++ .../UnavailableScreenplayGeneration.cs | 21 +++ Source/Cli/Registration/ScreenplayBranch.cs | 12 ++ 23 files changed, 1053 insertions(+), 1 deletion(-) create mode 100644 Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs create mode 100644 Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs create mode 100644 Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs create mode 100644 Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs create mode 100644 Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs create mode 100644 Source/Cli/Commands/Screenplay/LoadedCompilation.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayDiagnosticSeverity.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayDiagnostics.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayDocument.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayTarget.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs create mode 100644 Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.cs create mode 100644 Source/Cli/Registration/ScreenplayBranch.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2e0b32d..704fd59 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,7 +14,7 @@ - + @@ -27,6 +27,12 @@ + + + + + + diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 27d7c45..1f6cf40 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -35,6 +35,32 @@ + + + + + + + + + + + + $(DefineConstants);CRATIS_ARC_SCREENPLAY + + + + + + + + diff --git a/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs new file mode 100644 index 0000000..d360d72 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Generates a Screenplay document by loading the target into a Roslyn compilation and handing it to the +/// Cratis.Arc.Screenplay generator. +/// +/// +/// This is the only place in the CLI that knows the generator exists. Everything else is expressed against +/// . +/// +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) + { + return new GeneratedScreenplay(string.Empty, loaded.Diagnostics); + } + + var result = new ScreenplayGenerator().Generate( + loaded.Compilation, + new ScreenplayOptions + { + Domain = options.Domain, + Module = options.Module, + SegmentsToSkip = options.SegmentsToSkip + }); + + return new GeneratedScreenplay( + result.Source, + [.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)]); + } + + static ScreenplayDiagnostic Map(Cratis.Arc.Screenplay.ScreenplayDiagnostic diagnostic) => + new( + (ScreenplayDiagnosticSeverity)(int)diagnostic.Severity, + diagnostic.Code, + diagnostic.Message, + diagnostic.Location); +} diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs new file mode 100644 index 0000000..987d6ee --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs @@ -0,0 +1,155 @@ +// 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; + +/// +/// 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. +/// +[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))] +[CliExample("screenplay", "generate")] +[CliExample("screenplay", "generate", "./MyApp.slnx", "--file", "MyApp.play")] +[CliExample("screenplay", "generate", "./Source/MyApp/MyApp.csproj")] +[LlmOption("[PATH]", "string", "Solution (.slnx, .sln), 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("--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.")] +[LlmOutputAdvice("json-compact", "The .play document always goes to standard output verbatim; the format only shapes the summary and the diagnostics, and json-compact makes the diagnostics machine-readable on standard error.")] +public class GenerateScreenplayCommand : AsyncCommand +{ + readonly IScreenplayGeneration _generation; + readonly Func _standardOutput; + + /// + /// Initializes a new instance of the class. + /// + public GenerateScreenplayCommand() + : this(ScreenplayGenerations.Create(), Console.OpenStandardOutput) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The generation to produce the Screenplay with. + /// Opens the stream the document is written to when no file is given. + internal GenerateScreenplayCommand(IScreenplayGeneration generation, Func standardOutput) + { + _generation = generation; + _standardOutput = standardOutput; + } + + /// + protected override async Task ExecuteAsync(CommandContext context, GenerateScreenplaySettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + var currentDirectory = Directory.GetCurrentDirectory(); + + // Standard output carries the document itself unless a file is given, so failures may not be reported + // through the ordinary panel — it would end up inside the redirected '.play' file. + var writesDocumentToStandardOutput = string.IsNullOrWhiteSpace(settings.File); + + var target = ScreenplayTargetResolver.Resolve(settings.Path, currentDirectory); + if (!target.IsResolved) + { + WriteError(format, writesDocumentToStandardOutput, target.Error!, target.Suggestion, ExitCodes.NotFoundCode); + return ExitCodes.NotFound; + } + + var generated = await _generation.Generate(target.Path!, settings.ToGenerationOptions(), cancellationToken); + ScreenplayDiagnosticsWriter.Write(format, generated.Diagnostics); + + var exitCode = ScreenplayDiagnostics.ExitCodeFor(generated.Diagnostics); + if (exitCode != ExitCodes.Success) + { + var errors = generated.Diagnostics.Count(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error); + WriteError( + format, + writesDocumentToStandardOutput, + $"Screenplay generation reported {errors} error(s)", + "Resolve the reported errors, or generate from a project where the unsupported constructs are not used", + ExitCodes.ValidationErrorCode); + return exitCode; + } + + if (writesDocumentToStandardOutput) + { + await using var stream = _standardOutput(); + await ScreenplayDocument.Write(stream, generated.Source, cancellationToken); + return ExitCodes.Success; + } + + var outputPath = ScreenplayDocument.ResolvePath(settings.File!, currentDirectory); + await ScreenplayDocument.WriteToFile(outputPath, generated.Source, cancellationToken); + WriteResult(format, outputPath, target.Path!, generated); + return ExitCodes.Success; + } + + static void WriteError(string format, bool keepStandardOutputClean, string error, string? suggestion, string errorCode) + { + if (!keepStandardOutputClean || GoesToStandardError(format)) + { + OutputFormatter.WriteError(format, error, suggestion, errorCode); + return; + } + + Console.Error.WriteLine(); + Console.Error.WriteLine($"error: {error}"); + if (!string.IsNullOrWhiteSpace(suggestion)) + { + Console.Error.WriteLine($" -> {suggestion}"); + } + } + + /// + /// Mirrors the formats already reports on standard error. + /// + /// The resolved output format. + /// when the formatter writes errors to standard error. + static bool GoesToStandardError(string format) => + string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal); + + static void WriteResult(string format, string outputPath, string targetPath, GeneratedScreenplay generated) + { + if (string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal)) + { + Console.WriteLine(outputPath); + return; + } + + OutputFormatter.WriteObject( + format, + new + { + Path = outputPath, + Source = targetPath, + Lines = CountLines(generated.Source), + Diagnostics = generated.Diagnostics.Count + }, + result => + { + var content = new Markup( + $"[bold]{result.Path.EscapeMarkup()}[/]\n" + + $"Source: {result.Source.EscapeMarkup()}\n" + + $"Lines: {result.Lines}\n" + + $"Diagnostics: {result.Diagnostics}"); + var panel = new Panel(content) + .Header(" Screenplay generated ") + .Border(BoxBorder.Rounded) + .BorderStyle(new Style(OutputFormatter.Success)) + .Padding(1, 0); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]→ Run it in a local Stage sandbox with: cratis run[/]"); + }); + } + + static int CountLines(string source) => + source.Length == 0 ? 0 : source.AsSpan().TrimEnd('\n').Count('\n') + 1; +} diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs new file mode 100644 index 0000000..f9f0cc0 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs @@ -0,0 +1,54 @@ +// 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; + +/// +/// Settings for the screenplay generate command. +/// +public class GenerateScreenplaySettings : GlobalSettings +{ + /// + /// Gets or sets the solution, project, or folder to generate from. + /// + [CommandArgument(0, "[PATH]")] + [Description("Solution (.slnx, .sln), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] + public string? Path { get; set; } + + /// + /// Gets or sets the file the generated Screenplay is written to. + /// + /// + /// Named --file rather than -o because -o is the global output format flag. + /// + [CommandOption("--file ")] + [Description("File to write the generated Screenplay to. Writes to standard output when not given.")] + public string? File { get; set; } + + /// + /// 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.")] + public string? Domain { get; set; } + + /// + /// Gets or sets the module every discovered feature is placed within. + /// + [CommandOption("--module ")] + [Description("Name of the module every discovered feature is placed within. Defaults to the domain.")] + public string? Module { get; set; } + + /// + /// Gets or sets the number of leading namespace segments to skip when inferring features and slices. + /// + [CommandOption("--skip-segments ")] + [Description("Number of leading namespace segments to skip when inferring features and slices.")] + public int? SkipSegments { get; set; } + + /// + /// Gets the generation options these settings describe. + /// + /// The . + public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments); +} diff --git a/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs b/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs new file mode 100644 index 0000000..82353ba --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GeneratedScreenplay.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.Commands.Screenplay; + +/// +/// Represents the outcome of generating a Screenplay document from source code. +/// +/// The generated .play source; empty when generation failed outright. +/// Everything the generator could not fully express, in the order it was reported. +public record GeneratedScreenplay(string Source, IReadOnlyList Diagnostics) +{ + /// + /// Gets an outcome carrying no source and a single error diagnostic. + /// + /// The stable diagnostic code. + /// The human readable description. + /// The failed . + public static GeneratedScreenplay Failed(string code, string message) => + new(string.Empty, [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, code, message, null)]); +} diff --git a/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs new file mode 100644 index 0000000..40239f1 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.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. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Defines a system that generates a Screenplay document from the source code of a Cratis Arc application. +/// +/// +/// This is the seam between the CLI and the Cratis.Arc.Screenplay generator. 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. +/// +public interface IScreenplayGeneration +{ + /// + /// Generates the Screenplay document describing the application in the given solution or project. + /// + /// The full path of the solution or project file to read. + /// The options that shape the generated document. + /// Cancellation token. + /// The holding the source and any diagnostics. + Task Generate(string targetPath, ScreenplayGenerationOptions options, CancellationToken cancellationToken); +} diff --git a/Source/Cli/Commands/Screenplay/LoadedCompilation.cs b/Source/Cli/Commands/Screenplay/LoadedCompilation.cs new file mode 100644 index 0000000..067973c --- /dev/null +++ b/Source/Cli/Commands/Screenplay/LoadedCompilation.cs @@ -0,0 +1,29 @@ +// 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; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Represents the outcome of loading a solution or project into a Roslyn compilation. +/// +/// The compilation to generate from; when loading failed. +/// The name of the project the compilation came from; empty when loading failed. +/// Anything worth reporting about the load itself. +public record LoadedCompilation(Compilation? Compilation, string ProjectName, IReadOnlyList Diagnostics) +{ + /// + /// Gets a failed outcome carrying a single error diagnostic. + /// + /// The stable diagnostic code. + /// The human readable description. + /// The solution or project the failure applies to. + /// Diagnostics gathered before the failure. + /// 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 new file mode 100644 index 0000000..f243efa --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -0,0 +1,117 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.MSBuild; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Loads a solution or project into the Roslyn compilation 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. +/// +public static class ScreenplayCompilationLoader +{ + static readonly Lock _registration = new(); + + /// + /// Registers the .NET SDK MSBuild instance with the process. + /// + /// + /// This has to happen before any MSBuild type is touched, which is why every member that does touch one is + /// marked as not inlinable — the JIT would otherwise resolve those types while this method is still running. + /// + public static void RegisterMSBuild() + { + lock (_registration) + { + if (!MSBuildLocator.IsRegistered) + { + MSBuildLocator.RegisterDefaults(); + } + } + } + + /// + /// Loads the given solution or project and returns the compilation to generate from. + /// + /// The full path of the solution or project file. + /// Cancellation token. + /// The describing the outcome. + [MethodImpl(MethodImplOptions.NoInlining)] + public static async Task Load(string targetPath, CancellationToken cancellationToken) + { + RegisterMSBuild(); + return await LoadWithWorkspace(targetPath, cancellationToken); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task LoadWithWorkspace(string targetPath, CancellationToken cancellationToken) + { + var failures = new List(); + var failureLock = new Lock(); + using var workspace = MSBuildWorkspace.Create(); + using var subscription = workspace.RegisterWorkspaceFailedHandler(args => + { + lock (failureLock) + { + failures.Add(new ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity.Warning, + ScreenplayDiagnosticCodes.WorkspaceFailure, + args.Diagnostic.Message, + targetPath)); + } + }); + + var projects = ScreenplayTargetResolver.IsSolution(targetPath) + ? (await workspace.OpenSolutionAsync(targetPath, cancellationToken: cancellationToken)).Projects + : [await workspace.OpenProjectAsync(targetPath, cancellationToken: cancellationToken)]; + + var byName = projects + .Where(project => project.Language == LanguageNames.CSharp) + .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); + if (narrowed.Count == 0) + { + return LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.NoProject, + $"No C# project to generate from was found in '{targetPath}'", + targetPath, + failures); + } + + if (narrowed.Count > 1) + { + return LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.AmbiguousProject, + $"'{targetPath}' holds {narrowed.Count} candidate projects ({string.Join(", ", narrowed.Select(candidate => candidate.Name))}) — pass the project to generate from", + targetPath, + failures); + } + + var selected = 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); + } + + static bool IsExecutable(Project project) => + project.CompilationOptions?.OutputKind is OutputKind.ConsoleApplication or OutputKind.WindowsApplication; +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs new file mode 100644 index 0000000..8f8f66a --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.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.Commands.Screenplay; + +/// +/// Represents something the generator could not fully express in the generated Screenplay document. +/// +/// How severe the diagnostic is. +/// The stable diagnostic code, for example SP0001. +/// The human readable description. +/// The slice, artifact, or file the diagnostic points at; when it applies to the whole document. +public record ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity Severity, + string Code, + string Message, + string? Location); diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs new file mode 100644 index 0000000..1d52675 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.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; + +/// +/// The diagnostic codes the CLI itself reports while preparing a Screenplay generation. +/// +/// +/// 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. +/// +public static class ScreenplayDiagnosticCodes +{ + /// + /// No project in the solution could be generated from. + /// + 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. + /// + public const string NoCompilation = "CLI0004"; + + /// + /// The Cratis.Arc.Screenplay generator is not part of this build of the CLI. + /// + public const string GeneratorUnavailable = "CLI0005"; +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticSeverity.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticSeverity.cs new file mode 100644 index 0000000..43f2b04 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticSeverity.cs @@ -0,0 +1,25 @@ +// 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 how severe a reported during generation is. +/// +public enum ScreenplayDiagnosticSeverity +{ + /// + /// Informational — the generated document is complete; the diagnostic only adds context. + /// + Information = 0, + + /// + /// A construct was recognized but could not be fully represented in the generated document. + /// + Warning = 1, + + /// + /// Generation failed for the construct; the generated document does not describe the source faithfully. + /// + Error = 2 +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnostics.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostics.cs new file mode 100644 index 0000000..954eb04 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostics.cs @@ -0,0 +1,46 @@ +// 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; + +/// +/// Orders generation diagnostics and turns them into an exit code. +/// +public static class ScreenplayDiagnostics +{ + /// + /// Orders diagnostics by descending severity and then deterministically within each severity. + /// + /// The diagnostics to order. + /// The ordered diagnostics. + public static IReadOnlyList Order(IEnumerable diagnostics) => + [.. diagnostics + .OrderByDescending(diagnostic => diagnostic.Severity) + .ThenBy(diagnostic => diagnostic.Code, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Location ?? string.Empty, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal)]; + + /// + /// Groups diagnostics by severity, most severe first, keeping each group deterministically ordered. + /// + /// The diagnostics to group. + /// The groups, most severe first. + public static IReadOnlyList> GroupBySeverity(IEnumerable diagnostics) => + [.. Order(diagnostics).GroupBy(diagnostic => diagnostic.Severity)]; + + /// + /// Determines whether any diagnostic is an error. + /// + /// The diagnostics to inspect. + /// when at least one diagnostic is an error. + public static bool HasErrors(IEnumerable diagnostics) => + diagnostics.Any(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error); + + /// + /// Resolves the exit code for a set of diagnostics — non-zero as soon as one of them is an error. + /// + /// The diagnostics to inspect. + /// The exit code. + public static int ExitCodeFor(IEnumerable diagnostics) => + HasErrors(diagnostics) ? ExitCodes.ValidationError : ExitCodes.Success; +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs new file mode 100644 index 0000000..a8fee2a --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs @@ -0,0 +1,102 @@ +// 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; + +/// +/// Renders generation diagnostics to standard error, grouped by severity. +/// +/// +/// Diagnostics always go to standard error. The generated document may be on standard output, and mixing the two +/// would corrupt it — cratis screenplay generate > MyApp.play has to keep working. +/// +public static class ScreenplayDiagnosticsWriter +{ + /// + /// Writes the diagnostics to standard error in the given output format. + /// + /// The resolved output format. + /// The diagnostics to write. + public static void Write(string format, IEnumerable diagnostics) + { + var groups = ScreenplayDiagnostics.GroupBySeverity(diagnostics); + if (groups.Count == 0) + { + return; + } + + if (IsMachineReadable(format)) + { + WriteJson(format, groups); + return; + } + + WriteText(groups); + } + + /// + /// Gets the label used for a severity in text output. + /// + /// The severity to label. + /// The label. + public static string LabelFor(ScreenplayDiagnosticSeverity severity) => severity switch + { + ScreenplayDiagnosticSeverity.Error => "error", + ScreenplayDiagnosticSeverity.Warning => "warning", + _ => "info" + }; + + /// + /// Gets the heading used for a group of diagnostics of the same severity. + /// + /// The severity the group holds. + /// The heading. + public static string GroupHeadingFor(ScreenplayDiagnosticSeverity severity) => severity switch + { + ScreenplayDiagnosticSeverity.Error => "errors", + ScreenplayDiagnosticSeverity.Warning => "warnings", + _ => "information" + }; + + static bool IsMachineReadable(string format) => + string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonQuiet, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal); + + static void WriteJson(string format, IEnumerable> groups) + { + var options = string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) + ? OutputFormatter.IndentedJsonSerializerOptions + : OutputFormatter.JsonSerializerOptions; + + var payload = new + { + Diagnostics = groups.SelectMany(group => group.Select(diagnostic => new + { + Severity = LabelFor(diagnostic.Severity), + diagnostic.Code, + diagnostic.Message, + diagnostic.Location + })) + }; + + Console.Error.WriteLine(JsonSerializer.Serialize(payload, options)); + } + + static void WriteText(IEnumerable> groups) + { + foreach (var group in groups) + { + var label = LabelFor(group.Key); + Console.Error.WriteLine(); + Console.Error.WriteLine($"{GroupHeadingFor(group.Key)} ({group.Count()}):"); + + foreach (var diagnostic in group) + { + var location = string.IsNullOrWhiteSpace(diagnostic.Location) ? string.Empty : $" [{diagnostic.Location}]"; + Console.Error.WriteLine($" {label} {diagnostic.Code}:{location} {diagnostic.Message}"); + } + } + } +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDocument.cs b/Source/Cli/Commands/Screenplay/ScreenplayDocument.cs new file mode 100644 index 0000000..d5c496e --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDocument.cs @@ -0,0 +1,62 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Writes a generated Screenplay document without altering a single byte of it. +/// +/// +/// The generator produces source that ends with exactly one newline, and round-tripping a .play file has to +/// be byte identical. Everything here therefore writes raw UTF-8 without a byte order mark and never appends, +/// trims, or translates a line ending. +/// +public static class ScreenplayDocument +{ + static readonly UTF8Encoding _encoding = new(encoderShouldEmitUTF8Identifier: false); + + /// + /// Resolves the full path the document is written to. + /// + /// The file given on the command line. + /// The directory relative paths are resolved against. + /// The full path of the file to write. + public static string ResolvePath(string file, string currentDirectory) => Path.GetFullPath(file, currentDirectory); + + /// + /// Writes the document to a file, creating the folder it lives in when needed. + /// + /// The full path to write to. + /// The generated .play source. + /// Cancellation token. + /// Awaitable task. + public static async Task WriteToFile(string path, string source, CancellationToken cancellationToken) + { + var folder = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(folder)) + { + Directory.CreateDirectory(folder); + } + + await File.WriteAllBytesAsync(path, _encoding.GetBytes(source), cancellationToken); + } + + /// + /// Writes the document to a stream as raw UTF-8. + /// + /// The stream to write to. It is left open for the caller to dispose. + /// The generated .play source. + /// Cancellation token. + /// Awaitable task. + /// + /// Writing bytes to the raw standard output stream rather than through is deliberate: + /// the console writer applies its own encoding and would corrupt anything outside its code page. + /// + public static async Task Write(Stream stream, string source, CancellationToken cancellationToken) + { + await stream.WriteAsync(_encoding.GetBytes(source), cancellationToken); + await stream.FlushAsync(cancellationToken); + } +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs new file mode 100644 index 0000000..c061c56 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.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.Commands.Screenplay; + +/// +/// Represents the options that shape the generated Screenplay document. +/// +/// The domain the document belongs to; lets the generator derive it from the compilation. +/// 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. +public record ScreenplayGenerationOptions(string? Domain, string? Module, int? SegmentsToSkip) +{ + /// + /// Gets the options that leave every choice to the generator. + /// + public static ScreenplayGenerationOptions Default { get; } = new(null, null, null); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs new file mode 100644 index 0000000..01ca15a --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.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.Commands.Screenplay; + +/// +/// Creates the this build of the CLI can offer. +/// +public static class ScreenplayGenerations +{ + /// + /// Creates the generation to use. + /// + /// The to generate with. + public static IScreenplayGeneration Create() => +#if CRATIS_ARC_SCREENPLAY + new ArcScreenplayGeneration(); +#else + new UnavailableScreenplayGeneration(); +#endif +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs b/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs new file mode 100644 index 0000000..69ca5dc --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs @@ -0,0 +1,11 @@ +// 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 new file mode 100644 index 0000000..8be725d --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Selects which project 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. +/// +public static class ScreenplayProjectSelection +{ + static readonly string[] _specSuffixes = [".Specs", ".Specifications", ".Tests", ".Test", ".IntegrationTests"]; + + /// + /// Selects the single project to generate 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; + } + + /// + /// Determines whether the given project name identifies a spec or test project. + /// + /// The project name. + /// when the project holds specs or tests. + public static bool IsSpecProject(string name) => + Array.Exists(_specSuffixes, suffix => name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayTarget.cs b/Source/Cli/Commands/Screenplay/ScreenplayTarget.cs new file mode 100644 index 0000000..75353f6 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayTarget.cs @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Represents the outcome of resolving the solution or project a Screenplay is generated from. +/// +/// The full path of the resolved solution or project file; when resolution failed. +/// The reason resolution failed; when it succeeded. +/// A hint for resolving the failure; when there is none. +public record ScreenplayTarget(string? Path, string? Error, string? Suggestion) +{ + /// + /// Gets a value indicating whether a solution or project was resolved. + /// + public bool IsResolved => Path is not null; + + /// + /// Gets a resolved target. + /// + /// The full path of the resolved solution or project file. + /// The resolved . + public static ScreenplayTarget Resolved(string path) => new(path, null, null); + + /// + /// Gets an unresolved target carrying the reason and a hint. + /// + /// The reason resolution failed. + /// A hint for resolving the failure. + /// The unresolved . + public static ScreenplayTarget Unresolved(string error, string suggestion) => new(null, error, suggestion); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs b/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs new file mode 100644 index 0000000..d9639bc --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs @@ -0,0 +1,114 @@ +// 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; + +/// +/// Resolves the solution or project file a Screenplay is generated from. +/// +public static class ScreenplayTargetResolver +{ + static readonly string[] _extensions = [".slnx", ".sln", ".csproj"]; + + /// + /// Gets the file extensions that identify a solution or project the generator can read, in discovery order. + /// + public static IReadOnlyList Extensions => _extensions; + + /// + /// Resolves the solution or project file to read. + /// + /// The path given on the command line — a solution file, a project file, or a folder. uses . + /// The directory relative paths are resolved against and discovery starts from. + /// The describing the outcome. + public static ScreenplayTarget Resolve(string? path, string currentDirectory) + { + var candidate = string.IsNullOrWhiteSpace(path) + ? currentDirectory + : Path.GetFullPath(path, currentDirectory); + + if (File.Exists(candidate)) + { + return IsSupportedFile(candidate) + ? ScreenplayTarget.Resolved(candidate) + : ScreenplayTarget.Unresolved( + $"'{candidate}' is not a solution or project file", + $"Point the command at a {string.Join(", ", _extensions)} file, or at the folder holding one"); + } + + if (!Directory.Exists(candidate)) + { + return ScreenplayTarget.Unresolved( + $"'{candidate}' does not exist", + "Point the command at an existing solution file, project file, or folder"); + } + + return Discover(candidate); + } + + /// + /// Determines whether the given file is a solution or project file the generator can read. + /// + /// The file path to check. + /// when the file is supported. + public static bool IsSupportedFile(string path) => + _extensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); + + /// + /// Determines whether the given file is a solution file rather than a project file. + /// + /// The file path to check. + /// when the file is a solution. + public static bool IsSolution(string path) + { + var extension = Path.GetExtension(path); + return string.Equals(extension, ".sln", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".slnx", StringComparison.OrdinalIgnoreCase); + } + + static ScreenplayTarget Discover(string directory) + { + var current = new DirectoryInfo(directory); + while (current is not null) + { + var found = FindIn(current); + if (found is not null) + { + return found; + } + + current = current.Parent; + } + + return ScreenplayTarget.Unresolved( + $"No solution or project file found in '{directory}' or any parent folder", + $"Run the command from a folder holding a {string.Join(", ", _extensions)} file, or pass the path to one"); + } + + static ScreenplayTarget? FindIn(DirectoryInfo directory) + { + foreach (var extension in _extensions) + { + var matches = directory + .GetFiles($"*{extension}", SearchOption.TopDirectoryOnly) + .Select(file => file.FullName) + .Where(file => string.Equals(Path.GetExtension(file), extension, StringComparison.OrdinalIgnoreCase)) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (matches.Length == 1) + { + return ScreenplayTarget.Resolved(matches[0]); + } + + if (matches.Length > 1) + { + return ScreenplayTarget.Unresolved( + $"Found {matches.Length} {extension} files in '{directory.FullName}'", + $"Pass the one to read: {string.Join(", ", matches.Select(Path.GetFileName))}"); + } + } + + return null; + } +} diff --git a/Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.cs new file mode 100644 index 0000000..8d6ff8d --- /dev/null +++ b/Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.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.Commands.Screenplay; + +/// +/// Reports that the Cratis.Arc.Screenplay generator is not part of this build of the CLI. +/// +/// +/// The generator package is not published yet. Until it is, the CLI is built against it only when +/// CratisArcScreenplayProject points at a local checkout of it — see Cli.csproj. Reporting the gap as +/// an error diagnostic keeps the command's contract intact rather than failing in some other, less explicable way. +/// +public sealed class UnavailableScreenplayGeneration : IScreenplayGeneration +{ + /// + public Task Generate(string targetPath, ScreenplayGenerationOptions options, CancellationToken cancellationToken) => + Task.FromResult(GeneratedScreenplay.Failed( + ScreenplayDiagnosticCodes.GeneratorUnavailable, + "This build of the CLI does not include the Cratis.Arc.Screenplay generator")); +} diff --git a/Source/Cli/Registration/ScreenplayBranch.cs b/Source/Cli/Registration/ScreenplayBranch.cs new file mode 100644 index 0000000..2c66948 --- /dev/null +++ b/Source/Cli/Registration/ScreenplayBranch.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#pragma warning disable RCS1251, SA1502 // Marker type is intentionally empty + +namespace Cratis.Cli.Registration; + +/// +/// Authoring and generation of Cratis Screenplay (.play) documents. +/// +[CliBranch("screenplay", "Work with Cratis Screenplay (.play) documents. Generate a Screenplay from source code and inspect the result.")] +public static class ScreenplayBranch; From 465700ead75046bda19ca4cba356fd098de209c1 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 18:40:52 +0200 Subject: [PATCH 02/10] Add specs for screenplay generate command and its collaborators Covers the new generate command end to end: settings resolution and error reporting, target resolution (solution vs. project, ambiguous folders), project candidate narrowing, diagnostic grouping/ordering/ exit-code rules, and document line-ending/encoding handling. Co-Authored-By: Claude Opus 5 --- Source/Cli.Specs/Usings.cs | 3 + .../given/a_generate_screenplay_command.cs | 64 +++++++++++++++++++ .../when_generating/and_a_file_is_given.cs | 26 ++++++++ .../and_generation_options_are_given.cs | 22 +++++++ .../and_generation_reports_an_error.cs | 29 +++++++++ .../and_generation_reports_only_warnings.cs | 24 +++++++ .../when_generating/and_no_file_is_given.cs | 19 ++++++ .../and_the_path_cannot_be_resolved.cs | 18 ++++++ .../and_severities_are_mixed.cs | 21 ++++++ .../and_the_group_holds_information.cs | 13 ++++ .../when_ordering/and_severities_are_mixed.cs | 23 +++++++ .../and_one_of_them_is_an_error.cs | 18 ++++++ .../and_there_are_only_warnings.cs | 17 +++++ ...d_the_source_ends_with_a_single_newline.cs | 24 +++++++ ...d_the_source_holds_non_ascii_characters.cs | 21 ++++++ .../and_one_project_is_an_executable.cs | 18 ++++++ .../and_spec_projects_are_present.cs | 18 ++++++ .../and_there_are_only_spec_projects.cs | 17 +++++ .../and_two_libraries_remain.cs | 26 ++++++++ .../given/a_temporary_folder.cs | 19 ++++++ .../and_a_parent_folder_holds_the_project.cs | 22 +++++++ .../and_nothing_can_be_discovered.cs | 15 +++++ ...d_the_file_is_not_a_solution_or_project.cs | 16 +++++ .../and_the_folder_holds_a_solution.cs | 21 ++++++ .../and_the_path_does_not_exist.cs | 15 +++++ .../and_the_path_is_a_project_file.cs | 22 +++++++ 26 files changed, 551 insertions(+) create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/given/a_generate_screenplay_command.cs create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_a_file_is_given.cs create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error.cs create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_only_warnings.cs create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_no_file_is_given.cs create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_path_cannot_be_resolved.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnostics/when_grouping_by_severity/and_severities_are_mixed.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnostics/when_heading_a_group/and_the_group_holds_information.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnostics/when_ordering/and_severities_are_mixed.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_one_of_them_is_an_error.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_there_are_only_warnings.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_ends_with_a_single_newline.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_holds_non_ascii_characters.cs create 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_spec_projects_are_present.cs create mode 100644 Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.cs create mode 100644 Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs create mode 100644 Source/Cli.Specs/for_ScreenplayTargetResolver/given/a_temporary_folder.cs create mode 100644 Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_a_parent_folder_holds_the_project.cs create mode 100644 Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_nothing_can_be_discovered.cs create mode 100644 Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_file_is_not_a_solution_or_project.cs create mode 100644 Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution.cs create mode 100644 Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_does_not_exist.cs create mode 100644 Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_project_file.cs diff --git a/Source/Cli.Specs/Usings.cs b/Source/Cli.Specs/Usings.cs index a13ae28..9e3f830 100644 --- a/Source/Cli.Specs/Usings.cs +++ b/Source/Cli.Specs/Usings.cs @@ -10,5 +10,8 @@ global using Cratis.Cli.Commands.Llm; global using Cratis.Cli.Commands.Prologue; global using Cratis.Cli.Commands.Run; +global using Cratis.Cli.Commands.Screenplay; global using Cratis.Specifications; +global using NSubstitute; +global using Spectre.Console.Cli; global using Xunit; diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/given/a_generate_screenplay_command.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/given/a_generate_screenplay_command.cs new file mode 100644 index 0000000..49468f2 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/given/a_generate_screenplay_command.cs @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_GenerateScreenplayCommand.given; + +/// +/// Base context that puts a solution in a temporary folder, substitutes the generation, and captures what the +/// command writes to standard output. +/// +public class a_generate_screenplay_command : Specification +{ + protected const string GeneratedSource = "domain Library\n\nmodule Library\n"; + + protected string _folder; + protected string _solution; + protected string _previousDirectory; + protected IScreenplayGeneration _generation; + protected MemoryStream _standardOutput; + protected GenerateScreenplayCommand _command; + protected GenerateScreenplaySettings _settings; + + void Establish() + { + var created = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _previousDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(created); + + // Read the folder back so that specs compare against the same fully resolved path the command sees — + // the temp folder is reached through a symbolic link on macOS. + _folder = Directory.GetCurrentDirectory(); + _solution = Path.Combine(_folder, "MyApp.slnx"); + File.WriteAllText(_solution, ""); + + _generation = Substitute.For(); + _generation + .Generate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GeneratedScreenplay(GeneratedSource, []))); + + _standardOutput = new MemoryStream(); + _command = new GenerateScreenplayCommand(_generation, () => _standardOutput); + _settings = new GenerateScreenplaySettings { Output = OutputFormats.JsonCompact }; + } + + /// + /// Executes the command with the established settings. + /// + /// The exit code. + protected Task Execute() => + ((ICommand)_command).ExecuteAsync( + new CommandContext([], Substitute.For(), "generate", null), + _settings, + CancellationToken.None); + + void Destroy() + { + Directory.SetCurrentDirectory(_previousDirectory); + _standardOutput.Dispose(); + + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_a_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_a_file_is_given.cs new file mode 100644 index 0000000..fc26d8a --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_a_file_is_given.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_a_file_is_given : given.a_generate_screenplay_command +{ + string _outputPath; + int _result; + + void Establish() + { + _outputPath = Path.Combine(_folder, "plays", "MyApp.play"); + _settings.File = Path.Combine("plays", "MyApp.play"); + } + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_write_the_document_to_the_file() => File.ReadAllBytes(_outputPath).ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); + [Fact] void should_create_the_folder_the_file_lives_in() => Directory.Exists(Path.GetDirectoryName(_outputPath)).ShouldBeTrue(); + [Fact] void should_not_write_the_document_to_standard_output() => _standardOutput.ToArray().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 new file mode 100644 index 0000000..60f5568 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs @@ -0,0 +1,22 @@ +// 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_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_generation_options_are_given : given.a_generate_screenplay_command +{ + void Establish() + { + _settings.Domain = "Library"; + _settings.Module = "Lending"; + _settings.SkipSegments = 2; + } + + async Task Because() => await Execute(); + + [Fact] void should_pass_them_to_the_generation() => _generation.Received(1).Generate( + Arg.Any(), + Arg.Is(options => options.Domain == "Library" && options.Module == "Lending" && options.SegmentsToSkip == 2), + Arg.Any()); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error.cs new file mode 100644 index 0000000..fd5715f --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error.cs @@ -0,0 +1,29 @@ +// 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_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_generation_reports_an_error : given.a_generate_screenplay_command +{ + int _result; + + void Establish() + { + _settings.File = "MyApp.play"; + _generation + .Generate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GeneratedScreenplay( + GeneratedSource, + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", "Library.Lending") + ]))); + } + + async Task Because() => _result = await Execute(); + + [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_not_write_the_document_to_the_file() => File.Exists(Path.Combine(_folder, "MyApp.play")).ShouldBeFalse(); + [Fact] void should_not_write_the_document_to_standard_output() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_only_warnings.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_only_warnings.cs new file mode 100644 index 0000000..997761b --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_only_warnings.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 System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_generation_reports_only_warnings : given.a_generate_screenplay_command +{ + int _result; + + void Establish() => + _generation + .Generate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GeneratedScreenplay( + GeneratedSource, + [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null)]))); + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_still_write_the_document() => _standardOutput.ToArray().ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_no_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_no_file_is_given.cs new file mode 100644 index 0000000..3f83347 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_no_file_is_given.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_no_file_is_given : given.a_generate_screenplay_command +{ + int _result; + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_write_the_document_to_standard_output() => _standardOutput.ToArray().ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); + [Fact] void should_generate_from_the_discovered_solution() => _generation.Received(1).Generate(_solution, Arg.Any(), Arg.Any()); + [Fact] void should_not_write_a_file() => Directory.GetFiles(_folder, "*.play").ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_path_cannot_be_resolved.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_path_cannot_be_resolved.cs new file mode 100644 index 0000000..6cb0458 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_path_cannot_be_resolved.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_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_the_path_cannot_be_resolved : given.a_generate_screenplay_command +{ + int _result; + + void Establish() => _settings.Path = Path.Combine(_folder, "does-not-exist"); + + async Task Because() => _result = await Execute(); + + [Fact] void should_report_that_nothing_was_found() => _result.ShouldEqual(ExitCodes.NotFound); + [Fact] void should_not_generate() => _generation.DidNotReceive().Generate(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_write_anything_to_standard_output() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_grouping_by_severity/and_severities_are_mixed.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_grouping_by_severity/and_severities_are_mixed.cs new file mode 100644 index 0000000..eaa072d --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_grouping_by_severity/and_severities_are_mixed.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_ScreenplayDiagnostics.when_grouping_by_severity; + +public class and_severities_are_mixed : Specification +{ + IReadOnlyList> _result; + + void Because() => _result = ScreenplayDiagnostics.GroupBySeverity( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0101", "another warning", null) + ]); + + [Fact] void should_produce_one_group_per_severity() => _result.Count.ShouldEqual(2); + [Fact] void should_put_the_errors_first() => _result[0].Key.ShouldEqual(ScreenplayDiagnosticSeverity.Error); + [Fact] void should_put_the_warnings_after_them() => _result[1].Key.ShouldEqual(ScreenplayDiagnosticSeverity.Warning); + [Fact] void should_keep_both_warnings_together() => _result[1].Count().ShouldEqual(2); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_heading_a_group/and_the_group_holds_information.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_heading_a_group/and_the_group_holds_information.cs new file mode 100644 index 0000000..3a99d42 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_heading_a_group/and_the_group_holds_information.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayDiagnostics.when_heading_a_group; + +public class and_the_group_holds_information : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.GroupHeadingFor(ScreenplayDiagnosticSeverity.Information); + + [Fact] void should_read_as_english_rather_than_a_pluralized_label() => _result.ShouldEqual("information"); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_ordering/and_severities_are_mixed.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_ordering/and_severities_are_mixed.cs new file mode 100644 index 0000000..80cb19f --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_ordering/and_severities_are_mixed.cs @@ -0,0 +1,23 @@ +// 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_ScreenplayDiagnostics.when_ordering; + +public class and_severities_are_mixed : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayDiagnostics.Order( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Information, "SP0300", "third", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "second", "Library.Lending"), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "first", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0100", "zeroth", "Library.Authors") + ]); + + [Fact] void should_put_the_errors_first() => _result[0].Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Error); + [Fact] void should_order_errors_by_code() => _result[0].Code.ShouldEqual("SP0100"); + [Fact] void should_keep_the_second_error_next() => _result[1].Code.ShouldEqual("SP0200"); + [Fact] void should_put_the_warning_after_the_errors() => _result[2].Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning); + [Fact] void should_put_the_information_last() => _result[3].Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Information); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_one_of_them_is_an_error.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_one_of_them_is_an_error.cs new file mode 100644 index 0000000..1a30646 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_one_of_them_is_an_error.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_ScreenplayDiagnostics.when_resolving_the_exit_code; + +public class and_one_of_them_is_an_error : Specification +{ + int _result; + + void Because() => _result = ScreenplayDiagnostics.ExitCodeFor( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", null) + ]); + + [Fact] void should_be_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_not_be_success() => _result.ShouldNotEqual(ExitCodes.Success); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_there_are_only_warnings.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_there_are_only_warnings.cs new file mode 100644 index 0000000..8017bb9 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_there_are_only_warnings.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_ScreenplayDiagnostics.when_resolving_the_exit_code; + +public class and_there_are_only_warnings : Specification +{ + int _result; + + void Because() => _result = ScreenplayDiagnostics.ExitCodeFor( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Information, "SP0300", "a note", null) + ]); + + [Fact] void should_be_success() => _result.ShouldEqual(ExitCodes.Success); +} diff --git a/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_ends_with_a_single_newline.cs b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_ends_with_a_single_newline.cs new file mode 100644 index 0000000..91ade28 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_ends_with_a_single_newline.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 System.Text; + +namespace Cratis.Cli.for_ScreenplayDocument.when_writing; + +public class and_the_source_ends_with_a_single_newline : Specification +{ + const string Source = "domain Library\n\nmodule Library\n"; + byte[] _written; + + async Task Because() + { + await using var stream = new MemoryStream(); + await ScreenplayDocument.Write(stream, Source, CancellationToken.None); + _written = stream.ToArray(); + } + + [Fact] void should_write_the_source_byte_for_byte() => _written.ShouldEqual(Encoding.UTF8.GetBytes(Source)); + [Fact] void should_not_emit_a_byte_order_mark() => _written[0].ShouldEqual((byte)'d'); + [Fact] void should_end_with_exactly_one_newline() => _written[^1].ShouldEqual((byte)'\n'); + [Fact] void should_not_end_with_two_newlines() => _written[^2].ShouldNotEqual((byte)'\n'); +} diff --git a/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_holds_non_ascii_characters.cs b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_holds_non_ascii_characters.cs new file mode 100644 index 0000000..6450699 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_holds_non_ascii_characters.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. + +using System.Text; + +namespace Cratis.Cli.for_ScreenplayDocument.when_writing; + +public class and_the_source_holds_non_ascii_characters : Specification +{ + const string Source = "domain Bibliothèque\n\nmodule Utlån\n"; + byte[] _written; + + async Task Because() + { + await using var stream = new MemoryStream(); + await ScreenplayDocument.Write(stream, Source, CancellationToken.None); + _written = stream.ToArray(); + } + + [Fact] void should_write_them_as_utf8() => _written.ShouldEqual(Encoding.UTF8.GetBytes(Source)); +} 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 new file mode 100644 index 0000000..4786163 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.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_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 new file mode 100644 index 0000000..bd25617 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_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_ScreenplayProjectSelection.when_narrowing; + +public class and_spec_projects_are_present : Specification +{ + string? _result; + + void Because() => _result = ScreenplayProjectSelection.Select( + [ + new ScreenplayProjectCandidate("MyApp", false), + new ScreenplayProjectCandidate("MyApp.Specs", false), + new ScreenplayProjectCandidate("MyApp.Tests", false) + ]); + + [Fact] void should_select_the_only_project_that_is_not_specs() => _result.ShouldEqual("MyApp"); +} 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 new file mode 100644 index 0000000..f33d59e --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.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_ScreenplayProjectSelection.when_narrowing; + +public class and_there_are_only_spec_projects : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayProjectSelection.Narrow( + [ + new ScreenplayProjectCandidate("MyApp.Specs", false), + new ScreenplayProjectCandidate("MyApp.IntegrationTests", true) + ]); + + [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 new file mode 100644 index 0000000..e8ebd20 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.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.Specs/for_ScreenplayTargetResolver/given/a_temporary_folder.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/given/a_temporary_folder.cs new file mode 100644 index 0000000..7c41f4c --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// 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_ScreenplayTargetResolver.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_a_parent_folder_holds_the_project.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_a_parent_folder_holds_the_project.cs new file mode 100644 index 0000000..e8ec70c --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_a_parent_folder_holds_the_project.cs @@ -0,0 +1,22 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_a_parent_folder_holds_the_project : given.a_temporary_folder +{ + string _project; + string _nested; + ScreenplayTarget _result; + + void Establish() + { + _project = Path.Combine(_folder, "MyApp.csproj"); + File.WriteAllText(_project, ""); + _nested = Directory.CreateDirectory(Path.Combine(_folder, "Features", "Authors")).FullName; + } + + void Because() => _result = ScreenplayTargetResolver.Resolve(null, _nested); + + [Fact] void should_find_the_project_by_searching_upwards() => _result.Path.ShouldEqual(_project); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_nothing_can_be_discovered.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_nothing_can_be_discovered.cs new file mode 100644 index 0000000..a56a27d --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_nothing_can_be_discovered.cs @@ -0,0 +1,15 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_nothing_can_be_discovered : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = ScreenplayTargetResolver.Resolve(_folder, _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_nothing_was_found() => _result.Error.ShouldContain("No solution or project file found"); + [Fact] void should_suggest_passing_a_path() => _result.Suggestion.ShouldContain("pass the path to one"); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_file_is_not_a_solution_or_project.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_file_is_not_a_solution_or_project.cs new file mode 100644 index 0000000..08f6254 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_file_is_not_a_solution_or_project.cs @@ -0,0 +1,16 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_the_file_is_not_a_solution_or_project : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Establish() => File.WriteAllText(Path.Combine(_folder, "readme.md"), "not a project"); + + void Because() => _result = ScreenplayTargetResolver.Resolve("readme.md", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_is_not_a_solution_or_project() => _result.Error.ShouldContain("is not a solution or project file"); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution.cs new file mode 100644 index 0000000..07492e7 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution.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_ScreenplayTargetResolver.when_resolving; + +public class and_the_folder_holds_a_solution : given.a_temporary_folder +{ + string _solution; + ScreenplayTarget _result; + + void Establish() + { + _solution = Path.Combine(_folder, "MyApp.slnx"); + File.WriteAllText(_solution, ""); + File.WriteAllText(Path.Combine(_folder, "MyApp.csproj"), ""); + } + + void Because() => _result = ScreenplayTargetResolver.Resolve(null, _folder); + + [Fact] void should_prefer_the_solution_over_the_project() => _result.Path.ShouldEqual(_solution); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_does_not_exist.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_does_not_exist.cs new file mode 100644 index 0000000..24686d0 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_does_not_exist.cs @@ -0,0 +1,15 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_the_path_does_not_exist : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = ScreenplayTargetResolver.Resolve("Missing/MyApp.csproj", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_does_not_exist() => _result.Error.ShouldContain("does not exist"); + [Fact] void should_suggest_what_to_point_at() => _result.Suggestion.ShouldNotBeNull(); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_project_file.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_project_file.cs new file mode 100644 index 0000000..7fd1b52 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_project_file.cs @@ -0,0 +1,22 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_the_path_is_a_project_file : given.a_temporary_folder +{ + string _project; + ScreenplayTarget _result; + + void Establish() + { + _project = Path.Combine(_folder, "MyApp.csproj"); + File.WriteAllText(_project, ""); + } + + void Because() => _result = ScreenplayTargetResolver.Resolve("MyApp.csproj", _folder); + + [Fact] void should_resolve() => _result.IsResolved.ShouldBeTrue(); + [Fact] void should_resolve_the_project_relative_to_the_current_directory() => _result.Path.ShouldEqual(_project); + [Fact] void should_not_report_an_error() => _result.Error.ShouldBeNull(); +} From 824d984eb7ea17bceee743f090741de0747eeb65 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 18:41:39 +0200 Subject: [PATCH 03/10] Document the screenplay generate command Adds the reference page for `cratis screenplay generate`, covering target resolution, output options, and diagnostics, and links it from the reference index and table of contents. Co-Authored-By: Claude Opus 5 --- Documentation/reference/index.md | 1 + Documentation/reference/screenplay.md | 98 +++++++++++++++++++++++++++ Documentation/reference/toc.yml | 2 + 3 files changed, 101 insertions(+) create mode 100644 Documentation/reference/screenplay.md diff --git a/Documentation/reference/index.md b/Documentation/reference/index.md index 1d295b7..7bce49b 100644 --- a/Documentation/reference/index.md +++ b/Documentation/reference/index.md @@ -6,6 +6,7 @@ This section documents the cross-cutting mechanics of the `cratis` CLI: the flag - [LLM](llm.md) — Configuring the language model that Cratis tools like Prologue use. - [Prologue](prologue.md) — Capturing a running system and interpreting the captures into a Screenplay. +- [Screenplay](screenplay.md) — Generating a Screenplay from the source code of a Cratis Arc application. - [Global Options](global-options.md) — Flags such as `--output`, `--quiet`, `--yes`, and `--debug` that apply to every command. - [Output Formats](output-formats.md) — The four output formats (`table`, `plain`, `json`, `json-compact`) with guidance on when to use each and token cost comparisons. - [Connection](connection.md) — Connection string format, resolution order, and environment variable configuration. diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md new file mode 100644 index 0000000..8147311 --- /dev/null +++ b/Documentation/reference/screenplay.md @@ -0,0 +1,98 @@ +# Screenplay + +`cratis screenplay` works with Cratis Screenplay (`.play`) documents. Today 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. + +```bash +cratis screenplay generate [PATH] +``` + +Unlike `cratis arc`, nothing needs to be running. The command reads a solution or project with Roslyn, so the output is reproducible from a checkout — commit it, diff it, and regenerate it in CI. + +## `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. + +By default the document goes to standard output, so it composes with the shell: + +```bash +cratis screenplay generate > MyApp.play +``` + +Pass `--file` to write it directly instead. The output is written as raw UTF-8, byte for byte, ending in exactly one newline — regenerating an unchanged model produces an identical file. + +### Arguments + +| Argument | Description | +|---|---| +| `PATH` | Solution (`.slnx`, `.sln`), project (`.csproj`), or folder to read. Defaults to the current directory. | + +### Options + +| 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. | +| `--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. | + +The output file uses `--file` rather than `-o`, because `-o/--output` is the global output *format* flag — see [Global Options](global-options.md). + +```bash +cratis screenplay generate +cratis screenplay generate ./MyApp.slnx --file MyApp.play +cratis screenplay generate ./Source/MyApp/MyApp.csproj +cratis screenplay generate --domain Library --module Lending --file Library.play +``` + +### Finding the solution or project + +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: + +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. + +### Diagnostics + +Anything the generator cannot express in Screenplay is reported rather than silently dropped — a projection operator with no counterpart, a validator rule that has no equivalent, a construct only available as compiled metadata because it lives in a referenced package. + +Diagnostics always go to **standard error**, grouped by severity with errors first, so redirecting standard output to a `.play` file never mixes them in: + +```text +errors (1): + error SP0203: [Library.Lending.Reserving] projection uses $combine, which has no Screenplay counterpart + +warnings (2): + warning SP0110: [Library.Authors] command handler body is not available in this compilation + warning SP0141: [Library.Authors.Registration] validator rule Must() cannot be expressed +``` + +With `-o json` or `-o json-compact` the same diagnostics are written to standard error as a JSON object instead. + +**Warnings and information do not fail the command** — the document is still written. **An error does**: nothing is written and the command exits with a validation error, because a document that does not describe the source faithfully is worse than no document. + +### Prerequisites + +The command loads the project through MSBuild, so the **.NET SDK** must be installed — the same SDK you build the project with. Packages must be restorable; a project that cannot be restored cannot be read. + +### Errors + +| Condition | Result | +|---|---| +| `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. | +| Generation reports one or more errors | Validation error; no document is written. | + +## Where a Screenplay comes from + +Generating from source is one of three ways to arrive at a `.play` file, and they meet in the same place: + +- **From source** — this command, for an application that already exists in Cratis Arc. +- **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. + +Whichever route you take, [`cratis run`](run.md) boots the result in a local Stage sandbox. diff --git a/Documentation/reference/toc.yml b/Documentation/reference/toc.yml index ccb00da..e8a58d7 100644 --- a/Documentation/reference/toc.yml +++ b/Documentation/reference/toc.yml @@ -4,6 +4,8 @@ href: llm.md - name: Prologue href: prologue.md +- name: Screenplay + href: screenplay.md - name: Global Options href: global-options.md - name: Output Formats From c0cfccb36e5ba237866a014b530be5b7733116ca Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:09:06 +0200 Subject: [PATCH 04/10] Generate resource designer sources when reading a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design-time build only runs the Compile target, and PrepareResources — the chain that turns MSBuild:Compile resx entries into .Designer.cs sources and adds them to @(Compile) — is a sibling of Compile under CoreBuild rather than one of its dependencies. It never ran, so every use of a generated resource class became a compile error and an ordinary application looked like it did not compile at all. Inject a targets file through CustomAfterMicrosoftCommonTargets that puts PrepareResources back in front of CoreCompile, where a real build runs it. MSBuild then generates the sources into the project's own intermediate output folder and adds them itself, so no path is hard coded and the project need never have been built. GeneratedResourceSources is the safety net for when that cannot work — a read-only intermediate output folder, or an MSBuild that stops honouring the hook — reading back any .Designer.cs a previous ordinary build left on disk that the project does not already compile. Co-Authored-By: Claude Opus 5 --- Documentation/reference/screenplay.md | 2 + .../given/a_temporary_folder.cs | 19 +++ .../and_an_existing_file_is_chained.cs | 21 ++++ .../and_nothing_is_chained.cs | 15 +++ .../and_the_chained_file_does_not_exist.cs | 14 +++ ...he_chained_path_holds_markup_characters.cs | 21 ++++ .../when_creating.cs | 22 ++++ .../and_it_has_already_been_disposed.cs | 20 +++ .../and_the_targets_file_was_written.cs | 20 +++ .../given/a_loaded_project.cs | 27 ++++ .../given/an_intermediate_output_folder.cs | 43 +++++++ .../and_the_project_already_compiles_them.cs | 31 +++++ .../and_the_project_does_not_compile_them.cs | 18 +++ .../and_none_of_them_are_compiled.cs | 16 +++ .../and_one_of_them_is_compiled.cs | 13 ++ ..._the_intermediate_assembly_is_not_known.cs | 13 ++ .../and_the_project_has_never_been_built.cs | 15 +++ .../and_they_are_all_compiled.cs | 13 ++ .../DesignTimeResourceGeneration.cs | 117 ++++++++++++++++++ .../Screenplay/GeneratedResourceSources.cs | 79 ++++++++++++ .../Screenplay/ScreenplayCompilationLoader.cs | 5 +- 21 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/given/a_temporary_folder.cs create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_an_existing_file_is_chained.cs create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_nothing_is_chained.cs create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_file_does_not_exist.cs create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_path_holds_markup_characters.cs create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/when_creating.cs create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_it_has_already_been_disposed.cs create mode 100644 Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_the_targets_file_was_written.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/given/a_loaded_project.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/given/an_intermediate_output_folder.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_already_compiles_them.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_does_not_compile_them.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_none_of_them_are_compiled.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_one_of_them_is_compiled.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_intermediate_assembly_is_not_known.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_project_has_never_been_built.cs create mode 100644 Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_they_are_all_compiled.cs create mode 100644 Source/Cli/Commands/Screenplay/DesignTimeResourceGeneration.cs create mode 100644 Source/Cli/Commands/Screenplay/GeneratedResourceSources.cs diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md index 8147311..8063641 100644 --- a/Documentation/reference/screenplay.md +++ b/Documentation/reference/screenplay.md @@ -77,6 +77,8 @@ With `-o json` or `-o json-compact` the same diagnostics are written to standard The command loads the project through MSBuild, so the **.NET SDK** must be installed — the same SDK you build the project with. Packages must be restorable; a project that cannot be restored cannot be read. +The project does **not** have to have been built first. Sources MSBuild generates as part of a build — such as the strongly typed classes a `.resx` file declares with `MSBuild:Compile` — are produced while the project is read, so the model is derived from exactly what a real build compiles. + ### Errors | Condition | Result | diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/given/a_temporary_folder.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/given/a_temporary_folder.cs new file mode 100644 index 0000000..eeac957 --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// 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_DesignTimeResourceGeneration.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_an_existing_file_is_chained.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_an_existing_file_is_chained.cs new file mode 100644 index 0000000..f394348 --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_an_existing_file_is_chained.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_DesignTimeResourceGeneration.when_building_the_targets_file; + +public class and_an_existing_file_is_chained : given.a_temporary_folder +{ + string _chained; + string _result; + + void Establish() + { + _chained = Path.Combine(_folder, "Custom.After.targets"); + File.WriteAllText(_chained, ""); + } + + void Because() => _result = DesignTimeResourceGeneration.Targets(_chained); + + [Fact] void should_keep_importing_it() => _result.ShouldContain($""); + [Fact] void should_still_run_the_resource_target_chain() => _result.ShouldContain("DependsOnTargets=\"PrepareResources\""); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_nothing_is_chained.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_nothing_is_chained.cs new file mode 100644 index 0000000..388d46c --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_nothing_is_chained.cs @@ -0,0 +1,15 @@ +// 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_DesignTimeResourceGeneration.when_building_the_targets_file; + +public class and_nothing_is_chained : Specification +{ + string _result; + + void Because() => _result = DesignTimeResourceGeneration.Targets(null); + + [Fact] void should_run_the_resource_target_chain() => _result.ShouldContain("DependsOnTargets=\"PrepareResources\""); + [Fact] void should_run_it_before_the_compiler_inputs_are_gathered() => _result.ShouldContain("BeforeTargets=\"CoreCompile\""); + [Fact] void should_not_import_anything() => _result.ShouldNotContain(" _result = DesignTimeResourceGeneration.Targets(Path.Combine(_folder, "Missing.targets")); + + [Fact] void should_not_import_it() => _result.ShouldNotContain(" _result.ShouldContain("DependsOnTargets=\"PrepareResources\""); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_path_holds_markup_characters.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_path_holds_markup_characters.cs new file mode 100644 index 0000000..047c482 --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_path_holds_markup_characters.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_DesignTimeResourceGeneration.when_building_the_targets_file; + +public class and_the_chained_path_holds_markup_characters : given.a_temporary_folder +{ + string _chained; + string _result; + + void Establish() + { + _chained = Path.Combine(_folder, "Custom & After.targets"); + File.WriteAllText(_chained, ""); + } + + void Because() => _result = DesignTimeResourceGeneration.Targets(_chained); + + [Fact] void should_escape_them() => _result.ShouldContain("Custom & After.targets"); + [Fact] void should_leave_the_file_readable_as_markup() => _result.ShouldNotContain("& After"); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_creating.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_creating.cs new file mode 100644 index 0000000..93dd4be --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_creating.cs @@ -0,0 +1,22 @@ +// 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_DesignTimeResourceGeneration; + +public class when_creating : Specification +{ + DesignTimeResourceGeneration _generation; + string _file; + + void Because() + { + _generation = DesignTimeResourceGeneration.Create(); + _file = _generation.GlobalProperties[DesignTimeResourceGeneration.HookProperty]; + } + + [Fact] void should_hook_the_targets_file_into_every_project() => DesignTimeResourceGeneration.HookProperty.ShouldEqual("CustomAfterMicrosoftCommonTargets"); + [Fact] void should_write_the_targets_file() => File.Exists(_file).ShouldBeTrue(); + [Fact] void should_write_the_resource_target_chain_into_it() => File.ReadAllText(_file).ShouldContain("PrepareResources"); + + void Destroy() => _generation.Dispose(); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_it_has_already_been_disposed.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_it_has_already_been_disposed.cs new file mode 100644 index 0000000..5e5232d --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_it_has_already_been_disposed.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_DesignTimeResourceGeneration.when_disposing; + +public class and_it_has_already_been_disposed : Specification +{ + DesignTimeResourceGeneration _generation; + Exception _error; + + void Establish() + { + _generation = DesignTimeResourceGeneration.Create(); + _generation.Dispose(); + } + + void Because() => _error = Catch.Exception(_generation.Dispose); + + [Fact] void should_not_fail() => _error.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_the_targets_file_was_written.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_the_targets_file_was_written.cs new file mode 100644 index 0000000..c10beac --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_the_targets_file_was_written.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_DesignTimeResourceGeneration.when_disposing; + +public class and_the_targets_file_was_written : Specification +{ + DesignTimeResourceGeneration _generation; + string _file; + + void Establish() + { + _generation = DesignTimeResourceGeneration.Create(); + _file = _generation.GlobalProperties[DesignTimeResourceGeneration.HookProperty]; + } + + void Because() => _generation.Dispose(); + + [Fact] void should_remove_the_targets_file() => File.Exists(_file).ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/given/a_loaded_project.cs b/Source/Cli.Specs/for_GeneratedResourceSources/given/a_loaded_project.cs new file mode 100644 index 0000000..aa9779b --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/given/a_loaded_project.cs @@ -0,0 +1,27 @@ +// 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; + +namespace Cratis.Cli.for_GeneratedResourceSources.given; + +/// +/// Base context adding a project that compiles into the intermediate output folder, standing in for the project an +/// MSBuild workspace hands over. +/// +public class a_loaded_project : an_intermediate_output_folder +{ + protected AdhocWorkspace _workspace; + protected Project _project; + + void Establish() + { + _workspace = new AdhocWorkspace(); + _project = _workspace.AddProject( + ProjectInfo + .Create(ProjectId.CreateNewId(), VersionStamp.Create(), "MyApp", "MyApp", LanguageNames.CSharp) + .WithCompilationOutputInfo(default(CompilationOutputInfo).WithAssemblyPath(_assemblyPath))); + } + + void Destroy() => _workspace.Dispose(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/given/an_intermediate_output_folder.cs b/Source/Cli.Specs/for_GeneratedResourceSources/given/an_intermediate_output_folder.cs new file mode 100644 index 0000000..8cab952 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/given/an_intermediate_output_folder.cs @@ -0,0 +1,43 @@ +// 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_GeneratedResourceSources.given; + +/// +/// Base context holding an intermediate output folder as a build leaves it — the assembly the project compiles +/// into, the strongly typed resource sources generated next to it, and a generated source that is not one of them. +/// +public class an_intermediate_output_folder : Specification +{ + protected string _folder; + protected string _intermediate; + protected string _assemblyPath; + protected string _commonMessages; + protected string _adminMessages; + + void Establish() + { + _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _intermediate = Directory.CreateDirectory(Path.Combine(_folder, "obj", "Debug", "net10.0")).FullName; + _assemblyPath = Path.Combine(_intermediate, "MyApp.dll"); + + _adminMessages = Write("AdminMessages"); + _commonMessages = Write("CommonMessages"); + File.WriteAllText(Path.Combine(_intermediate, "MyApp.AssemblyInfo.cs"), "// Not a resource class"); + } + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } + + string Write(string name) + { + var file = Path.Combine(_intermediate, $"{name}.Designer.cs"); + File.WriteAllText(file, $"namespace MyApp.Resources;\n\npublic static class {name}\n{{\n}}\n"); + return file; + } +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_already_compiles_them.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_already_compiles_them.cs new file mode 100644 index 0000000..961df50 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_already_compiles_them.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Cratis.Cli.for_GeneratedResourceSources.when_adding_missing_sources; + +public class and_the_project_already_compiles_them : given.a_loaded_project +{ + Project _result; + + void Establish() + { + var solution = _project.Solution; + foreach (var file in new[] { _adminMessages, _commonMessages }) + { + solution = solution.AddDocument( + DocumentId.CreateNewId(_project.Id), + Path.GetFileName(file), + SourceText.From(File.ReadAllText(file)), + filePath: file); + } + + _project = solution.GetProject(_project.Id); + } + + void Because() => _result = GeneratedResourceSources.AddMissingTo(_project); + + [Fact] void should_not_add_them_a_second_time() => _result.Documents.Count().ShouldEqual(2); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_does_not_compile_them.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_does_not_compile_them.cs new file mode 100644 index 0000000..8a75433 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_does_not_compile_them.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. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Cli.for_GeneratedResourceSources.when_adding_missing_sources; + +public class and_the_project_does_not_compile_them : given.a_loaded_project +{ + Project _result; + + void Because() => _result = GeneratedResourceSources.AddMissingTo(_project); + + [Fact] void should_add_every_generated_resource_source() => _result.Documents.Count().ShouldEqual(2); + [Fact] void should_add_the_admin_messages() => _result.Documents.Select(document => document.FilePath).ShouldContain(_adminMessages); + [Fact] void should_add_the_common_messages() => _result.Documents.Select(document => document.FilePath).ShouldContain(_commonMessages); + [Fact] async Task should_read_the_source_back_from_disk() => (await _result.Documents.First().GetTextAsync()).ToString().ShouldContain("public static class"); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_none_of_them_are_compiled.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_none_of_them_are_compiled.cs new file mode 100644 index 0000000..5d45b13 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_none_of_them_are_compiled.cs @@ -0,0 +1,16 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_none_of_them_are_compiled : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(_assemblyPath, []); + + [Fact] void should_find_every_generated_resource_source() => _result.Count.ShouldEqual(2); + [Fact] void should_find_them_in_a_stable_order() => _result[0].ShouldEqual(_adminMessages); + [Fact] void should_find_the_second_one_too() => _result[1].ShouldEqual(_commonMessages); + [Fact] void should_leave_other_generated_sources_alone() => _result.ShouldNotContain(Path.Combine(_intermediate, "MyApp.AssemblyInfo.cs")); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_one_of_them_is_compiled.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_one_of_them_is_compiled.cs new file mode 100644 index 0000000..7936549 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_one_of_them_is_compiled.cs @@ -0,0 +1,13 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_one_of_them_is_compiled : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(_assemblyPath, [_adminMessages, null]); + + [Fact] void should_find_only_the_other_one() => _result.ShouldContainOnly([_commonMessages]); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_intermediate_assembly_is_not_known.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_intermediate_assembly_is_not_known.cs new file mode 100644 index 0000000..1075583 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_intermediate_assembly_is_not_known.cs @@ -0,0 +1,13 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_the_intermediate_assembly_is_not_known : Specification +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(null, []); + + [Fact] void should_find_nothing_missing() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_project_has_never_been_built.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_project_has_never_been_built.cs new file mode 100644 index 0000000..cb2bc84 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_project_has_never_been_built.cs @@ -0,0 +1,15 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_the_project_has_never_been_built : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom( + Path.Combine(_folder, "obj", "Release", "net10.0", "MyApp.dll"), + []); + + [Fact] void should_find_nothing_missing() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_they_are_all_compiled.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_they_are_all_compiled.cs new file mode 100644 index 0000000..9cddaec --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_they_are_all_compiled.cs @@ -0,0 +1,13 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_they_are_all_compiled : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(_assemblyPath, [_adminMessages, _commonMessages]); + + [Fact] void should_find_nothing_missing() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli/Commands/Screenplay/DesignTimeResourceGeneration.cs b/Source/Cli/Commands/Screenplay/DesignTimeResourceGeneration.cs new file mode 100644 index 0000000..4c502ca --- /dev/null +++ b/Source/Cli/Commands/Screenplay/DesignTimeResourceGeneration.cs @@ -0,0 +1,117 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Makes the MSBuild design-time build a workspace performs generate the strongly typed resource classes that +/// .resx files ask for, so that the loaded compilation holds the same sources a real build compiles. +/// +/// +/// A design-time build only runs the Compile target. PrepareResources — the target chain that turns +/// <Generator>MSBuild:Compile</Generator> resource entries into .Designer.cs sources and +/// adds them to @(Compile) — is a sibling of Compile within CoreBuild rather than one of its +/// dependencies, so it never runs. Every use of a generated resource class then becomes a compile error, and a +/// perfectly ordinary application looks like it does not compile at all. +/// +/// Injecting a targets file through the CustomAfterMicrosoftCommonTargets hook puts PrepareResources +/// back in front of CoreCompile, which is exactly where a real build runs it. Nothing is hard coded — MSBuild +/// generates the sources into the project's own intermediate output folder and adds them to the compilation itself, +/// whether or not the project has ever been built. +/// +/// +public sealed class DesignTimeResourceGeneration : IDisposable +{ + /// + /// The MSBuild property naming a targets file that every project imports after the common targets. + /// + public const string HookProperty = "CustomAfterMicrosoftCommonTargets"; + + readonly string? _folder; + + DesignTimeResourceGeneration(string? folder, Dictionary globalProperties) + { + _folder = folder; + GlobalProperties = globalProperties; + } + + /// + /// Gets the global properties to open the MSBuild workspace with. + /// + public IDictionary GlobalProperties { get; } + + /// + /// Writes the targets file to a temporary folder and describes how to hand it to a workspace. + /// + /// The owning the written file. + /// + /// Falls back to opening the workspace unchanged when the file cannot be written — a missing resource class is a + /// far better outcome than not being able to read the application at all. + /// + public static DesignTimeResourceGeneration Create() + { + try + { + var folder = Directory.CreateTempSubdirectory("cratis-screenplay-").FullName; + var file = Path.Combine(folder, "Cratis.Screenplay.DesignTimeResources.targets"); + File.WriteAllText(file, Targets(Environment.GetEnvironmentVariable(HookProperty))); + return new(folder, new(StringComparer.Ordinal) { [HookProperty] = file }); + } + catch (IOException) + { + return new(null, new(StringComparer.Ordinal)); + } + catch (UnauthorizedAccessException) + { + return new(null, new(StringComparer.Ordinal)); + } + } + + /// + /// Builds the content of the targets file every loaded project imports. + /// + /// A targets file the hook already pointed at, which the injected one must keep importing; ignored when it does not exist. + /// The MSBuild project content. + public static string Targets(string? chained) + { + List lines = [""]; + + if (!string.IsNullOrWhiteSpace(chained) && File.Exists(chained)) + { + lines.Add($" "); + } + + lines.AddRange( + " ", + " ", + "", + string.Empty); + + return string.Join('\n', lines); + } + + /// + public void Dispose() + { + if (_folder is null || !Directory.Exists(_folder)) + { + return; + } + + try + { + Directory.Delete(_folder, true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/Source/Cli/Commands/Screenplay/GeneratedResourceSources.cs b/Source/Cli/Commands/Screenplay/GeneratedResourceSources.cs new file mode 100644 index 0000000..52ecd52 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GeneratedResourceSources.cs @@ -0,0 +1,79 @@ +// 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.Text; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Puts the strongly typed resource classes MSBuild generates into a project's intermediate output folder back into +/// the loaded project when the design-time build left them out. +/// +/// +/// makes the design-time build produce these itself, which also works for +/// a project that has never been built. This is the safety net for when it cannot — a read-only intermediate output +/// folder, or an MSBuild that no longer honours the hook. A previous ordinary build then still left the sources on +/// disk, and reading them back is enough to make the compilation whole. Only files the project does not already +/// compile are added, so this does nothing at all once the design-time build does its job. +/// +public static class GeneratedResourceSources +{ + const string Suffix = ".Designer.cs"; + + /// + /// Adds every generated resource source missing from the project. + /// + /// The project as the workspace loaded it. + /// The project, with the missing sources added as documents. + public static Project AddMissingTo(Project project) + { + var missing = MissingFrom( + project.CompilationOutputInfo.AssemblyPath, + project.Documents.Select(document => document.FilePath)); + + var solution = project.Solution; + foreach (var file in missing) + { + solution = solution.AddDocument( + DocumentId.CreateNewId(project.Id), + Path.GetFileName(file), + SourceText.From(File.ReadAllText(file)), + filePath: file); + } + + return solution.GetProject(project.Id) ?? project; + } + + /// + /// Finds the generated resource sources sitting next to the intermediate assembly that are not compiled already. + /// + /// The path of the assembly the project compiles into its intermediate output folder. + /// The paths of the files the project already compiles. + /// The paths of the missing sources, ordered. + public static IReadOnlyList MissingFrom(string? intermediateAssemblyPath, IEnumerable compiledPaths) + { + if (string.IsNullOrWhiteSpace(intermediateAssemblyPath)) + { + return []; + } + + var folder = Path.GetDirectoryName(intermediateAssemblyPath); + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) + { + return []; + } + + var compiled = compiledPaths + .Where(path => !string.IsNullOrEmpty(path)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return + [ + .. Directory + .EnumerateFiles(folder, $"*{Suffix}", SearchOption.TopDirectoryOnly) + .Where(file => !compiled.Contains(file)) + .Order(StringComparer.Ordinal) + ]; + } +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs index f243efa..5420ee5 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -56,7 +56,8 @@ static async Task LoadWithWorkspace(string targetPath, Cancel { var failures = new List(); var failureLock = new Lock(); - using var workspace = MSBuildWorkspace.Create(); + using var resources = DesignTimeResourceGeneration.Create(); + using var workspace = MSBuildWorkspace.Create(resources.GlobalProperties); using var subscription = workspace.RegisterWorkspaceFailedHandler(args => { lock (failureLock) @@ -101,7 +102,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel failures); } - var selected = byName[narrowed[0].Name]; + var selected = GeneratedResourceSources.AddMissingTo(byName[narrowed[0].Name]); var compilation = await selected.GetCompilationAsync(cancellationToken); return compilation is null ? LoadedCompilation.Failed( From 4bc48bc38ce3fc954c79fea2ae92bd64c2644530 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:11:46 +0200 Subject: [PATCH 05/10] Reference the published Cratis.Arc.Screenplay package The generator has shipped as 20.65.0, so the opt-in scaffold that let the CLI build without it has served its purpose. An ordinary PackageReference replaces the CratisArcScreenplayProject property, the conditional ProjectReference, the CRATIS_ARC_SCREENPLAY define and the Compile Remove that hid ArcScreenplayGeneration. With generation always available there is no gap left to report, so UnavailableScreenplayGeneration and the CLI0005 code it raised are gone and ScreenplayGenerations always creates the real generation. The package depends on exactly Cratis.Screenplay 1.5.2, which the repository already pins, so the two stay aligned. Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 1 + Source/Cli/Cli.csproj | 20 +----------------- .../Screenplay/ScreenplayDiagnosticCodes.cs | 5 ----- .../Screenplay/ScreenplayGenerations.cs | 7 +------ .../UnavailableScreenplayGeneration.cs | 21 ------------------- 5 files changed, 3 insertions(+), 51 deletions(-) delete mode 100644 Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 704fd59..925f0e5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 1f6cf40..6447a44 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -19,6 +19,7 @@ + @@ -44,25 +45,6 @@ - - - $(DefineConstants);CRATIS_ARC_SCREENPLAY - - - - - - - - - - diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs index 1d52675..9b77be5 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -31,9 +31,4 @@ public static class ScreenplayDiagnosticCodes /// The project loaded but Roslyn could not produce a compilation for it. /// public const string NoCompilation = "CLI0004"; - - /// - /// The Cratis.Arc.Screenplay generator is not part of this build of the CLI. - /// - public const string GeneratorUnavailable = "CLI0005"; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs index 01ca15a..c698c6b 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs @@ -12,10 +12,5 @@ public static class ScreenplayGenerations /// Creates the generation to use. /// /// The to generate with. - public static IScreenplayGeneration Create() => -#if CRATIS_ARC_SCREENPLAY - new ArcScreenplayGeneration(); -#else - new UnavailableScreenplayGeneration(); -#endif + public static IScreenplayGeneration Create() => new ArcScreenplayGeneration(); } diff --git a/Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.cs deleted file mode 100644 index 8d6ff8d..0000000 --- a/Source/Cli/Commands/Screenplay/UnavailableScreenplayGeneration.cs +++ /dev/null @@ -1,21 +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; - -/// -/// Reports that the Cratis.Arc.Screenplay generator is not part of this build of the CLI. -/// -/// -/// The generator package is not published yet. Until it is, the CLI is built against it only when -/// CratisArcScreenplayProject points at a local checkout of it — see Cli.csproj. Reporting the gap as -/// an error diagnostic keeps the command's contract intact rather than failing in some other, less explicable way. -/// -public sealed class UnavailableScreenplayGeneration : IScreenplayGeneration -{ - /// - public Task Generate(string targetPath, ScreenplayGenerationOptions options, CancellationToken cancellationToken) => - Task.FromResult(GeneratedScreenplay.Failed( - ScreenplayDiagnosticCodes.GeneratorUnavailable, - "This build of the CLI does not include the Cratis.Arc.Screenplay generator")); -} From 2980ab409bd00a7f92bc006608fde664542e6520 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:26:58 +0200 Subject: [PATCH 06/10] Add screenplay validate command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Screenplay document is only worth committing if something checks it, and until now nothing in the CLI did. 'cratis screenplay validate' compiles a .play file, or every .play file beneath a folder, and reports what the compiler found — whatever wrote the document. It is deliberately a sibling of generate rather than a flag on it: the documents worth checking come from prologue and from people writing them by hand just as often as from generation, and a CI gate should not have to regenerate anything to run. ScreenplayValidation is the only place that knows the Cratis.Screenplay compiler exists, mirroring how ArcScreenplayGeneration isolates the generator. Its diagnostics are translated into the shape generate already reports, so both commands read identically. The compiler assigns no codes, so the code is left empty and the writer leaves it out rather than the CLI inventing a taxonomy; the location carries the file and position instead, in the form editors already understand. An empty folder is reported rather than quietly succeeding — a validation that checked nothing is never the answer anyone wanted, and CI is exactly where that would go unnoticed. Co-Authored-By: Claude Opus 5 --- .../Screenplay/IScreenplayValidation.cs | 23 ++++ .../Screenplay/PlayFileTargetResolver.cs | 55 +++++++++ .../Screenplay/ScreenplayDiagnostic.cs | 5 +- .../Screenplay/ScreenplayDiagnosticsWriter.cs | 20 +++- .../Commands/Screenplay/ScreenplayTarget.cs | 5 +- .../Screenplay/ScreenplayValidation.cs | 64 ++++++++++ .../Screenplay/ValidateScreenplayCommand.cs | 113 ++++++++++++++++++ .../Screenplay/ValidateScreenplaySettings.cs | 17 +++ .../Screenplay/ValidatedScreenplay.cs | 11 ++ Source/Cli/Registration/ScreenplayBranch.cs | 2 +- 10 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 Source/Cli/Commands/Screenplay/IScreenplayValidation.cs create mode 100644 Source/Cli/Commands/Screenplay/PlayFileTargetResolver.cs create mode 100644 Source/Cli/Commands/Screenplay/ScreenplayValidation.cs create mode 100644 Source/Cli/Commands/Screenplay/ValidateScreenplayCommand.cs create mode 100644 Source/Cli/Commands/Screenplay/ValidateScreenplaySettings.cs create mode 100644 Source/Cli/Commands/Screenplay/ValidatedScreenplay.cs diff --git a/Source/Cli/Commands/Screenplay/IScreenplayValidation.cs b/Source/Cli/Commands/Screenplay/IScreenplayValidation.cs new file mode 100644 index 0000000..014399a --- /dev/null +++ b/Source/Cli/Commands/Screenplay/IScreenplayValidation.cs @@ -0,0 +1,23 @@ +// 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 a system that compiles Screenplay documents and reports what the compiler found. +/// +/// +/// This is the seam between the CLI and the Cratis.Screenplay compiler, mirroring +/// . Everything the CLI does around validation — resolving the path, reporting +/// diagnostics, deciding the exit code — is expressed against this interface so that it stays independent of how a +/// document is compiled. +/// +public interface IScreenplayValidation +{ + /// + /// Compiles the Screenplay document, or every document beneath the folder, at the given path. + /// + /// The full path of a .play file, or of a folder to search. + /// The holding what was compiled and any diagnostics. + ValidatedScreenplay Validate(string targetPath); +} diff --git a/Source/Cli/Commands/Screenplay/PlayFileTargetResolver.cs b/Source/Cli/Commands/Screenplay/PlayFileTargetResolver.cs new file mode 100644 index 0000000..c91045b --- /dev/null +++ b/Source/Cli/Commands/Screenplay/PlayFileTargetResolver.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Resolves the Screenplay document, or the folder of documents, that is validated. +/// +/// +/// Unlike nothing is discovered by searching upwards — a folder is a perfectly +/// good answer on its own, because every .play file beneath it is compiled. +/// +public static class PlayFileTargetResolver +{ + /// + /// The file extension that identifies a Screenplay document. + /// + public const string Extension = ".play"; + + /// + /// Resolves the document or folder to compile. + /// + /// The path given on the command line — a .play file or a folder. uses . + /// The directory relative paths are resolved against. + /// The describing the outcome. + public static ScreenplayTarget Resolve(string? path, string currentDirectory) + { + var candidate = string.IsNullOrWhiteSpace(path) + ? currentDirectory + : Path.GetFullPath(path, currentDirectory); + + if (File.Exists(candidate)) + { + return IsPlayFile(candidate) + ? ScreenplayTarget.Resolved(candidate) + : ScreenplayTarget.Unresolved( + $"'{candidate}' is not a Screenplay ({Extension}) file", + $"Point the command at a {Extension} file, or at the folder holding one"); + } + + return Directory.Exists(candidate) + ? ScreenplayTarget.Resolved(candidate) + : ScreenplayTarget.Unresolved( + $"'{candidate}' does not exist", + $"Point the command at an existing {Extension} file or folder"); + } + + /// + /// Determines whether the given file is a Screenplay document. + /// + /// The file path to check. + /// when the file is a .play file. + public static bool IsPlayFile(string path) => + string.Equals(Path.GetExtension(path), Extension, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs index 8f8f66a..2bbae8b 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs @@ -4,10 +4,11 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Represents something the generator could not fully express in the generated Screenplay document. +/// Represents something a Screenplay document does not fully express — a construct the generator could not +/// represent, or a problem the compiler found in a document that already exists. /// /// How severe the diagnostic is. -/// The stable diagnostic code, for example SP0001. +/// The stable diagnostic code, for example SP0001; empty when the reporting system assigns none. /// The human readable description. /// The slice, artifact, or file the diagnostic points at; when it applies to the whole document. public record ScreenplayDiagnostic( diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs index a8fee2a..9952734 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs @@ -58,6 +58,22 @@ public static void Write(string format, IEnumerable diagno _ => "information" }; + /// + /// Builds the line a single diagnostic is written as in text output. + /// + /// The diagnostic to write. + /// The line. + /// + /// The code and the location are both left out when they are absent — the compiler behind + /// screenplay validate assigns no codes, and a diagnostic about a whole document has no location. + /// + public static string LineFor(ScreenplayDiagnostic diagnostic) + { + var code = string.IsNullOrWhiteSpace(diagnostic.Code) ? string.Empty : $" {diagnostic.Code}"; + var location = string.IsNullOrWhiteSpace(diagnostic.Location) ? string.Empty : $" [{diagnostic.Location}]"; + return $" {LabelFor(diagnostic.Severity)}{code}:{location} {diagnostic.Message}"; + } + static bool IsMachineReadable(string format) => string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) || string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal) || @@ -88,14 +104,12 @@ static void WriteText(IEnumerable -/// Represents the outcome of resolving the solution or project a Screenplay is generated from. +/// Represents the outcome of resolving the path a Screenplay command works on — the solution or project one is +/// generated from, or the document one is validated in. /// -/// The full path of the resolved solution or project file; when resolution failed. +/// The full path that was resolved; when resolution failed. /// The reason resolution failed; when it succeeded. /// A hint for resolving the failure; when there is none. public record ScreenplayTarget(string? Path, string? Error, string? Suggestion) diff --git a/Source/Cli/Commands/Screenplay/ScreenplayValidation.cs b/Source/Cli/Commands/Screenplay/ScreenplayValidation.cs new file mode 100644 index 0000000..3390222 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayValidation.cs @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay; +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Files; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Compiles Screenplay documents with the Cratis.Screenplay compiler. +/// +/// +/// This is the only place in the CLI that knows the compiler exists. Everything else is expressed against +/// , and the diagnostics the compiler reports are translated into the same shape +/// generation reports, so both commands read identically. +/// +/// Compiles every document beneath a folder. +/// Compiles the source of a single document. +public sealed class ScreenplayValidation(IPlayFileCompiler playFileCompiler, IScreenplayCompiler compiler) : IScreenplayValidation +{ + /// + /// Initializes a new instance of the class with the default compilers. + /// + public ScreenplayValidation() + : this(new PlayFileCompiler(), new ScreenplayCompiler()) + { + } + + /// + public ValidatedScreenplay Validate(string targetPath) + { + var compilations = File.Exists(targetPath) + ? [CompileFile(targetPath)] + : playFileCompiler.CompileIn(targetPath).ToArray(); + + return new( + compilations.Length, + [.. compilations.SelectMany(compilation => compilation.Result.Diagnostics.Select(diagnostic => Map(compilation.File, diagnostic)))]); + } + + /// + /// Translates a compiler diagnostic into the shape the CLI reports. + /// + /// The file the diagnostic belongs to. + /// The diagnostic the compiler reported. + /// The . + /// + /// The compiler does not assign codes, so the code is left empty. The location carries the file and the position + /// within it, in the file(line,column) form editors and build logs already understand. + /// + static ScreenplayDiagnostic Map(PlayFile file, Diagnostic diagnostic) => + new( + (ScreenplayDiagnosticSeverity)(int)diagnostic.Severity, + string.Empty, + diagnostic.Message, + $"{file.RelativePath}({diagnostic.Location.Line},{diagnostic.Location.Column})"); + + PlayFileCompilation CompileFile(string path) + { + var source = File.ReadAllText(path); + return new(new PlayFile(path, Path.GetFileName(path)), source, compiler.Compile(source)); + } +} diff --git a/Source/Cli/Commands/Screenplay/ValidateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/ValidateScreenplayCommand.cs new file mode 100644 index 0000000..84a5be9 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ValidateScreenplayCommand.cs @@ -0,0 +1,113 @@ +// 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; + +/// +/// Compiles Cratis Screenplay (.play) documents and reports everything the compiler found, whatever wrote +/// them — screenplay generate, prologue, or a person. +/// +[LlmDescription("Compiles Cratis Screenplay (.play) documents and reports every diagnostic the compiler produces. Takes a .play file, or a folder in which case every .play file beneath it is compiled. Nothing needs to be running. Diagnostics go to standard error, grouped by severity; the command exits with a validation error when any of them is an error.")] +[CliCommand("validate", "Validate Screenplay (.play) documents", Branch = typeof(ScreenplayBranch))] +[CliExample("screenplay", "validate")] +[CliExample("screenplay", "validate", "./MyApp.play")] +[CliExample("screenplay", "validate", "./plays")] +[LlmOption("[PATH]", "string", "Screenplay (.play) file, or folder to compile every .play file beneath. Defaults to the current directory.")] +[LlmOutputAdvice("json-compact", "The summary goes to standard output and the diagnostics to standard error; json-compact makes both machine-readable.")] +public class ValidateScreenplayCommand : Command +{ + readonly IScreenplayValidation _validation; + + /// + /// Initializes a new instance of the class. + /// + public ValidateScreenplayCommand() + : this(new ScreenplayValidation()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The validation to compile the documents with. + internal ValidateScreenplayCommand(IScreenplayValidation validation) + { + _validation = validation; + } + + /// + protected override int Execute(CommandContext context, ValidateScreenplaySettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + + var target = PlayFileTargetResolver.Resolve(settings.Path, Directory.GetCurrentDirectory()); + if (!target.IsResolved) + { + OutputFormatter.WriteError(format, target.Error!, target.Suggestion, ExitCodes.NotFoundCode); + return ExitCodes.NotFound; + } + + var validated = _validation.Validate(target.Path!); + if (validated.FileCount == 0) + { + // Silently succeeding on a folder holding nothing turns the command into a no-op in CI, which is + // exactly where it is trusted the most. + OutputFormatter.WriteError( + format, + $"No Screenplay ({PlayFileTargetResolver.Extension}) files found in '{target.Path}'", + $"Point the command at a {PlayFileTargetResolver.Extension} file, or at a folder holding one", + ExitCodes.NotFoundCode); + return ExitCodes.NotFound; + } + + ScreenplayDiagnosticsWriter.Write(format, validated.Diagnostics); + + var exitCode = ScreenplayDiagnostics.ExitCodeFor(validated.Diagnostics); + if (exitCode != ExitCodes.Success) + { + var errors = validated.Diagnostics.Count(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error); + OutputFormatter.WriteError( + format, + $"Validation reported {errors} error(s)", + "Fix the reported errors in the Screenplay document", + ExitCodes.ValidationErrorCode); + return exitCode; + } + + WriteResult(format, target.Path!, validated); + return ExitCodes.Success; + } + + static void WriteResult(string format, string targetPath, ValidatedScreenplay validated) + { + if (string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal)) + { + Console.WriteLine(targetPath); + return; + } + + OutputFormatter.WriteObject( + format, + new + { + Path = targetPath, + Files = validated.FileCount, + Diagnostics = validated.Diagnostics.Count + }, + result => + { + var content = new Markup( + $"[bold]{result.Path.EscapeMarkup()}[/]\n" + + $"Files: {result.Files}\n" + + $"Diagnostics: {result.Diagnostics}"); + var panel = new Panel(content) + .Header(" Valid ") + .Border(BoxBorder.Rounded) + .BorderStyle(new Style(OutputFormatter.Success)) + .Padding(1, 0); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + }); + } +} diff --git a/Source/Cli/Commands/Screenplay/ValidateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/ValidateScreenplaySettings.cs new file mode 100644 index 0000000..54ed758 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ValidateScreenplaySettings.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.Commands.Screenplay; + +/// +/// Settings for the screenplay validate command. +/// +public class ValidateScreenplaySettings : GlobalSettings +{ + /// + /// Gets or sets the document or folder to compile. + /// + [CommandArgument(0, "[PATH]")] + [Description("Screenplay (.play) file, or folder to compile every .play file beneath. Defaults to the current directory.")] + public string? Path { get; set; } +} diff --git a/Source/Cli/Commands/Screenplay/ValidatedScreenplay.cs b/Source/Cli/Commands/Screenplay/ValidatedScreenplay.cs new file mode 100644 index 0000000..3c1cab0 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ValidatedScreenplay.cs @@ -0,0 +1,11 @@ +// 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 the outcome of compiling one or more Screenplay documents. +/// +/// The number of .play files that were compiled. +/// Everything the compiler reported, across every file. +public record ValidatedScreenplay(int FileCount, IReadOnlyList Diagnostics); diff --git a/Source/Cli/Registration/ScreenplayBranch.cs b/Source/Cli/Registration/ScreenplayBranch.cs index 2c66948..f9fc9cd 100644 --- a/Source/Cli/Registration/ScreenplayBranch.cs +++ b/Source/Cli/Registration/ScreenplayBranch.cs @@ -8,5 +8,5 @@ namespace Cratis.Cli.Registration; /// /// Authoring and generation of Cratis Screenplay (.play) documents. /// -[CliBranch("screenplay", "Work with Cratis Screenplay (.play) documents. Generate a Screenplay from source code and inspect the result.")] +[CliBranch("screenplay", "Work with Cratis Screenplay (.play) documents. Generate a Screenplay from source code and validate the documents you have.")] public static class ScreenplayBranch; From f3eb936483e9256dc529f76f9f094a96556a6564 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:27:05 +0200 Subject: [PATCH 07/10] Add specs for screenplay validate command and its collaborators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command specs substitute the validation, mirroring how the generate command specs substitute the generation. ScreenplayValidation is specified against the real compiler and real files instead — it is the translation between two libraries, which a substitute would prove nothing about. Co-Authored-By: Claude Opus 5 --- .../given/a_temporary_folder.cs | 19 +++++++ .../when_resolving/and_no_path_is_given.cs | 13 +++++ .../and_the_file_is_not_a_play_file.cs | 17 ++++++ .../and_the_path_does_not_exist.cs | 15 +++++ .../and_the_path_is_a_folder.cs | 17 ++++++ .../and_the_path_is_a_play_file.cs | 22 ++++++++ .../and_the_diagnostic_has_a_code.cs | 14 +++++ .../and_the_diagnostic_has_no_code.cs | 14 +++++ .../and_the_diagnostic_has_no_location.cs | 14 +++++ .../given/a_folder_with_documents.cs | 44 +++++++++++++++ .../and_the_document_has_an_error.cs | 19 +++++++ .../and_the_document_is_valid.cs | 17 ++++++ .../and_the_folder_holds_no_documents.cs | 14 +++++ .../and_the_folder_holds_several_documents.cs | 21 +++++++ .../given/a_validate_screenplay_command.cs | 56 +++++++++++++++++++ .../and_no_documents_are_found.cs | 19 +++++++ .../when_validating/and_no_path_is_given.cs | 15 +++++ .../and_the_documents_are_valid.cs | 17 ++++++ .../and_the_path_cannot_be_resolved.cs | 17 ++++++ .../and_validation_reports_an_error.cs | 24 ++++++++ .../and_validation_reports_only_warnings.cs | 21 +++++++ 21 files changed, 429 insertions(+) create mode 100644 Source/Cli.Specs/for_PlayFileTargetResolver/given/a_temporary_folder.cs create mode 100644 Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_no_path_is_given.cs create mode 100644 Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_file_is_not_a_play_file.cs create mode 100644 Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_does_not_exist.cs create mode 100644 Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_folder.cs create mode 100644 Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_play_file.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_a_code.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_code.cs create mode 100644 Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_location.cs create mode 100644 Source/Cli.Specs/for_ScreenplayValidation/given/a_folder_with_documents.cs create mode 100644 Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_has_an_error.cs create mode 100644 Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_is_valid.cs create mode 100644 Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_no_documents.cs create mode 100644 Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_several_documents.cs create mode 100644 Source/Cli.Specs/for_ValidateScreenplayCommand/given/a_validate_screenplay_command.cs create mode 100644 Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_documents_are_found.cs create mode 100644 Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_path_is_given.cs create mode 100644 Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_documents_are_valid.cs create mode 100644 Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_path_cannot_be_resolved.cs create mode 100644 Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_an_error.cs create mode 100644 Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_only_warnings.cs diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/given/a_temporary_folder.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/given/a_temporary_folder.cs new file mode 100644 index 0000000..9f5efe9 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// 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_PlayFileTargetResolver.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_no_path_is_given.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_no_path_is_given.cs new file mode 100644 index 0000000..92f3729 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_no_path_is_given.cs @@ -0,0 +1,13 @@ +// 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_PlayFileTargetResolver.when_resolving; + +public class and_no_path_is_given : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = PlayFileTargetResolver.Resolve(null, _folder); + + [Fact] void should_resolve_the_current_directory() => _result.Path.ShouldEqual(_folder); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_file_is_not_a_play_file.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_file_is_not_a_play_file.cs new file mode 100644 index 0000000..011f2d3 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_file_is_not_a_play_file.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_PlayFileTargetResolver.when_resolving; + +public class and_the_file_is_not_a_play_file : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Establish() => File.WriteAllText(Path.Combine(_folder, "readme.md"), "not a Screenplay"); + + void Because() => _result = PlayFileTargetResolver.Resolve("readme.md", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_is_not_a_play_file() => _result.Error.ShouldContain("is not a Screenplay (.play) file"); + [Fact] void should_suggest_what_to_point_at() => _result.Suggestion.ShouldNotBeNull(); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_does_not_exist.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_does_not_exist.cs new file mode 100644 index 0000000..f1e8f03 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_does_not_exist.cs @@ -0,0 +1,15 @@ +// 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_PlayFileTargetResolver.when_resolving; + +public class and_the_path_does_not_exist : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = PlayFileTargetResolver.Resolve("Missing/MyApp.play", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_does_not_exist() => _result.Error.ShouldContain("does not exist"); + [Fact] void should_suggest_what_to_point_at() => _result.Suggestion.ShouldNotBeNull(); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_folder.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_folder.cs new file mode 100644 index 0000000..a2c7095 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_folder.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_PlayFileTargetResolver.when_resolving; + +public class and_the_path_is_a_folder : given.a_temporary_folder +{ + string _nested; + ScreenplayTarget _result; + + void Establish() => _nested = Directory.CreateDirectory(Path.Combine(_folder, "plays")).FullName; + + void Because() => _result = PlayFileTargetResolver.Resolve("plays", _folder); + + [Fact] void should_resolve() => _result.IsResolved.ShouldBeTrue(); + [Fact] void should_resolve_the_folder_itself() => _result.Path.ShouldEqual(_nested); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_play_file.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_play_file.cs new file mode 100644 index 0000000..0004b5e --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_play_file.cs @@ -0,0 +1,22 @@ +// 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_PlayFileTargetResolver.when_resolving; + +public class and_the_path_is_a_play_file : given.a_temporary_folder +{ + string _document; + ScreenplayTarget _result; + + void Establish() + { + _document = Path.Combine(_folder, "MyApp.play"); + File.WriteAllText(_document, "domain Library\n"); + } + + void Because() => _result = PlayFileTargetResolver.Resolve("MyApp.play", _folder); + + [Fact] void should_resolve() => _result.IsResolved.ShouldBeTrue(); + [Fact] void should_resolve_the_document_relative_to_the_current_directory() => _result.Path.ShouldEqual(_document); + [Fact] void should_not_report_an_error() => _result.Error.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_a_code.cs b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_a_code.cs new file mode 100644 index 0000000..4101ba9 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_a_code.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayDiagnosticsWriter.when_formatting_a_line; + +public class and_the_diagnostic_has_a_code : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.LineFor( + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", "Library.Lending")); + + [Fact] void should_write_the_code_before_the_location() => _result.ShouldEqual(" error SP0200: [Library.Lending] an error"); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_code.cs b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_code.cs new file mode 100644 index 0000000..8bf5b0b --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_code.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayDiagnosticsWriter.when_formatting_a_line; + +public class and_the_diagnostic_has_no_code : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.LineFor( + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, string.Empty, "a warning", "MyApp.play(3,1)")); + + [Fact] void should_leave_the_code_out() => _result.ShouldEqual(" warning: [MyApp.play(3,1)] a warning"); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_location.cs b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_location.cs new file mode 100644 index 0000000..2972de7 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_location.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayDiagnosticsWriter.when_formatting_a_line; + +public class and_the_diagnostic_has_no_location : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.LineFor( + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Information, "SP0001", "something to know", null)); + + [Fact] void should_leave_the_location_out() => _result.ShouldEqual(" info SP0001: something to know"); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/given/a_folder_with_documents.cs b/Source/Cli.Specs/for_ScreenplayValidation/given/a_folder_with_documents.cs new file mode 100644 index 0000000..8ec1f08 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/given/a_folder_with_documents.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.for_ScreenplayValidation.given; + +/// +/// Base context that puts documents in a temporary folder and compiles them with the real Screenplay compiler. +/// +public class a_folder_with_documents : Specification +{ + protected const string ValidSource = "domain Library\n\nmodule Library\n"; + protected const string InvalidSource = "domain Library\n\nmodule Library\n feature Lending\n slice Reserving\n"; + + protected string _folder; + protected ScreenplayValidation _validation; + + void Establish() + { + _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _validation = new ScreenplayValidation(); + } + + /// + /// Writes a document into the folder. + /// + /// The file name to write, relative to the folder. + /// The source to write. + /// The full path of the written document. + protected string WriteDocument(string name, string source) + { + var path = Path.Combine(_folder, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, source); + return path; + } + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_has_an_error.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_has_an_error.cs new file mode 100644 index 0000000..8c137ea --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_has_an_error.cs @@ -0,0 +1,19 @@ +// 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_ScreenplayValidation.when_validating; + +public class and_the_document_has_an_error : given.a_folder_with_documents +{ + string _document; + ValidatedScreenplay _result; + + void Establish() => _document = WriteDocument("MyApp.play", InvalidSource); + + void Because() => _result = _validation.Validate(_document); + + [Fact] void should_compile_the_document() => _result.FileCount.ShouldEqual(1); + [Fact] void should_report_an_error() => ScreenplayDiagnostics.HasErrors(_result.Diagnostics).ShouldBeTrue(); + [Fact] void should_point_at_the_file_and_position() => _result.Diagnostics[0].Location.ShouldEqual("MyApp.play(5,5)"); + [Fact] void should_not_invent_a_code() => _result.Diagnostics[0].Code.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_is_valid.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_is_valid.cs new file mode 100644 index 0000000..d445443 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_is_valid.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_ScreenplayValidation.when_validating; + +public class and_the_document_is_valid : given.a_folder_with_documents +{ + string _document; + ValidatedScreenplay _result; + + void Establish() => _document = WriteDocument("MyApp.play", ValidSource); + + void Because() => _result = _validation.Validate(_document); + + [Fact] void should_compile_the_document() => _result.FileCount.ShouldEqual(1); + [Fact] void should_not_report_anything() => _result.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_no_documents.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_no_documents.cs new file mode 100644 index 0000000..33c4b5b --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_no_documents.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayValidation.when_validating; + +public class and_the_folder_holds_no_documents : given.a_folder_with_documents +{ + ValidatedScreenplay _result; + + void Because() => _result = _validation.Validate(_folder); + + [Fact] void should_compile_nothing() => _result.FileCount.ShouldEqual(0); + [Fact] void should_not_report_anything() => _result.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_several_documents.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_several_documents.cs new file mode 100644 index 0000000..2aebe00 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_several_documents.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_ScreenplayValidation.when_validating; + +public class and_the_folder_holds_several_documents : given.a_folder_with_documents +{ + ValidatedScreenplay _result; + + void Establish() + { + WriteDocument("MyApp.play", ValidSource); + WriteDocument(Path.Combine("nested", "Broken.play"), InvalidSource); + } + + void Because() => _result = _validation.Validate(_folder); + + [Fact] void should_compile_every_document_beneath_it() => _result.FileCount.ShouldEqual(2); + [Fact] void should_report_the_error_in_the_nested_document() => ScreenplayDiagnostics.HasErrors(_result.Diagnostics).ShouldBeTrue(); + [Fact] void should_point_at_the_document_relative_to_the_folder() => _result.Diagnostics[0].Location.ShouldEqual("nested/Broken.play(5,5)"); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/given/a_validate_screenplay_command.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/given/a_validate_screenplay_command.cs new file mode 100644 index 0000000..4f90583 --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/given/a_validate_screenplay_command.cs @@ -0,0 +1,56 @@ +// 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_ValidateScreenplayCommand.given; + +/// +/// Base context that puts a document in a temporary folder and substitutes the validation. +/// +public class a_validate_screenplay_command : Specification +{ + protected string _folder; + protected string _document; + protected string _previousDirectory; + protected IScreenplayValidation _validation; + protected ValidateScreenplayCommand _command; + protected ValidateScreenplaySettings _settings; + + void Establish() + { + var created = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _previousDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(created); + + // Read the folder back so that specs compare against the same fully resolved path the command sees — + // the temp folder is reached through a symbolic link on macOS. + _folder = Directory.GetCurrentDirectory(); + _document = Path.Combine(_folder, "MyApp.play"); + File.WriteAllText(_document, "domain Library\n"); + + _validation = Substitute.For(); + _validation.Validate(Arg.Any()).Returns(new ValidatedScreenplay(1, [])); + + _command = new ValidateScreenplayCommand(_validation); + _settings = new ValidateScreenplaySettings { Output = OutputFormats.JsonCompact }; + } + + /// + /// Executes the command with the established settings. + /// + /// The exit code. + protected Task Execute() => + ((ICommand)_command).ExecuteAsync( + new CommandContext([], Substitute.For(), "validate", null), + _settings, + CancellationToken.None); + + void Destroy() + { + Directory.SetCurrentDirectory(_previousDirectory); + + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_documents_are_found.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_documents_are_found.cs new file mode 100644 index 0000000..e4cdf7b --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_documents_are_found.cs @@ -0,0 +1,19 @@ +// 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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_no_documents_are_found : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => + _validation + .Validate(Arg.Any()) + .Returns(new ValidatedScreenplay(0, [])); + + async Task Because() => _result = await Execute(); + + [Fact] void should_report_that_nothing_was_found() => _result.ShouldEqual(ExitCodes.NotFound); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_path_is_given.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_path_is_given.cs new file mode 100644 index 0000000..cddefad --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_path_is_given.cs @@ -0,0 +1,15 @@ +// 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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_no_path_is_given : given.a_validate_screenplay_command +{ + int _result; + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_validate_the_current_directory() => _validation.Received(1).Validate(_folder); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_documents_are_valid.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_documents_are_valid.cs new file mode 100644 index 0000000..e8dc3fd --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_documents_are_valid.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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_the_documents_are_valid : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => _settings.Path = "MyApp.play"; + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_validate_the_resolved_document() => _validation.Received(1).Validate(_document); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_path_cannot_be_resolved.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_path_cannot_be_resolved.cs new file mode 100644 index 0000000..8bbfd41 --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_path_cannot_be_resolved.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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_the_path_cannot_be_resolved : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => _settings.Path = "Missing/MyApp.play"; + + async Task Because() => _result = await Execute(); + + [Fact] void should_report_that_it_was_not_found() => _result.ShouldEqual(ExitCodes.NotFound); + [Fact] void should_not_validate_anything() => _validation.DidNotReceive().Validate(Arg.Any()); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_an_error.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_an_error.cs new file mode 100644 index 0000000..d32d6de --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_an_error.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. + +namespace Cratis.Cli.for_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_validation_reports_an_error : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => + _validation + .Validate(Arg.Any()) + .Returns(new ValidatedScreenplay( + 1, + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, string.Empty, "a warning", "MyApp.play(3,1)"), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, string.Empty, "an error", "MyApp.play(5,5)") + ])); + + async Task Because() => _result = await Execute(); + + [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_only_warnings.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_only_warnings.cs new file mode 100644 index 0000000..5ae8ac3 --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_only_warnings.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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_validation_reports_only_warnings : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => + _validation + .Validate(Arg.Any()) + .Returns(new ValidatedScreenplay( + 1, + [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, string.Empty, "a warning", "MyApp.play(3,1)")])); + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); +} From c1795957bd6d62d42afcd714a817dfd913fad452 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:27:19 +0200 Subject: [PATCH 08/10] Write the generated document even when generation reports errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading a real application produced a 5,362-line document that compiles cleanly under the Screenplay compiler, alongside error diagnostics about constructs the generator could not read. The command threw all of it away and left the user with nothing at all — the one outcome that helps nobody. A document that is very nearly right, plus honest diagnostics saying where it is not, is strictly more useful. With --file the document is now always written and the command still exits non-zero, so a CI gate keeps failing while a person gets something to look at. Writing to standard output is unchanged: whatever consumes 'screenplay generate > MyApp.play' cannot tell a partial document from a complete one, so nothing is written there. Co-Authored-By: Claude Opus 5 --- .../and_a_file_is_given.cs | 25 ++++++++++++ .../and_no_file_is_given.cs | 15 +++++++ .../given/a_generation_reporting_an_error.cs} | 21 +++------- .../Screenplay/GenerateScreenplayCommand.cs | 39 +++++++++++++------ 4 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_a_file_is_given.cs create mode 100644 Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_no_file_is_given.cs rename Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/{and_generation_reports_an_error.cs => and_generation_reports_an_error/given/a_generation_reporting_an_error.cs} (53%) diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_a_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_a_file_is_given.cs new file mode 100644 index 0000000..e011c4f --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_a_file_is_given.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating.and_generation_reports_an_error; + +[Collection(CliSpecsCollection.Name)] +public class and_a_file_is_given : given.a_generation_reporting_an_error +{ + string _outputPath; + int _result; + + void Establish() + { + _outputPath = Path.Combine(_folder, "MyApp.play"); + _settings.File = "MyApp.play"; + } + + async Task Because() => _result = await Execute(); + + [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_still_write_the_document_to_the_file() => File.ReadAllBytes(_outputPath).ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); + [Fact] void should_not_write_the_document_to_standard_output() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_no_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_no_file_is_given.cs new file mode 100644 index 0000000..8e93bc2 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_no_file_is_given.cs @@ -0,0 +1,15 @@ +// 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_GenerateScreenplayCommand.when_generating.and_generation_reports_an_error; + +[Collection(CliSpecsCollection.Name)] +public class and_no_file_is_given : given.a_generation_reporting_an_error +{ + int _result; + + async Task Because() => _result = await Execute(); + + [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_keep_standard_output_clean() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/given/a_generation_reporting_an_error.cs similarity index 53% rename from Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error.cs rename to Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/given/a_generation_reporting_an_error.cs index fd5715f..0bf5151 100644 --- a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error.cs +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/given/a_generation_reporting_an_error.cs @@ -1,16 +1,14 @@ // 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_GenerateScreenplayCommand.when_generating; +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating.and_generation_reports_an_error.given; -[Collection(CliSpecsCollection.Name)] -public class and_generation_reports_an_error : given.a_generate_screenplay_command +/// +/// A generation that produced a document and reported an error alongside it. +/// +public class a_generation_reporting_an_error : for_GenerateScreenplayCommand.given.a_generate_screenplay_command { - int _result; - - void Establish() - { - _settings.File = "MyApp.play"; + void Establish() => _generation .Generate(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new GeneratedScreenplay( @@ -19,11 +17,4 @@ void Establish() new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", "Library.Lending") ]))); - } - - async Task Because() => _result = await Execute(); - - [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); - [Fact] void should_not_write_the_document_to_the_file() => File.Exists(Path.Combine(_folder, "MyApp.play")).ShouldBeFalse(); - [Fact] void should_not_write_the_document_to_standard_output() => _standardOutput.ToArray().ShouldBeEmpty(); } diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs index 987d6ee..32cb055 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs @@ -63,20 +63,22 @@ protected override async Task ExecuteAsync(CommandContext context, Generate ScreenplayDiagnosticsWriter.Write(format, generated.Diagnostics); var exitCode = ScreenplayDiagnostics.ExitCodeFor(generated.Diagnostics); - if (exitCode != ExitCodes.Success) - { - var errors = generated.Diagnostics.Count(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error); - WriteError( - format, - writesDocumentToStandardOutput, - $"Screenplay generation reported {errors} error(s)", - "Resolve the reported errors, or generate from a project where the unsupported constructs are not used", - ExitCodes.ValidationErrorCode); - return exitCode; - } + // Standard output is the document itself, so a partial one cannot be written there — whatever consumes the + // redirect would take it for a complete document. A file can, and is: the diagnostics say what is missing. if (writesDocumentToStandardOutput) { + if (exitCode != ExitCodes.Success) + { + WriteError( + format, + true, + ErrorFor(generated), + "Pass --file to write the document that was generated anyway, or resolve the reported errors", + ExitCodes.ValidationErrorCode); + return exitCode; + } + await using var stream = _standardOutput(); await ScreenplayDocument.Write(stream, generated.Source, cancellationToken); return ExitCodes.Success; @@ -84,10 +86,25 @@ protected override async Task ExecuteAsync(CommandContext context, Generate var outputPath = ScreenplayDocument.ResolvePath(settings.File!, currentDirectory); await ScreenplayDocument.WriteToFile(outputPath, generated.Source, cancellationToken); + + if (exitCode != ExitCodes.Success) + { + WriteError( + format, + false, + ErrorFor(generated), + $"The document was still written to {outputPath} — review it, then resolve the reported errors", + ExitCodes.ValidationErrorCode); + return exitCode; + } + WriteResult(format, outputPath, target.Path!, generated); return ExitCodes.Success; } + static string ErrorFor(GeneratedScreenplay generated) => + $"Screenplay generation reported {generated.Diagnostics.Count(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error)} error(s)"; + static void WriteError(string format, bool keepStandardOutputClean, string error, string? suggestion, string errorCode) { if (!keepStandardOutputClean || GoesToStandardError(format)) From cb86ea5bfc468425f538be8f8fd4d65e4766f1ce Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:27:25 +0200 Subject: [PATCH 09/10] Align Cratis.Fundamentals with 7.16.6 Keeps the Cratis pins in step with the Cratis.Arc.Screenplay 20.65.0 addition; Cratis.Screenplay stays at 1.5.2, which is what the package depends on. Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 925f0e5..9d5d675 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + From 3f5ba48cf0bc54c6edc78e4e79f4511ee3ac4d49 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 28 Jul 2026 20:27:36 +0200 Subject: [PATCH 10/10] Document screenplay validate and the source-only generation story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the new command, and the change in what an error means for generate — the document is written anyway with --file, and only standard output stays empty. Also states plainly what separates 'cratis screenplay' from 'cratis arc' now that both describe an Arc application: arc talks to a running application over HTTP, screenplay only ever reads files. Fetching a document from a running application is a complementary route that does not exist yet, on either side, and is described as such rather than left as an implication. Co-Authored-By: Claude Opus 5 --- Documentation/reference/index.md | 2 +- Documentation/reference/screenplay.md | 55 ++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/Documentation/reference/index.md b/Documentation/reference/index.md index 7bce49b..b3ad952 100644 --- a/Documentation/reference/index.md +++ b/Documentation/reference/index.md @@ -6,7 +6,7 @@ This section documents the cross-cutting mechanics of the `cratis` CLI: the flag - [LLM](llm.md) — Configuring the language model that Cratis tools like Prologue use. - [Prologue](prologue.md) — Capturing a running system and interpreting the captures into a Screenplay. -- [Screenplay](screenplay.md) — Generating a Screenplay from the source code of a Cratis Arc application. +- [Screenplay](screenplay.md) — Generating a Screenplay from the source code of a Cratis Arc application, and validating the documents you have. - [Global Options](global-options.md) — Flags such as `--output`, `--quiet`, `--yes`, and `--debug` that apply to every command. - [Output Formats](output-formats.md) — The four output formats (`table`, `plain`, `json`, `json-compact`) with guidance on when to use each and token cost comparisons. - [Connection](connection.md) — Connection string format, resolution order, and environment variable configuration. diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md index 8063641..8c7c324 100644 --- a/Documentation/reference/screenplay.md +++ b/Documentation/reference/screenplay.md @@ -1,12 +1,15 @@ # Screenplay -`cratis screenplay` works with Cratis Screenplay (`.play`) documents. Today 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. +`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. ```bash cratis screenplay generate [PATH] +cratis screenplay validate [PATH] ``` -Unlike `cratis arc`, nothing needs to be running. The command reads a solution or project with Roslyn, so the output is reproducible from a checkout — commit it, diff it, and regenerate it in CI. +**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. ## `cratis screenplay generate [PATH]` @@ -87,14 +90,56 @@ The project does **not** have to have been built first. Sources MSBuild generate | `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. | -| Generation reports one or more errors | Validation error; no document is written. | +| 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. | + +An error means the document does not describe the source faithfully — but a document that is 99% right plus honest diagnostics is more useful than nothing at all, so `--file` still writes it. Read the diagnostics before trusting it, and re-run with `screenplay validate` to see what the Screenplay compiler makes of the result. + +Standard output is the exception: whatever consumes `cratis screenplay generate > MyApp.play` cannot tell a partial document from a complete one, so nothing is written there. Pass `--file` when you want the partial document. + +## `cratis screenplay validate [PATH]` + +Compiles Screenplay documents and reports everything the compiler found. It does not care what wrote them — `screenplay generate`, [`cratis prologue`](prologue.md), or a person designing a system before any code exists. + +`PATH` is a Screenplay (`.play`) file, or a folder — in which case every `.play` file beneath it is compiled. It defaults to the current directory. + +```bash +cratis screenplay validate # every .play file beneath the current folder +cratis screenplay validate ./MyApp.play # one document +cratis screenplay validate ./plays # every .play file beneath a folder +``` + +### Compiler diagnostics + +Diagnostics go to **standard error**, grouped by severity with errors first, in the same shape `generate` uses. The compiler does not assign codes, so each line carries the file and the position within it instead: + +```text +errors (1): + error: [MyApp.play(5,5)] Invalid slice declaration 'slice Reserving' - expected 'slice ' + +warnings (1): + warning: [MyApp.play(787,11)] Unknown event 'InvitationToJoinAdaAccepted' - declare it with 'event InvitationToJoinAdaAccepted' +``` + +With `-o json` or `-o json-compact` the same diagnostics are written to standard error as a JSON object instead. + +**Warnings and information do not fail the command. An error does** — which is what makes this usable as a CI gate on a committed `.play` file. + +### Validation outcomes + +| Condition | Result | +|---|---| +| `PATH` does not exist | Not-found error. | +| `PATH` is a file that is not a `.play` file | Not-found error. | +| No `.play` file found in the folder | Not-found error — validating nothing is never the answer you wanted. | +| Compilation reports one or more errors | Validation error. | ## Where a Screenplay comes from Generating from source is one of three ways to arrive at a `.play` file, and they meet in the same place: -- **From source** — this command, for an application that already exists in Cratis Arc. +- **From source** — `screenplay generate`, for an application that already exists in Cratis Arc. Needs the .NET SDK and a 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. -Whichever route you take, [`cratis run`](run.md) boots the result in a local Stage sandbox. +Whichever route you take, `screenplay validate` compiles the result and [`cratis run`](run.md) boots it in a local Stage sandbox.