diff --git a/Directory.Packages.props b/Directory.Packages.props index 71003fcd..1572a637 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 0.18.0 4.154.0 - 4.96.0 + 4.97.0 diff --git a/Documentation/reference/screenplay-mcp.md b/Documentation/reference/screenplay-mcp.md index c02842d6..bf72b6a8 100644 --- a/Documentation/reference/screenplay-mcp.md +++ b/Documentation/reference/screenplay-mcp.md @@ -5,7 +5,7 @@ description: Run the embedded Screenplay MCP server and configure project-local `cratis screenplay mcp` hosts the embedded Screenplay 4.154.0 Model Context Protocol (MCP) server over standard input and output. `cratis version` shows the bundled compiler version; JSON output includes `screenplay.version`. It does not install tools, download packages, or check for CLI updates at startup. Native and Homebrew CLI distributions include the runtime; this command does not require a separate .NET installation. -The server can author and bind every ESM version the bundled Screenplay compiler reads; this does not establish renderability. The CLI's bundled Stage 4.96.0 renderer admits ESM v1 through v16, including event sources, streams and routes, v14 equality query filters on snapshot and observable collection queries, v15 concept generation strategies and v16 stored event names, and renders policy negation, authored XML documentation, non-optional UUID-backed generated concepts as Arc generated-value parameters, scalar and named scalar command responses, and supported event-triggered Automation slices. An event's `id` pin keeps its stored event type name when you rename it. Every render checks existing event type ids before publication; changes or unreadable ids report `CLI-RENDER-016` with `STAGE-EVENT-002`. Observable collections return Chronicle's live read-model stream through Arc; snapshot collections return a one-time list. Projection `all` blocks whose mappings set event-source identities render as Chronicle `FromAll`; literal-mapping or mapping-less blocks still report `STAGE-ESM-017`. PascalCase event-source names render as `{Name}EventSource` with public stream-name constants; colliding constants report `STAGE-ESM-012`. Unsupported generated types (`STAGE-ESM-028`), unsupported generation/response combinations (`STAGE-ESM-038`), mixed reaction effects (`STAGE-ESM-037`), captures, non-event triggers and Translate slices (`STAGE-ESM-024`) remain refused, as do read-model absence assertions (`STAGE-ESM-027`), public events and event-target projections and reducers (`STAGE-ESM-032`), foreign origins and event-source captures (`STAGE-ESM-033`), system-identity reactions that invoke commands whose authorization reads claims or uses opaque policies (`STAGE-ESM-034`) and per-production route overrides (`STAGE-ESM-036`); reaction `runs as` identity and reaction and reducer observer filters render. A specification whose unauthenticated caller carries roles or claims is refused with `STAGE-ESM-011`, including the unchanged canonical negation example. See [Screenplay rendering](screenplay.md#executable-model-versions) for command-specific compatibility. +The server can author and bind every ESM version the bundled Screenplay compiler reads; this does not establish renderability. The CLI's bundled Stage 4.97.0 renderer admits ESM v1 through v16, including event sources, streams and routes, v14 equality query filters on snapshot and observable collection queries, v15 concept generation strategies and v16 stored event names, and renders policy negation, authored XML documentation, non-optional UUID-backed generated concepts as Arc generated-value parameters, scalar and named scalar command responses, and supported event-triggered Automation slices. An event's `id` pin keeps its stored event type name when you rename it. Every render checks existing event type ids before publication; changes or unreadable ids report `CLI-RENDER-016` with `STAGE-EVENT-002`. Observable collections return Chronicle's live read-model stream through Arc; snapshot collections return a one-time list. Projection `all` blocks whose mappings set event-source identities render as Chronicle `FromAll`; literal-mapping or mapping-less blocks still report `STAGE-ESM-017`. PascalCase event-source names render as `{Name}EventSource` with public stream-name constants; colliding constants report `STAGE-ESM-012`. Unsupported generated types (`STAGE-ESM-028`), unsupported generation/response combinations (`STAGE-ESM-038`), mixed reaction effects (`STAGE-ESM-037`), captures, non-event triggers and Translate slices (`STAGE-ESM-024`) remain refused, as do read-model absence assertions (`STAGE-ESM-027`), public events and event-target projections and reducers (`STAGE-ESM-032`), foreign origins and event-source captures (`STAGE-ESM-033`), system-identity reactions that invoke commands whose authorization reads claims or uses opaque policies (`STAGE-ESM-034`) and per-production route overrides (`STAGE-ESM-036`); reaction `runs as` identity and reaction and reducer observer filters render. A specification whose unauthenticated caller carries roles or claims is refused with `STAGE-ESM-011`, including the unchanged canonical negation example. See [Screenplay rendering](screenplay.md#executable-model-versions) for command-specific compatibility. For Claude Desktop and ChatGPT Desktop **installation**, see [Screenplay desktop MCP](screenplay-desktop-mcp.md). Desktop management uses diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md index d3d6bb8c..9a1e0c01 100644 --- a/Documentation/reference/screenplay.md +++ b/Documentation/reference/screenplay.md @@ -14,7 +14,7 @@ cratis screenplay mcp [PATH] cratis screenplay desktop install ``` -The CLI bundles Screenplay 4.154.0 (including its MCP server), Stage 4.96.0, and the Arc source adapter 22.54.0. `cratis render` admits ESM v1 through v16, including the canonical screen-composition corpus observable query and query-only specification shapes, and preserves authored Scene layouts, profiles, templates, navigation and forms when a model declares screens. Guarded screen actions render as runnable Scene guards: the generated frontend evaluates the authored guard, including enum-typed comparisons against the read model's declared members, and shows the action only when it passes. The renderer still refuses unsupported constructs within those versions: captures, non-event triggers and Translate slices report `STAGE-ESM-024`; supported event-triggered Automation slices render, while reactions mixing event production and command invocation report `STAGE-ESM-037`, read-model absence assertions report `STAGE-ESM-027`, and unsupported v9 and v10 constructs report the codes listed in the admission table below. Reaction system identities and reaction/reducer observer filters render within their supported subset. It also refuses an evolved event until Stage can render its event-type migrations. Validation rejects `numbers exact` (`PLAY0268`) and `implementation` attachments on concept, built-in, and whole-command rules. +The CLI bundles Screenplay 4.154.0 (including its MCP server), Stage 4.97.0, and the Arc source adapter 22.54.0. `cratis render` admits ESM v1 through v16, including the canonical screen-composition corpus observable query and query-only specification shapes, and preserves authored Scene layouts, profiles, templates, navigation and forms when a model declares screens. Guarded screen actions render as runnable Scene guards: the generated frontend evaluates the authored guard, including enum-typed comparisons against the read model's declared members, and shows the action only when it passes. The renderer still refuses unsupported constructs within those versions: captures, non-event triggers and Translate slices report `STAGE-ESM-024`; supported event-triggered Automation slices render, while reactions mixing event production and command invocation report `STAGE-ESM-037`, read-model absence assertions report `STAGE-ESM-027`, and unsupported v9 and v10 constructs report the codes listed in the admission table below. Reaction system identities and reaction/reducer observer filters render within their supported subset. It also refuses an evolved event until Stage can render its event-type migrations. Validation rejects `numbers exact` (`PLAY0268`) and `implementation` attachments on concept, built-in, and whole-command rules. For the embedded stdio server, the `visualize-model` MCP App, and native AI host registration, see [Screenplay MCP](screenplay-mcp.md). For Claude Desktop and ChatGPT Desktop installation, see [Screenplay desktop MCP](screenplay-desktop-mcp.md). @@ -32,7 +32,7 @@ A newer bundled compiler can produce a newer executable semantic model (ESM) ver | `cratis screenplay mcp` | Admitted for authoring and reading. The embedded server binds v7 models, pages their executable model (generated properties, responses, generation fixtures and return expectations) and reports execution readiness. The embedded server offers deterministic reference execution through `run-specifications`, including retained proposal candidates. | | `cratis render` | Admitted. Policy negation renders. Server-generated, non-optional UUID-backed concepts render as Arc generated-value parameters. Scalar responses and named responses whose fields come from scalar command properties render. Unsupported generated types report `STAGE-ESM-028`; unsupported generation/response combinations report `STAGE-ESM-038`. Stage refuses a specification whose unauthenticated caller carries roles or claims with `STAGE-ESM-011`; this includes the unchanged canonical negation example. A refusal leaves no publishable artifacts. Versions newer than v16 are still blocked by `CLI-RENDER-004`, naming the version. | | `cratis screenplay generate` | From Arc 22.52.0, the Arc adapter recovers supported generated UUID values and command responses by default for commands without successful scenarios. Output containing these constructs compiles to ESM v7, which `cratis render` admits. The renderer supports non-optional UUID-backed generated concepts and scalar or named scalar command responses; unsupported types or combinations still receive typed refusals. Unsupported shapes and values needed by pre-generation protection remain in code with `SP0052`. Marten and Critter Stack retain their existing recovery surface. | -| `cratis run` | The Stage 4.96.0 container compiles the mounted `.play` files with its own bundled Screenplay compiler. Passing `cratis screenplay validate` does not establish Stage support for every construct. | +| `cratis run` | The Stage 4.97.0 container compiles the mounted `.play` files with its own bundled Screenplay compiler. Passing `cratis screenplay validate` does not establish Stage support for every construct. | The bundled Screenplay reader supports ESM schemas through v16. ESM v5 adds keyed read-model absence assertions; v8 adds event sources, streams, command routes and specification routes. ESM v9 adds public events, foreign event origins, translation direction, event-target projections/reducers and event-source captures; v10 adds reaction system identity (`runsAs`), per-production route overrides (`SemanticProducedEvent.Route`) and observer filters (`SemanticReaction.From` and `SemanticReducer.From`). Version reading and command admission are separate: @@ -44,8 +44,8 @@ The bundled Screenplay reader supports ESM schemas through v16. ESM v5 adds keye | `cratis screenplay generate` | Checks recovered source with the bundled compiler. The Arc adapter's output remains ESM v7; recovery is provider-owned. | Does not execute or render these constructs. Recovery remains provider-owned. | Does not execute or render these constructs. Recovery remains provider-owned. | | `cratis view` | Serves embedded or generated source through the bundled event-model explorer. | Viewing is not renderer admission. | Viewing is not renderer admission. | | Generation statistics | Count declarations in compiled source, including documents with event routes. | Counts do not establish executable or renderer support. | Counts do not establish executable or renderer support. | -| `cratis render` | Admitted through Stage 4.96.0, including routed commands and supported routed specifications. PascalCase event-source names render as `{Name}EventSource` with public stream-name constants; colliding constants report `STAGE-ESM-012`. Unsupported constructs still block publication. | Admitted for construct-level planning. Public events and event-target projections/reducers report `STAGE-ESM-032`; foreign origins and event-source captures report `STAGE-ESM-033`; translation direction reports `STAGE-ESM-024`. | Admitted for construct-level planning. Reaction `runsAs` renders with the declared system roles; invoking a command whose authorization reads claims or uses opaque policies reports `STAGE-ESM-034`. Reaction `from` filters (`SemanticReaction.From`) render as Chronicle source/stream attributes. Per-production route overrides (`SemanticProducedEvent.Route`) report `STAGE-ESM-036`. Reducer `from` filters (`SemanticReducer.From`) render as Chronicle source/stream attributes; specifications requiring filtered read-model replay remain refused. | -| `cratis run` | Refused by the Stage 4.96.0 container: `cratis run` starts Stage's default `eventmodel` engine, which reports `STAGE-ESM-030` for named event sources, streams and routes because only Stage's semantic engine runs them. Render the model with `cratis render` instead. | Stage applies its own runtime admission; CLI validation is not Stage admission. | Stage applies its own runtime admission; CLI validation is not Stage admission. | +| `cratis render` | Admitted through Stage 4.97.0, including routed commands and supported routed specifications. PascalCase event-source names render as `{Name}EventSource` with public stream-name constants; colliding constants report `STAGE-ESM-012`. Unsupported constructs still block publication. | Admitted for construct-level planning. Public events and event-target projections/reducers report `STAGE-ESM-032`; foreign origins and event-source captures report `STAGE-ESM-033`; translation direction reports `STAGE-ESM-024`. | Admitted for construct-level planning. Reaction `runsAs` renders with the declared system roles; invoking a command whose authorization reads claims or uses opaque policies reports `STAGE-ESM-034`. Reaction `from` filters (`SemanticReaction.From`) render as Chronicle source/stream attributes. Per-production route overrides (`SemanticProducedEvent.Route`) report `STAGE-ESM-036`. Reducer `from` filters (`SemanticReducer.From`) render as Chronicle source/stream attributes; specifications requiring filtered read-model replay remain refused. | +| `cratis run` | Refused by the Stage 4.97.0 container: `cratis run` starts Stage's default `eventmodel` engine, which reports `STAGE-ESM-030` for named event sources, streams and routes because only Stage's semantic engine runs them. Render the model with `cratis render` instead. | Stage applies its own runtime admission; CLI validation is not Stage admission. | Stage applies its own runtime admission; CLI validation is not Stage admission. | Version admission does not admit every construct. A selected scope without unsupported constructs can plan normally at v9 or v10; a typed renderer refusal leaves no publishable artifacts. ESM v11 (lossless lowering of Double-mode number literals within ±(2^53−1); `numbers exact` documents still do not bind), v12 enum member numbers and v13 concept compliance are admitted by the bundled renderer. Snapshot collection queries render as one-time queries; observable collection queries return Chronicle's live read-model stream through Arc. Both support a `by` key and ESM v14 equality query filters, with optional filters narrowing only when supplied; DateTime filters remain refused with `STAGE-ESM-010`. Protected observable collection-success specifications remain refused with `STAGE-ESM-011`; denial specifications still render. Projection `all` blocks whose mappings set event-source identities render as Chronicle `FromAll`; literal-mapping or mapping-less `all` blocks remain refused with `STAGE-ESM-017`. ESM v15 concept generation strategies render with freshness scopes on generated event sources. Time-ordered factories use the wall clock, not a command-receipt timestamp. ESM v16 stored event names render: an event's `id` pin keeps its stored Chronicle event type name when you rename the event. For example, an event named `Renamed` with `id "Original"` renders with `[EventType("Original")]`. Newer versions remain blocked by `CLI-RENDER-004` before target planning or publication. Non-text secret concepts without personal-data classification are refused by both the renderer and Host with `STAGE-ESM-041`. @@ -116,6 +116,8 @@ cratis render ./plays --name Shop --destination ./out \ cratis render ./plays --name Shop --destination ./out --scope Orders.Placing --check ``` +[Release-baseline warnings](#release-baseline-warnings) cover the whole application, including admitted event revisions outside the selected scopes. + The entire source set must compile and bind. Execution-plan (`PLAN-*`) problems in unselected slices are reported as warnings; problems in selected slices and application-level declarations still block a scoped render. When the selected module or feature no longer contains any slices, execution admission covers application-level declarations only; unrelated slice problems remain warnings, so they do not prevent removing that scope's previously owned artifacts. Renderer (`STAGE-ESM-*`) refusals in unselected slices do not block a scoped render: Stage admits and renders only the selected slices. Shared concepts and composite types referenced by those slices are included. Authored XML documentation and shared policy artifacts have the same bytes as in a full render. Unknown addresses report `CLI-RENDER-011`, including existing sibling names at the first unmatched level. An address that names both a feature and a slice reports `CLI-RENDER-012`. Both exit with 5. The application scaffold and default Scene belong to Application scope. A scoped plan excludes both. When the destination has **no ownership manifest**, publication adds the plain application scaffold, using the same application name, project name and root namespace as a full render; it does not add a default Scene. Once a manifest exists, scoped publication leaves the existing scaffold alone. @@ -138,6 +140,8 @@ cratis render --workspace ./application.workspace.json \ --project-name Delivery.Backend ``` +For release-baseline warnings, workspace mode reads `.screenplay/release-baseline.json` beside the workspace file, or the file you select with `--baseline `. It checks the supplied workspace's documents and identities, not `.play` files or identities beside it. See [Release-baseline warnings](#release-baseline-warnings). + `--workspace ` is an explicit file-only input mode, mutually exclusive with positional `PATH`. Without it, the existing `.play` file/folder discovery and current-directory default remain unchanged. There is no automatic JSON detection, archive extraction, or Git operation. The CLI uses the shared `Cratis.Screenplay.Workspaces.ScreenplayWorkspaceSerializer.Deserialize(ReadOnlySpan)` API for the canonical version 1 envelope. The supplied application identity and name, exact identity catalog, document IDs, stable keys, portable display paths, and exact source bytes are authoritative. Planning receives the documents' strictly decoded text (including original line endings) and supplied identities through the real document-set compiler; it never bootstraps replacement identities from file paths. The input file is not modified or extracted. @@ -157,7 +161,8 @@ Revision verification detects content inconsistency, not authenticity. Import do | Option | Description | |---|---| | `--workspace ` | Canonical version 1 workspace envelope, at most 32 MiB. File only; mutually exclusive with positional `PATH`. | -| `--scope
` | Repeatable exact authored module, feature or slice address (`Module.Feature.Slice`). Omit to render the whole application. Works with `--check` and `--workspace`. | +| `--baseline ` | Release-baseline file for `--workspace` only. Overrides `.screenplay/release-baseline.json` beside the workspace file. An explicitly missing file warns; an absent discovered baseline does not. With PATH input, exits with 5. | +| `--scope
` | Repeatable exact authored module, feature or slice address (`Module.Feature.Slice`). Omit to render the whole application. Works with `--check` and `--workspace`; release-baseline warnings still cover the whole application. | | `--target ` | Statically bundled renderer target. Defaults to `cratis`; arbitrary plugins cannot add executable targets. | | `--destination ` | Managed artifact destination. Defaults to `./out`. | | `--name ` | Required for plain source; optional with `--workspace`, where it must exactly match the supplied name. Defaults the project name and root namespace. | @@ -284,8 +289,11 @@ Use `--check` when generated code is committed beside your model and CI must det ```bash cratis render ./Model --name App --destination ./src/App --check +cratis screenplay baseline check ./Model ``` +Pair the publication check with `screenplay baseline check`: release-baseline warnings from render do not fail CI when an admitted event revision changes or disappears. The baseline check exits with 1 for those violations. + The command runs the same compilation, diagnostics, and artifact planning as a normal render, then checks ownership and bytes at the real destination. It writes nothing: no destination directory, ownership manifest, `.cratis-render/` control directory, journal, staging, or backups. Existing files and directories remain untouched, including interrupted publication state. | Exit code | Meaning with `--check` | @@ -310,6 +318,25 @@ The check receipt uses the publication receipt's `schemaVersion`, path, and hash With `--force --check`, a modified active managed file is evaluated as a write instead of a refusal. This never changes the file and still refuses unmanaged overwrites and modified stale deletions. Pending recovery is always a refusal: run render without `--check` to recover it. Table output lists verdicts and refusal reasons; quiet output emits no receipt or summary and communicates the verdict through the exit code. +### Release-baseline warnings + +Rendering reports changes to admitted event revisions as warnings, in both normal publication and `--check`. Text output groups them under `warnings` on standard error; JSON output includes them in the standard-error `diagnostics` array. Standard-output results, artifact bytes, publication receipts and exit codes are unchanged. + +| Code | Meaning | +|---|---| +| `STAGE-BASELINE-001` | An admitted event revision's stored schema changed. The warning names the event, contract, revision, release and first differing path. | +| `STAGE-BASELINE-002` | An admitted event revision is missing from the current model. | +| `STAGE-BASELINE-003` | The baseline is unreadable, malformed, unsupported, too large, or belongs to another application. An explicitly selected file that does not exist also produces this warning. | +| `STAGE-BASELINE-004` | Checking is unavailable, for example because PATH input lacks persisted identities or the model root needs recovery. | + +For **PATH input**, the baseline comes from `/.screenplay/release-baseline.json`. The model root is the selected folder, or a single `.play` file's parent directory. When a baseline exists, checking uses that root's persisted `.screenplay/identities.json` and current model files. Without those identities, render warns rather than creating or reconciling them. `--baseline` is not supported with PATH input: choose workspace input to supply a standalone baseline. + +For **workspace input**, `--baseline ` takes precedence over `.screenplay/release-baseline.json` in the workspace file's directory. The assessment uses the exact compiled workspace documents and identity catalog. Baseline files are limited to 4 MiB and must be regular files. A missing implicitly discovered baseline produces no warning or error in either input mode. + +Warnings cover the **whole application**, even with `--scope`. They appear only when planning reaches the renderer; an earlier compilation, scope or execution-plan failure does not produce a baseline assessment. + +Use [`cratis screenplay baseline check`](#cratis-screenplay-baseline) as the CI gate: it exits with 1 for admitted-revision violations and 2 when checking cannot run. Render warnings do not waive the independent `CLI-RENDER-016` publication refusal for changed or unreadable Chronicle event type ids, and that refusal does not replace the baseline gate. + ### Publication receipts JSON output also contains a versioned `publication` receipt. It describes successful **filesystem publication**, not a Git commit or push. The existing human-readable panel and quiet destination output are unchanged. @@ -691,7 +718,7 @@ Protect released event contracts from accidental edits outside MCP. `check` comp Both commands require persisted `.screenplay/identities.json`, created by an applied MCP proposal. `ROOT` is the model directory and defaults to the current directory, honoring `mcpServers.screenplay.root` in `.cratis/ai.json` through the existing MCP root resolver. Pass an explicit root to select a different model. -Run the read-only gate in CI: +[`cratis render` reports release-baseline warnings](#release-baseline-warnings) without refusing publication or changing its exit code. Keep `baseline check` as the read-only gate in CI: ```bash cratis screenplay baseline check ./Model diff --git a/README.md b/README.md index cd7559c8..28041569 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,7 @@ The CLI repository carries additional command groups whose exact behavior and st - `cratis arc` inspects registered commands and queries in a running [Arc](https://github.com/Cratis/Arc) application. - `cratis view` opens a .NET application as an event model in the browser, from the Screenplay documents it embeds or ones generated from its source. See the [View command reference](https://github.com/Cratis/cli/blob/main/Documentation/reference/view.md). -- `cratis screenplay` and `cratis render` work with Cratis Screenplay (`.play`) documents — generation, validation, and rendering from files, with nothing running. The CLI bundles Screenplay 4.154.0 and Stage 4.96.0; rendering admits ESM v1 through v16, including event sources, streams, routed commands and policy negation, and carries authored `description` and event `documentation` into XML doc comments. ESM v16 keeps an event's stored type name through its `id` pin when you rename it. Every render checks existing event type ids before publication and refuses changes or unreadable ids with `CLI-RENDER-016` and `STAGE-EVENT-002`. Server-generated values render with v15 concept generation strategies and freshness scopes, snapshot collection queries render as one-time lists, observable collection queries return Chronicle's live stream through Arc, equality filters render on both, and scalar or named command responses render. Projection `all` blocks with event-source identity mappings render as Chronicle `FromAll`; literal-mapping or mapping-less blocks still report `STAGE-ESM-017`. PascalCase event-source names render as `{Name}EventSource` with public stream-name constants; colliding constants report `STAGE-ESM-012`; unsupported generated types report `STAGE-ESM-028` and unsupported generation/response combinations `STAGE-ESM-038`; specifications whose unauthenticated callers carry roles or claims are refused with `STAGE-ESM-011`. See the [Screenplay command reference](https://github.com/Cratis/cli/blob/main/Documentation/reference/screenplay.md) for the supported constructs and other renderer limits. +- `cratis screenplay` and `cratis render` work with Cratis Screenplay (`.play`) documents — generation, validation, and rendering from files, with nothing running. The CLI bundles Screenplay 4.154.0 and Stage 4.97.0; rendering admits ESM v1 through v16, including event sources, streams, routed commands and policy negation, and carries authored `description` and event `documentation` into XML doc comments. ESM v16 keeps an event's stored type name through its `id` pin when you rename it. Every render checks existing event type ids before publication and refuses changes or unreadable ids with `CLI-RENDER-016` and `STAGE-EVENT-002`. Server-generated values render with v15 concept generation strategies and freshness scopes, snapshot collection queries render as one-time lists, observable collection queries return Chronicle's live stream through Arc, equality filters render on both, and scalar or named command responses render. Projection `all` blocks with event-source identity mappings render as Chronicle `FromAll`; literal-mapping or mapping-less blocks still report `STAGE-ESM-017`. PascalCase event-source names render as `{Name}EventSource` with public stream-name constants; colliding constants report `STAGE-ESM-012`; unsupported generated types report `STAGE-ESM-028` and unsupported generation/response combinations `STAGE-ESM-038`; specifications whose unauthenticated callers carry roles or claims are refused with `STAGE-ESM-011`. See the [Screenplay command reference](https://github.com/Cratis/cli/blob/main/Documentation/reference/screenplay.md) for the supported constructs and other renderer limits. - `cratis screenplay desktop install --clients claude,chatgpt` acquires verified, self-contained local Screenplay desktop packages. Complete installation/enabling in each host's UI; use `status`, `update`, and `uninstall` to manage Cratis-owned sources safely. See [desktop MCP installation](https://github.com/Cratis/cli/blob/main/Documentation/reference/screenplay-desktop-mcp.md). - The [canonical CLI page](https://cratis.io/cli/) carries the currently admitted command-group documentation. diff --git a/Source/Cli.Specs/for_CratisRenderTarget/when_planning_a_supported_application.cs b/Source/Cli.Specs/for_CratisRenderTarget/when_planning_a_supported_application.cs index 8c06390b..218de7b3 100644 --- a/Source/Cli.Specs/for_CratisRenderTarget/when_planning_a_supported_application.cs +++ b/Source/Cli.Specs/for_CratisRenderTarget/when_planning_a_supported_application.cs @@ -31,8 +31,8 @@ Task Because() [Fact] void should_plan_the_exact_facade_target_version() => _result.TargetVersion.ShouldEqual(_expected.TargetVersion); [Fact] void should_plan_the_exact_facade_renderer() => _result.Renderer.ShouldEqual(_expected.Renderer); [Fact] void should_plan_the_exact_facade_renderer_version() => _result.RendererVersion.ShouldEqual(_expected.RendererVersion); - [Fact] void should_load_the_released_stage_4960_renderer_package() => - typeof(CratisRendering).Assembly.GetCustomAttribute()!.InformationalVersion.Split('+')[0].ShouldEqual("4.96.0"); + [Fact] void should_load_the_released_stage_4970_renderer_package() => + typeof(CratisRendering).Assembly.GetCustomAttribute()!.InformationalVersion.Split('+')[0].ShouldEqual("4.97.0"); [Fact] void should_plan_the_same_application_name() => _result.ApplicationName.ShouldEqual(_expected.ApplicationName); [Fact] void should_plan_the_same_semantic_revision() => _result.SemanticRevision.ShouldEqual(_expected.SemanticRevision); [Fact] void should_plan_the_exact_facade_artifacts() => diff --git a/Source/Cli.Specs/for_CratisRenderTarget/when_planning_with_a_release_baseline_assessment.cs b/Source/Cli.Specs/for_CratisRenderTarget/when_planning_with_a_release_baseline_assessment.cs new file mode 100644 index 00000000..3d94d0a8 --- /dev/null +++ b/Source/Cli.Specs/for_CratisRenderTarget/when_planning_with_a_release_baseline_assessment.cs @@ -0,0 +1,42 @@ +// 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.Semantics; +using Cratis.Screenplay.Semantics.Execution; +using Cratis.Stage.Contracts.Rendering; +using Cratis.Stage.Contracts.Semantics; +using Cratis.Stage.Rendering.Cratis; + +namespace Cratis.Cli.for_CratisRenderTarget; + +public class when_planning_with_a_release_baseline_assessment : given.a_cratis_render_target +{ + SemanticCompilation _compilation = null!; + SemanticExecutionPlan _execution = null!; + ArtifactRenderPlan _result = null!; + ArtifactRenderPlan _withoutBaseline = null!; + ArtifactRenderRequest _request = null!; + ReleaseBaselineAssessment _assessment = null!; + + void Establish() + { + (_compilation, _execution) = Compile(SupportedSource); + _assessment = new(ReleaseBaselineAssessmentStatus.Unavailable, _compilation.Model.Revision, [], "persisted identities required"); + var planner = Substitute.For(); + planner.Plan(Arg.Any()).Returns(call => + { + _request = call.Arg(); + return new CratisArtifactRenderPlanner().Plan(_request); + }); + _target = new(planner); + _withoutBaseline = new CratisRenderTarget().Plan(_compilation, _execution, null, null, [], [], [], [], []); + } + + void Because() => _result = _target.Plan(_compilation, _execution, null, null, [], [], [], [], [], releaseBaseline: _assessment); + + [Fact] void should_forward_the_assessment() => _request.ReleaseBaseline.ShouldBeSame(_assessment); + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_report_stage_unavailable_warning() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "STAGE-BASELINE-004"); + [Fact] void should_keep_artifact_bytes() => _result.Artifacts.Zip(_withoutBaseline.Artifacts).All(pair => pair.First.Bytes.SequenceEqual(pair.Second.Bytes)).ShouldBeTrue(); + [Fact] void should_keep_artifact_count() => _result.Artifacts.Length.ShouldEqual(_withoutBaseline.Artifacts.Length); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/given/a_model_root.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/given/a_model_root.cs new file mode 100644 index 00000000..385a3b1e --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/given/a_model_root.cs @@ -0,0 +1,36 @@ +// 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.Mcp; +using Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.given; + +public class a_model_root : for_ScreenplayPlanning.when_planning_with_a_release_baseline.given.a_release_baseline +{ + protected string _root = null!; + protected string _baselinePath = null!; + protected string _identitiesPath = null!; + protected Func _run = null!; + protected ReleaseBaselineAssessment _assessment = null!; + protected SemanticCompilation _compilation = null!; + private protected ModelRootReleaseBaseline _check = null!; + + void Establish() + { + _root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"cli-baseline-{Guid.NewGuid():N}")).FullName; + var metadata = Directory.CreateDirectory(Path.Combine(_root, ".screenplay")).FullName; + _baselinePath = Path.Combine(metadata, ReleaseBaselineStore.FileName); + _identitiesPath = Path.Combine(metadata, "identities.json"); + File.WriteAllBytes(_baselinePath, ReleaseBaselineStore.Serialize(_baseline.Document!)); + File.WriteAllText(_identitiesPath, "persisted identities checked by the injected runner"); + _compilation = Workspace(Source, SemanticIdentityCatalog.Empty(ApplicationIdentity.Create("TemporaryRender"))).Compilation.Value!; + _run = Substitute.For>(); + _run(_root).Returns(new ReleaseBaselineOutcome(0, [])); + _check = new(_root, _run); + } + + void Destroy() => Directory.Delete(_root, true); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_identities_are_absent.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_identities_are_absent.cs new file mode 100644 index 00000000..4032056a --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_identities_are_absent.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. + +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_identities_are_absent : given.a_model_root +{ + void Establish() => File.Delete(_identitiesPath); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unavailable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unavailable); + [Fact] void should_explain_the_required_identities() => _assessment.Reason!.ShouldContain(".screenplay/identities.json"); + [Fact] void should_not_run_the_persisted_check() => _run.DidNotReceive().Invoke(Arg.Any()); + [Fact] void should_stamp_the_rendered_revision() => _assessment.ModelRevision.ShouldEqual(_compilation.Model.Revision); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_mapping_metadata_changed_after_the_check.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_mapping_metadata_changed_after_the_check.cs new file mode 100644 index 00000000..6fb47b79 --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_mapping_metadata_changed_after_the_check.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Mcp; +using Cratis.Screenplay.Workspaces; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_mapping_metadata_changed_after_the_check : given.a_model_root +{ + void Establish() + { + var entry = _baseline.Document!.Entries[0]; + _run(_root).Returns(new ReleaseBaselineOutcome(1, [new("BaselinedRevisionChanged", entry.Event, entry.Revision)])); + File.WriteAllBytes(_baselinePath, ReleaseBaselineStore.Serialize(_baseline.Document with { Entries = [] })); + } + + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unavailable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unavailable); + [Fact] void should_explain_the_mapping_drift() => _assessment.Reason!.ShouldContain("changed after its check"); + [Fact] void should_not_report_partial_violations() => _assessment.Violations.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_mapping_metadata_is_unreadable.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_mapping_metadata_is_unreadable.cs new file mode 100644 index 00000000..d19be3e4 --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_mapping_metadata_is_unreadable.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. + +using Cratis.Screenplay.Mcp; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_mapping_metadata_is_unreadable : given.a_model_root +{ + void Establish() + { + var entry = _baseline.Document!.Entries[0]; + _run(_root).Returns(new ReleaseBaselineOutcome(1, [new("BaselinedRevisionChanged", entry.Event, entry.Revision)])); + File.WriteAllText(_baselinePath, "malformed metadata"); + } + + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unreadable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unreadable); + [Fact] void should_not_report_partial_violations() => _assessment.Violations.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_recovery_is_pending.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_recovery_is_pending.cs new file mode 100644 index 00000000..9ee467cc --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_recovery_is_pending.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. + +using Cratis.Screenplay.Mcp; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_recovery_is_pending : given.a_model_root +{ + void Establish() => _run(_root).Returns(new ReleaseBaselineOutcome(2, [], "PendingOperation", "explicit recovery required")); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unavailable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unavailable); + [Fact] void should_keep_the_reason() => _assessment.Reason.ShouldEqual("explicit recovery required"); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_baseline_is_absent.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_baseline_is_absent.cs new file mode 100644 index 00000000..bb5dddba --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_baseline_is_absent.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. + +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_the_baseline_is_absent : given.a_model_root +{ + void Establish() => File.Delete(_baselinePath); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_not_supply_an_assessment() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.NotSupplied); + [Fact] void should_not_run_the_persisted_check() => _run.DidNotReceive().Invoke(Arg.Any()); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_baseline_is_unreadable.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_baseline_is_unreadable.cs new file mode 100644 index 00000000..3967b65d --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_baseline_is_unreadable.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. + +using Cratis.Screenplay.Mcp; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_the_baseline_is_unreadable : given.a_model_root +{ + void Establish() => _run(_root).Returns(new ReleaseBaselineOutcome(2, [], "UnreadableReleaseBaseline", "malformed metadata")); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unreadable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unreadable); + [Fact] void should_keep_the_reason() => _assessment.Reason.ShouldEqual("malformed metadata"); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_fails_with_a_failure_kind.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_fails_with_a_failure_kind.cs new file mode 100644 index 00000000..91e29edb --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_fails_with_a_failure_kind.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. + +using Cratis.Screenplay.Mcp; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_the_check_fails_with_a_failure_kind : given.a_model_root +{ + void Establish() => _check = new(_root, _ => new ReleaseBaselineOutcome(1, [], "UnexpectedFailure", "something else went wrong")); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unavailable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unavailable); + [Fact] void should_keep_the_reason() => _assessment.Reason.ShouldEqual("something else went wrong"); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_is_clean.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_is_clean.cs new file mode 100644 index 00000000..4befff3e --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_is_clean.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. + +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_the_check_is_clean : given.a_model_root +{ + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_check_the_model_root() => _run.Received(1).Invoke(_root); + [Fact] void should_report_checked() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Checked); + [Fact] void should_have_no_violations() => _assessment.Violations.ShouldBeEmpty(); + [Fact] void should_stamp_the_rendered_revision() => _assessment.ModelRevision.ShouldEqual(_compilation.Model.Revision); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_reports_violations.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_reports_violations.cs new file mode 100644 index 00000000..bb275564 --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_reports_violations.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. + +using Cratis.Screenplay.Mcp; +using Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_the_check_reports_violations : given.a_model_root +{ + void Establish() => _run(_root).Returns(new ReleaseBaselineOutcome(1, [.. _baseline.Document!.Entries.Select((entry, index) => new ReleaseBaselineViolation(index == 0 ? "BaselinedRevisionChanged" : "BaselinedRevisionMissing", entry.Event, entry.Revision, index == 0 ? "properties[0].type" : null))])); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_checked() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Checked); + [Fact] void should_map_every_admitted_revision() => _assessment.Violations.Select(violation => (violation.ContractId, violation.Revision, violation.StoredName, violation.AdmittedIn)).ShouldEqual(_baseline.Document!.Entries.Select(entry => (EventContractId.Parse(entry.Event), entry.Revision, entry.EventType, entry.AdmittedIn))); + [Fact] void should_map_display_hint_kinds() => _assessment.Violations.Select(violation => violation.DisplayHint.Kind).ShouldEqual(_baseline.Document!.Entries.Select(entry => entry.LastAddress.Kind)); + [Fact] void should_map_display_hint_parts() => _assessment.Violations.SelectMany(violation => violation.DisplayHint.Parts).ShouldEqual(_baseline.Document!.Entries.SelectMany(entry => entry.LastAddress.Parts)); + [Fact] void should_keep_violation_kinds() => _assessment.Violations.Select(violation => violation.Kind).ShouldEqual(["BaselinedRevisionChanged", "BaselinedRevisionMissing"]); + [Fact] void should_keep_the_difference_path() => _assessment.Violations[0].Path.ShouldEqual("properties[0].type"); + [Fact] void should_stamp_the_rendered_revision() => _assessment.ModelRevision.ShouldEqual(_compilation.Model.Revision); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_throws.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_throws.cs new file mode 100644 index 00000000..ae6cdf23 --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_check_throws.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. + +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_the_check_throws : given.a_model_root +{ + void Establish() => _check = new(_root, _ => throw new IOException("cannot read persisted state")); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unavailable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unavailable); + [Fact] void should_keep_the_reason() => _assessment.Reason.ShouldEqual("cannot read persisted state"); +} diff --git a/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_root_reader_rejects_a_document.cs b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_root_reader_rejects_a_document.cs new file mode 100644 index 00000000..7d72a020 --- /dev/null +++ b/Source/Cli.Specs/for_ModelRootReleaseBaseline/when_assessing/and_the_root_reader_rejects_a_document.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. + +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_ModelRootReleaseBaseline.when_assessing; + +public class and_the_root_reader_rejects_a_document : given.a_model_root +{ + void Establish() => _check = new(_root, _ => throw new DocumentRejected()); + void Because() => _assessment = _check.Assess(_compilation); + + [Fact] void should_report_unavailable() => _assessment.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Unavailable); + [Fact] void should_keep_the_reason() => _assessment.Reason.ShouldEqual("The document is not valid UTF-8."); + + sealed class DocumentRejected() : Exception("The document is not valid UTF-8."); +} diff --git a/Source/Cli.Specs/for_ReleaseBaselineFile/given/a_baseline_file.cs b/Source/Cli.Specs/for_ReleaseBaselineFile/given/a_baseline_file.cs new file mode 100644 index 00000000..870b6444 --- /dev/null +++ b/Source/Cli.Specs/for_ReleaseBaselineFile/given/a_baseline_file.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; + +namespace Cratis.Cli.for_ReleaseBaselineFile.given; + +public class a_baseline_file : Specification +{ + protected string _folder = null!; + protected string _path = null!; + protected ApplicationIdentity _application; + protected ReleaseBaselineRead _result = null!; + + void Establish() + { + _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"cli-baseline-file-{Guid.NewGuid():N}")).FullName; + _path = Path.Combine(_folder, ReleaseBaselineStore.FileName); + _application = ApplicationIdentity.Create("App"); + } + + void Destroy() => Directory.Delete(_folder, true); +} diff --git a/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_an_explicit_file_is_missing.cs b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_an_explicit_file_is_missing.cs new file mode 100644 index 00000000..8c745888 --- /dev/null +++ b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_an_explicit_file_is_missing.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. + +namespace Cratis.Cli.for_ReleaseBaselineFile.when_reading; + +public class and_an_explicit_file_is_missing : given.a_baseline_file +{ + void Because() => _result = ReleaseBaselineFile.Read(_path, _application, required: true); + + [Fact] void should_report_unreadable() => _result.Status.ShouldEqual("unreadable"); + [Fact] void should_explain_the_missing_file() => _result.Reason.ShouldEqual($"the release baseline file '{_path}' does not exist"); +} diff --git a/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_exceeds_the_limit.cs b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_exceeds_the_limit.cs new file mode 100644 index 00000000..0ebcb4a8 --- /dev/null +++ b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_exceeds_the_limit.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. + +using Cratis.Screenplay.Workspaces; + +namespace Cratis.Cli.for_ReleaseBaselineFile.when_reading; + +public class and_it_exceeds_the_limit : given.a_baseline_file +{ + void Establish() + { + using var file = File.Create(_path); + file.SetLength(ReleaseBaselineStore.MaximumBytes + 1L); + } + + void Because() => _result = ReleaseBaselineFile.Read(_path, _application); + + [Fact] void should_report_unreadable() => _result.Status.ShouldEqual("unreadable"); + [Fact] void should_explain_the_size_limit() => _result.Reason!.ShouldContain("bounded file size"); +} diff --git a/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_is_a_directory.cs b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_is_a_directory.cs new file mode 100644 index 00000000..50c96ec6 --- /dev/null +++ b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_is_a_directory.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. + +namespace Cratis.Cli.for_ReleaseBaselineFile.when_reading; + +public class and_it_is_a_directory : given.a_baseline_file +{ + void Establish() => Directory.CreateDirectory(_path); + void Because() => _result = ReleaseBaselineFile.Read(_path, _application); + + [Fact] void should_report_unreadable() => _result.Status.ShouldEqual("unreadable"); +} diff --git a/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_is_malformed.cs b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_is_malformed.cs new file mode 100644 index 00000000..f5fa02c5 --- /dev/null +++ b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_it_is_malformed.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_ReleaseBaselineFile.when_reading; + +public class and_it_is_malformed : given.a_baseline_file +{ + void Establish() => File.WriteAllText(_path, "not JSON"); + void Because() => _result = ReleaseBaselineFile.Read(_path, _application); + + [Fact] void should_report_unreadable() => _result.Status.ShouldEqual("unreadable"); + [Fact] void should_not_return_a_document() => _result.Document.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_the_file_is_missing.cs b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_the_file_is_missing.cs new file mode 100644 index 00000000..109708ff --- /dev/null +++ b/Source/Cli.Specs/for_ReleaseBaselineFile/when_reading/and_the_file_is_missing.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. + +namespace Cratis.Cli.for_ReleaseBaselineFile.when_reading; + +public class and_the_file_is_missing : given.a_baseline_file +{ + void Because() => _result = ReleaseBaselineFile.Read(_path, _application); + + [Fact] void should_report_absence_for_implicit_discovery() => _result.Status.ShouldEqual("absent"); + [Fact] void should_not_create_the_file() => File.Exists(_path).ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_RenderCommand/given/a_baseline_workspace.cs b/Source/Cli.Specs/for_RenderCommand/given/a_baseline_workspace.cs new file mode 100644 index 00000000..a0e64bc6 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/given/a_baseline_workspace.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Cli.Commands.Render.Publication; +using Cratis.Screenplay.Workspaces; +using Spectre.Console; + +namespace Cratis.Cli.for_RenderCommand.given; + +public class a_baseline_workspace : a_workspace_render_command +{ + protected string _baselinePath = null!; + protected string _implicitPath = null!; + protected (int ExitCode, string Stdout, string Stderr) _output; + + void Establish() + { + var workspace = CreateWorkspace(); + WriteWorkspace(workspace); + var admitted = ReleaseBaseline.Admit(workspace, ReleaseBaselineStore.ReadBytes(null, workspace.IdentityCatalog.Application), "v1.0", DateTimeOffset.UnixEpoch); + var document = admitted.Document!; + var entry = document.Entries[0]; + var schema = entry.Schema with { Properties = entry.Schema.Properties.SetItem(0, entry.Schema.Properties[0] with { Optional = !entry.Schema.Properties[0].Optional }) }; + document = document with { Entries = document.Entries.SetItem(0, entry with { Schema = schema, SchemaHash = ReleaseSchemaCanonical.Hash(schema) }) }; + _baselinePath = Path.Combine(_folder, "selected-baseline.json"); + _implicitPath = Path.Combine(Directory.CreateDirectory(Path.Combine(_folder, ".screenplay")).FullName, ReleaseBaselineStore.FileName); + File.WriteAllBytes(_baselinePath, ReleaseBaselineStore.Serialize(document)); + _settings.Baseline = _baselinePath; + _publication.Check(Arg.Any(), Arg.Any()).Returns(call => new ArtifactPublisher().Check(call.Arg(), call.Arg())); + } + + protected async Task<(int ExitCode, string Stdout, string Stderr)> Capture() + { + var previousOutput = Console.Out; + var previousError = Console.Error; + var previousConsole = AnsiConsole.Console; + await using var output = new StringWriter(); + await using var error = new StringWriter(); + try + { + Console.SetOut(output); + Console.SetError(error); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(output), Ansi = AnsiSupport.No }); + AnsiConsole.Console.Profile.Width = 500; + var exitCode = await Execute(); + return (exitCode, output.ToString(), error.ToString()); + } + finally + { + Console.SetOut(previousOutput); + Console.SetError(previousError); + AnsiConsole.Console = previousConsole; + } + } +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_checking/with_a_missing_baseline_file.cs b/Source/Cli.Specs/for_RenderCommand/when_checking/with_a_missing_baseline_file.cs new file mode 100644 index 00000000..9bfdb0fe --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_checking/with_a_missing_baseline_file.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. + +using System.Text.Json; + +namespace Cratis.Cli.for_RenderCommand.when_checking; + +[Collection(CliSpecsCollection.Name)] +public class with_a_missing_baseline_file : given.a_baseline_workspace +{ + void Establish() + { + File.Delete(_baselinePath); + _settings.Check = true; + } + + async Task Because() => _output = await Capture(); + + [Fact] void should_keep_the_publication_exit_code() => _output.ExitCode.ShouldEqual(ExitCodes.ChangesPending); + [Fact] void should_report_unreadable_warning() => _output.Stderr.ShouldContain("STAGE-BASELINE-003"); + [Fact] void should_explain_the_missing_file() => JsonDocument.Parse(_output.Stderr).RootElement.GetProperty("diagnostics").EnumerateArray().Single(diagnostic => diagnostic.GetProperty("code").GetString() == "STAGE-BASELINE-003").GetProperty("message").GetString()!.ShouldContain($"the release baseline file '{_baselinePath}' does not exist"); + [Fact] void should_not_write_the_destination() => Directory.Exists(_destination).ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_checking/with_baseline_warnings.cs b/Source/Cli.Specs/for_RenderCommand/when_checking/with_baseline_warnings.cs new file mode 100644 index 00000000..86ed78da --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_checking/with_baseline_warnings.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Cli.Commands.Render.Publication; + +namespace Cratis.Cli.for_RenderCommand.when_checking; + +[Collection(CliSpecsCollection.Name)] +public class with_baseline_warnings : given.a_baseline_workspace +{ + (int ExitCode, string Stdout, string Stderr) _pending; + + async Task Because() + { + _settings.Check = true; + _pending = await Capture(); + _settings.Check = false; + (await Capture()).ExitCode.ShouldEqual(ExitCodes.Success); + _publication.ClearReceivedCalls(); + _settings.Check = true; + _output = await Capture(); + } + + [Fact] void should_keep_changes_pending_exit_for_a_fresh_destination() => _pending.ExitCode.ShouldEqual(ExitCodes.ChangesPending); + [Fact] void should_keep_success_exit_for_a_current_destination() => _output.ExitCode.ShouldEqual(ExitCodes.Success); + [Fact] void should_report_the_warning_for_changes_pending() => _pending.Stderr.ShouldContain("STAGE-BASELINE-001"); + [Fact] void should_report_the_warning_for_an_unchanged_destination() => _output.Stderr.ShouldContain("STAGE-BASELINE-001"); + [Fact] async Task should_not_recover_in_check_mode() => await _publication.DidNotReceive().Recover(Arg.Any(), Arg.Any()); + [Fact] async Task should_not_publish_in_check_mode() => await _publication.DidNotReceive().Publish(Arg.Any(), Arg.Any()); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_a_baseline_is_given_with_a_path.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_a_baseline_is_given_with_a_path.cs new file mode 100644 index 00000000..8acccf57 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_a_baseline_is_given_with_a_path.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. + +using Cratis.Cli.Commands.Render.Publication; + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_a_baseline_is_given_with_a_path : given.a_render_command +{ + int _result; + + void Establish() => _settings.Baseline = "release-baseline.json"; + async Task Because() => _result = await Execute(); + + [Fact] void should_return_usage_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_not_plan() => _planning.DidNotReceive().Plan(Arg.Any(), Arg.Any()); + [Fact] async Task should_not_recover() => await _publication.DidNotReceive().Recover(Arg.Any(), Arg.Any()); + [Fact] async Task should_not_publish() => await _publication.DidNotReceive().Publish(Arg.Any(), Arg.Any()); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_a_baseline_is_given_with_a_workspace.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_a_baseline_is_given_with_a_workspace.cs new file mode 100644 index 00000000..663abf41 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_a_baseline_is_given_with_a_workspace.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 Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_a_baseline_is_given_with_a_workspace : given.a_baseline_workspace +{ + void Establish() => File.WriteAllText(_implicitPath, "malformed implicit baseline"); + async Task Because() => _output = await Capture(); + + [Fact] void should_publish_successfully() => _output.ExitCode.ShouldEqual(ExitCodes.Success); + [Fact] void should_use_the_explicit_baseline_instead_of_the_discovered_file() => _requests.Single().ReleaseBaseline.Status.ShouldEqual(ReleaseBaselineAssessmentStatus.Checked); + [Fact] void should_report_the_explicit_baseline_violation() => _output.Stderr.ShouldContain("STAGE-BASELINE-001"); + [Fact] void should_not_report_the_implicit_baseline_error() => _output.Stderr.ShouldNotContain("STAGE-BASELINE-003"); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_baseline_warnings_are_reported_in_json.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_baseline_warnings_are_reported_in_json.cs new file mode 100644 index 00000000..1718a510 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_baseline_warnings_are_reported_in_json.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.Json; + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_baseline_warnings_are_reported_in_json : given.a_baseline_workspace +{ + string _withoutBaseline = null!; + + async Task Because() + { + _output = await Capture(); + _settings.Baseline = null; + _withoutBaseline = (await Capture()).Stdout; + } + + [Fact] void should_return_success() => _output.ExitCode.ShouldEqual(ExitCodes.Success); + [Fact] void should_report_the_stage_warning_in_json_on_stderr() => JsonDocument.Parse(_output.Stderr).RootElement.GetProperty("diagnostics").EnumerateArray().Any(diagnostic => diagnostic.GetProperty("code").GetString() == "STAGE-BASELINE-001").ShouldBeTrue(); + [Fact] void should_keep_warnings_out_of_stdout() => _output.Stdout.ShouldNotContain("STAGE-BASELINE-"); + [Fact] void should_keep_the_publication_receipt_shape() => JsonDocument.Parse(_output.Stdout).RootElement.EnumerateObject().Select(property => property.Name).ShouldEqual(JsonDocument.Parse(_withoutBaseline).RootElement.EnumerateObject().Select(property => property.Name)); + [Fact] void should_report_no_diagnostics_in_the_receipt() => JsonDocument.Parse(_output.Stdout).RootElement.GetProperty("publication").TryGetProperty("diagnostics", out _).ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_baseline_warnings_are_reported_in_text.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_baseline_warnings_are_reported_in_text.cs new file mode 100644 index 00000000..070874bd --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_baseline_warnings_are_reported_in_text.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_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_baseline_warnings_are_reported_in_text : given.a_baseline_workspace +{ + void Establish() => _settings.Output = OutputFormats.Table; + async Task Because() => _output = await Capture(); + + [Fact] void should_return_success() => _output.ExitCode.ShouldEqual(ExitCodes.Success); + [Fact] void should_report_the_stage_warning_on_stderr() => _output.Stderr.ShouldContain("STAGE-BASELINE-001"); + [Fact] void should_name_the_admitted_release() => _output.Stderr.ShouldContain("v1.0"); + [Fact] void should_keep_warnings_out_of_stdout() => _output.Stdout.ShouldNotContain("STAGE-BASELINE-"); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_baseline_is_discovered_beside_the_workspace.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_baseline_is_discovered_beside_the_workspace.cs new file mode 100644 index 00000000..f5fd6bc8 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_baseline_is_discovered_beside_the_workspace.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_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_the_baseline_is_discovered_beside_the_workspace : given.a_baseline_workspace +{ + void Establish() + { + File.Move(_baselinePath, _implicitPath); + _settings.Baseline = null; + } + + async Task Because() => _output = await Capture(); + + [Fact] void should_return_success() => _output.ExitCode.ShouldEqual(ExitCodes.Success); + [Fact] void should_report_the_discovered_violation() => _output.Stderr.ShouldContain("STAGE-BASELINE-001"); +} diff --git a/Source/Cli.Specs/for_RunCommand/when_running/with_a_folder_and_the_default_stage_tag.cs b/Source/Cli.Specs/for_RunCommand/when_running/with_a_folder_and_the_default_stage_tag.cs index 48195bf9..8a41cc2f 100644 --- a/Source/Cli.Specs/for_RunCommand/when_running/with_a_folder_and_the_default_stage_tag.cs +++ b/Source/Cli.Specs/for_RunCommand/when_running/with_a_folder_and_the_default_stage_tag.cs @@ -15,8 +15,8 @@ public class with_a_folder_and_the_default_stage_tag : given.a_run_command [Unix.Fact] void should_start_the_released_stage_image_that_matches_the_renderer_packages() => DockerArguments.ShouldContain($"cratis/stage:{StageContainer.DefaultTag}"); - [Unix.Fact] void should_run_stage_4960_by_default() => - DockerArguments.ShouldContain("cratis/stage:4.96.0"); + [Unix.Fact] void should_run_stage_4970_by_default() => + DockerArguments.ShouldContain("cratis/stage:4.97.0"); [Unix.Fact] void should_mount_only_the_selected_folder_read_only() => DockerArguments.ShouldContain($"{_folder}:/eventmodel:ro"); diff --git a/Source/Cli.Specs/for_RunCommand/when_running/with_a_play_file_and_the_default_stage_tag.cs b/Source/Cli.Specs/for_RunCommand/when_running/with_a_play_file_and_the_default_stage_tag.cs index ba55394e..07147ade 100644 --- a/Source/Cli.Specs/for_RunCommand/when_running/with_a_play_file_and_the_default_stage_tag.cs +++ b/Source/Cli.Specs/for_RunCommand/when_running/with_a_play_file_and_the_default_stage_tag.cs @@ -20,8 +20,8 @@ void Establish() [Unix.Fact] void should_start_the_released_stage_image_that_matches_the_renderer_packages() => DockerArguments.ShouldContain($"cratis/stage:{StageContainer.DefaultTag}"); - [Unix.Fact] void should_run_stage_4960_by_default() => - DockerArguments.ShouldContain("cratis/stage:4.96.0"); + [Unix.Fact] void should_run_stage_4970_by_default() => + DockerArguments.ShouldContain("cratis/stage:4.97.0"); [Unix.Fact] void should_mount_only_the_selected_file_read_only() => DockerArguments.ShouldContain($"{_path}:/eventmodel/input.play:ro"); diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_revision_is_missing.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_revision_is_missing.cs new file mode 100644 index 00000000..7f38175c --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_revision_is_missing.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. + +using Cratis.Screenplay.Semantics; + +namespace Cratis.Cli.for_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_a_revision_is_missing : given.a_release_baseline +{ + void Establish() + { + var source = Source[..Source.IndexOf(" slice StateChange Observe", StringComparison.Ordinal)]; + var current = Workspace(source, SemanticIdentityCatalog.Empty(_workspace.IdentityCatalog.Application)); + _request = Request(current) with { ReleaseBaseline = new FileReleaseBaseline(_baseline) }; + } + void Because() => Plan(); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_warn_about_the_missing_revision() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "STAGE-BASELINE-002"); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_scope_is_selected.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_scope_is_selected.cs new file mode 100644 index 00000000..74c5466a --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_scope_is_selected.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_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_a_scope_is_selected : given.a_release_baseline +{ + void Establish() => _request = Changed(Source.Replace("value String", "value Int", StringComparison.Ordinal)) with { Scope = ["Records.Recording.Observe"] }; + void Because() => Plan(); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_report_the_unselected_sibling_violation() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "STAGE-BASELINE-001" && diagnostic.Message.Contains("Recorded", StringComparison.Ordinal)); + [Fact] void should_not_render_the_unselected_event() => _result.Artifacts!.Artifacts.SelectMany(artifact => artifact.Sources).Select(source => _result.SemanticAddresses.Resolve(source.ToString()).Address).ShouldNotContain("Records/Recording/Record/Recorded"); + [Fact] void should_attribute_artifacts_to_the_selected_event() => _result.Artifacts!.Artifacts.SelectMany(artifact => artifact.Sources).Select(source => _result.SemanticAddresses.Resolve(source.ToString()).Address).ShouldContain("Records/Recording/Observe/Removed"); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_workspace_revision_changed.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_workspace_revision_changed.cs new file mode 100644 index 00000000..2d5491ab --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_a_workspace_revision_changed.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_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_a_workspace_revision_changed : given.a_release_baseline +{ + void Establish() => _request = Changed(Source.Replace("value String", "value Int", StringComparison.Ordinal)); + void Because() => Plan(); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_warn_about_the_changed_revision() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "STAGE-BASELINE-001" && diagnostic.Severity == ScreenplayDiagnosticSeverity.Warning); + [Fact] void should_keep_the_artifact_paths_and_hashes() => _result.Artifacts!.Artifacts.Select(artifact => (artifact.RelativePath, artifact.Sha256)).ShouldEqual(_withoutBaseline.Artifacts!.Artifacts.Select(artifact => (artifact.RelativePath, artifact.Sha256))); + [Fact] void should_keep_the_artifact_bytes() => _result.Artifacts!.Artifacts.Zip(_withoutBaseline.Artifacts!.Artifacts).All(pair => pair.First.Bytes.SequenceEqual(pair.Second.Bytes)).ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_compilation_fails.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_compilation_fails.cs new file mode 100644 index 00000000..a9d3f7eb --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_compilation_fails.cs @@ -0,0 +1,28 @@ +// 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.Semantics; + +namespace Cratis.Cli.for_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_compilation_fails : given.a_release_baseline +{ + IRenderReleaseBaseline _check = null!; + + void Establish() + { + _check = Substitute.For(); + var catalog = _workspace.IdentityCatalog; + _request = _request with + { + Documents = SemanticDocumentSet.Create([SemanticSourceDocument.Create(catalog.ResolveDocument("source"), "source", "source.play", "not a screenplay")], catalog), + ReleaseBaseline = _check + }; + } + + void Because() => Plan(); + + [Fact] void should_fail_planning() => _result.Success.ShouldBeFalse(); + [Fact] void should_not_assess_the_baseline() => _check.DidNotReceive().Assess(Arg.Any()); + [Fact] void should_not_report_baseline_warnings() => _result.Diagnostics.Where(diagnostic => diagnostic.Code.StartsWith("STAGE-BASELINE-", StringComparison.Ordinal)).ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_no_baseline_is_supplied.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_no_baseline_is_supplied.cs new file mode 100644 index 00000000..e32e0279 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_no_baseline_is_supplied.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_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_no_baseline_is_supplied : given.a_release_baseline +{ + void Establish() => _request = _request with { ReleaseBaseline = null }; + void Because() => Plan(); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_not_report_baseline_warnings() => _result.Diagnostics.Where(diagnostic => diagnostic.Code.StartsWith("STAGE-BASELINE-", StringComparison.Ordinal)).ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_scope_resolution_fails.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_scope_resolution_fails.cs new file mode 100644 index 00000000..ee85867a --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_scope_resolution_fails.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. + +using Cratis.Screenplay.Semantics; + +namespace Cratis.Cli.for_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_scope_resolution_fails : given.a_release_baseline +{ + IRenderReleaseBaseline _check = null!; + + void Establish() + { + _check = Substitute.For(); + _request = _request with { Scope = ["Unknown"], ReleaseBaseline = _check }; + } + + void Because() => Plan(); + + [Fact] void should_fail_planning() => _result.Success.ShouldBeFalse(); + [Fact] void should_report_the_scope_error() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "CLI-RENDER-011"); + [Fact] void should_not_assess_the_baseline() => _check.DidNotReceive().Assess(Arg.Any()); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_baseline_belongs_to_another_application.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_baseline_belongs_to_another_application.cs new file mode 100644 index 00000000..1567f647 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_baseline_belongs_to_another_application.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. + +using Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; + +namespace Cratis.Cli.for_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_the_baseline_belongs_to_another_application : given.a_release_baseline +{ + void Establish() => _request = _request with { ReleaseBaseline = new FileReleaseBaseline(ReleaseBaselineStore.ReadBytes(ReleaseBaselineStore.Serialize(_baseline.Document!), ApplicationIdentity.Create("AnotherApplication"))) }; + void Because() => Plan(); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_warn_about_the_application_mismatch() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "STAGE-BASELINE-003"); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_baseline_is_unreadable.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_baseline_is_unreadable.cs new file mode 100644 index 00000000..7120afc9 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_baseline_is_unreadable.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_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_the_baseline_is_unreadable : given.a_release_baseline +{ + void Establish() => _request = _request with { ReleaseBaseline = new FileReleaseBaseline(new("unreadable", null, null, "malformed baseline")) }; + void Because() => Plan(); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_warn_about_the_unreadable_baseline() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "STAGE-BASELINE-003" && diagnostic.Message.Contains("malformed baseline", StringComparison.Ordinal)); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_path_has_persisted_identities.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_path_has_persisted_identities.cs new file mode 100644 index 00000000..9ca791bb --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/and_the_path_has_persisted_identities.cs @@ -0,0 +1,34 @@ +// 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.Mcp; + +namespace Cratis.Cli.for_ScreenplayPlanning.when_planning_with_a_release_baseline; + +public class and_the_path_has_persisted_identities : for_ScreenplayReleaseBaseline.given.a_release_model +{ + ScreenplayRenderPlan _result = null!; + string[] _metadataBefore = null!; + string[] _metadataAfter = null!; + + protected override string ModelSource => "concept RecordId : Uuid\nmodule M\n feature F\n slice StateChange Record\n command Record\n recordId RecordId identifier\n value String\n produces Recorded\n for recordId\n value = value\n event Recorded\n value String\n"; + + void Establish() + { + ReleaseBaselineCommands.Admit(_root, "v1.0").ExitCode.ShouldEqual(0); + File.WriteAllText(_document, ModelSource.Replace("value String", "value Int", StringComparison.Ordinal)); + _metadataBefore = Metadata(); + } + + async Task Because() + { + _result = await new ScreenplayPlanning().Plan(new(_root, "Model", "cratis"), CancellationToken.None); + _metadataAfter = Metadata(); + } + + string[] Metadata() => [.. Directory.EnumerateFiles(Path.Combine(_root, ".screenplay"), "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal).Select(path => $"{Path.GetRelativePath(_root, path)}:{Convert.ToHexString(File.ReadAllBytes(path))}")]; + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_report_the_real_persisted_identity_violation() => _result.Diagnostics.ShouldContain(diagnostic => diagnostic.Code == "STAGE-BASELINE-001"); + [Fact] void should_not_write_metadata() => _metadataAfter.ShouldEqual(_metadataBefore); +} diff --git a/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/given/a_release_baseline.cs b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/given/a_release_baseline.cs new file mode 100644 index 00000000..fce8fedb --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayPlanning/when_planning_with_a_release_baseline/given/a_release_baseline.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 System.Text; +using Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; + +namespace Cratis.Cli.for_ScreenplayPlanning.when_planning_with_a_release_baseline.given; + +public class a_release_baseline : Specification +{ + protected const string Source = "concept RecordId : Uuid\nmodule Records\n feature Recording\n slice StateChange Record\n command Record\n recordId RecordId identifier\n value String\n produces Recorded\n for recordId\n value = value\n event Recorded\n value String\n slice StateChange Observe\n command Observe\n recordId RecordId identifier\n produces Removed\n for recordId\n event Removed\n"; + protected ScreenplayWorkspace _workspace = null!; + protected ReleaseBaselineRead _baseline = null!; + private protected ScreenplayDocumentRenderRequest _request = null!; + private protected ScreenplayRenderPlan _result = null!; + private protected ScreenplayRenderPlan _withoutBaseline = null!; + + void Establish() + { + var bootstrap = Workspace(Source, SemanticIdentityCatalog.Empty(ApplicationIdentity.Create("RecordsApp"))); + _workspace = Workspace(Source, bootstrap.IdentityCatalog); + var admitted = ReleaseBaseline.Admit(_workspace, ReleaseBaselineStore.ReadBytes(null, _workspace.IdentityCatalog.Application), "v1.0", DateTimeOffset.UnixEpoch); + _baseline = ReleaseBaselineStore.ReadBytes(ReleaseBaselineStore.Serialize(admitted.Document!), _workspace.IdentityCatalog.Application); + _request = Request(_workspace) with { ReleaseBaseline = new FileReleaseBaseline(_baseline) }; + } + + protected static ScreenplayWorkspace Workspace(string source, SemanticIdentityCatalog catalog) => ScreenplayWorkspace.Create( + catalog.Application, + "RecordsApp", + [WorkspaceDocument.Create(catalog.ResolveDocument("source"), "source", PortablePlayPath.Parse("source.play"), Encoding.UTF8.GetBytes(source))], + catalog); + + private protected static ScreenplayDocumentRenderRequest Request(ScreenplayWorkspace workspace) => new( + SemanticDocumentSet.Create([.. workspace.Documents.Select(document => SemanticSourceDocument.Create(document.Id, document.StableKey, document.Path.Value, document.Text))], workspace.IdentityCatalog), + workspace.ApplicationName, + "cratis"); + + private protected ScreenplayDocumentRenderRequest Changed(string source) => Request(Workspace(source, _workspace.IdentityCatalog)) with { ReleaseBaseline = new FileReleaseBaseline(_baseline) }; + + private protected void Plan() + { + var planning = new ScreenplayPlanning(); + _result = planning.PlanDocuments(_request, CancellationToken.None); + _withoutBaseline = planning.PlanDocuments(_request with { ReleaseBaseline = null }, CancellationToken.None); + } +} diff --git a/Source/Cli.Specs/for_ScreenplayReleaseBaseline/given/a_release_model.cs b/Source/Cli.Specs/for_ScreenplayReleaseBaseline/given/a_release_model.cs index cb63652f..6a3d8b61 100644 --- a/Source/Cli.Specs/for_ScreenplayReleaseBaseline/given/a_release_model.cs +++ b/Source/Cli.Specs/for_ScreenplayReleaseBaseline/given/a_release_model.cs @@ -26,11 +26,13 @@ public class a_release_model : Specification protected string _output; protected string _error; + protected virtual string ModelSource => Source; + void Establish() { _root = Directory.CreateDirectory(Path.Combine(WithoutLinks(Path.GetTempPath()), "cratis-cli-baseline-specs", Guid.NewGuid().ToString())).FullName; _document = Path.Combine(_root, "application.play"); - File.WriteAllText(_document, Source); + File.WriteAllText(_document, ModelSource); PersistIdentities(); } @@ -81,7 +83,7 @@ void PersistIdentities() expectedRevision = opened.GetProperty("revision").GetString(), expectedCatalogRevision = opened.GetProperty("catalogRevision").GetString(), formatting = "CanonicalizeTouchedDocuments", - documents = new[] { new { operation = "replace-document", documentId, source = Source } } + documents = new[] { new { operation = "replace-document", documentId, source = ModelSource } } }); }, () => diff --git a/Source/Cli.Specs/for_StageContainer/when_resolving_the_default_tag.cs b/Source/Cli.Specs/for_StageContainer/when_resolving_the_default_tag.cs index 8afb75bf..0d122578 100644 --- a/Source/Cli.Specs/for_StageContainer/when_resolving_the_default_tag.cs +++ b/Source/Cli.Specs/for_StageContainer/when_resolving_the_default_tag.cs @@ -26,7 +26,7 @@ void Establish() => _renderer = typeof(ArtifactRenderPlan).Assembly [Fact] void should_run_the_stage_the_renderer_came_from() => _tag.ShouldEqual(_renderer); - [Fact] void should_run_the_released_stage_4960_image() => _tag.ShouldEqual("4.96.0"); + [Fact] void should_run_the_released_stage_4970_image() => _tag.ShouldEqual("4.97.0"); [Fact] void should_not_run_a_moving_tag() => _tag.ShouldNotEqual(StageContainer.FallbackTag); diff --git a/Source/Cli/Commands/Render/CratisRenderTarget.cs b/Source/Cli/Commands/Render/CratisRenderTarget.cs index 35985174..6e9e889f 100644 --- a/Source/Cli/Commands/Render/CratisRenderTarget.cs +++ b/Source/Cli/Commands/Render/CratisRenderTarget.cs @@ -12,6 +12,7 @@ using Cratis.Screenplay.Workspaces; using Cratis.Stage.Contracts.Rendering; using Cratis.Stage.Contracts.Scene; +using Cratis.Stage.Contracts.Semantics; using Cratis.Stage.Rendering.Cratis; using Cratis.Stage.Rendering.Cratis.Scene; @@ -53,7 +54,8 @@ public ArtifactRenderPlan Plan( ICollection warnings, SceneApplication? scene = null, RenderSelection? selection = null, - ApprovedPackageSet? approvedPackages = null) + ApprovedPackageSet? approvedPackages = null, + ReleaseBaselineAssessment? releaseBaseline = null) { var model = compilation.Model; var options = new CratisRenderingOptions(projectName ?? model.Application.Name, rootNamespace ?? model.Application.Name); @@ -65,7 +67,8 @@ public ArtifactRenderPlan Plan( ImplementationRequirements = requirements, ImplementationContents = contents, TypedContextDescriptors = typedContextDescriptors, - AttachmentDiagnostics = attachmentDiagnostics + AttachmentDiagnostics = attachmentDiagnostics, + ReleaseBaseline = releaseBaseline ?? ReleaseBaselineAssessment.NotSupplied }); } diff --git a/Source/Cli/Commands/Render/FileReleaseBaseline.cs b/Source/Cli/Commands/Render/FileReleaseBaseline.cs new file mode 100644 index 00000000..363412a9 --- /dev/null +++ b/Source/Cli/Commands/Render/FileReleaseBaseline.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 Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.Commands.Render; + +/// +/// Checks a workspace baseline against the exact compilation supplied to Stage. +/// +/// The bounded, read-only baseline input. +internal sealed class FileReleaseBaseline(ReleaseBaselineRead read) : IRenderReleaseBaseline +{ + /// + public ReleaseBaselineAssessment Assess(SemanticCompilation compilation) => StageReleaseBaseline.Check(compilation, read); +} diff --git a/Source/Cli/Commands/Render/IRenderReleaseBaseline.cs b/Source/Cli/Commands/Render/IRenderReleaseBaseline.cs new file mode 100644 index 00000000..de803138 --- /dev/null +++ b/Source/Cli/Commands/Render/IRenderReleaseBaseline.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. + +using Cratis.Screenplay.Semantics; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.Commands.Render; + +/// +/// Assesses release metadata against the whole application before target planning. +/// +internal interface IRenderReleaseBaseline +{ + /// + /// Checks admitted event revisions without writing model metadata. + /// + /// The whole application being rendered. + /// The nonblocking release-baseline assessment. + ReleaseBaselineAssessment Assess(SemanticCompilation compilation); +} diff --git a/Source/Cli/Commands/Render/IRenderTarget.cs b/Source/Cli/Commands/Render/IRenderTarget.cs index 83f2c462..0edd97dd 100644 --- a/Source/Cli/Commands/Render/IRenderTarget.cs +++ b/Source/Cli/Commands/Render/IRenderTarget.cs @@ -9,6 +9,7 @@ using Cratis.Screenplay.Semantics.Execution; using Cratis.Stage.Contracts.Rendering; using Cratis.Stage.Contracts.Scene; +using Cratis.Stage.Contracts.Semantics; namespace Cratis.Cli.Commands.Render; @@ -35,6 +36,7 @@ internal interface IRenderTarget /// The authored Scene composition translated from the same source documents, when one exists. /// The resolved scopes, or null for the application. /// The custom runtime packages the application approved, or null for none. + /// The whole-application release-baseline assessment, or null when not supplied. /// The immutable artifact plan. ArtifactRenderPlan Plan( SemanticCompilation compilation, @@ -48,5 +50,6 @@ ArtifactRenderPlan Plan( ICollection warnings, SceneApplication? scene = null, RenderSelection? selection = null, - ApprovedPackageSet? approvedPackages = null); + ApprovedPackageSet? approvedPackages = null, + ReleaseBaselineAssessment? releaseBaseline = null); } diff --git a/Source/Cli/Commands/Render/ModelRootReleaseBaseline.cs b/Source/Cli/Commands/Render/ModelRootReleaseBaseline.cs new file mode 100644 index 00000000..32fc1548 --- /dev/null +++ b/Source/Cli/Commands/Render/ModelRootReleaseBaseline.cs @@ -0,0 +1,91 @@ +// 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.Json; +using Cratis.Cli.Commands.Screenplay; +using Cratis.Screenplay.Mcp; +using Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; +using Cratis.Stage.Contracts.Semantics; + +namespace Cratis.Cli.Commands.Render; + +/// +/// Bridges PATH rendering's temporary identities to the persisted model-root baseline check. +/// +/// The source root owning .screenplay metadata. +/// The read-only persisted-identity check. +internal sealed class ModelRootReleaseBaseline(string root, Func run) : IRenderReleaseBaseline +{ + internal ModelRootReleaseBaseline(string root) : this(root, ReleaseBaselineCommands.Check) + { + } + + /// + public ReleaseBaselineAssessment Assess(SemanticCompilation compilation) + { + var revision = compilation.Model.Revision; + var path = Path.Combine(root, ".screenplay", ReleaseBaselineStore.FileName); + if (!File.Exists(path) && !Directory.Exists(path)) + { + return ReleaseBaselineAssessment.NotSupplied; + } + + if (!File.Exists(Path.Combine(root, ".screenplay", "identities.json"))) + { + return new(ReleaseBaselineAssessmentStatus.Unavailable, revision, [], "PATH renders need persisted .screenplay/identities.json; run cratis screenplay baseline check"); + } + + try + { + var outcome = run(root); + + // PATH documents use temporary identities, so Stage cannot reconstruct their persisted workspace. + // The bridge checks the root read-only and deliberately stamps the rendered revision. The root's + // sources may change between these two reads; this does not replace the baseline CI gate. + if (outcome.ExitCode == 0) + { + return new(ReleaseBaselineAssessmentStatus.Checked, revision, []); + } + + if (outcome.ExitCode == 1 && outcome.FailureKind is null) + { + return Violations(path, revision, outcome); + } + + return new( + outcome.FailureKind == "UnreadableReleaseBaseline" ? ReleaseBaselineAssessmentStatus.Unreadable : ReleaseBaselineAssessmentStatus.Unavailable, + revision, + [], + outcome.Reason ?? outcome.FailureKind); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // The assessment only produces warnings, so no failure of the optional root check may fail the render. + return new(ReleaseBaselineAssessmentStatus.Unavailable, revision, [], exception.Message); + } + } + + static ReleaseBaselineAssessment Violations(string path, SemanticRevision revision, ReleaseBaselineOutcome outcome) + { + var read = ReleaseBaselineFile.ReadForMapping(path); + if (read.Status != "valid") + { + return new(ReleaseBaselineAssessmentStatus.Unreadable, revision, [], read.Reason); + } + + var entries = read.Document!.Entries.ToDictionary(entry => (entry.Event, entry.Revision)); + var violations = new List(); + foreach (var violation in outcome.Violations) + { + if (!entries.TryGetValue((violation.Event, violation.Revision), out var entry)) + { + return new(ReleaseBaselineAssessmentStatus.Unavailable, revision, [], "The release baseline changed after its check; an admitted revision could not be mapped."); + } + + violations.Add(new(violation.Kind, EventContractId.Parse(violation.Event), violation.Revision, entry.EventType, entry.LastAddress, entry.AdmittedIn, violation.Path)); + } + + return new(ReleaseBaselineAssessmentStatus.Checked, revision, [.. violations]); + } +} diff --git a/Source/Cli/Commands/Render/ReleaseBaselineFile.cs b/Source/Cli/Commands/Render/ReleaseBaselineFile.cs new file mode 100644 index 00000000..586f865a --- /dev/null +++ b/Source/Cli/Commands/Render/ReleaseBaselineFile.cs @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.Screenplay.Semantics; +using Cratis.Screenplay.Workspaces; + +namespace Cratis.Cli.Commands.Render; + +/// +/// Reads bounded release metadata from one admitted regular-file handle. +/// +internal static class ReleaseBaselineFile +{ + internal static ReleaseBaselineRead Read(string path, ApplicationIdentity application, bool required = false) => + Read(path, bytes => ReleaseBaselineStore.ReadBytes(bytes, application), required); + + internal static ReleaseBaselineRead ReadForMapping(string path) => Read( + path, + bytes => + { + using var json = JsonDocument.Parse(bytes!); + var application = ApplicationIdentity.Parse(json.RootElement.GetProperty("applicationIdentity").GetString()!); + return ReleaseBaselineStore.ReadBytes(bytes, application); + }, + required: true); + + static ReleaseBaselineRead Read(string path, Func parse, bool required) + { + try + { + using var handle = WorkspaceFile.Open(path, CancellationToken.None); + using var stream = new FileStream(handle, FileAccess.Read, 65536, isAsync: OperatingSystem.IsWindows()); + using var bytes = new MemoryStream(); + var buffer = new byte[65536]; + while (true) + { + var remaining = ReleaseBaselineStore.MaximumBytes - checked((int)bytes.Length); + var read = stream.Read(buffer, 0, Math.Min(buffer.Length, remaining + 1)); + if (read > remaining) + { + return Unreadable("Release baseline exceeds the bounded file size."); + } + + if (read == 0) + { + break; + } + + bytes.Write(buffer, 0, read); + } + + return parse(bytes.ToArray()); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return required ? Unreadable($"the release baseline file '{path}' does not exist") : parse(null); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or JsonException or InvalidSemanticContract or InvalidOperationException or KeyNotFoundException) + { + return Unreadable(exception.Message); + } + } + + static ReleaseBaselineRead Unreadable(string reason) => new("unreadable", null, null, reason); +} diff --git a/Source/Cli/Commands/Render/RenderCommand.cs b/Source/Cli/Commands/Render/RenderCommand.cs index d7536d37..72d9e226 100644 --- a/Source/Cli/Commands/Render/RenderCommand.cs +++ b/Source/Cli/Commands/Render/RenderCommand.cs @@ -13,7 +13,7 @@ namespace Cratis.Cli.Commands.Render; /// /// Plans and safely publishes one logical Screenplay application. /// -[LlmDescription("Compiles a Screenplay file, folder, or canonical workspace into ESM, plans a complete bundled target before writing, and safely publishes only managed artifacts through a durable recovery journal, or checks publication read-only with --check.")] +[LlmDescription("Compiles a Screenplay file, folder, or canonical workspace into ESM, plans a complete bundled target before writing, and safely publishes only managed artifacts through a durable recovery journal, or checks publication read-only with --check. Reports release-baseline warnings without changing artifacts, receipts or exit codes.")] [CommandEffect(CommandEffect.Local)] [CliCommand("render", "Plan and safely publish a Screenplay application")] [CliExample("render", "./plays", "--target", "cratis", "--destination", "./out", "--name", "MyApplication")] @@ -21,8 +21,9 @@ namespace Cratis.Cli.Commands.Render; [CliExample("render", "./Model", "--name", "App", "--destination", "./src/App", "--check")] [LlmOption("[PATH]", "string", "Screenplay (.play) file, or folder representing one logical application. Defaults to the current directory only without --workspace; mutually exclusive with --workspace.")] [LlmOption("--workspace", "string", "Canonical Screenplay workspace envelope file, at most 32 MiB of actual input bytes. Preserves authoritative application name, identity catalog and documents; no archive extraction.")] +[LlmOption("--baseline", "string", "Release-baseline file for --workspace only. Overrides .screenplay/release-baseline.json beside the workspace file; an explicitly missing file warns. PATH input uses its model root's persisted metadata. Warnings do not change exit codes.")] [LlmOption("--target", "string", "Statically bundled renderer target (default: cratis).")] -[LlmOption("--scope", "string[]", "Repeatable authored module, feature or slice address: Module.Feature.Slice. Unselected managed output is preserved.")] +[LlmOption("--scope", "string[]", "Repeatable authored module, feature or slice address: Module.Feature.Slice. Unselected managed output is preserved; release-baseline warnings cover the whole application.")] [LlmOption("--destination", "string", "Managed artifact destination (default: ./out).")] [LlmOption("--name", "string", "Required destination-independent application identity for plain source; optional with --workspace, where it must exactly match the supplied application name, never rename it.")] [LlmOption("--project-name", "string", "Generated project and solution name (default: application name); does not change application identity.")] @@ -103,6 +104,12 @@ public override async Task ExecuteAsync(CommandContext context, RenderSetti } else { + if (settings.Baseline is not null) + { + OutputFormatter.WriteError(format, "--baseline requires --workspace", "PATH renders use the model root's .screenplay/release-baseline.json and persisted identities", ExitCodes.ValidationErrorCode); + return ExitCodes.ValidationError; + } + var resolved = PlayFileTargetResolver.Resolve(settings.Path, currentDirectory); if (!resolved.IsResolved) { @@ -154,7 +161,15 @@ public override async Task ExecuteAsync(CommandContext context, RenderSetti workspace.ApplicationName, target, settings.ProjectName, - settings.RootNamespace) { Scope = settings.Scope, ApprovedPackages = approvedPackages }; + settings.RootNamespace) + { + Scope = settings.Scope, + ApprovedPackages = approvedPackages, + ReleaseBaseline = new FileReleaseBaseline(ReleaseBaselineFile.Read( + settings.Baseline ?? Path.Combine(Path.GetDirectoryName(Path.GetFullPath(settings.Workspace!, currentDirectory))!, ".screenplay", "release-baseline.json"), + workspace.IdentityCatalog.Application, + required: settings.Baseline is not null)) + }; planned = _planning.PlanDocuments(request, cancellationToken); } cancellationToken.ThrowIfCancellationRequested(); diff --git a/Source/Cli/Commands/Render/RenderSettings.cs b/Source/Cli/Commands/Render/RenderSettings.cs index e70a94d2..26d86497 100644 --- a/Source/Cli/Commands/Render/RenderSettings.cs +++ b/Source/Cli/Commands/Render/RenderSettings.cs @@ -24,6 +24,13 @@ public class RenderSettings : GlobalSettings [Description("Canonical Screenplay workspace file (maximum 32 MiB). Mutually exclusive with PATH; preserves its application name and identities.")] public string? Workspace { get; set; } + /// + /// Gets or sets the release-baseline file for workspace input. + /// + [CommandOption("--baseline ")] + [Description("Release baseline for --workspace only. Defaults to .screenplay/release-baseline.json beside the workspace file. Baseline warnings do not change the exit code.")] + public string? Baseline { get; set; } + /// /// Gets or sets the statically bundled renderer target. /// @@ -35,7 +42,7 @@ public class RenderSettings : GlobalSettings /// Gets or sets the repeatable module, feature, or slice addresses to render. /// [CommandOption("--scope
")] - [Description("Render only this authored module, feature, or slice address. Repeat to render several scopes.")] + [Description("Render only this authored module, feature, or slice address. Repeat to render several scopes. Release-baseline warnings still cover the whole application.")] [SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Spectre.Console.Cli uses arrays for repeatable options.")] public string[] Scope { get; set; } = []; diff --git a/Source/Cli/Commands/Render/RenderedSemanticVersions.cs b/Source/Cli/Commands/Render/RenderedSemanticVersions.cs index eca61f96..c3b761e3 100644 --- a/Source/Cli/Commands/Render/RenderedSemanticVersions.cs +++ b/Source/Cli/Commands/Render/RenderedSemanticVersions.cs @@ -19,7 +19,7 @@ namespace Cratis.Cli.Commands.Render; /// overrides receive typed refusals; reaction identity and observer filters render within their supported subset. /// ESM v11 lossless Double-mode number literals, v12 enum member numbers, v13 concept compliance, /// v14 equality query filters, v15 concept generation strategies with freshness scopes and v16 stored event names -/// are admitted by Stage 4.96.0. +/// are admitted by Stage 4.97.0. /// Newer versions remain refused until their renderer admission has been reviewed. /// public static class RenderedSemanticVersions diff --git a/Source/Cli/Commands/Render/ScreenplayDocumentRenderRequest.cs b/Source/Cli/Commands/Render/ScreenplayDocumentRenderRequest.cs index 905895b2..61215ede 100644 --- a/Source/Cli/Commands/Render/ScreenplayDocumentRenderRequest.cs +++ b/Source/Cli/Commands/Render/ScreenplayDocumentRenderRequest.cs @@ -29,6 +29,11 @@ internal sealed record ScreenplayDocumentRenderRequest( /// public string[] Scope { get; init; } = []; + /// + /// Gets the whole-application release-baseline check, when supplied. + /// + public IRenderReleaseBaseline? ReleaseBaseline { get; init; } + /// Warnings from the source-file attachment loader. public ImmutableArray AttachmentDiagnostics { get; init; } = []; diff --git a/Source/Cli/Commands/Render/ScreenplayPlanning.cs b/Source/Cli/Commands/Render/ScreenplayPlanning.cs index 2588d809..9dab77df 100644 --- a/Source/Cli/Commands/Render/ScreenplayPlanning.cs +++ b/Source/Cli/Commands/Render/ScreenplayPlanning.cs @@ -11,6 +11,7 @@ using Cratis.Screenplay.Semantics.Execution; using Cratis.Stage.Contracts.Rendering; using Cratis.Stage.Contracts.Scene; +using Cratis.Stage.Contracts.Semantics; using Cratis.Stage.Rendering.Cratis; using Cratis.Stage.Rendering.Cratis.Scaffolding; @@ -76,7 +77,8 @@ public async Task Plan(ScreenplayRenderRequest request, Ca { Scope = request.Scope, AttachmentDiagnostics = attachments.Diagnostics, - ApprovedPackages = request.ApprovedPackages + ApprovedPackages = request.ApprovedPackages, + ReleaseBaseline = request.ReleaseBaseline ?? new ModelRootReleaseBaseline(root) }; return PlanDocuments(documentRequest, cancellationToken); } @@ -146,6 +148,7 @@ public ScreenplayRenderPlan PlanDocuments(ScreenplayDocumentRenderRequest reques { cancellationToken.ThrowIfCancellationRequested(); var contents = RenderImplementationBodies.Resolve(request.Documents, compilation.ImplementationRequirements); + var releaseBaseline = request.ReleaseBaseline?.Assess(compilation.Value) ?? ReleaseBaselineAssessment.NotSupplied; var artifacts = target!.Plan( compilation.Value, execution.Plan!, @@ -158,7 +161,8 @@ public ScreenplayRenderPlan PlanDocuments(ScreenplayDocumentRenderRequest reques diagnostics, scene, selection, - request.ApprovedPackages); + request.ApprovedPackages, + releaseBaseline); cancellationToken.ThrowIfCancellationRequested(); diagnostics.AddRange(artifacts.Diagnostics.Select(Map)); return new(count, diagnostics, artifacts) diff --git a/Source/Cli/Commands/Render/ScreenplayRenderRequest.cs b/Source/Cli/Commands/Render/ScreenplayRenderRequest.cs index a44e8fc6..894303df 100644 --- a/Source/Cli/Commands/Render/ScreenplayRenderRequest.cs +++ b/Source/Cli/Commands/Render/ScreenplayRenderRequest.cs @@ -25,6 +25,11 @@ internal sealed record ScreenplayRenderRequest( /// public string[] Scope { get; init; } = []; + /// + /// Gets the release-baseline check, defaulting to the source root's persisted metadata. + /// + public IRenderReleaseBaseline? ReleaseBaseline { get; init; } + /// /// Gets the custom runtime packages the application approved, beyond the packages the target ships. ///