diff --git a/Directory.Build.props b/Directory.Build.props
index a440f38..308305a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -10,12 +10,12 @@
true
true
- 0.14.0
+ 0.15.0
false
diff --git a/Documentation/guides/build-source-adapter.md b/Documentation/guides/build-source-adapter.md
index 2b362f8..6f03917 100644
--- a/Documentation/guides/build-source-adapter.md
+++ b/Documentation/guides/build-source-adapter.md
@@ -39,28 +39,30 @@ Keep runtime packages for the framework you analyze out of the adapter whenever
Reference one version across all directly referenced Screenplay Generation packages.
:::note
-`v0.14.0` is the current public release. Exact alternate source owners, explicit flat-source compatibility placement, and command-free read-model/query specification lowering are available in that lockstep package set.
+`v0.15.0` is the current public release and package-validation baseline. Exact method signatures, complete bounded source-value extraction, and authoritative invocation and assignment enumeration are included in that lockstep package set. The described-adapter execution boundary is additive on `main`.
:::
-| Capability | Released `0.14.0` | Current `main` |
+| Capability | Released `0.15.0` | Current `main` |
| --- | ---: | ---: |
-| Adapter, context, and fact contracts | Yes | Yes |
-| Stable source identity | Yes | Yes |
-| Executable specification facts | Yes | Yes |
-| `DotNetConceptFacts` | Yes | Yes |
-| Symbol and invocation helpers | Yes | Yes |
-| Exact normalized method-signature matching | No | Yes |
-| Bounded scalar and `typeof` extraction | No | Yes |
-| Atomic payload and collection extraction | No | Yes |
-| Fixed source snapshots and strict placement | Yes | Yes |
-| Overload-safe method subjects | Yes | Yes |
-| Exact alternate source owners | Yes | Yes |
-| Explicit flat-source compatibility placement | Yes | Yes |
-| Authoritative invocation and assignment enumeration | No | Yes |
+| Adapter, context, neutral fact, evidence, and diagnostic contracts | Yes | Yes |
+| Stable source identity, fixed source snapshots, and strict placement | Yes | Yes |
+| Executable specification facts and `DotNetConceptFacts` | Yes | Yes |
+| Symbol, invocation, and exact normalized signature helpers | Yes | Yes |
+| Bounded scalar, `typeof`, payload, and collection extraction | Yes | Yes |
+| Overload-safe subjects, alternate source owners, and flat compatibility placement | Yes | Yes |
+| Authoritative invocation and assignment enumeration | Yes | Yes |
+| Legacy `IDotNetScreenplayAdapter` | Yes | Yes |
+| Descriptors, structured probes, and atomic public admission | No | Yes |
+| Explicit modern/legacy registration and deterministic .NET runner | No | Yes |
+| Immutable adapter-run snapshots and `Generate(snapshot)` | No | Yes |
+| Per-fact generation dispositions | No | Yes |
+| Vogen modern descriptor/probe with legacy contribution parity | No | Yes |
## Implement the adapter contract
-Implement `IDotNetScreenplayAdapter`:
+The original `IDotNetScreenplayAdapter` remains supported for existing adapters and hosts. New adapters should implement `IDescribedDotNetScreenplayAdapter`. During migration, implement both interfaces over one analysis path: modern hosts receive a descriptor and structured probe, while legacy hosts retain `Identity`, `CanAnalyze()`, and byte-compatible contributions.
+
+A descriptor states what the host is about to trust. Choose the narrow semantic `AdapterCategory` (`ApplicationFramework`, `EventSourcing`, `EventStore`, `Messaging`, `Concepts`, `Validation`, or `Integration`), declare `CSharp` or truly `SourceIndependent` input, bound compatible Generation versions when the adapter has a tested range, list required host services, name exact framework API capabilities that an applicable probe must prove, and list every neutral fact family analysis may emit. `Legacy` is reserved for the compatibility registration synthesized by `ForLegacy(...)`.
```csharp
using Cratis.Screenplay.Generation;
@@ -69,19 +71,68 @@ using Microsoft.CodeAnalysis;
namespace Acme.Screenplay;
-public sealed class AcmeScreenplayAdapter : IDotNetScreenplayAdapter
+public sealed class AcmeScreenplayAdapter :
+ IDescribedDotNetScreenplayAdapter,
+ IDotNetScreenplayAdapter
{
- static readonly AdapterIdentity _identity = new()
+ static readonly AdapterApiCapability _commandDeclarationApi = new()
+ {
+ Id = "acme.command-declaration"
+ };
+
+ public AdapterDescriptor Descriptor { get; } = new()
{
- Id = "acme",
- Version = "1.0.0"
+ Identity = new AdapterIdentity { Id = "acme", Version = "1.0.0" },
+ SourceLanguage = AdapterSourceLanguage.CSharp,
+ Category = AdapterCategory.ApplicationFramework,
+ RequiredHostCapabilities =
+ [
+ AdapterHostCapability.AuthoredSource,
+ AdapterHostCapability.StableSourceLocations,
+ AdapterHostCapability.SemanticAnalysis
+ ],
+ RequiredApiCapabilities = [_commandDeclarationApi],
+ EmittedFactCapabilities = [GenerationFactCapability.Artifact]
};
- public AdapterIdentity Identity => _identity;
+ // Legacy compatibility surface.
+ public AdapterIdentity Identity => Descriptor.Identity;
+ // A blocked modern probe maps to false because the legacy Boolean cannot report why analysis is unsafe.
public bool CanAnalyze(DotNetAnalysisContext context) =>
- context.Projects.Any(project =>
- project.Compilation.GetTypeByMetadataName("Acme.CommandAttribute") is not null);
+ Probe(context) is AdapterProbeApplicable;
+
+ public AdapterProbeResult Probe(DotNetAnalysisContext context)
+ {
+ var declarations = context.Projects
+ .SelectMany(project => new DotNetArtifactCatalog(project.Compilation).Types
+ .SelectMany(type => DotNetSource.AuthoredAttributesOf(type, project.AuthoredSyntaxTrees)
+ .Where(attribute =>
+ attribute.AttributeClass is not null &&
+ DotNetSubjectIds.MetadataName(attribute.AttributeClass) == "Acme.CommandAttribute")
+ .Select(attribute => (Project: project, Type: type, Attribute: attribute))))
+ .ToArray();
+ if (declarations.Length == 0)
+ {
+ return new AdapterProbeNotApplicable();
+ }
+
+ return new AdapterProbeApplicable
+ {
+ Evidence =
+ [
+ .. declarations.Select(declaration => new AdapterProbeEvidence
+ {
+ Description = "An authored type uses the exact Acme command declaration API",
+ ApiCapability = _commandDeclarationApi,
+ Source = DotNetSource.RangeForProject(
+ declaration.Attribute.ApplicationSyntaxReference!.GetSyntax().GetLocation(),
+ declaration.Project),
+ Subject = declaration.Project.SubjectForType(declaration.Type)
+ })
+ ]
+ };
+ }
public AdapterContribution Analyze(
DotNetAnalysisContext context,
@@ -143,9 +194,9 @@ public sealed class AcmeScreenplayAdapter : IDotNetScreenplayAdapter
}
```
-Keep `CanAnalyze()` cheap, deterministic, and semantic. Package presence alone must not create facts. Return only the facts and diagnostics the adapter can establish from `Analyze()`.
+Keep `Probe()` cheap, deterministic, and semantic. Package presence alone must not make a probe applicable. `AdapterProbeEvidence` can identify an exact required `AdapterApiCapability`, source range, and subject. Return `AdapterProbeBlocked` with valid diagnostics when the source applies but analysis cannot proceed safely. The runner admits and freezes probe evidence before it considers analysis.
-This first pass emits only the exact command artifact. Add placement through the fixed source snapshot and shared derivation pipeline in [Derive source placement](#derive-source-placement); do not derive it ad hoc inside artifact discovery.
+The compatibility `CanAnalyze()` above delegates to the modern probe without changing `Analyze()`. Existing binaries can continue to call the legacy interface, while a modern host registers the same adapter with `DotNetAdapterRegistration.For(...)`. This first pass emits only the exact command artifact. Add placement through the fixed source snapshot and shared derivation pipeline in [Derive source placement](#derive-source-placement); do not derive it ad hoc inside artifact discovery.
## Establish the analysis context in the host
@@ -232,6 +283,10 @@ Capture authored trees from `Project.Documents`. Generated filenames and headers
Use `DotNetSourcePaths.Create(...)` to map project documents into a stable source context. Physical checkout roots must not become identities. Prefer `DotNetSource.EvidenceFor(..., project, ...)` and `DotNetSource.RangeForProject(...)` over the legacy `SourceRoot` overloads.
+A modern adapter should declare `AdapterHostCapability.StableSourceLocations` when its probe, facts, or diagnostics depend on portable source identity. The runner then requires every project to expose authoritative authored trees and a complete `DotNetProjectSourceContext`; located probe evidence and contribution ranges must map to those exact trees and include their stable `SourceFileIdentity`. Missing host capability blocks before `Probe()`, malformed or nonauthoritative probe evidence blocks after `Probe()`, and nonauthoritative contribution source rejects that contribution atomically. The modern Vogen descriptor requires this capability. Legacy registrations retain path-only source compatibility.
+
+A truly source-neutral adapter can declare `AdapterSourceLanguage.SourceIndependent`, no host capabilities, and no source ranges. It can run with `new DotNetAnalysisContext([])`. Declaring any host capability intentionally restores host and project-roster gating.
+
## Use shared Roslyn mechanics
Prefer semantic helpers over adapter-specific syntax utilities:
@@ -410,23 +465,68 @@ Tests must assert required artifacts and relationships directly from `Graph` or
## Compose and verify once
-A host runs each admitted adapter once, keeps contributions separate, and invokes one generator:
+The host supplies an explicit registration roster. There is no package scanning or implicit adapter discovery:
```csharp
-var contributions = adapters
- .Where(adapter => adapter.CanAnalyze(context))
- .Select(adapter => adapter.Analyze(context, options));
+var roster = new DotNetAdapterRegistration[]
+{
+ DotNetAdapterRegistration.For(new AcmeScreenplayAdapter()),
+ DotNetAdapterRegistration.For(new VogenConceptScreenplayAdapter()),
+ DotNetAdapterRegistration.ForLegacy(unchangedLegacyAdapter)
+};
+var snapshot = DotNetAdapterRunner.Run(roster, context, options);
var result = new ScreenplayDefinitionGenerator().Generate(
- contributions,
+ snapshot,
new ScreenplayGenerationOptions { Domain = "Ordering" });
```
-`GeneratedScreenplayDefinition` contains canonical source, syntax, the resolved graph, and all diagnostics. `IsSuccess` means there are no error diagnostics; warnings may still describe semantic loss.
+`DotNetAdapterRunner` canonicalizes the roster and project input, considers every registration once, probes each eligible adapter once, and analyzes each applicable adapter once. `NotApplicable` and `Blocked` adapters never execute. Contributions remain separate and are admitted atomically before the runner returns a deeply frozen `AdapterRunSnapshot`; mutating adapter-owned lists or records after `Run()` cannot change it.
+
+The boundary fails closed at a precise stage:
+
+| Failure | Result |
+| --- | --- |
+| Duplicate adapter ID | Every duplicate registration is `RosterRejected` before probe or analysis |
+| Invalid descriptor | Registration is `RosterRejected` with deterministic descriptor-admission diagnostics |
+| Incompatible Generation version | `Blocked` before probe against the host's loaded `Generation.Contracts` version |
+| Unsupported language or missing host capability | `Blocked` before probe |
+| Ambiguous or duplicate project identity | Source-dependent adapters are `Blocked` before probe; a host-free source-independent adapter may continue |
+| Malformed or nonauthoritative probe evidence, or missing required API evidence | `Blocked` after one probe; analysis does not run |
+| Probe-declared known limitation | `AdapterProbeBlocked` preserves canonical diagnostics; analysis does not run |
+| Malformed, unscoped, undeclared, identity-mismatched, or nonauthoritative contribution | The complete contribution is `ContributionRejected`; no partial facts enter the snapshot |
+| Probe or analysis callback throws | `ExecutionFailed` with a stable boundary diagnostic; exception details and machine paths are not exposed |
+
+`AdapterRunSnapshot.Adapters` preserves each descriptor, structured probe, execution result, and disposition. Its admitted facts initially have `GenerationFactDisposition.Unknown`. `Generate(snapshot, options)` returns a new canonical snapshot in `GeneratedScreenplayDefinition.AdapterRun` with every admitted fact classified:
+
+| Disposition | Meaning |
+| --- | --- |
+| `Lowered` | The fact contributed directly to emitted Screenplay syntax |
+| `ProvenanceOnly` | The assertion was retained as supporting provenance but did not add syntax |
+| `OmittedWithDiagnostic` | Generation omitted the fact and attached the diagnostic that explains why |
+| `Conflicted` | The fact participated in an unresolved competing definition |
+
+The snapshot overload preserves runner, contribution, resolution, lowering, and verification diagnostics. For the same admitted contributions it produces the same canonical bytes as the original `Generate(IEnumerable, ...)` overload. The original overload and `IDotNetScreenplayAdapter` remain supported; use them only when a host does not need the execution record.
+
+`VogenConceptScreenplayAdapter` implements both interfaces. Prefer the modern registration when the host supplies stable mappings:
+
+```csharp
+var modern = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(new VogenConceptScreenplayAdapter())],
+ stableContext,
+ options);
+
+var legacy = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.ForLegacy(new VogenConceptScreenplayAdapter())],
+ legacyCompatibleContext,
+ options);
+```
+
+The modern descriptor has category `Concepts`, source language `CSharp`, requires authored source, stable source locations, semantic analysis, and exact Vogen declaration API evidence, and declares its concept fact families. Its probe distinguishes no declarations, safely applicable declarations, and unsafe mappings. Run the modern and legacy registrations separately: both use the `vogen` identity, so placing both in one roster is a deliberate duplicate rejection. When each path is safely applicable, their contribution facts and diagnostics are identical.
-Adapters never call the resolver, lowerer, printer, or compiler themselves. Generation does not discover adapter packages automatically. Direct hosts construct the `IDotNetScreenplayAdapter[]`; package or provider discovery and admission remain host-specific.
+This execution snapshot is not a history model. It does not implement issue #19 adapter or fact lineage. It also has no issue #24 serializer or stable fingerprints; keep snapshots in process and compare canonical generated bytes when determinism matters.
-Adopt a newly required API in this order:
+Adapters never call the runner, resolver, lowerer, printer, or compiler themselves. Adopt a newly required API in this order:
1. release Screenplay Generation;
2. upgrade and release the ecosystem adapter;
diff --git a/README.md b/README.md
index 9b88c9f..b1b6300 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,23 @@ Framework adapters remain owned by their source ecosystems:
See [Build a .NET source adapter](Documentation/guides/build-source-adapter.md) for the canonical adapter contract, source-authority rules, fact and evidence patterns, composition flow, and verification checklist.
+## Public baseline and current main
+
+`0.15.0` is the current public lockstep release and package-validation baseline. The adapter execution boundary is additive on `main`.
+
+| Capability | Released `0.15.0` | Current `main` |
+| --- | ---: | ---: |
+| Adapter, context, neutral fact, evidence, and diagnostic contracts | Yes | Yes |
+| Stable source identity, fixed source snapshots, and strict placement | Yes | Yes |
+| Exact method signatures and bounded scalar, payload, and collection extraction | Yes | Yes |
+| Authoritative invocation and assignment enumeration | Yes | Yes |
+| Legacy `IDotNetScreenplayAdapter` | Yes | Yes |
+| Descriptors, structured probes, and atomic public admission | No | Yes |
+| Explicit modern/legacy registration and deterministic .NET runner | No | Yes |
+| Immutable adapter-run snapshots and `Generate(snapshot)` | No | Yes |
+| Per-fact generation dispositions | No | Yes |
+| Vogen modern descriptor/probe with legacy contribution parity | No | Yes |
+
## Architecture
```text
@@ -35,6 +52,18 @@ Adapters contribute semantic facts; they do not construct syntax nodes or concat
`Cratis.Screenplay.Generation.DotNet` deliberately does not own `MSBuildWorkspace`. Hosts such as Cratis CLI load a project once and pass Roslyn compilations to official adapters.
+## Adapter discovery and execution
+
+The original `IDotNetScreenplayAdapter` remains source- and binary-compatible. New adapters should implement `IDescribedDotNetScreenplayAdapter`; its `AdapterDescriptor` declares the adapter category, source language, compatible Generation version range, required host and API capabilities, and the neutral fact families it may emit. `Probe()` returns `AdapterProbeNotApplicable`, `AdapterProbeApplicable`, or `AdapterProbeBlocked` together with canonical capability and source evidence instead of reducing admission to a Boolean.
+
+Hosts own an explicit roster. Register modern adapters with `DotNetAdapterRegistration.For(...)` and unchanged legacy adapters with `ForLegacy(...)`, then call `DotNetAdapterRunner.Run(...)` once. The runner canonicalizes the project and adapter rosters, considers and probes each eligible registration once, executes only applicable adapters once, admits each contribution atomically, and returns an immutable `AdapterRunSnapshot`. It never discovers packages implicitly.
+
+Duplicate adapter IDs are rejected before probe or analysis. Invalid descriptors reject the registration; incompatible Generation versions, unavailable host capabilities, unsupported source languages, and unsafe project rosters block before probing; missing API evidence or a blocked probe prevents execution; nonauthoritative source and malformed contributions reject the whole contribution. Callback failures become stable diagnostics and do not expose exception text or machine paths.
+
+A modern `SourceIndependent` adapter with no host requirements can run against an empty .NET context. A source adapter that declares `StableSourceLocations` requires every authored tree to have an authoritative `DotNetProjectSourceContext` mapping, and every located probe, fact, and diagnostic must use that mapping. The modern Vogen path requires stable locations; its legacy interface remains available for compatibility.
+
+Pass the frozen snapshot to `ScreenplayDefinitionGenerator.Generate(snapshot, options)` to preserve runner diagnostics and receive final fact dispositions: `Lowered`, `ProvenanceOnly`, `OmittedWithDiagnostic`, or `Conflicted`. This snapshot records one run only. It does not add issue #19 adapter/fact lineage, and it does not add issue #24 serialization or fingerprints.
+
### Adapter syntax robustness
`DotNetSource.AuthoredInvocationsIn(...)` and `AuthoredAssignmentsIn(...)` enumerate operations only from the host-authoritative authored syntax-tree snapshot, in deterministic source order. `DotNetSymbols`, `DotNetInvocations`, and `DotNetMethodSignatures` provide shared metadata-name, companion-method, named-argument, invocation-binding, and exact normalized signature mechanics. `DotNetMethodSignatures` preserves nullability, generic arity, return/ref shape, and ordered parameter/ref/`params`/extension-receiver shape across direct and reduced calls. Its Roslyn symbols are compilation-bound: create expected signatures from the exact allowlisted method symbols in the analyzed compilation rather than reconstructing them from names. Adapters should use these helpers instead of duplicating syntax-shape assumptions.
@@ -81,25 +110,25 @@ Flat compatibility is opt-in per artifact. An adapter may supply a versioned `Do
A composition host references `Cratis.Screenplay.Generation` and `Cratis.Screenplay.Generation.DotNet.Vogen` directly, plus its external ecosystem adapter package. The Vogen adapter package brings `Cratis.Screenplay.Generation.DotNet` and `Cratis.Screenplay.Generation.Contracts` transitively; the analyzed application references Vogen itself.
-A clean consumer composes Vogen with any external ecosystem adapter by keeping contributions separate until neutral resolution:
+A clean consumer composes Vogen with explicitly selected ecosystem adapters through one runner invocation:
```csharp
-var adapters = new IDotNetScreenplayAdapter[]
+var roster = new DotNetAdapterRegistration[]
{
- new VogenConceptScreenplayAdapter(),
- externalAdapter
+ DotNetAdapterRegistration.For(new VogenConceptScreenplayAdapter()),
+ DotNetAdapterRegistration.For(externalModernAdapter),
+ DotNetAdapterRegistration.ForLegacy(unchangedLegacyAdapter)
};
var adapterOptions = new DotNetAdapterOptions();
-var contributions = adapters
- .Where(adapter => adapter.CanAnalyze(context))
- .Select(adapter => adapter.Analyze(context, adapterOptions));
-
+var snapshot = DotNetAdapterRunner.Run(roster, context, adapterOptions);
var definition = new ScreenplayDefinitionGenerator().Generate(
- contributions,
+ snapshot,
new ScreenplayGenerationOptions { Domain = "Ordering" });
```
+`VogenConceptScreenplayAdapter` implements both contracts. Prefer `For(vogen)` for its `Concepts` descriptor, exact Vogen API probe evidence, declared fact capabilities, and stable-source enforcement. Use `ForLegacy(vogen)` only when preserving a legacy host path; run the two registrations separately because their shared `vogen` identity is intentionally a duplicate in one roster. Safely applicable modern and legacy runs produce the same Vogen contribution.
+
The Vogen contribution establishes authored concepts, supported primitive representations, and one named validation rule only when the attribute-bearing declaration contains an authored static `Validate(TBacking)` method returning the exact `Vogen.Validation` type. The rule keeps the authored predicate and implementation file; a single semantically constant `Validation.Invalid("message")` return can also preserve its message. Arbitrary validation bodies are never translated into built-in rules.
Generated members never provide primary evidence. The adapter never infers identity from `Guid` or `Id`, never treats normalization as validation, and never treats named instances as optional values or defaults. It reports stable loss diagnostics instead: `VOG0001` for unsupported backing representations, `VOG0002` for authored `NormalizeInput(TBacking)` behavior, and `VOG0003` for authored `Vogen.InstanceAttribute` declarations.
@@ -158,7 +187,7 @@ dotnet pack Screenplay.Generation.slnx --no-build --configuration Release -o Art
./scripts/verify-package-consumers.sh 9999.0.0 Artifacts/NuGet
```
-Package validation runs during pack against the latest released API baseline, `0.14.0`, for all four packages. Baseline strict mode remains disabled so intentional compatible additions are accepted while removals and signature changes still fail; no compatibility diagnostics are suppressed. The sentinel version must be applied to both the Release build and the no-build pack so package and assembly versions agree. The consumer smoke keeps clean legacy binaries compiled against the `0.1.0` core and `0.5.0` Vogen ancestry and runs them unchanged with current packages. A separate clean current-source consumer compiles only against the candidate packages and verifies the current authored-source, shared symbol helpers, declared concept nomination, neutral-fact, resolver, Vogen, adapter-composition, and deterministic compiler-verified generation APIs.
+Package validation runs during pack against the latest released API baseline, `0.15.0`, for all four packages. Baseline strict mode remains disabled so intentional compatible additions are accepted while removals and signature changes still fail; no compatibility diagnostics are suppressed. The sentinel version must be applied to both the Release build and the no-build pack so package and assembly versions agree. The consumer smoke keeps clean legacy binaries compiled against the `0.1.0` core and `0.5.0` Vogen ancestry and runs them unchanged with current packages. A separate clean current-source consumer compiles only against the candidate packages and verifies the current authored-source, shared symbol helpers, declared concept nomination, neutral-fact, resolver, Vogen, adapter-composition, and deterministic compiler-verified generation APIs.
All builds require zero errors and zero warnings. Generated Screenplay output must compile and remain stable through print/compile/print.
diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionAdmission.cs b/Source/DotNET/Generation.Contracts/AdapterContributionAdmission.cs
new file mode 100644
index 0000000..267d943
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmission.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.
+
+namespace Cratis.Screenplay.Generation;
+
+///
+/// Deeply freezes and atomically admits source-neutral adapter contributions.
+///
+public static class AdapterContributionAdmission
+{
+ ///
+ /// Deeply freezes, canonically orders, and validates an adapter contribution without partially admitting facts.
+ ///
+ /// The descriptor governing the contribution.
+ /// The untrusted contribution to admit.
+ /// The optional host source-authority validator.
+ /// An admitted immutable snapshot, or deterministic diagnostics with no admitted facts.
+ public static AdapterContributionAdmissionResult Admit(
+ AdapterDescriptor? descriptor,
+ AdapterContribution? contribution,
+ ISourceAuthorityValidator? sourceAuthorityValidator = null)
+ {
+ var context = new AdapterContributionAdmissionContext();
+ var frozen = AdapterContributionFreezer.Freeze(descriptor, contribution, context);
+ AdapterContributionAdmissionValidator.Validate(frozen, sourceAuthorityValidator, context);
+ var diagnostics = context.Diagnostics();
+ if (context.HasDiagnostics)
+ {
+ return new AdapterContributionAdmissionResult { Diagnostics = diagnostics };
+ }
+
+ return new AdapterContributionAdmissionResult
+ {
+ Snapshot = new AdapterContributionSnapshot
+ {
+ Descriptor = frozen.Descriptor,
+ Facts = frozen.Facts,
+ Diagnostics = frozen.Diagnostics
+ }
+ };
+ }
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContext.cs b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContext.cs
new file mode 100644
index 0000000..219c410
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContext.cs
@@ -0,0 +1,97 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation;
+
+sealed class AdapterContributionAdmissionContext
+{
+ readonly List _diagnostics = [];
+
+ public bool HasDiagnostics => _diagnostics.Count > 0;
+
+ public void Missing(string path) => Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ path,
+ $"{path} is required");
+
+ public void NullCollection(string path) => Add(
+ AdapterContributionAdmissionDiagnosticCode.NullRequiredCollection,
+ path,
+ $"{path} must not be null");
+
+ public void Add(
+ AdapterContributionAdmissionDiagnosticCode code,
+ string path,
+ string message,
+ FactId? fact = null,
+ SubjectId? subject = null,
+ SourceRange? source = null) =>
+ _diagnostics.Add(new AdapterContributionAdmissionDiagnostic
+ {
+ Code = code,
+ Path = path,
+ Message = message,
+ Fact = fact is null ? null : new FactId { Value = fact.Value ?? string.Empty },
+ Subject = subject is null ? null : new SubjectId { Value = subject.Value ?? string.Empty },
+ Source = source is null
+ ? null
+ : new SourceRange
+ {
+ Path = source.Path,
+ FileIdentity = source.FileIdentity is null
+ ? null
+ : new SourceFileIdentity
+ {
+ Project = source.FileIdentity.Project,
+ Path = source.FileIdentity.Path
+ },
+ StartLine = source.StartLine,
+ StartColumn = source.StartColumn,
+ EndLine = source.EndLine,
+ EndColumn = source.EndColumn
+ }
+ });
+
+ public void Enum(T value, T unknown, string path, FactId? fact = null, SubjectId? subject = null)
+ where T : struct, Enum
+ {
+ if (EqualityComparer.Default.Equals(value, unknown))
+ {
+ Add(
+ AdapterContributionAdmissionDiagnosticCode.UnknownEnumValue,
+ path,
+ $"{path} must not use {typeof(T).Name}.{unknown}",
+ fact,
+ subject);
+ }
+ else if (!System.Enum.IsDefined(value))
+ {
+ Add(
+ AdapterContributionAdmissionDiagnosticCode.UndefinedEnumValue,
+ path,
+ $"{path} contains undefined {typeof(T).Name} value '{Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture)}'",
+ fact,
+ subject);
+ }
+ }
+
+ public ImmutableArray Diagnostics() =>
+ [
+ .. _diagnostics
+ .OrderBy(diagnostic => diagnostic.Code)
+ .ThenBy(diagnostic => diagnostic.Path, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Fact?.Value, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Subject?.Value, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Source is null ? 0 : 1)
+ .ThenBy(diagnostic => diagnostic.Source?.FileIdentity?.Project, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Source?.FileIdentity?.Path, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Source?.Path, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Source?.StartLine)
+ .ThenBy(diagnostic => diagnostic.Source?.StartColumn)
+ .ThenBy(diagnostic => diagnostic.Source?.EndLine)
+ .ThenBy(diagnostic => diagnostic.Source?.EndColumn)
+ ];
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs
new file mode 100644
index 0000000..427237f
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs
@@ -0,0 +1,184 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation;
+
+///
+/// Defines a deterministic structural contribution-admission failure.
+///
+public enum AdapterContributionAdmissionDiagnosticCode
+{
+ ///
+ /// The admission failure is unknown.
+ ///
+ Unknown = -1,
+
+ ///
+ /// A required value is absent or blank.
+ ///
+ MissingRequiredValue = 0,
+
+ ///
+ /// A required collection is unexpectedly null.
+ ///
+ NullRequiredCollection = 1,
+
+ ///
+ /// An enum contains its explicit unknown value.
+ ///
+ UnknownEnumValue = 2,
+
+ ///
+ /// An enum contains an undefined numeric value.
+ ///
+ UndefinedEnumValue = 3,
+
+ ///
+ /// The adapter descriptor identity is malformed.
+ ///
+ InvalidDescriptorIdentity = 4,
+
+ ///
+ /// The compatible Generation version range is malformed.
+ ///
+ InvalidGenerationVersionRange = 5,
+
+ ///
+ /// The contribution producer does not equal the descriptor identity.
+ ///
+ ContributionAdapterMismatch = 6,
+
+ ///
+ /// Fact evidence names a producer other than the descriptor identity.
+ ///
+ EvidenceAdapterMismatch = 7,
+
+ ///
+ /// A fact identity is empty or not normalized.
+ ///
+ InvalidFactId = 8,
+
+ ///
+ /// A fact identity is not scoped beneath the producing adapter identity.
+ ///
+ UnscopedFactId = 9,
+
+ ///
+ /// A fact identity occurs more than once in the contribution.
+ ///
+ DuplicateFactId = 10,
+
+ ///
+ /// A subject is not a normalized absolute stable URI.
+ ///
+ InvalidSubject = 11,
+
+ ///
+ /// The descriptor does not declare the emitted fact family.
+ ///
+ UndeclaredFactCapability = 12,
+
+ ///
+ /// The contribution contains a fact family not defined by the neutral contracts.
+ ///
+ UnsupportedFactType = 13,
+
+ ///
+ /// A nested definition identifies a different owner from its containing fact or chain.
+ ///
+ OwnershipMismatch = 14,
+
+ ///
+ /// A fact discriminator does not carry the operand required by its kind.
+ ///
+ InvalidKindOperand = 15,
+
+ ///
+ /// A source range is structurally malformed.
+ ///
+ InvalidSourceRange = 16,
+
+ ///
+ /// The source host rejected a range as nonauthoritative.
+ ///
+ SourceNotAuthoritative = 17,
+
+ ///
+ /// An adapter diagnostic is structurally malformed.
+ ///
+ InvalidContributionDiagnostic = 18,
+
+ ///
+ /// Source evidence was supplied without a host authority validator.
+ ///
+ SourceAuthorityRequired = 19,
+
+ ///
+ /// A required API capability identity is malformed.
+ ///
+ InvalidApiCapability = 20,
+
+ ///
+ /// A required API capability occurs more than once.
+ ///
+ DuplicateApiCapability = 21
+}
+
+///
+/// Describes one deterministic contribution-admission diagnostic.
+///
+public sealed record AdapterContributionAdmissionDiagnostic
+{
+ ///
+ /// Gets the typed diagnostic code.
+ ///
+ public required AdapterContributionAdmissionDiagnosticCode Code { get; init; }
+
+ ///
+ /// Gets the stable contract path identifying the malformed value.
+ ///
+ public required string Path { get; init; }
+
+ ///
+ /// Gets the human-readable diagnostic message.
+ ///
+ public required string Message { get; init; }
+
+ ///
+ /// Gets the affected fact identity, when available.
+ ///
+ public FactId? Fact { get; init; }
+
+ ///
+ /// Gets the affected subject, when available.
+ ///
+ public SubjectId? Subject { get; init; }
+
+ ///
+ /// Gets the affected source range, when available.
+ ///
+ public SourceRange? Source { get; init; }
+}
+
+///
+/// Represents the nonthrowing atomic result of contribution admission.
+///
+public sealed record AdapterContributionAdmissionResult
+{
+ ///
+ /// Gets the admitted immutable snapshot, or when any diagnostic rejected the contribution.
+ ///
+ public AdapterContributionSnapshot? Snapshot { get; init; }
+
+ ///
+ /// Gets the deterministic admission diagnostics.
+ ///
+ public ImmutableArray Diagnostics { get; init; } = [];
+
+ ///
+ /// Gets whether the contribution was admitted atomically.
+ ///
+ public bool IsAdmitted => Snapshot is not null && Diagnostics.IsEmpty;
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs
new file mode 100644
index 0000000..62c1816
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs
@@ -0,0 +1,492 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text;
+
+namespace Cratis.Screenplay.Generation;
+
+static class AdapterContributionAdmissionValidator
+{
+ public static void Validate(
+ FrozenAdapterContributionInput input,
+ ISourceAuthorityValidator? sourceAuthorityValidator,
+ AdapterContributionAdmissionContext context)
+ {
+ ValidateDescriptor(input.Descriptor, context);
+ ValidateProducer(input, context);
+ ValidateDuplicateFactIds(input.Facts, context);
+
+ foreach (var fact in input.Facts)
+ {
+ ValidateFact(input.Descriptor, fact, sourceAuthorityValidator, context);
+ }
+
+ for (var index = 0; index < input.Diagnostics.Length; index++)
+ {
+ ValidateDiagnostic(input.Diagnostics[index], index, sourceAuthorityValidator, context);
+ }
+ }
+
+ public static void ValidateSubject(
+ SubjectId subject,
+ string path,
+ FactId? fact,
+ AdapterContributionAdmissionContext context)
+ {
+ var value = subject.Value;
+ if (!AdapterContributionText.IsNormalized(value, false) ||
+ value.Contains('\\') ||
+ !Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
+ string.IsNullOrWhiteSpace(uri.Scheme) ||
+ HasAuthoredDotPathSegment(value))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidSubject,
+ path,
+ $"Subject '{value}' must be a normalized absolute stable URI without whitespace, control characters, backslashes, or dot path segments",
+ fact,
+ subject);
+ }
+ }
+
+ public static void ValidateRequiredText(
+ string? value,
+ string path,
+ FactId fact,
+ SubjectId subject,
+ AdapterContributionAdmissionContext context)
+ {
+ if (!AdapterContributionText.IsRequired(value))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ path,
+ $"{path} is required",
+ fact,
+ subject);
+ }
+ }
+
+ public static void ValidateArtifactKey(
+ ArtifactKey key,
+ string path,
+ FactId fact,
+ AdapterContributionAdmissionContext context)
+ {
+ ValidateSubject(key.Subject, $"{path}.Subject", fact, context);
+ context.Enum(key.Kind, ArtifactKind.Unknown, $"{path}.Kind", fact, key.Subject);
+ }
+
+ public static void ValidateType(
+ TypeReferenceDefinition type,
+ string path,
+ FactId fact,
+ SubjectId subject,
+ AdapterContributionAdmissionContext context)
+ {
+ ValidateRequiredText(type.Name, $"{path}.Name", fact, subject, context);
+ if (type.Subject is not null)
+ {
+ ValidateSubject(type.Subject, $"{path}.Subject", fact, context);
+ }
+ }
+
+ internal static void ValidateDescriptor(
+ AdapterDescriptor descriptor,
+ AdapterContributionAdmissionContext context)
+ {
+ if (!IsIdentityPart(descriptor.Identity.Id) || descriptor.Identity.Id.Contains(':', StringComparison.Ordinal))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidDescriptorIdentity,
+ "Descriptor.Identity.Id",
+ "Descriptor.Identity.Id must be normalized, contain no whitespace or control characters, and contain no ':' separator");
+ }
+
+ if (!IsIdentityPart(descriptor.Identity.Version))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidDescriptorIdentity,
+ "Descriptor.Identity.Version",
+ "Descriptor.Identity.Version must be normalized and contain no whitespace or control characters");
+ }
+
+ context.Enum(descriptor.SourceLanguage, AdapterSourceLanguage.Unknown, "Descriptor.SourceLanguage");
+ context.Enum(descriptor.Category, AdapterCategory.Unknown, "Descriptor.Category");
+ for (var index = 0; index < descriptor.RequiredHostCapabilities.Length; index++)
+ {
+ context.Enum(
+ descriptor.RequiredHostCapabilities[index],
+ AdapterHostCapability.Unknown,
+ $"Descriptor.RequiredHostCapabilities[{index}]");
+ }
+
+ for (var index = 0; index < descriptor.RequiredApiCapabilities.Length; index++)
+ {
+ var capability = descriptor.RequiredApiCapabilities[index];
+ if (!AdapterContributionText.IsNormalized(capability.Id, false))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidApiCapability,
+ $"Descriptor.RequiredApiCapabilities[{index}]",
+ "Required API capability identities must be nonempty, normalized, and contain no whitespace or control characters");
+ }
+ }
+
+ foreach (var duplicate in descriptor.RequiredApiCapabilities
+ .GroupBy(capability => capability.Id, StringComparer.Ordinal)
+ .Where(group => group.Count() > 1)
+ .OrderBy(group => group.Key, StringComparer.Ordinal))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.DuplicateApiCapability,
+ "Descriptor.RequiredApiCapabilities",
+ $"Required API capability '{duplicate.Key}' occurs {duplicate.Count()} times");
+ }
+
+ for (var index = 0; index < descriptor.EmittedFactCapabilities.Length; index++)
+ {
+ context.Enum(
+ descriptor.EmittedFactCapabilities[index],
+ GenerationFactCapability.Unknown,
+ $"Descriptor.EmittedFactCapabilities[{index}]");
+ }
+
+ var range = descriptor.CompatibleGenerationVersions;
+ if (range.MinimumInclusive is null ||
+ (range.MaximumExclusive is not null && range.MaximumExclusive <= range.MinimumInclusive))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidGenerationVersionRange,
+ "Descriptor.CompatibleGenerationVersions",
+ "Descriptor.CompatibleGenerationVersions must have a minimum and an optional greater exclusive maximum");
+ }
+ }
+
+ static void ValidateProducer(
+ FrozenAdapterContributionInput input,
+ AdapterContributionAdmissionContext context)
+ {
+ if (input.ContributionAdapter != input.Descriptor.Identity)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.ContributionAdapterMismatch,
+ "Contribution.Adapter",
+ $"Contribution adapter '{input.ContributionAdapter.Id}@{input.ContributionAdapter.Version}' does not equal descriptor adapter '{input.Descriptor.Identity.Id}@{input.Descriptor.Identity.Version}'");
+ }
+ }
+
+ static void ValidateDuplicateFactIds(
+ IEnumerable facts,
+ AdapterContributionAdmissionContext context)
+ {
+ foreach (var duplicate in facts
+ .GroupBy(fact => fact.Id.Value, StringComparer.Ordinal)
+ .Where(group => group.Count() > 1)
+ .OrderBy(group => group.Key, StringComparer.Ordinal))
+ {
+ var first = duplicate.First();
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.DuplicateFactId,
+ "Contribution.Facts",
+ $"Fact identity '{duplicate.Key}' occurs {duplicate.Count()} times",
+ first.Id,
+ first.Subject);
+ }
+ }
+
+ static void ValidateFact(
+ AdapterDescriptor descriptor,
+ GenerationFact fact,
+ ISourceAuthorityValidator? sourceAuthorityValidator,
+ AdapterContributionAdmissionContext context)
+ {
+ var factPath = $"Contribution.Facts[{fact.Id.Value}]";
+ ValidateFactId(descriptor.Identity.Id, fact, factPath, context);
+ ValidateSubject(fact.Subject, $"{factPath}.Subject", fact.Id, context);
+
+ if (fact.Evidence.Adapter != descriptor.Identity)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.EvidenceAdapterMismatch,
+ $"{factPath}.Evidence.Adapter",
+ $"Fact evidence adapter '{fact.Evidence.Adapter.Id}@{fact.Evidence.Adapter.Version}' does not equal descriptor adapter '{descriptor.Identity.Id}@{descriptor.Identity.Version}'",
+ fact.Id,
+ fact.Subject);
+ }
+
+ context.Enum(
+ fact.Evidence.Strength,
+ EvidenceStrength.Unknown,
+ $"{factPath}.Evidence.Strength",
+ fact.Id,
+ fact.Subject);
+ if (fact.Evidence.Source is not null)
+ {
+ ValidateSource(
+ fact.Evidence.Source,
+ $"{factPath}.Evidence.Source",
+ fact.Id,
+ fact.Subject,
+ sourceAuthorityValidator,
+ context);
+ }
+
+ var capability = CapabilityFor(fact);
+ if (!descriptor.EmittedFactCapabilities.Contains(capability))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.UndeclaredFactCapability,
+ factPath,
+ $"Descriptor '{descriptor.Identity.Id}' does not declare emitted fact capability '{capability}'",
+ fact.Id,
+ fact.Subject);
+ }
+
+ AdapterFactAdmissionValidator.Validate(fact, factPath, context);
+ }
+
+ static void ValidateFactId(
+ string adapterId,
+ GenerationFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var value = fact.Id.Value;
+ if (!AdapterContributionText.IsNormalized(value, false))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidFactId,
+ $"{path}.Id",
+ "Fact identity must be nonempty, normalized, and contain no whitespace or control characters",
+ fact.Id,
+ fact.Subject);
+ return;
+ }
+
+ var prefix = $"{adapterId}:";
+ if (!value.StartsWith(prefix, StringComparison.Ordinal) || value.Length == prefix.Length)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.UnscopedFactId,
+ $"{path}.Id",
+ $"Fact identity '{value}' must be scoped beneath producer '{prefix}' and is never rewritten during admission",
+ fact.Id,
+ fact.Subject);
+ }
+ }
+
+ static void ValidateDiagnostic(
+ GenerationDiagnostic diagnostic,
+ int index,
+ ISourceAuthorityValidator? sourceAuthorityValidator,
+ AdapterContributionAdmissionContext context)
+ {
+ var path = $"Contribution.Diagnostics[{index}]";
+ if (!AdapterContributionText.IsRequired(diagnostic.Code))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidContributionDiagnostic,
+ $"{path}.Code",
+ "Contribution diagnostic code is required");
+ }
+
+ if (!AdapterContributionText.IsRequired(diagnostic.Message))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidContributionDiagnostic,
+ $"{path}.Message",
+ "Contribution diagnostic message is required");
+ }
+
+ context.Enum(diagnostic.Severity, GenerationDiagnosticSeverity.Unknown, $"{path}.Severity");
+ if (diagnostic.Outcome is { } outcome && !Enum.IsDefined(outcome))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.UndefinedEnumValue,
+ $"{path}.Outcome",
+ $"{path}.Outcome contains undefined {nameof(GenerationDiagnosticOutcome)} value '{(int)outcome}'");
+ }
+
+ if (diagnostic.Subject is not null)
+ {
+ ValidateSubject(diagnostic.Subject, $"{path}.Subject", null, context);
+ }
+
+ if (diagnostic.Source is not null)
+ {
+ ValidateSource(diagnostic.Source, $"{path}.Source", null, diagnostic.Subject, sourceAuthorityValidator, context);
+ }
+ }
+
+ static void ValidateSource(
+ SourceRange source,
+ string path,
+ FactId? fact,
+ SubjectId? subject,
+ ISourceAuthorityValidator? sourceAuthorityValidator,
+ AdapterContributionAdmissionContext context)
+ {
+ var isOrdered = source.EndLine > source.StartLine ||
+ (source.EndLine == source.StartLine && source.EndColumn >= source.StartColumn);
+ if (!IsPortableRelativePath(source.Path) ||
+ source.StartLine < 1 ||
+ source.StartColumn < 1 ||
+ source.EndLine < 1 ||
+ source.EndColumn < 1 ||
+ !isOrdered ||
+ (source.FileIdentity is not null && !IsFileIdentity(source.FileIdentity)))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidSourceRange,
+ path,
+ $"{path} must identify a normalized portable relative path without rooted, backslash, empty, or dot segments and an ordered positive 1-based range",
+ fact,
+ subject,
+ source);
+ return;
+ }
+
+ if (sourceAuthorityValidator is null)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.SourceAuthorityRequired,
+ path,
+ $"Source range '{source.Path}:{source.StartLine}:{source.StartColumn}' requires a host authority validator",
+ fact,
+ subject,
+ source);
+ }
+ else if (!sourceAuthorityValidator.IsAuthoritative(source))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.SourceNotAuthoritative,
+ path,
+ $"Source range '{source.Path}:{source.StartLine}:{source.StartColumn}' is not authoritative authored source",
+ fact,
+ subject,
+ source);
+ }
+ }
+
+ static bool IsIdentityPart(string? value) =>
+ AdapterContributionText.IsNormalized(value, false);
+
+ static bool IsNormalizedPath(string? value)
+ {
+ if (!AdapterContributionText.IsNormalized(value, true))
+ {
+ return false;
+ }
+
+ var normalizedPath = value!;
+ try
+ {
+ return string.Equals(normalizedPath, normalizedPath.Normalize(NormalizationForm.FormC), StringComparison.Ordinal);
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ static bool IsFileIdentity(SourceFileIdentity identity) =>
+ AdapterContributionText.IsNormalized(identity.Project, true) &&
+ IsPortableRelativePath(identity.Path);
+
+ static bool IsPortableRelativePath(string? value)
+ {
+ if (!IsNormalizedPath(value))
+ {
+ return false;
+ }
+
+ var path = value!;
+ if (path[0] == '/' ||
+ path.Contains('\\') ||
+ IsDriveRooted(path))
+ {
+ return false;
+ }
+
+ var segments = path.Split('/');
+ for (var index = 0; index < segments.Length; index++)
+ {
+ if (string.IsNullOrEmpty(segments[index]) ||
+ !TryDecodeSegment(segments[index], out var decoded) ||
+ string.Equals(decoded, ".", StringComparison.Ordinal) ||
+ string.Equals(decoded, "..", StringComparison.Ordinal) ||
+ decoded.Contains('/') ||
+ decoded.Contains('\\') ||
+ (index == 0 && IsDriveRooted(decoded)))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ static bool TryDecodeSegment(string segment, out string decoded)
+ {
+ decoded = segment;
+ try
+ {
+ while (true)
+ {
+ var unescaped = Uri.UnescapeDataString(decoded);
+ if (string.Equals(unescaped, decoded, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ decoded = unescaped;
+ }
+ }
+ catch (UriFormatException)
+ {
+ return false;
+ }
+ }
+
+ static bool IsDriveRooted(string path) =>
+ path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':';
+
+ static bool HasAuthoredDotPathSegment(string value)
+ {
+ var schemeSeparator = value.IndexOf(':', StringComparison.Ordinal);
+ var pathStart = schemeSeparator + 1;
+ if (value.AsSpan(pathStart).StartsWith("//", StringComparison.Ordinal))
+ {
+ pathStart = value.IndexOf('/', pathStart + 2);
+ if (pathStart < 0)
+ {
+ return false;
+ }
+ }
+
+ var pathEnd = value.IndexOfAny(['?', '#'], pathStart);
+ var path = pathEnd < 0 ? value[pathStart..] : value[pathStart..pathEnd];
+ return path.Split('/').Any(IsDotSegment);
+ }
+
+ static bool IsDotSegment(string segment)
+ {
+ var unescaped = Uri.UnescapeDataString(segment);
+ return string.Equals(unescaped, ".", StringComparison.Ordinal) || string.Equals(unescaped, "..", StringComparison.Ordinal);
+ }
+
+ static GenerationFactCapability CapabilityFor(GenerationFact fact) => fact switch
+ {
+ ArtifactFact => GenerationFactCapability.Artifact,
+ ArtifactPlacementFact => GenerationFactCapability.ArtifactPlacement,
+ RelationshipFact => GenerationFactCapability.Relationship,
+ ConceptRepresentationFact => GenerationFactCapability.ConceptRepresentation,
+ ConceptAttributeFact => GenerationFactCapability.ConceptAttribute,
+ ConceptValidationRuleFact => GenerationFactCapability.ConceptValidationRule,
+ SpecificationScenarioFact => GenerationFactCapability.SpecificationScenario,
+ SpecificationStepFact => GenerationFactCapability.SpecificationStep,
+ SpecificationValueFact => GenerationFactCapability.SpecificationValue,
+ _ => GenerationFactCapability.Unknown
+ };
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs b/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs
new file mode 100644
index 0000000..92ef1ef
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs
@@ -0,0 +1,863 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation;
+
+static class AdapterContributionFreezer
+{
+ public static FrozenAdapterContributionInput Freeze(
+ AdapterDescriptor? descriptor,
+ AdapterContribution? contribution,
+ AdapterContributionAdmissionContext context)
+ {
+ var frozenDescriptor = FreezeDescriptor(descriptor, context);
+ if (contribution is null)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ "Contribution",
+ "The adapter contribution is required");
+ return new(
+ frozenDescriptor,
+ new AdapterIdentity { Id = string.Empty, Version = string.Empty },
+ [],
+ []);
+ }
+
+ var adapter = FreezeIdentity(contribution.Adapter, "Contribution.Adapter", context);
+ var facts = FreezeFacts(contribution.Facts, context);
+ var diagnostics = FreezeDiagnostics(contribution.Diagnostics, context);
+ return new(frozenDescriptor, adapter, facts, diagnostics);
+ }
+
+ internal static AdapterDescriptor FreezeDescriptor(
+ AdapterDescriptor? descriptor,
+ AdapterContributionAdmissionContext context)
+ {
+ if (descriptor is null)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ "Descriptor",
+ "The adapter descriptor is required");
+ return new AdapterDescriptor
+ {
+ Identity = new AdapterIdentity { Id = string.Empty, Version = string.Empty },
+ SourceLanguage = AdapterSourceLanguage.Unknown,
+ Category = AdapterCategory.Unknown
+ };
+ }
+
+ var range = descriptor.CompatibleGenerationVersions;
+ if (range is null)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ "Descriptor.CompatibleGenerationVersions",
+ "The compatible Generation version range is required");
+ range = GenerationVersionRange.Any;
+ }
+
+ return new AdapterDescriptor
+ {
+ Identity = FreezeIdentity(descriptor.Identity, "Descriptor.Identity", context),
+ SourceLanguage = descriptor.SourceLanguage,
+ Category = descriptor.Category,
+ CompatibleGenerationVersions = new GenerationVersionRange
+ {
+ MinimumInclusive = range.MinimumInclusive,
+ MaximumExclusive = range.MaximumExclusive
+ },
+ RequiredHostCapabilities = Canonical(descriptor.RequiredHostCapabilities),
+ RequiredApiCapabilities = FreezeApiCapabilities(descriptor.RequiredApiCapabilities, context),
+ EmittedFactCapabilities = Canonical(descriptor.EmittedFactCapabilities)
+ };
+ }
+
+ static ImmutableArray FreezeApiCapabilities(
+ ImmutableArray capabilities,
+ AdapterContributionAdmissionContext context)
+ {
+ if (capabilities.IsDefault)
+ {
+ return [];
+ }
+
+ var frozen = ImmutableArray.CreateBuilder();
+ for (var index = 0; index < capabilities.Length; index++)
+ {
+ var capability = capabilities[index];
+ if (capability is null)
+ {
+ context.Missing($"Descriptor.RequiredApiCapabilities[{index}]");
+ continue;
+ }
+
+ frozen.Add(new AdapterApiCapability { Id = capability.Id ?? string.Empty });
+ }
+
+ return [.. frozen.OrderBy(capability => capability.Id, StringComparer.Ordinal)];
+ }
+
+ static ImmutableArray Canonical(ImmutableArray values)
+ where T : struct, Enum =>
+ values.IsDefault
+ ? []
+ : [.. values.Distinct().OrderBy(value => Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture))];
+
+ static AdapterIdentity FreezeIdentity(
+ AdapterIdentity? identity,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (identity is null)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ path,
+ $"{path} is required");
+ return new AdapterIdentity { Id = string.Empty, Version = string.Empty };
+ }
+
+ return new AdapterIdentity { Id = identity.Id ?? string.Empty, Version = identity.Version ?? string.Empty };
+ }
+
+ static ImmutableArray FreezeFacts(
+ IReadOnlyList? facts,
+ AdapterContributionAdmissionContext context)
+ {
+ if (facts is null)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.NullRequiredCollection,
+ "Contribution.Facts",
+ "Contribution.Facts must not be null");
+ return [];
+ }
+
+ var frozen = ImmutableArray.CreateBuilder();
+ for (var index = 0; index < facts.Count; index++)
+ {
+ var fact = facts[index];
+ var path = FactPath(fact);
+ if (fact is null)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ path,
+ $"{path} is required");
+ continue;
+ }
+
+ var copy = FreezeFact(fact, path, context);
+ if (copy is not null)
+ {
+ frozen.Add(copy);
+ }
+ }
+
+ return
+ [
+ .. frozen
+ .OrderBy(fact => fact.Id.Value, StringComparer.Ordinal)
+ .ThenBy(fact => fact.Subject.Value, StringComparer.Ordinal)
+ .ThenBy(FactFamily)
+ ];
+ }
+
+ static GenerationFact? FreezeFact(
+ GenerationFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var id = fact.Id is null
+ ? MissingFactId(path, context)
+ : new FactId { Value = fact.Id.Value ?? string.Empty };
+ var subject = FreezeSubject(fact.Subject, $"{path}.Subject", context);
+ var evidence = FreezeEvidence(fact.Evidence, $"{path}.Evidence", context);
+
+ return fact switch
+ {
+ ArtifactFact artifact => new ArtifactFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeArtifactDefinition(artifact.Definition, $"{path}.Definition", context)
+ },
+ ArtifactPlacementFact placement => new ArtifactPlacementFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Artifact = FreezeArtifactKey(placement.Artifact, $"{path}.Artifact", context),
+ Placement = FreezePlacement(placement.Placement, $"{path}.Placement", context)
+ },
+ RelationshipFact relationship => new RelationshipFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeRelationship(relationship.Definition, $"{path}.Definition", context)
+ },
+ ConceptRepresentationFact representation => new ConceptRepresentationFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeConceptRepresentation(representation.Definition, $"{path}.Definition", context)
+ },
+ ConceptAttributeFact attribute => new ConceptAttributeFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeConceptAttribute(attribute.Definition, $"{path}.Definition", context)
+ },
+ ConceptValidationRuleFact validation => new ConceptValidationRuleFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeConceptValidation(validation.Definition, $"{path}.Definition", context)
+ },
+ SpecificationScenarioFact scenario => new SpecificationScenarioFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeScenario(scenario.Definition, $"{path}.Definition", context)
+ },
+ SpecificationStepFact step => new SpecificationStepFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeStep(step.Definition, $"{path}.Definition", context)
+ },
+ SpecificationValueFact value => new SpecificationValueFact
+ {
+ Id = id,
+ Subject = subject,
+ Evidence = evidence,
+ Definition = FreezeValue(value.Definition, $"{path}.Definition", context)
+ },
+ _ => UnsupportedFact(fact, path, context)
+ };
+ }
+
+ static FactId MissingFactId(string path, AdapterContributionAdmissionContext context)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue,
+ $"{path}.Id",
+ $"{path}.Id is required");
+ return new FactId { Value = string.Empty };
+ }
+
+ static GenerationFact? UnsupportedFact(
+ GenerationFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.UnsupportedFactType,
+ path,
+ $"Fact type '{fact.GetType().FullName}' is not a supported neutral fact family",
+ fact.Id,
+ fact.Subject);
+ return null;
+ }
+
+ static ArtifactDefinition FreezeArtifactDefinition(
+ ArtifactDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new ArtifactDefinition
+ {
+ Key = FreezeArtifactKey(null, $"{path}.Key", context),
+ Name = string.Empty
+ };
+ }
+
+ return new ArtifactDefinition
+ {
+ Key = FreezeArtifactKey(definition.Key, $"{path}.Key", context),
+ Name = definition.Name ?? string.Empty,
+ Description = definition.Description,
+ File = definition.File,
+ Properties = FreezeProperties(definition.Properties, $"{path}.Properties", context)
+ };
+ }
+
+ static ImmutableArray FreezeProperties(
+ IReadOnlyList? properties,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (properties is null)
+ {
+ context.NullCollection(path);
+ return [];
+ }
+
+ var frozen = ImmutableArray.CreateBuilder();
+ for (var index = 0; index < properties.Count; index++)
+ {
+ var property = properties[index];
+ var itemPath = $"{path}[{index}]";
+ if (property is null)
+ {
+ context.Missing(itemPath);
+ continue;
+ }
+
+ frozen.Add(new PropertyDefinition
+ {
+ Name = property.Name ?? string.Empty,
+ Type = FreezeType(property.Type, $"{itemPath}.Type", context),
+ IsIdentifier = property.IsIdentifier
+ });
+ }
+
+ return frozen.ToImmutable();
+ }
+
+ static TypeReferenceDefinition FreezeType(
+ TypeReferenceDefinition? type,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (type is null)
+ {
+ context.Missing(path);
+ return new TypeReferenceDefinition { Name = string.Empty };
+ }
+
+ return new TypeReferenceDefinition
+ {
+ Name = type.Name ?? string.Empty,
+ Subject = type.Subject is null ? null : FreezeSubject(type.Subject, $"{path}.Subject", context),
+ IsCollection = type.IsCollection,
+ IsOptional = type.IsOptional
+ };
+ }
+
+ static ArtifactKey FreezeArtifactKey(
+ ArtifactKey? key,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (key is null)
+ {
+ context.Missing(path);
+ return new ArtifactKey
+ {
+ Subject = new SubjectId { Value = string.Empty },
+ Kind = ArtifactKind.Unknown
+ };
+ }
+
+ return new ArtifactKey
+ {
+ Subject = FreezeSubject(key.Subject, $"{path}.Subject", context),
+ Kind = key.Kind
+ };
+ }
+
+ static ArtifactPlacement FreezePlacement(
+ ArtifactPlacement? placement,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (placement is null)
+ {
+ context.Missing(path);
+ return new ArtifactPlacement
+ {
+ Module = string.Empty,
+ Slice = string.Empty,
+ SliceKind = GenerationSliceKind.Unknown
+ };
+ }
+
+ return new ArtifactPlacement
+ {
+ Module = placement.Module ?? string.Empty,
+ Features = FreezeStrings(placement.Features, $"{path}.Features", context),
+ Slice = placement.Slice ?? string.Empty,
+ SliceKind = placement.SliceKind
+ };
+ }
+
+ static RelationshipDefinition FreezeRelationship(
+ RelationshipDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new RelationshipDefinition
+ {
+ Key = new RelationshipKey
+ {
+ Kind = RelationshipKind.Unknown,
+ Source = new SubjectId { Value = string.Empty },
+ Target = new SubjectId { Value = string.Empty }
+ }
+ };
+ }
+
+ var key = definition.Key;
+ if (key is null)
+ {
+ context.Missing($"{path}.Key");
+ key = new RelationshipKey
+ {
+ Kind = RelationshipKind.Unknown,
+ Source = new SubjectId { Value = string.Empty },
+ Target = new SubjectId { Value = string.Empty }
+ };
+ }
+
+ return new RelationshipDefinition
+ {
+ Key = new RelationshipKey
+ {
+ Kind = key.Kind,
+ Source = FreezeSubject(key.Source, $"{path}.Key.Source", context),
+ Target = FreezeSubject(key.Target, $"{path}.Key.Target", context),
+ Discriminator = key.Discriminator
+ },
+ SourceMember = definition.SourceMember,
+ TargetMember = definition.TargetMember,
+ IsCollection = definition.IsCollection,
+ IsOptional = definition.IsOptional
+ };
+ }
+
+ static ConceptRepresentationDefinition FreezeConceptRepresentation(
+ ConceptRepresentationDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new ConceptRepresentationDefinition
+ {
+ Concept = new SubjectId { Value = string.Empty },
+ Kind = ConceptRepresentationKind.Unknown
+ };
+ }
+
+ return new ConceptRepresentationDefinition
+ {
+ Concept = FreezeSubject(definition.Concept, $"{path}.Concept", context),
+ Kind = definition.Kind,
+ Primitive = definition.Primitive,
+ EnumerationValues = FreezeStrings(definition.EnumerationValues, $"{path}.EnumerationValues", context)
+ };
+ }
+
+ static ConceptAttributeDefinition FreezeConceptAttribute(
+ ConceptAttributeDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new ConceptAttributeDefinition
+ {
+ Concept = new SubjectId { Value = string.Empty },
+ Kind = ConceptAttributeKind.Unknown,
+ Name = string.Empty
+ };
+ }
+
+ return new ConceptAttributeDefinition
+ {
+ Concept = FreezeSubject(definition.Concept, $"{path}.Concept", context),
+ Kind = definition.Kind,
+ Name = definition.Name ?? string.Empty,
+ Reason = definition.Reason
+ };
+ }
+
+ static ConceptValidationRuleDefinition FreezeConceptValidation(
+ ConceptValidationRuleDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new ConceptValidationRuleDefinition
+ {
+ Concept = new SubjectId { Value = string.Empty },
+ RuleIdentity = string.Empty,
+ Kind = ConceptValidationRuleKind.Unknown
+ };
+ }
+
+ return new ConceptValidationRuleDefinition
+ {
+ Concept = FreezeSubject(definition.Concept, $"{path}.Concept", context),
+ RuleIdentity = definition.RuleIdentity ?? string.Empty,
+ Kind = definition.Kind,
+ Predicate = definition.Predicate,
+ Message = definition.Message,
+ ImplementationFile = definition.ImplementationFile
+ };
+ }
+
+ static SpecificationScenarioDefinition FreezeScenario(
+ SpecificationScenarioDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new SpecificationScenarioDefinition
+ {
+ Key = FreezeScenarioKey(null, $"{path}.Key", context),
+ Name = string.Empty,
+ TargetArtifact = FreezeArtifactKey(null, $"{path}.TargetArtifact", context)
+ };
+ }
+
+ return new SpecificationScenarioDefinition
+ {
+ Key = FreezeScenarioKey(definition.Key, $"{path}.Key", context),
+ Name = definition.Name ?? string.Empty,
+ TargetArtifact = FreezeArtifactKey(definition.TargetArtifact, $"{path}.TargetArtifact", context),
+ Steps = FreezeStepKeys(definition.Steps, $"{path}.Steps", context)
+ };
+ }
+
+ static SpecificationStepDefinition FreezeStep(
+ SpecificationStepDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new SpecificationStepDefinition
+ {
+ Key = FreezeStepKey(null, $"{path}.Key", context),
+ Phase = SpecificationStepPhase.Unknown,
+ Kind = SpecificationStepKind.Unknown
+ };
+ }
+
+ return new SpecificationStepDefinition
+ {
+ Key = FreezeStepKey(definition.Key, $"{path}.Key", context),
+ Phase = definition.Phase,
+ Kind = definition.Kind,
+ Artifact = definition.Artifact is null
+ ? null
+ : FreezeArtifactKey(definition.Artifact, $"{path}.Artifact", context),
+ ErrorCode = definition.ErrorCode,
+ ErrorMessage = definition.ErrorMessage,
+ Values = FreezeValueKeys(definition.Values, $"{path}.Values", context)
+ };
+ }
+
+ static SpecificationValueDefinition FreezeValue(
+ SpecificationValueDefinition? definition,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (definition is null)
+ {
+ context.Missing(path);
+ return new SpecificationValueDefinition
+ {
+ Key = FreezeValueKey(null, $"{path}.Key", context),
+ Kind = SpecificationValueKind.Unknown
+ };
+ }
+
+ return new SpecificationValueDefinition
+ {
+ Key = FreezeValueKey(definition.Key, $"{path}.Key", context),
+ Kind = definition.Kind,
+ Type = definition.Type is null ? null : FreezeType(definition.Type, $"{path}.Type", context),
+ Scalar = definition.Scalar,
+ Children = FreezeValueKeys(definition.Children, $"{path}.Children", context)
+ };
+ }
+
+ static SpecificationScenarioKey FreezeScenarioKey(
+ SpecificationScenarioKey? key,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (key is null)
+ {
+ context.Missing(path);
+ return new SpecificationScenarioKey { Scenario = new SubjectId { Value = string.Empty } };
+ }
+
+ return new SpecificationScenarioKey
+ {
+ Scenario = FreezeSubject(key.Scenario, $"{path}.Scenario", context)
+ };
+ }
+
+ static SpecificationStepKey FreezeStepKey(
+ SpecificationStepKey? key,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (key is null)
+ {
+ context.Missing(path);
+ return new SpecificationStepKey
+ {
+ Scenario = FreezeScenarioKey(null, $"{path}.Scenario", context),
+ Index = -1
+ };
+ }
+
+ return new SpecificationStepKey
+ {
+ Scenario = FreezeScenarioKey(key.Scenario, $"{path}.Scenario", context),
+ Index = key.Index
+ };
+ }
+
+ static SpecificationValueKey FreezeValueKey(
+ SpecificationValueKey? key,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (key is null)
+ {
+ context.Missing(path);
+ return new SpecificationValueKey
+ {
+ Step = FreezeStepKey(null, $"{path}.Step", context)
+ };
+ }
+
+ return new SpecificationValueKey
+ {
+ Step = FreezeStepKey(key.Step, $"{path}.Step", context),
+ Path = FreezeStrings(key.Path, $"{path}.Path", context)
+ };
+ }
+
+ static ImmutableArray FreezeStepKeys(
+ IReadOnlyList? keys,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (keys is null)
+ {
+ context.NullCollection(path);
+ return [];
+ }
+
+ var frozen = ImmutableArray.CreateBuilder();
+ for (var index = 0; index < keys.Count; index++)
+ {
+ frozen.Add(FreezeStepKey(keys[index], $"{path}[{index}]", context));
+ }
+
+ return frozen.ToImmutable();
+ }
+
+ static ImmutableArray FreezeValueKeys(
+ IReadOnlyList? keys,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (keys is null)
+ {
+ context.NullCollection(path);
+ return [];
+ }
+
+ var frozen = ImmutableArray.CreateBuilder();
+ for (var index = 0; index < keys.Count; index++)
+ {
+ frozen.Add(FreezeValueKey(keys[index], $"{path}[{index}]", context));
+ }
+
+ return frozen.ToImmutable();
+ }
+
+ static ImmutableArray FreezeStrings(
+ IReadOnlyList? values,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (values is null)
+ {
+ context.NullCollection(path);
+ return [];
+ }
+
+ return [.. values.Select(value => value ?? string.Empty)];
+ }
+
+ static SubjectId FreezeSubject(
+ SubjectId? subject,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (subject is null)
+ {
+ context.Missing(path);
+ return new SubjectId { Value = string.Empty };
+ }
+
+ return new SubjectId { Value = subject.Value ?? string.Empty };
+ }
+
+ static Evidence FreezeEvidence(
+ Evidence? evidence,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (evidence is null)
+ {
+ context.Missing(path);
+ return new Evidence
+ {
+ Adapter = new AdapterIdentity { Id = string.Empty, Version = string.Empty },
+ Strength = EvidenceStrength.Unknown
+ };
+ }
+
+ return new Evidence
+ {
+ Adapter = FreezeIdentity(evidence.Adapter, $"{path}.Adapter", context),
+ Strength = evidence.Strength,
+ Source = evidence.Source is null ? null : FreezeSource(evidence.Source),
+ Explanation = evidence.Explanation
+ };
+ }
+
+ static ImmutableArray FreezeDiagnostics(
+ IReadOnlyList? diagnostics,
+ AdapterContributionAdmissionContext context)
+ {
+ if (diagnostics is null)
+ {
+ context.NullCollection("Contribution.Diagnostics");
+ return [];
+ }
+
+ var frozen = ImmutableArray.CreateBuilder();
+ for (var index = 0; index < diagnostics.Count; index++)
+ {
+ var diagnostic = diagnostics[index];
+ var path = DiagnosticPath(diagnostic);
+ if (diagnostic is null)
+ {
+ context.Missing(path);
+ continue;
+ }
+
+ frozen.Add(new GenerationDiagnostic
+ {
+ Code = diagnostic.Code ?? string.Empty,
+ Severity = diagnostic.Severity,
+ Message = diagnostic.Message ?? string.Empty,
+ Outcome = diagnostic.Outcome,
+ Source = diagnostic.Source is null ? null : FreezeSource(diagnostic.Source),
+ Subject = diagnostic.Subject is null ? null : FreezeSubject(diagnostic.Subject, $"{path}.Subject", context)
+ });
+ }
+
+ return
+ [
+ .. frozen
+ .OrderBy(diagnostic => diagnostic.Code, StringComparer.Ordinal)
+ .ThenBy(diagnostic => (int)diagnostic.Severity)
+ .ThenBy(diagnostic => diagnostic.Outcome is null ? int.MinValue : (int)diagnostic.Outcome.Value)
+ .ThenBy(diagnostic => diagnostic.Source?.FileIdentity?.Project, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Source?.FileIdentity?.Path, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Source?.Path, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Source?.StartLine)
+ .ThenBy(diagnostic => diagnostic.Source?.StartColumn)
+ .ThenBy(diagnostic => diagnostic.Source?.EndLine)
+ .ThenBy(diagnostic => diagnostic.Source?.EndColumn)
+ .ThenBy(diagnostic => diagnostic.Subject?.Value, StringComparer.Ordinal)
+ .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal)
+ ];
+ }
+
+ static SourceRange FreezeSource(SourceRange source) => new()
+ {
+ Path = source.Path ?? string.Empty,
+ FileIdentity = source.FileIdentity is null
+ ? null
+ : new SourceFileIdentity
+ {
+ Project = source.FileIdentity.Project ?? string.Empty,
+ Path = source.FileIdentity.Path ?? string.Empty
+ },
+ StartLine = source.StartLine,
+ StartColumn = source.StartColumn,
+ EndLine = source.EndLine,
+ EndColumn = source.EndColumn
+ };
+
+ static string FactPath(GenerationFact? fact)
+ {
+ var identity = StablePathComponent(fact?.Id?.Value, "invalid-id");
+ if (string.IsNullOrEmpty(fact?.Id?.Value))
+ {
+ var family = fact?.GetType().Name ?? "null";
+ var subject = StablePathComponent(fact?.Subject?.Value, "unidentified-subject");
+ identity = $"{family}:{subject}";
+ }
+
+ return $"Contribution.Facts[{identity}]";
+ }
+
+ static string DiagnosticPath(GenerationDiagnostic? diagnostic)
+ {
+ var code = StablePathComponent(diagnostic?.Code, "unidentified-code");
+ return $"Contribution.Diagnostics[{code}]";
+ }
+
+ static string StablePathComponent(string? value, string fallback) =>
+ AdapterContributionText.IsNormalized(value, false)
+ ? value!
+ : $"{fallback}:{Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(value ?? string.Empty))}";
+
+ static int FactFamily(GenerationFact fact) => fact switch
+ {
+ ArtifactFact => (int)GenerationFactCapability.Artifact,
+ ArtifactPlacementFact => (int)GenerationFactCapability.ArtifactPlacement,
+ RelationshipFact => (int)GenerationFactCapability.Relationship,
+ ConceptRepresentationFact => (int)GenerationFactCapability.ConceptRepresentation,
+ ConceptAttributeFact => (int)GenerationFactCapability.ConceptAttribute,
+ ConceptValidationRuleFact => (int)GenerationFactCapability.ConceptValidationRule,
+ SpecificationScenarioFact => (int)GenerationFactCapability.SpecificationScenario,
+ SpecificationStepFact => (int)GenerationFactCapability.SpecificationStep,
+ SpecificationValueFact => (int)GenerationFactCapability.SpecificationValue,
+ _ => int.MaxValue
+ };
+}
+
+sealed record FrozenAdapterContributionInput(
+ AdapterDescriptor Descriptor,
+ AdapterIdentity ContributionAdapter,
+ ImmutableArray Facts,
+ ImmutableArray Diagnostics);
diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionText.cs b/Source/DotNET/Generation.Contracts/AdapterContributionText.cs
new file mode 100644
index 0000000..cda4940
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterContributionText.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text;
+
+namespace Cratis.Screenplay.Generation;
+
+static class AdapterContributionText
+{
+ public static bool IsRequired(string? value) => !string.IsNullOrWhiteSpace(value);
+
+ public static bool IsNormalized(string? value, bool allowWhitespace)
+ {
+ if (string.IsNullOrEmpty(value) || !string.Equals(value, value.Trim(), StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (value.Any(character => char.IsControl(character) || (!allowWhitespace && char.IsWhiteSpace(character))))
+ {
+ return false;
+ }
+
+ try
+ {
+ return string.Equals(value, value.Normalize(NormalizationForm.FormC), StringComparison.Ordinal);
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterDescriptorAdmission.cs b/Source/DotNET/Generation.Contracts/AdapterDescriptorAdmission.cs
new file mode 100644
index 0000000..70f969c
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterDescriptorAdmission.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation;
+
+///
+/// Represents the deeply frozen result of admitting an adapter descriptor.
+///
+public sealed record AdapterDescriptorAdmissionResult
+{
+ ///
+ /// Gets the deeply frozen descriptor, including when diagnostics reject it.
+ ///
+ public required AdapterDescriptor Descriptor { get; init; }
+
+ ///
+ /// Gets deterministic descriptor admission diagnostics.
+ ///
+ public ImmutableArray Diagnostics { get; init; } = [];
+
+ ///
+ /// Gets whether the descriptor was admitted.
+ ///
+ public bool IsAdmitted => Diagnostics.IsEmpty;
+}
+
+///
+/// Deeply freezes, canonicalizes, and validates source-neutral adapter descriptors.
+///
+public static class AdapterDescriptorAdmission
+{
+ ///
+ /// Deeply freezes, canonicalizes, and validates an adapter descriptor.
+ ///
+ /// The descriptor to admit.
+ /// The frozen descriptor and deterministic admission diagnostics.
+ public static AdapterDescriptorAdmissionResult Admit(AdapterDescriptor? descriptor)
+ {
+ var context = new AdapterContributionAdmissionContext();
+ var frozen = AdapterContributionFreezer.FreezeDescriptor(descriptor, context);
+ AdapterContributionAdmissionValidator.ValidateDescriptor(frozen, context);
+ return new AdapterDescriptorAdmissionResult
+ {
+ Descriptor = frozen,
+ Diagnostics = context.Diagnostics()
+ };
+ }
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterDescriptors.cs b/Source/DotNET/Generation.Contracts/AdapterDescriptors.cs
new file mode 100644
index 0000000..c8aad06
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterDescriptors.cs
@@ -0,0 +1,242 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation;
+
+///
+/// Defines the source language understood by an adapter.
+///
+public enum AdapterSourceLanguage
+{
+ ///
+ /// The source language is unknown or unsupported.
+ ///
+ Unknown = -1,
+
+ ///
+ /// The adapter consumes source-neutral contributions and requires no source-language host.
+ ///
+ SourceIndependent = 0,
+
+ ///
+ /// C# source.
+ ///
+ CSharp = 1
+}
+
+///
+/// Defines the semantic concern owned by an adapter.
+///
+public enum AdapterCategory
+{
+ ///
+ /// The adapter category is unknown or unsupported.
+ ///
+ Unknown = -1,
+
+ ///
+ /// An application framework that spans several semantic concerns.
+ ///
+ ApplicationFramework = 0,
+
+ ///
+ /// Event-sourcing behavior.
+ ///
+ EventSourcing = 1,
+
+ ///
+ /// Event storage behavior.
+ ///
+ EventStore = 2,
+
+ ///
+ /// Messaging behavior.
+ ///
+ Messaging = 3,
+
+ ///
+ /// Domain concept or strongly typed value behavior.
+ ///
+ Concepts = 4,
+
+ ///
+ /// Validation behavior.
+ ///
+ Validation = 5,
+
+ ///
+ /// Semantics established only by an explicit integration between otherwise independent APIs.
+ ///
+ Integration = 6,
+
+ ///
+ /// A compatibility descriptor wrapping an adapter that predates structured categories.
+ ///
+ Legacy = 7
+}
+
+///
+/// Defines a source-neutral host capability an adapter can require.
+///
+public enum AdapterHostCapability
+{
+ ///
+ /// The host capability is unknown or unsupported.
+ ///
+ Unknown = -1,
+
+ ///
+ /// Access to authoritative authored source.
+ ///
+ AuthoredSource = 0,
+
+ ///
+ /// Access to stable locations within authoritative authored source.
+ ///
+ StableSourceLocations = 1,
+
+ ///
+ /// Access to source-language semantic analysis.
+ ///
+ SemanticAnalysis = 2,
+
+ ///
+ /// Access to the selected project's referenced source projects.
+ ///
+ ProjectReferences = 3
+}
+
+///
+/// Defines one existing neutral fact family an adapter can emit.
+///
+public enum GenerationFactCapability
+{
+ ///
+ /// The emitted fact capability is unknown or unsupported.
+ ///
+ Unknown = -1,
+
+ ///
+ /// facts.
+ ///
+ Artifact = 0,
+
+ ///
+ /// facts.
+ ///
+ ArtifactPlacement = 1,
+
+ ///
+ /// facts.
+ ///
+ Relationship = 2,
+
+ ///
+ /// facts.
+ ///
+ ConceptRepresentation = 3,
+
+ ///
+ /// facts.
+ ///
+ ConceptAttribute = 4,
+
+ ///
+ /// facts.
+ ///
+ ConceptValidationRule = 5,
+
+ ///
+ /// facts.
+ ///
+ SpecificationScenario = 6,
+
+ ///
+ /// facts.
+ ///
+ SpecificationStep = 7,
+
+ ///
+ /// facts.
+ ///
+ SpecificationValue = 8
+}
+
+///
+/// Identifies one source-neutral API capability that an adapter requires and its probe can prove.
+///
+public sealed record AdapterApiCapability
+{
+ ///
+ /// Gets the stable normalized capability identity.
+ ///
+ public required string Id { get; init; }
+}
+
+///
+/// Describes the inclusive and exclusive Generation package versions supported by an adapter.
+///
+///
+/// This range is distinct from , which identifies the adapter implementation
+/// that produced a contribution. A missing upper bound intentionally means that no upper bound is declared.
+///
+public sealed record GenerationVersionRange
+{
+ ///
+ /// Gets a range that accepts every nonnegative Generation version.
+ ///
+ public static GenerationVersionRange Any { get; } = new();
+
+ ///
+ /// Gets the minimum supported Generation version, inclusive.
+ ///
+ public Version MinimumInclusive { get; init; } = new(0, 0);
+
+ ///
+ /// Gets the maximum supported Generation version, exclusive, or when unbounded.
+ ///
+ public Version? MaximumExclusive { get; init; }
+}
+
+///
+/// Describes one trusted adapter independently from any source-language runner.
+///
+public sealed record AdapterDescriptor
+{
+ ///
+ /// Gets the stable adapter identity and implementation version.
+ ///
+ public required AdapterIdentity Identity { get; init; }
+
+ ///
+ /// Gets the source language understood by the adapter.
+ ///
+ public required AdapterSourceLanguage SourceLanguage { get; init; }
+
+ ///
+ /// Gets the semantic concern owned by the adapter.
+ ///
+ public required AdapterCategory Category { get; init; }
+
+ ///
+ /// Gets the supported Generation package versions.
+ ///
+ public GenerationVersionRange CompatibleGenerationVersions { get; init; } = GenerationVersionRange.Any;
+
+ ///
+ /// Gets the source-neutral host capabilities required before the adapter can execute.
+ ///
+ public ImmutableArray RequiredHostCapabilities { get; init; } = [];
+
+ ///
+ /// Gets the source-neutral API capabilities that an applicable probe must prove before execution.
+ ///
+ public ImmutableArray RequiredApiCapabilities { get; init; } = [];
+
+ ///
+ /// Gets the neutral fact families the adapter is allowed to emit.
+ ///
+ public ImmutableArray EmittedFactCapabilities { get; init; } = [];
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterFactAdmissionValidator.cs b/Source/DotNET/Generation.Contracts/AdapterFactAdmissionValidator.cs
new file mode 100644
index 0000000..ac5a0da
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterFactAdmissionValidator.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation;
+
+static class AdapterFactAdmissionValidator
+{
+ public static void Validate(
+ GenerationFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ switch (fact)
+ {
+ case ArtifactFact artifact:
+ ArtifactFactAdmissionValidator.Validate(artifact, path, context);
+ break;
+ case ArtifactPlacementFact placement:
+ ArtifactFactAdmissionValidator.Validate(placement, path, context);
+ break;
+ case RelationshipFact relationship:
+ RelationshipFactAdmissionValidator.Validate(relationship, path, context);
+ break;
+ case ConceptRepresentationFact representation:
+ ConceptFactAdmissionValidator.Validate(representation, path, context);
+ break;
+ case ConceptAttributeFact attribute:
+ ConceptFactAdmissionValidator.Validate(attribute, path, context);
+ break;
+ case ConceptValidationRuleFact validation:
+ ConceptFactAdmissionValidator.Validate(validation, path, context);
+ break;
+ case SpecificationScenarioFact scenario:
+ SpecificationFactAdmissionValidator.Validate(scenario, path, context);
+ break;
+ case SpecificationStepFact step:
+ SpecificationFactAdmissionValidator.Validate(step, path, context);
+ break;
+ case SpecificationValueFact value:
+ SpecificationFactAdmissionValidator.Validate(value, path, context);
+ break;
+ }
+ }
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterProbes.cs b/Source/DotNET/Generation.Contracts/AdapterProbes.cs
new file mode 100644
index 0000000..6074952
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterProbes.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation;
+
+///
+/// Describes source-neutral evidence considered while probing adapter applicability.
+///
+public sealed record AdapterProbeEvidence
+{
+ ///
+ /// Gets a human-readable explanation of what the probe observed.
+ ///
+ public required string Description { get; init; }
+
+ ///
+ /// Gets the exact API capability established by this evidence, when the observation proves one.
+ ///
+ public AdapterApiCapability? ApiCapability { get; init; }
+
+ ///
+ /// Gets the source range that established the observation, when available.
+ ///
+ public SourceRange? Source { get; init; }
+
+ ///
+ /// Gets the source-level subject associated with the observation, when available.
+ ///
+ public SubjectId? Subject { get; init; }
+}
+
+///
+/// Represents the structured result of probing one adapter.
+///
+public abstract record AdapterProbeResult
+{
+ ///
+ /// Gets the canonical evidence supporting the result.
+ ///
+ public ImmutableArray Evidence { get; init; } = [];
+}
+
+///
+/// Indicates that an adapter was not probed.
+///
+public sealed record AdapterProbeNotRun : AdapterProbeResult;
+
+///
+/// Indicates that an adapter found no applicable source evidence.
+///
+public sealed record AdapterProbeNotApplicable : AdapterProbeResult;
+
+///
+/// Indicates that an adapter found sufficient source evidence to execute.
+///
+public sealed record AdapterProbeApplicable : AdapterProbeResult;
+
+///
+/// Indicates that source evidence applies but safe adapter execution is blocked.
+///
+public sealed record AdapterProbeBlocked : AdapterProbeResult
+{
+ ///
+ /// Gets one or more valid diagnostics explaining why execution is blocked.
+ ///
+ public required ImmutableArray Diagnostics { get; init; }
+}
diff --git a/Source/DotNET/Generation.Contracts/AdapterRuns.cs b/Source/DotNET/Generation.Contracts/AdapterRuns.cs
new file mode 100644
index 0000000..c29484a
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/AdapterRuns.cs
@@ -0,0 +1,230 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation;
+
+///
+/// Defines the final disposition of an adapter considered by a run.
+///
+public enum AdapterRunDisposition
+{
+ ///
+ /// No disposition has been calculated.
+ ///
+ Unknown = -1,
+
+ ///
+ /// The adapter was not applicable.
+ ///
+ NotApplicable = 0,
+
+ ///
+ /// The adapter was applicable but blocked before execution.
+ ///
+ Blocked = 1,
+
+ ///
+ /// The adapter did not execute.
+ ///
+ NotExecuted = 2,
+
+ ///
+ /// Adapter execution failed.
+ ///
+ ExecutionFailed = 3,
+
+ ///
+ /// The adapter executed but its contribution was rejected atomically.
+ ///
+ ContributionRejected = 4,
+
+ ///
+ /// The adapter contribution was admitted.
+ ///
+ Admitted = 5,
+
+ ///
+ /// The adapter registration was rejected before probing.
+ ///
+ RosterRejected = 6
+}
+
+///
+/// Defines how one admitted fact was handled by later generation stages.
+///
+public enum GenerationFactDisposition
+{
+ ///
+ /// No disposition has been calculated.
+ ///
+ Unknown = -1,
+
+ ///
+ /// The fact contributed directly to generated Screenplay syntax.
+ ///
+ Lowered = 0,
+
+ ///
+ /// The fact was retained as provenance without contributing syntax directly.
+ ///
+ ProvenanceOnly = 1,
+
+ ///
+ /// The fact was omitted and a diagnostic explains the omission.
+ ///
+ OmittedWithDiagnostic = 2,
+
+ ///
+ /// The fact participated in an unresolved conflict.
+ ///
+ Conflicted = 3
+}
+
+///
+/// Represents the immutable result of executing one adapter.
+///
+public abstract record AdapterExecutionResult
+{
+ ///
+ /// Gets diagnostics produced by the execution boundary.
+ ///
+ public ImmutableArray Diagnostics { get; init; } = [];
+}
+
+///
+/// Indicates that an adapter was not executed.
+///
+public sealed record AdapterExecutionNotRun : AdapterExecutionResult;
+
+///
+/// Indicates that adapter execution and contribution admission completed successfully.
+///
+public sealed record AdapterExecutionCompleted : AdapterExecutionResult
+{
+ ///
+ /// Gets the admitted, deeply frozen contribution.
+ ///
+ public required AdapterContributionSnapshot Contribution { get; init; }
+}
+
+///
+/// Indicates that adapter execution completed but contribution admission rejected the result.
+///
+public sealed record AdapterExecutionRejected : AdapterExecutionResult
+{
+ ///
+ /// Gets the deterministic admission diagnostics.
+ ///
+ public ImmutableArray AdmissionDiagnostics { get; init; } = [];
+}
+
+///
+/// Indicates that adapter execution failed before it produced an admissible contribution.
+///
+public sealed record AdapterExecutionFailed : AdapterExecutionResult;
+
+///
+/// Represents one deeply frozen and canonically ordered adapter contribution.
+///
+public sealed record AdapterContributionSnapshot
+{
+ ///
+ /// Gets the canonical descriptor under which the contribution was admitted.
+ ///
+ public required AdapterDescriptor Descriptor { get; init; }
+
+ ///
+ /// Gets the deeply frozen facts in canonical identity order.
+ ///
+ public ImmutableArray Facts { get; init; } = [];
+
+ ///
+ /// Gets the deeply frozen contribution diagnostics in canonical order.
+ ///
+ public ImmutableArray Diagnostics { get; init; } = [];
+}
+
+///
+/// Records one admitted fact and its later generation disposition.
+///
+public sealed record GenerationFactRecord
+{
+ ///
+ /// Gets the admitted fact.
+ ///
+ public required GenerationFact Fact { get; init; }
+
+ ///
+ /// Gets the disposition calculated by later generation stages.
+ ///
+ public GenerationFactDisposition Disposition { get; init; } = GenerationFactDisposition.Unknown;
+
+ ///
+ /// Gets diagnostics specifically associated with the disposition.
+ ///
+ public ImmutableArray Diagnostics { get; init; } = [];
+}
+
+///
+/// Records how one adapter was considered during a run.
+///
+public sealed record AdapterRunRecord
+{
+ ///
+ /// Gets whether the registration was considered by the runner.
+ ///
+ public bool Considered { get; init; }
+
+ ///
+ /// Gets whether the adapter probe callback was invoked.
+ ///
+ public bool Probed { get; init; }
+
+ ///
+ /// Gets whether the adapter analysis callback was invoked.
+ ///
+ public bool Executed { get; init; }
+
+ ///
+ /// Gets the adapter descriptor.
+ ///
+ public required AdapterDescriptor Descriptor { get; init; }
+
+ ///
+ /// Gets the structured probe result.
+ ///
+ public required AdapterProbeResult Probe { get; init; }
+
+ ///
+ /// Gets the execution result, defaulting to not run.
+ ///
+ public AdapterExecutionResult Execution { get; init; } = new AdapterExecutionNotRun();
+
+ ///
+ /// Gets the final adapter disposition.
+ ///
+ public AdapterRunDisposition Disposition { get; init; } = AdapterRunDisposition.Unknown;
+}
+
+///
+/// Represents one immutable source-adapter run snapshot.
+///
+public sealed record AdapterRunSnapshot
+{
+ ///
+ /// Gets per-adapter run records in canonical descriptor order.
+ ///
+ public ImmutableArray Adapters { get; init; } = [];
+
+ ///
+ /// Gets admitted fact records in canonical fact order.
+ ///
+ public ImmutableArray Facts { get; init; } = [];
+
+ ///
+ /// Gets run-level diagnostics in canonical order.
+ ///
+ public ImmutableArray Diagnostics { get; init; } = [];
+}
diff --git a/Source/DotNET/Generation.Contracts/ArtifactFactAdmissionValidator.cs b/Source/DotNET/Generation.Contracts/ArtifactFactAdmissionValidator.cs
new file mode 100644
index 0000000..9264bad
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/ArtifactFactAdmissionValidator.cs
@@ -0,0 +1,116 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation;
+
+static class ArtifactFactAdmissionValidator
+{
+ public static void Validate(
+ ArtifactFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ AdapterContributionAdmissionValidator.ValidateArtifactKey(
+ definition.Key,
+ $"{path}.Definition.Key",
+ fact.Id,
+ context);
+ ValidateOwner(fact, definition.Key.Subject, $"{path}.Definition.Key.Subject", context);
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ definition.Name,
+ $"{path}.Definition.Name",
+ fact.Id,
+ fact.Subject,
+ context);
+
+ for (var index = 0; index < definition.Properties.Count; index++)
+ {
+ var property = definition.Properties[index];
+ var propertyPath = $"{path}.Definition.Properties[{index}]";
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ property.Name,
+ $"{propertyPath}.Name",
+ fact.Id,
+ fact.Subject,
+ context);
+ AdapterContributionAdmissionValidator.ValidateType(
+ property.Type,
+ $"{propertyPath}.Type",
+ fact.Id,
+ fact.Subject,
+ context);
+ }
+
+ foreach (var duplicate in definition.Properties
+ .Where(property => !string.IsNullOrWhiteSpace(property.Name))
+ .GroupBy(property => property.Name, StringComparer.Ordinal)
+ .Where(group => group.Count() > 1))
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.OwnershipMismatch,
+ $"{path}.Definition.Properties",
+ $"Artifact property name '{duplicate.Key}' occurs more than once",
+ fact.Id,
+ fact.Subject);
+ }
+ }
+
+ public static void Validate(
+ ArtifactPlacementFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ AdapterContributionAdmissionValidator.ValidateArtifactKey(
+ fact.Artifact,
+ $"{path}.Artifact",
+ fact.Id,
+ context);
+ ValidateOwner(fact, fact.Artifact.Subject, $"{path}.Artifact.Subject", context);
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ fact.Placement.Module,
+ $"{path}.Placement.Module",
+ fact.Id,
+ fact.Subject,
+ context);
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ fact.Placement.Slice,
+ $"{path}.Placement.Slice",
+ fact.Id,
+ fact.Subject,
+ context);
+ context.Enum(
+ fact.Placement.SliceKind,
+ GenerationSliceKind.Unknown,
+ $"{path}.Placement.SliceKind",
+ fact.Id,
+ fact.Subject);
+
+ for (var index = 0; index < fact.Placement.Features.Count; index++)
+ {
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ fact.Placement.Features[index],
+ $"{path}.Placement.Features[{index}]",
+ fact.Id,
+ fact.Subject,
+ context);
+ }
+ }
+
+ static void ValidateOwner(
+ GenerationFact fact,
+ SubjectId owner,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (owner != fact.Subject)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.OwnershipMismatch,
+ path,
+ $"Nested artifact owner '{owner.Value}' does not equal fact subject '{fact.Subject.Value}'",
+ fact.Id,
+ fact.Subject);
+ }
+ }
+}
diff --git a/Source/DotNET/Generation.Contracts/ConceptFactAdmissionValidator.cs b/Source/DotNET/Generation.Contracts/ConceptFactAdmissionValidator.cs
new file mode 100644
index 0000000..3a9ccb6
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/ConceptFactAdmissionValidator.cs
@@ -0,0 +1,161 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation;
+
+static class ConceptFactAdmissionValidator
+{
+ public static void Validate(
+ ConceptRepresentationFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ ValidateConceptOwner(fact, definition.Concept, $"{path}.Definition.Concept", context);
+ context.Enum(
+ definition.Kind,
+ ConceptRepresentationKind.Unknown,
+ $"{path}.Definition.Kind",
+ fact.Id,
+ fact.Subject);
+ if (definition.Primitive is { } configuredPrimitive)
+ {
+ context.Enum(
+ configuredPrimitive,
+ GenerationPrimitiveKind.Unknown,
+ $"{path}.Definition.Primitive",
+ fact.Id,
+ fact.Subject);
+ }
+
+ switch (definition.Kind)
+ {
+ case ConceptRepresentationKind.Primitive:
+ if (definition.Primitive is null)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Primitive", "Primitive concepts require a primitive operand", context);
+ }
+
+ if (definition.EnumerationValues.Count > 0)
+ {
+ InvalidOperand(fact, $"{path}.Definition.EnumerationValues", "Primitive concepts cannot declare enumeration values", context);
+ }
+ break;
+ case ConceptRepresentationKind.Enumeration:
+ if (definition.Primitive is not null)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Primitive", "Enumeration concepts cannot declare a primitive operand", context);
+ }
+
+ if (definition.EnumerationValues.Count == 0)
+ {
+ InvalidOperand(fact, $"{path}.Definition.EnumerationValues", "Enumeration concepts require at least one named value", context);
+ }
+ break;
+ }
+
+ for (var index = 0; index < definition.EnumerationValues.Count; index++)
+ {
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ definition.EnumerationValues[index],
+ $"{path}.Definition.EnumerationValues[{index}]",
+ fact.Id,
+ fact.Subject,
+ context);
+ }
+
+ foreach (var duplicate in definition.EnumerationValues
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .GroupBy(value => value, StringComparer.Ordinal)
+ .Where(group => group.Count() > 1))
+ {
+ InvalidOperand(
+ fact,
+ $"{path}.Definition.EnumerationValues",
+ $"Enumeration value '{duplicate.Key}' occurs more than once",
+ context);
+ }
+ }
+
+ public static void Validate(
+ ConceptAttributeFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ ValidateConceptOwner(fact, definition.Concept, $"{path}.Definition.Concept", context);
+ context.Enum(
+ definition.Kind,
+ ConceptAttributeKind.Unknown,
+ $"{path}.Definition.Kind",
+ fact.Id,
+ fact.Subject);
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ definition.Name,
+ $"{path}.Definition.Name",
+ fact.Id,
+ fact.Subject,
+ context);
+ }
+
+ public static void Validate(
+ ConceptValidationRuleFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ ValidateConceptOwner(fact, definition.Concept, $"{path}.Definition.Concept", context);
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ definition.RuleIdentity,
+ $"{path}.Definition.RuleIdentity",
+ fact.Id,
+ fact.Subject,
+ context);
+ context.Enum(
+ definition.Kind,
+ ConceptValidationRuleKind.Unknown,
+ $"{path}.Definition.Kind",
+ fact.Id,
+ fact.Subject);
+
+ if (definition.Kind == ConceptValidationRuleKind.NamedPredicate)
+ {
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ definition.Predicate,
+ $"{path}.Definition.Predicate",
+ fact.Id,
+ fact.Subject,
+ context);
+ }
+ }
+
+ static void ValidateConceptOwner(
+ GenerationFact fact,
+ SubjectId concept,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ AdapterContributionAdmissionValidator.ValidateSubject(concept, path, fact.Id, context);
+ if (concept != fact.Subject)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.OwnershipMismatch,
+ path,
+ $"Concept owner '{concept.Value}' does not equal fact subject '{fact.Subject.Value}'",
+ fact.Id,
+ fact.Subject);
+ }
+ }
+
+ static void InvalidOperand(
+ GenerationFact fact,
+ string path,
+ string message,
+ AdapterContributionAdmissionContext context) =>
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidKindOperand,
+ path,
+ message,
+ fact.Id,
+ fact.Subject);
+}
diff --git a/Source/DotNET/Generation.Contracts/Diagnostics.cs b/Source/DotNET/Generation.Contracts/Diagnostics.cs
index 235c805..25ac59f 100644
--- a/Source/DotNET/Generation.Contracts/Diagnostics.cs
+++ b/Source/DotNET/Generation.Contracts/Diagnostics.cs
@@ -29,6 +29,11 @@ public enum GenerationDiagnosticOutcome
///
public enum GenerationDiagnosticSeverity
{
+ ///
+ /// The diagnostic severity is unknown or unsupported.
+ ///
+ Unknown = -1,
+
///
/// Informational context that does not make the result incomplete.
///
diff --git a/Source/DotNET/Generation.Contracts/ISourceAuthorityValidator.cs b/Source/DotNET/Generation.Contracts/ISourceAuthorityValidator.cs
new file mode 100644
index 0000000..fbaa6a5
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/ISourceAuthorityValidator.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation;
+
+///
+/// Validates that an authored source range belongs to source the host has declared authoritative.
+///
+///
+/// Implementations are supplied by a source-language host. The contract deliberately has no dependency on Roslyn
+/// or any other source platform.
+///
+public interface ISourceAuthorityValidator
+{
+ ///
+ /// Gets whether a source range belongs to authoritative authored source.
+ ///
+ /// The source range to validate.
+ /// when the source is authoritative; otherwise, .
+ bool IsAuthoritative(SourceRange source);
+}
diff --git a/Source/DotNET/Generation.Contracts/RelationshipFactAdmissionValidator.cs b/Source/DotNET/Generation.Contracts/RelationshipFactAdmissionValidator.cs
new file mode 100644
index 0000000..c526424
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/RelationshipFactAdmissionValidator.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation;
+
+static class RelationshipFactAdmissionValidator
+{
+ public static void Validate(
+ RelationshipFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var key = fact.Definition.Key;
+ context.Enum(
+ key.Kind,
+ RelationshipKind.Unknown,
+ $"{path}.Definition.Key.Kind",
+ fact.Id,
+ fact.Subject);
+ AdapterContributionAdmissionValidator.ValidateSubject(
+ key.Source,
+ $"{path}.Definition.Key.Source",
+ fact.Id,
+ context);
+ AdapterContributionAdmissionValidator.ValidateSubject(
+ key.Target,
+ $"{path}.Definition.Key.Target",
+ fact.Id,
+ context);
+
+ if (key.Source != fact.Subject)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.OwnershipMismatch,
+ $"{path}.Definition.Key.Source",
+ $"Relationship source '{key.Source.Value}' does not equal fact subject '{fact.Subject.Value}'",
+ fact.Id,
+ fact.Subject);
+ }
+ }
+}
diff --git a/Source/DotNET/Generation.Contracts/SpecificationFactAdmissionValidator.cs b/Source/DotNET/Generation.Contracts/SpecificationFactAdmissionValidator.cs
new file mode 100644
index 0000000..a5e0666
--- /dev/null
+++ b/Source/DotNET/Generation.Contracts/SpecificationFactAdmissionValidator.cs
@@ -0,0 +1,357 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Globalization;
+
+namespace Cratis.Screenplay.Generation;
+
+static class SpecificationFactAdmissionValidator
+{
+ public static void Validate(
+ SpecificationScenarioFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ ValidateScenarioKey(definition.Key, $"{path}.Definition.Key", fact, context);
+ if (definition.Key.Scenario != fact.Subject)
+ {
+ Ownership(
+ fact,
+ $"{path}.Definition.Key.Scenario",
+ $"Scenario owner '{definition.Key.Scenario.Value}' does not equal fact subject '{fact.Subject.Value}'",
+ context);
+ }
+
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ definition.Name,
+ $"{path}.Definition.Name",
+ fact.Id,
+ fact.Subject,
+ context);
+ AdapterContributionAdmissionValidator.ValidateArtifactKey(
+ definition.TargetArtifact,
+ $"{path}.Definition.TargetArtifact",
+ fact.Id,
+ context);
+
+ for (var index = 0; index < definition.Steps.Count; index++)
+ {
+ var step = definition.Steps[index];
+ var stepPath = $"{path}.Definition.Steps[{index}]";
+ ValidateStepKey(step, stepPath, fact, context);
+ if (step.Scenario != definition.Key)
+ {
+ Ownership(fact, $"{stepPath}.Scenario", "Scenario step belongs to a different scenario", context);
+ }
+
+ if (step.Index != index)
+ {
+ Ownership(
+ fact,
+ $"{stepPath}.Index",
+ $"Scenario step at authored position '{index}' identifies position '{step.Index}'",
+ context);
+ }
+ }
+ }
+
+ public static void Validate(
+ SpecificationStepFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ ValidateStepKey(definition.Key, $"{path}.Definition.Key", fact, context);
+ context.Enum(
+ definition.Phase,
+ SpecificationStepPhase.Unknown,
+ $"{path}.Definition.Phase",
+ fact.Id,
+ fact.Subject);
+ context.Enum(
+ definition.Kind,
+ SpecificationStepKind.Unknown,
+ $"{path}.Definition.Kind",
+ fact.Id,
+ fact.Subject);
+
+ ValidateStepOperand(fact, path, context);
+ for (var index = 0; index < definition.Values.Count; index++)
+ {
+ var value = definition.Values[index];
+ var valuePath = $"{path}.Definition.Values[{index}]";
+ ValidateValueKey(value, valuePath, fact, context);
+ if (value.Step != definition.Key)
+ {
+ Ownership(fact, $"{valuePath}.Step", "Specification value belongs to a different step", context);
+ }
+ }
+
+ ValidateUniqueValues(definition.Values, $"{path}.Definition.Values", fact, context);
+ }
+
+ public static void Validate(
+ SpecificationValueFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ ValidateValueKey(definition.Key, $"{path}.Definition.Key", fact, context);
+ context.Enum(
+ definition.Kind,
+ SpecificationValueKind.Unknown,
+ $"{path}.Definition.Kind",
+ fact.Id,
+ fact.Subject);
+ if (definition.Type is not null)
+ {
+ AdapterContributionAdmissionValidator.ValidateType(
+ definition.Type,
+ $"{path}.Definition.Type",
+ fact.Id,
+ fact.Subject,
+ context);
+ }
+
+ ValidateValueOperand(fact, path, context);
+ for (var index = 0; index < definition.Children.Count; index++)
+ {
+ var child = definition.Children[index];
+ var childPath = $"{path}.Definition.Children[{index}]";
+ ValidateValueKey(child, childPath, fact, context);
+ if (child.Step != definition.Key.Step || !IsDirectChild(definition.Key.Path, child.Path))
+ {
+ Ownership(
+ fact,
+ childPath,
+ "Specification value child must belong to the same step and directly extend its parent path",
+ context);
+ }
+ }
+
+ ValidateUniqueValues(definition.Children, $"{path}.Definition.Children", fact, context);
+ }
+
+ static void ValidateStepOperand(
+ SpecificationStepFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ if (definition.Artifact is not null)
+ {
+ AdapterContributionAdmissionValidator.ValidateArtifactKey(
+ definition.Artifact,
+ $"{path}.Definition.Artifact",
+ fact.Id,
+ context);
+ }
+
+ if (definition.Kind == SpecificationStepKind.Error)
+ {
+ if (definition.Phase != SpecificationStepPhase.Then)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Phase", "Error steps must use the Then phase", context);
+ }
+
+ if (definition.Artifact is not null)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Artifact", "Error steps cannot carry an artifact operand", context);
+ }
+
+ if (definition.Values.Count > 0)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Values", "Error steps cannot carry value operands", context);
+ }
+ return;
+ }
+
+ if (definition.Kind is SpecificationStepKind.Unknown || !Enum.IsDefined(definition.Kind))
+ {
+ return;
+ }
+
+ if (definition.Artifact is null)
+ {
+ InvalidOperand(
+ fact,
+ $"{path}.Definition.Artifact",
+ $"{definition.Kind} steps require an artifact operand",
+ context);
+ return;
+ }
+
+ if (definition.ErrorCode is not null || definition.ErrorMessage is not null)
+ {
+ InvalidOperand(
+ fact,
+ $"{path}.Definition",
+ $"{definition.Kind} steps cannot carry error operands",
+ context);
+ }
+
+ var validKind = definition.Kind switch
+ {
+ SpecificationStepKind.Event => definition.Artifact.Kind == ArtifactKind.Event,
+ SpecificationStepKind.ReadModel => definition.Artifact.Kind == ArtifactKind.ReadModel,
+ SpecificationStepKind.Command => definition.Artifact.Kind == ArtifactKind.Command,
+ SpecificationStepKind.Read => definition.Artifact.Kind == ArtifactKind.Query,
+ _ => false
+ };
+ if (!validKind)
+ {
+ InvalidOperand(
+ fact,
+ $"{path}.Definition.Artifact.Kind",
+ $"Artifact kind '{definition.Artifact.Kind}' is not valid for a '{definition.Kind}' specification step",
+ context);
+ }
+ }
+
+ static void ValidateValueOperand(
+ SpecificationValueFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ var definition = fact.Definition;
+ switch (definition.Kind)
+ {
+ case SpecificationValueKind.Null:
+ if (definition.Scalar is not null || definition.Children.Count > 0)
+ {
+ InvalidOperand(fact, $"{path}.Definition", "Null values cannot carry scalar or child operands", context);
+ }
+ break;
+ case SpecificationValueKind.Text:
+ if (definition.Scalar is null)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Scalar", "Text values require a scalar operand", context);
+ }
+
+ RejectScalarChildren(fact, path, context);
+ break;
+ case SpecificationValueKind.Number:
+ if (!decimal.TryParse(definition.Scalar, NumberStyles.Number, CultureInfo.InvariantCulture, out _))
+ {
+ InvalidOperand(fact, $"{path}.Definition.Scalar", "Number values require a valid invariant decimal scalar operand", context);
+ }
+
+ RejectScalarChildren(fact, path, context);
+ break;
+ case SpecificationValueKind.Boolean:
+ if (definition.Scalar is not ("true" or "false"))
+ {
+ InvalidOperand(fact, $"{path}.Definition.Scalar", "Boolean values require the lowercase scalar 'true' or 'false'", context);
+ }
+
+ RejectScalarChildren(fact, path, context);
+ break;
+ case SpecificationValueKind.Collection:
+ case SpecificationValueKind.Composite:
+ if (definition.Scalar is not null)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Scalar", $"{definition.Kind} values cannot carry a scalar operand", context);
+ }
+ break;
+ }
+ }
+
+ static void RejectScalarChildren(
+ SpecificationValueFact fact,
+ string path,
+ AdapterContributionAdmissionContext context)
+ {
+ if (fact.Definition.Children.Count > 0)
+ {
+ InvalidOperand(fact, $"{path}.Definition.Children", $"{fact.Definition.Kind} values cannot carry child operands", context);
+ }
+ }
+
+ static void ValidateScenarioKey(
+ SpecificationScenarioKey key,
+ string path,
+ GenerationFact fact,
+ AdapterContributionAdmissionContext context) =>
+ AdapterContributionAdmissionValidator.ValidateSubject(key.Scenario, $"{path}.Scenario", fact.Id, context);
+
+ static void ValidateStepKey(
+ SpecificationStepKey key,
+ string path,
+ GenerationFact fact,
+ AdapterContributionAdmissionContext context)
+ {
+ ValidateScenarioKey(key.Scenario, $"{path}.Scenario", fact, context);
+ if (key.Index < 0)
+ {
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidKindOperand,
+ $"{path}.Index",
+ "Specification step index must be nonnegative",
+ fact.Id,
+ fact.Subject);
+ }
+ }
+
+ static void ValidateValueKey(
+ SpecificationValueKey key,
+ string path,
+ GenerationFact fact,
+ AdapterContributionAdmissionContext context)
+ {
+ ValidateStepKey(key.Step, $"{path}.Step", fact, context);
+ for (var index = 0; index < key.Path.Count; index++)
+ {
+ AdapterContributionAdmissionValidator.ValidateRequiredText(
+ key.Path[index],
+ $"{path}.Path[{index}]",
+ fact.Id,
+ fact.Subject,
+ context);
+ }
+ }
+
+ static void ValidateUniqueValues(
+ IReadOnlyList values,
+ string path,
+ GenerationFact fact,
+ AdapterContributionAdmissionContext context)
+ {
+ foreach (var duplicate in values
+ .GroupBy(ValueKey, StringComparer.Ordinal)
+ .Where(group => group.Count() > 1))
+ {
+ Ownership(fact, path, $"Specification value key '{duplicate.Key}' occurs more than once", context);
+ }
+ }
+
+ static bool IsDirectChild(IReadOnlyList parent, IReadOnlyList child) =>
+ child.Count == parent.Count + 1 && parent.SequenceEqual(child.Take(parent.Count), StringComparer.Ordinal);
+
+ static string ValueKey(SpecificationValueKey value) =>
+ $"{value.Step.Scenario.Scenario.Value}\u001f{value.Step.Index}\u001f{string.Join('\u001f', value.Path)}";
+
+ static void Ownership(
+ GenerationFact fact,
+ string path,
+ string message,
+ AdapterContributionAdmissionContext context) =>
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.OwnershipMismatch,
+ path,
+ message,
+ fact.Id,
+ fact.Subject);
+
+ static void InvalidOperand(
+ GenerationFact fact,
+ string path,
+ string message,
+ AdapterContributionAdmissionContext context) =>
+ context.Add(
+ AdapterContributionAdmissionDiagnosticCode.InvalidKindOperand,
+ path,
+ message,
+ fact.Id,
+ fact.Subject);
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/given/a_runner_context.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/given/a_runner_context.cs
new file mode 100644
index 0000000..4794d87
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/given/a_runner_context.cs
@@ -0,0 +1,164 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner.given;
+
+public class a_runner_context : DotNet.given.a_compilation
+{
+ protected static readonly DotNetAdapterOptions Options = new();
+
+ protected static AdapterDescriptor Descriptor(
+ string id,
+ string version = "1.0.0",
+ AdapterSourceLanguage language = AdapterSourceLanguage.SourceIndependent,
+ AdapterCategory category = AdapterCategory.Concepts,
+ ImmutableArray hostCapabilities = default,
+ ImmutableArray apiCapabilities = default,
+ ImmutableArray factCapabilities = default,
+ GenerationVersionRange? generationVersions = null) => new()
+ {
+ Identity = new AdapterIdentity { Id = id, Version = version },
+ SourceLanguage = language,
+ Category = category,
+ CompatibleGenerationVersions = generationVersions ?? GenerationVersionRange.Any,
+ RequiredHostCapabilities = hostCapabilities.IsDefault ? [] : hostCapabilities,
+ RequiredApiCapabilities = apiCapabilities.IsDefault ? [] : apiCapabilities,
+ EmittedFactCapabilities = factCapabilities.IsDefault ? [] : factCapabilities
+ };
+
+ protected static AdapterContribution EmptyContribution(string id, string version = "1.0.0") => new()
+ {
+ Adapter = new AdapterIdentity { Id = id, Version = version }
+ };
+
+ protected static AdapterContribution ArtifactContribution(
+ AdapterIdentity identity,
+ SourceRange? source = null,
+ string? factId = null,
+ List? facts = null)
+ {
+ facts ??= [];
+ facts.Add(new ArtifactFact
+ {
+ Id = new FactId { Value = factId ?? $"{identity.Id}:artifact" },
+ Subject = new SubjectId { Value = $"dotnet://Specs/{identity.Id}/Artifact" },
+ Evidence = new Evidence
+ {
+ Adapter = identity,
+ Strength = EvidenceStrength.Exact,
+ Source = source
+ },
+ Definition = new ArtifactDefinition
+ {
+ Key = new ArtifactKey
+ {
+ Subject = new SubjectId { Value = $"dotnet://Specs/{identity.Id}/Artifact" },
+ Kind = ArtifactKind.Command
+ },
+ Name = "Register"
+ }
+ });
+ return new AdapterContribution { Adapter = identity, Facts = facts };
+ }
+
+ protected static DotNetProjectCompilation MappedProject(
+ string projectIdentity,
+ string name,
+ string assemblyName,
+ string path,
+ string content,
+ string displayPath = "Code.cs",
+ bool authored = true)
+ {
+ var tree = CSharpSyntaxTree.ParseText(
+ content,
+ CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview),
+ path);
+ var compilation = CSharpCompilation.Create(
+ assemblyName,
+ [tree],
+ CompilationFrom().References,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ var sourceContext = DotNetSourcePaths.Create(
+ projectIdentity,
+ new DotNetSourcePathPolicy
+ {
+ DisplayRoot = DotNetSourceDisplayRoot.Project,
+ CasePolicy = DotNetSourcePathCasePolicy.Ordinal
+ },
+ [
+ new DotNetSourceDocument
+ {
+ SyntaxTree = tree,
+ ProjectRelativePath = displayPath,
+ WorkspaceRelativePath = displayPath
+ }
+ ]);
+ return new DotNetProjectCompilation
+ {
+ Name = name,
+ Compilation = compilation,
+ SourceContext = sourceContext,
+ AuthoredSyntaxTrees = compilation.SyntaxTrees.Where(_ => authored).ToHashSet()
+ };
+ }
+
+ protected sealed class ModernAdapter(AdapterDescriptor descriptor) : IDescribedDotNetScreenplayAdapter
+ {
+ public AdapterDescriptor Descriptor
+ {
+ get
+ {
+ DescriptorCount++;
+ return OnDescriptor?.Invoke() ?? descriptor;
+ }
+ }
+
+ public AdapterProbeResult ProbeResult { get; set; } = new AdapterProbeApplicable();
+ public AdapterContribution Contribution { get; set; } = EmptyContribution(descriptor.Identity.Id, descriptor.Identity.Version);
+ public Func? OnDescriptor { get; set; }
+ public Func? OnProbe { get; set; }
+ public Func? OnAnalyze { get; set; }
+ public int DescriptorCount { get; private set; }
+ public int ProbeCount { get; private set; }
+ public int AnalyzeCount { get; private set; }
+
+ public AdapterProbeResult Probe(DotNetAnalysisContext context)
+ {
+ ProbeCount++;
+ return OnProbe?.Invoke(context) ?? ProbeResult;
+ }
+
+ public AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterOptions options)
+ {
+ AnalyzeCount++;
+ return OnAnalyze?.Invoke(context, options) ?? Contribution;
+ }
+ }
+
+ protected sealed class LegacyAdapter(AdapterIdentity identity) : IDotNetScreenplayAdapter
+ {
+ public AdapterIdentity Identity => identity;
+ public bool IsApplicable { get; set; } = true;
+ public AdapterContribution Contribution { get; set; } = EmptyContribution(identity.Id, identity.Version);
+ public int CanAnalyzeCount { get; private set; }
+ public int AnalyzeCount { get; private set; }
+
+ public bool CanAnalyze(DotNetAnalysisContext context)
+ {
+ _ = context;
+ CanAnalyzeCount++;
+ return IsApplicable;
+ }
+
+ public AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterOptions options)
+ {
+ _ = context;
+ _ = options;
+ AnalyzeCount++;
+ return Contribution;
+ }
+ }
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_admitting_contributions_with_source_ranges.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_admitting_contributions_with_source_ranges.cs
new file mode 100644
index 0000000..25997b1
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_admitting_contributions_with_source_ranges.cs
@@ -0,0 +1,116 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_admitting_contributions_with_source_ranges : given.a_runner_context
+{
+ AdapterRunSnapshot _snapshot = null!;
+ AdapterRunRecord _identityLess = null!;
+
+ void Because()
+ {
+ var validProject = MappedProject(
+ "project-valid",
+ "Valid.Project",
+ "Valid",
+ "/checkout/valid/Code.cs",
+ "public class Valid { }\n");
+ var generatedProject = MappedProject(
+ "project-generated",
+ "Generated.Project",
+ "Generated",
+ "/checkout/generated/Generated.g.cs",
+ "// \npublic class Generated { }\n");
+ var unmappedProject = UnmappedProject();
+ var validFile = validProject.SourceContext!.Files.Values.Single();
+ var generatedFile = generatedProject.SourceContext!.Files.Values.Single();
+ var valid = Range(validFile.DisplayPath, validFile.Identity, 1, 1, 1, 7);
+ var adapters = new[]
+ {
+ Adapter("valid", valid),
+ Adapter("foreign", valid with { FileIdentity = new SourceFileIdentity { Project = "foreign", Path = validFile.Identity.Path } }),
+ Adapter("display-mismatch", valid with { Path = "Other.cs" }),
+ Adapter("out-of-bounds", valid with { EndLine = 99 }),
+ Adapter("generated", Range(generatedFile.DisplayPath, generatedFile.Identity, 2, 1, 2, 7)),
+ Adapter("unmapped", Range("Unmapped.cs", new SourceFileIdentity { Project = "project-unmapped", Path = "Unmapped.cs" }, 1, 1, 1, 7))
+ };
+ _snapshot = DotNetAdapterRunner.Run(
+ adapters.Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([unmappedProject, generatedProject, validProject]),
+ Options);
+
+ var strictDescriptor = Descriptor(
+ "identity-less",
+ language: AdapterSourceLanguage.CSharp,
+ hostCapabilities:
+ [
+ AdapterHostCapability.AuthoredSource,
+ AdapterHostCapability.StableSourceLocations,
+ AdapterHostCapability.SemanticAnalysis
+ ],
+ factCapabilities: [GenerationFactCapability.Artifact]);
+ var identityLess = new ModernAdapter(strictDescriptor)
+ {
+ Contribution = ArtifactContribution(strictDescriptor.Identity, valid with { FileIdentity = null })
+ };
+ _identityLess = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(identityLess)],
+ new DotNetAnalysisContext([validProject]),
+ Options).Adapters.Single();
+ }
+
+ [Fact] void should_admit_exact_authoritative_source() => Record("valid").Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_reject_foreign_source_identity() => Record("foreign").Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_reject_display_path_mismatch() => Record("display-mismatch").Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_reject_out_of_bounds_coordinates() => Record("out-of-bounds").Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_reject_generated_source() => Record("generated").Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_reject_unmapped_source() => Record("unmapped").Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_reject_identityless_source_for_a_modern_stable_source_adapter() => _identityLess.Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_admit_facts_only_from_the_valid_adapter() => _snapshot.Facts.Select(record => record.Fact.Id.Value).ShouldContainOnly("valid:artifact");
+
+ AdapterRunRecord Record(string id) => _snapshot.Adapters.Single(record => record.Descriptor.Identity.Id == id);
+
+ static ModernAdapter Adapter(string id, SourceRange source)
+ {
+ var descriptor = Descriptor(id, factCapabilities: [GenerationFactCapability.Artifact]);
+ return new ModernAdapter(descriptor) { Contribution = ArtifactContribution(descriptor.Identity, source) };
+ }
+
+ static SourceRange Range(
+ string path,
+ SourceFileIdentity identity,
+ int startLine,
+ int startColumn,
+ int endLine,
+ int endColumn) => new()
+ {
+ Path = path,
+ FileIdentity = identity,
+ StartLine = startLine,
+ StartColumn = startColumn,
+ EndLine = endLine,
+ EndColumn = endColumn
+ };
+
+ static DotNetProjectCompilation UnmappedProject()
+ {
+ var tree = CSharpSyntaxTree.ParseText("public class Unmapped { }\n", path: "/checkout/unmapped/Unmapped.cs");
+ var compilation = CSharpCompilation.Create("Unmapped", [tree], CompilationFrom().References);
+ var sourceContext = DotNetSourcePaths.Create(
+ "project-unmapped",
+ new DotNetSourcePathPolicy
+ {
+ DisplayRoot = DotNetSourceDisplayRoot.Project,
+ CasePolicy = DotNetSourcePathCasePolicy.Ordinal
+ },
+ []);
+ return new DotNetProjectCompilation
+ {
+ Name = "Unmapped.Project",
+ Compilation = compilation,
+ SourceContext = sourceContext,
+ AuthoredSyntaxTrees = new HashSet { tree }
+ };
+ }
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_callbacks_throw_under_reversed_input.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_callbacks_throw_under_reversed_input.cs
new file mode 100644
index 0000000..f7aa23c
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_callbacks_throw_under_reversed_input.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_callbacks_throw_under_reversed_input : given.a_runner_context
+{
+ string _forward = null!;
+ string _reverse = null!;
+
+ void Because()
+ {
+ var forward = Adapters("first secret /checkout/a", "second secret /checkout/b");
+ var reverse = Adapters("different /private/c", "different /private/d");
+ _forward = Projection(DotNetAdapterRunner.Run(
+ forward.Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([]),
+ Options));
+ _reverse = Projection(DotNetAdapterRunner.Run(
+ reverse.AsEnumerable().Reverse().Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([]),
+ Options));
+ }
+
+ [Fact] void should_keep_exception_diagnostics_deterministic() => _reverse.ShouldEqual(_forward);
+ [Fact] void should_include_only_exception_types_not_messages_or_paths() => _forward.ShouldEqual("analysis:Analyze:System.InvalidOperationException|probe:Probe:System.ArgumentException");
+
+ static ModernAdapter[] Adapters(string analyzeMessage, string probeMessage)
+ {
+ var analysis = new ModernAdapter(Descriptor("analysis"))
+ {
+ OnAnalyze = (_, _) => throw new InvalidOperationException(analyzeMessage)
+ };
+ var probe = new ModernAdapter(Descriptor("probe"))
+ {
+ OnProbe = _ => throw new ArgumentException(probeMessage)
+ };
+ return [analysis, probe];
+ }
+
+ static string Projection(AdapterRunSnapshot snapshot) => string.Join(
+ '|',
+ snapshot.Adapters.Select(record =>
+ {
+ var message = record.Execution.Diagnostics.Single().Message;
+ var operation = message.Split('\'')[3];
+ var exceptionType = message.Split('\'')[5];
+ return $"{record.Descriptor.Identity.Id}:{operation}:{exceptionType}";
+ }));
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_descriptor_getters_fail.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_descriptor_getters_fail.cs
new file mode 100644
index 0000000..b18c283
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_descriptor_getters_fail.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_descriptor_getters_fail : given.a_runner_context
+{
+ ModernAdapter[] _forwardAdapters = null!;
+ ModernAdapter[] _reverseAdapters = null!;
+ ModernAdapter _forwardValid = null!;
+ ModernAdapter _reverseValid = null!;
+ AdapterRunSnapshot _forward = null!;
+ AdapterRunSnapshot _reverse = null!;
+
+ void Because()
+ {
+ (_forwardAdapters, _forwardValid) = Adapters();
+ (_reverseAdapters, _reverseValid) = Adapters();
+ _forward = DotNetAdapterRunner.Run(
+ _forwardAdapters.Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([]),
+ Options);
+ _reverse = DotNetAdapterRunner.Run(
+ _reverseAdapters.AsEnumerable().Reverse().Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([]),
+ Options);
+ }
+
+ [Fact] void should_handle_every_descriptor_failure_before_duplicate_grouping() => Failed(_forward).All(record => record.Execution.Diagnostics.Single().Code == DotNetAdapterGenerationDiagnosticCodes.OperationFailed).ShouldBeTrue();
+ [Fact] void should_record_each_descriptor_failure() => Failed(_forward).Length.ShouldEqual(2);
+ [Fact] void should_not_probe_or_analyze_failed_descriptor_registrations() => _forwardAdapters.Take(2).All(adapter => adapter.ProbeCount == 0 && adapter.AnalyzeCount == 0).ShouldBeTrue();
+ [Fact] void should_not_allow_the_synthetic_failure_identity_to_suppress_a_real_adapter() => _forwardValid.AnalyzeCount.ShouldEqual(1);
+ [Fact] void should_admit_the_unrelated_valid_adapter() => _forward.Adapters.Single(record => record.Descriptor.Identity.Id == "descriptor-failure").Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_keep_multiple_descriptor_failures_deterministic_under_reversed_input() => Projection(_reverse).ShouldEqual(Projection(_forward));
+ [Fact] void should_keep_the_reversed_unrelated_adapter_valid() => _reverseValid.AnalyzeCount.ShouldEqual(1);
+
+ static (ModernAdapter[] Adapters, ModernAdapter Valid) Adapters()
+ {
+ var first = new ModernAdapter(Descriptor("unreachable-first"))
+ {
+ OnDescriptor = () => throw new InvalidOperationException("secret /checkout/first")
+ };
+ var second = new ModernAdapter(Descriptor("unreachable-second"))
+ {
+ OnDescriptor = () => throw new ArgumentException("secret /private/second")
+ };
+ var valid = new ModernAdapter(Descriptor("descriptor-failure"));
+ return ([first, second, valid], valid);
+ }
+
+ static AdapterRunRecord[] Failed(AdapterRunSnapshot snapshot) =>
+ [.. snapshot.Adapters.Where(record => record.Descriptor.Identity.Id == "runner:descriptor-failure")];
+
+ static string Projection(AdapterRunSnapshot snapshot) => string.Join(
+ '|',
+ snapshot.Adapters.Select(record =>
+ $"{record.Descriptor.Identity.Id}:{record.Disposition}:{string.Join(',', record.Execution.Diagnostics.Select(diagnostic => diagnostic.Message))}"));
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_enforcing_generation_contract_version_compatibility.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_enforcing_generation_contract_version_compatibility.cs
new file mode 100644
index 0000000..bcd6755
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_enforcing_generation_contract_version_compatibility.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_enforcing_generation_contract_version_compatibility : given.a_runner_context
+{
+ ModernAdapter _belowMinimum = null!;
+ ModernAdapter _atMinimum = null!;
+ ModernAdapter _belowMaximum = null!;
+ ModernAdapter _atMaximum = null!;
+ ModernAdapter _unbounded = null!;
+ AdapterRunRecord _belowMinimumRecord = null!;
+ AdapterRunRecord _atMinimumRecord = null!;
+ AdapterRunRecord _belowMaximumRecord = null!;
+ AdapterRunRecord _atMaximumRecord = null!;
+ AdapterRunRecord _unboundedRecord = null!;
+ AdapterRunRecord _defaultVersionRecord = null!;
+
+ void Establish()
+ {
+ var bounded = new GenerationVersionRange
+ {
+ MinimumInclusive = new Version(1, 0, 0),
+ MaximumExclusive = new Version(2, 0, 0)
+ };
+ _belowMinimum = Adapter("below-minimum", bounded);
+ _atMinimum = Adapter("at-minimum", bounded);
+ _belowMaximum = Adapter("below-maximum", bounded);
+ _atMaximum = Adapter("at-maximum", bounded);
+ _unbounded = Adapter(
+ "unbounded",
+ new GenerationVersionRange { MinimumInclusive = new Version(2, 0, 0) });
+ }
+
+ void Because()
+ {
+ _belowMinimumRecord = Run(_belowMinimum, new Version(0, 99, 0));
+ _atMinimumRecord = Run(_atMinimum, new Version(1, 0, 0));
+ _belowMaximumRecord = Run(_belowMaximum, new Version(1, 99, 0));
+ _atMaximumRecord = Run(_atMaximum, new Version(2, 0, 0));
+ _unboundedRecord = Run(_unbounded, new Version(99, 0, 0));
+
+ var currentVersion = typeof(AdapterDescriptor).Assembly.GetName().Version!;
+ var defaultVersion = Adapter(
+ "default-version",
+ new GenerationVersionRange { MinimumInclusive = currentVersion });
+ _defaultVersionRecord = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(defaultVersion)],
+ new DotNetAnalysisContext([]),
+ Options).Adapters.Single();
+ }
+
+ [Fact] void should_block_a_version_below_the_inclusive_minimum_before_probe() => _belowMinimumRecord.Disposition.ShouldEqual(AdapterRunDisposition.Blocked);
+ [Fact] void should_admit_the_inclusive_minimum() => _atMinimumRecord.Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_admit_a_version_below_the_exclusive_maximum() => _belowMaximumRecord.Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_block_the_exclusive_maximum_before_probe() => _atMaximumRecord.Disposition.ShouldEqual(AdapterRunDisposition.Blocked);
+ [Fact] void should_admit_versions_above_an_unbounded_minimum() => _unboundedRecord.Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_derive_the_default_from_the_loaded_generation_contracts_assembly() => _defaultVersionRecord.Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_not_probe_incompatible_adapters() => (_belowMinimum.ProbeCount + _atMaximum.ProbeCount).ShouldEqual(0);
+ [Fact] void should_probe_each_compatible_adapter_once() => new[] { _atMinimum, _belowMaximum, _unbounded }.All(adapter => adapter.ProbeCount == 1).ShouldBeTrue();
+ [Fact] void should_report_a_stable_version_diagnostic_without_machine_paths() => IncompatibleDiagnostics().ShouldEqual("DOTNETADAPTER009:Adapter 'at-maximum' supports Generation.Contracts versions from '1.0.0' inclusive through '2.0.0' exclusive, but the runner host version is '2.0.0'");
+
+ string IncompatibleDiagnostics() => string.Join(
+ '|',
+ _atMaximumRecord.Execution.Diagnostics.Select(diagnostic => $"{diagnostic.Code}:{diagnostic.Message}"));
+
+ static ModernAdapter Adapter(string id, GenerationVersionRange range) =>
+ new(Descriptor(id, generationVersions: range));
+
+ static AdapterRunRecord Run(ModernAdapter adapter, Version version) =>
+ DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(adapter)],
+ new DotNetAnalysisContext([]),
+ Options,
+ version).Adapters.Single();
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_legacy_project_order_depends_on_physical_or_ambiguous_paths.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_legacy_project_order_depends_on_physical_or_ambiguous_paths.cs
new file mode 100644
index 0000000..bdf4d1b
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_legacy_project_order_depends_on_physical_or_ambiguous_paths.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_legacy_project_order_depends_on_physical_or_ambiguous_paths : given.a_runner_context
+{
+ ModernAdapter[] _adapters = null!;
+ AdapterRunSnapshot _forward = null!;
+ AdapterRunSnapshot _reverse = null!;
+ AdapterRunSnapshot _relocated = null!;
+ AdapterRunSnapshot _ambiguous = null!;
+ AdapterRunSnapshot _portable = null!;
+ string _portableOrder = null!;
+
+ void Because()
+ {
+ _adapters =
+ [
+ new ModernAdapter(Descriptor("forward", language: AdapterSourceLanguage.CSharp)),
+ new ModernAdapter(Descriptor("reverse", language: AdapterSourceLanguage.CSharp)),
+ new ModernAdapter(Descriptor("relocated", language: AdapterSourceLanguage.CSharp)),
+ new ModernAdapter(Descriptor("ambiguous", language: AdapterSourceLanguage.CSharp)),
+ new ModernAdapter(Descriptor("portable", language: AdapterSourceLanguage.CSharp))
+ ];
+ var first = Project("/checkout/one/Shared.csproj");
+ var second = Project("/checkout/two/Shared.csproj");
+ _forward = Run(_adapters[0], [first, second]);
+ _reverse = Run(_adapters[1], [second, first]);
+ _relocated = Run(
+ _adapters[2],
+ [Project("/private/relocated-a/Shared.csproj"), Project("/private/relocated-b/Shared.csproj")]);
+ _ambiguous = Run(_adapters[3], [Project(null), Project(null)]);
+ _adapters[4].OnProbe = context =>
+ {
+ _portableOrder = string.Join(',', context.Projects.Select(project => project.ProjectPath));
+ return new AdapterProbeApplicable();
+ };
+ _portable = Run(_adapters[4], [Project("z/Shared.csproj"), Project("a/Shared.csproj")]);
+ }
+
+ [Fact] void should_reject_absolute_physical_project_path_ordering() => Code(_forward).ShouldEqual(DotNetAdapterGenerationDiagnosticCodes.InvalidProjectRoster);
+ [Fact] void should_reject_ambiguous_legacy_project_ordering() => Code(_ambiguous).ShouldEqual(DotNetAdapterGenerationDiagnosticCodes.InvalidProjectRoster);
+ [Fact] void should_keep_the_host_diagnostic_identical_under_reversed_input() => Projection(_reverse).ShouldEqual(Projection(_forward));
+ [Fact] void should_keep_the_host_diagnostic_identical_after_checkout_relocation() => Projection(_relocated).ShouldEqual(Projection(_forward));
+ [Fact] void should_not_leak_any_physical_project_path() => Projection(_forward).ShouldNotContain("checkout");
+ [Fact] void should_validate_every_invalid_project_roster_before_probe_or_analysis() => _adapters.Take(4).All(adapter => adapter.DescriptorCount == 1 && adapter.ProbeCount == 0 && adapter.AnalyzeCount == 0).ShouldBeTrue();
+ [Fact] void should_record_every_invalid_project_roster_adapter_as_blocked() => new[] { _forward, _reverse, _relocated, _ambiguous }.All(snapshot => snapshot.Adapters.Single().Disposition == AdapterRunDisposition.Blocked).ShouldBeTrue();
+ [Fact] void should_continue_to_run_legacy_projects_disambiguated_by_portable_relative_paths() => _portable.Adapters.Single().Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_preserve_portable_relative_legacy_project_ordering() => _portableOrder.ShouldEqual("a/Shared.csproj,z/Shared.csproj");
+
+ static AdapterRunSnapshot Run(ModernAdapter adapter, DotNetProjectCompilation[] projects) =>
+ DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(adapter)],
+ new DotNetAnalysisContext(projects),
+ Options);
+
+ static DotNetProjectCompilation Project(string? projectPath) => new()
+ {
+ Name = "Shared.Project",
+ ProjectPath = projectPath,
+ Compilation = CSharpCompilation.Create("Shared"),
+ AuthoredSyntaxTrees = Enumerable.Empty().ToHashSet()
+ };
+
+ static string Code(AdapterRunSnapshot snapshot) => snapshot.Diagnostics.Single().Code;
+
+ static string Projection(AdapterRunSnapshot snapshot) => string.Join(
+ '|',
+ snapshot.Diagnostics.Select(diagnostic => $"{diagnostic.Code}:{diagnostic.Message}"));
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_modern_probes_are_malformed.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_modern_probes_are_malformed.cs
new file mode 100644
index 0000000..ec31b15
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_modern_probes_are_malformed.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_modern_probes_are_malformed : given.a_runner_context
+{
+ ModernAdapter[] _adapters = null!;
+ AdapterRunSnapshot _snapshot = null!;
+
+ void Establish()
+ {
+ var unknown = new ModernAdapter(Descriptor("unknown")) { ProbeResult = new UnknownProbeResult() };
+ var malformedEvidence = new ModernAdapter(Descriptor("malformed-evidence"))
+ {
+ ProbeResult = new AdapterProbeApplicable
+ {
+ Evidence = [new AdapterProbeEvidence { Description = " malformed " }]
+ }
+ };
+ var malformedEnum = new ModernAdapter(Descriptor("malformed-enum"))
+ {
+ ProbeResult = new AdapterProbeBlocked
+ {
+ Diagnostics =
+ [
+ new GenerationDiagnostic
+ {
+ Code = "PROBE",
+ Severity = GenerationDiagnosticSeverity.Unknown,
+ Message = "Malformed severity"
+ }
+ ]
+ }
+ };
+ var emptyBlock = new ModernAdapter(Descriptor("empty-block"))
+ {
+ ProbeResult = new AdapterProbeBlocked { Diagnostics = [] }
+ };
+ _adapters = [unknown, malformedEvidence, malformedEnum, emptyBlock];
+ }
+
+ void Because() => _snapshot = DotNetAdapterRunner.Run(
+ _adapters.Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([]),
+ Options);
+
+ [Fact] void should_probe_each_adapter_once() => _adapters.All(adapter => adapter.ProbeCount == 1).ShouldBeTrue();
+ [Fact] void should_not_execute_any_malformed_probe() => _adapters.All(adapter => adapter.AnalyzeCount == 0).ShouldBeTrue();
+ [Fact] void should_block_every_malformed_probe() => _snapshot.Adapters.All(record => record.Disposition == AdapterRunDisposition.Blocked).ShouldBeTrue();
+ [Fact] void should_replace_malformed_details_with_stable_probe_diagnostics() => _snapshot.Adapters.All(record => ((AdapterProbeBlocked)record.Probe).Diagnostics.Single().Code == DotNetAdapterGenerationDiagnosticCodes.ProbeRejected).ShouldBeTrue();
+
+ sealed record UnknownProbeResult : AdapterProbeResult;
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_mutating_adapter_inputs_after_run.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_mutating_adapter_inputs_after_run.cs
new file mode 100644
index 0000000..b3f8a1a
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_mutating_adapter_inputs_after_run.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.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_mutating_adapter_inputs_after_run : given.a_runner_context
+{
+ ModernAdapter _adapter = null!;
+ AdapterDescriptor _descriptor = null!;
+ AdapterProbeEvidence _evidence = null!;
+ List _facts = null!;
+ GenerationFact _fact = null!;
+ AdapterRunSnapshot _snapshot = null!;
+
+ void Establish()
+ {
+ _descriptor = Descriptor("mutable", factCapabilities: [GenerationFactCapability.Artifact]);
+ _evidence = new AdapterProbeEvidence { Description = "Exact framework API evidence" };
+ _facts = [];
+ var contribution = ArtifactContribution(_descriptor.Identity, facts: _facts);
+ _fact = contribution.Facts.Single();
+ _adapter = new(_descriptor)
+ {
+ ProbeResult = new AdapterProbeApplicable { Evidence = [_evidence] },
+ Contribution = contribution
+ };
+ }
+
+ void Because()
+ {
+ _snapshot = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(_adapter)],
+ new DotNetAnalysisContext([]),
+ Options);
+ _facts.Clear();
+ }
+
+ [Fact] void should_keep_the_admitted_fact() => _snapshot.Facts.Length.ShouldEqual(1);
+ [Fact] void should_not_retain_the_adapter_fact_object() => ReferenceEquals(_fact, _snapshot.Facts.Single().Fact).ShouldBeFalse();
+ [Fact] void should_not_retain_the_adapter_descriptor_object() => ReferenceEquals(_descriptor, _snapshot.Adapters.Single().Descriptor).ShouldBeFalse();
+ [Fact] void should_not_retain_the_adapter_probe_evidence_object() => ReferenceEquals(_evidence, _snapshot.Adapters.Single().Probe.Evidence.Single()).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_duplicate_descriptors_with_identical_identities.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_duplicate_descriptors_with_identical_identities.cs
new file mode 100644
index 0000000..c530b5d
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_duplicate_descriptors_with_identical_identities.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_ordering_duplicate_descriptors_with_identical_identities : given.a_runner_context
+{
+ ModernAdapter[] _forwardAdapters = null!;
+ ModernAdapter[] _reverseAdapters = null!;
+ string _forward = null!;
+ string _reverse = null!;
+
+ void Because()
+ {
+ _forwardAdapters = Adapters();
+ _reverseAdapters = Adapters();
+ _forward = Projection(DotNetAdapterRunner.Run(
+ _forwardAdapters.Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([]),
+ Options));
+ _reverse = Projection(DotNetAdapterRunner.Run(
+ _reverseAdapters.AsEnumerable().Reverse().Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([]),
+ Options));
+ }
+
+ [Fact] void should_use_the_full_frozen_descriptor_as_the_duplicate_tie_breaker() => _forward.ShouldEqual("Concepts:AuthoredSource:vogen.alpha:Artifact|Validation:SemanticAnalysis:vogen.zeta:ConceptValidationRule");
+ [Fact] void should_keep_duplicate_records_identical_under_reversed_input() => _reverse.ShouldEqual(_forward);
+ [Fact] void should_reject_every_duplicate_before_probe_or_analyze() => _forwardAdapters.Concat(_reverseAdapters).All(adapter => adapter.ProbeCount == 0 && adapter.AnalyzeCount == 0).ShouldBeTrue();
+
+ static ModernAdapter[] Adapters() =>
+ [
+ new(Descriptor(
+ "duplicate",
+ category: AdapterCategory.Validation,
+ hostCapabilities: [AdapterHostCapability.SemanticAnalysis],
+ apiCapabilities: [new AdapterApiCapability { Id = "vogen.zeta" }],
+ factCapabilities: [GenerationFactCapability.ConceptValidationRule],
+ generationVersions: new GenerationVersionRange
+ {
+ MinimumInclusive = new Version(1, 0, 0),
+ MaximumExclusive = new Version(3, 0, 0)
+ })),
+ new(Descriptor(
+ "duplicate",
+ category: AdapterCategory.Concepts,
+ hostCapabilities: [AdapterHostCapability.AuthoredSource],
+ apiCapabilities: [new AdapterApiCapability { Id = "vogen.alpha" }],
+ factCapabilities: [GenerationFactCapability.Artifact],
+ generationVersions: new GenerationVersionRange
+ {
+ MinimumInclusive = new Version(1, 0, 0),
+ MaximumExclusive = new Version(2, 0, 0)
+ }))
+ ];
+
+ static string Projection(AdapterRunSnapshot snapshot) => string.Join(
+ '|',
+ snapshot.Adapters.Select(record =>
+ $"{record.Descriptor.Category}:" +
+ $"{string.Join(',', record.Descriptor.RequiredHostCapabilities)}:" +
+ $"{string.Join(',', record.Descriptor.RequiredApiCapabilities.Select(capability => capability.Id))}:" +
+ $"{string.Join(',', record.Descriptor.EmittedFactCapabilities)}"));
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_probe_evidence_with_shared_starts.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_probe_evidence_with_shared_starts.cs
new file mode 100644
index 0000000..85ed7ae
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_probe_evidence_with_shared_starts.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_ordering_probe_evidence_with_shared_starts : given.a_runner_context
+{
+ string _forward = null!;
+ string _reverse = null!;
+
+ void Because()
+ {
+ var project = MappedProject(
+ "project",
+ "Project",
+ "Project",
+ "/checkout/Project/Code.cs",
+ "public class Code;\n");
+ var file = project.SourceContext!.Files.Values.Single();
+ var shortRange = Range(file, 2);
+ var longRange = Range(file, 7);
+ _forward = Projection(Run(project, [Evidence(longRange), Evidence(shortRange)]));
+ _reverse = Projection(Run(project, [Evidence(shortRange), Evidence(longRange)]));
+ }
+
+ [Fact] void should_include_end_coordinates_in_the_total_evidence_order() => _forward.ShouldEqual("1:1-1:2|1:1-1:7");
+ [Fact] void should_keep_the_snapshot_identical_when_same_start_ranges_are_reversed() => _reverse.ShouldEqual(_forward);
+
+ static AdapterRunSnapshot Run(
+ DotNetProjectCompilation project,
+ AdapterProbeEvidence[] evidence)
+ {
+ var adapter = new ModernAdapter(Descriptor("evidence-order"))
+ {
+ ProbeResult = new AdapterProbeApplicable { Evidence = [.. evidence] }
+ };
+ return DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(adapter)],
+ new DotNetAnalysisContext([project]),
+ Options);
+ }
+
+ static AdapterProbeEvidence Evidence(SourceRange source) => new()
+ {
+ Description = "The exact API is present",
+ Source = source,
+ Subject = new SubjectId { Value = "dotnet://Project/Code" }
+ };
+
+ static SourceRange Range(DotNetSourceFile file, int endColumn) => new()
+ {
+ Path = file.DisplayPath,
+ FileIdentity = file.Identity,
+ StartLine = 1,
+ StartColumn = 1,
+ EndLine = 1,
+ EndColumn = endColumn
+ };
+
+ static string Projection(AdapterRunSnapshot snapshot) => string.Join(
+ '|',
+ snapshot.Adapters.Single().Probe.Evidence.Select(evidence =>
+ $"{evidence.Source!.StartLine}:{evidence.Source.StartColumn}-{evidence.Source.EndLine}:{evidence.Source.EndColumn}"));
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_project_roster_has_duplicate_stable_identities.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_project_roster_has_duplicate_stable_identities.cs
new file mode 100644
index 0000000..0d0fdb7
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_project_roster_has_duplicate_stable_identities.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_project_roster_has_duplicate_stable_identities : given.a_runner_context
+{
+ ModernAdapter _forwardAdapter = null!;
+ ModernAdapter _reverseAdapter = null!;
+ ModernAdapter _forwardIndependent = null!;
+ ModernAdapter _reverseIndependent = null!;
+ AdapterRunSnapshot _forward = null!;
+ AdapterRunSnapshot _reverse = null!;
+
+ void Because()
+ {
+ var first = MappedProject(
+ "duplicate-project",
+ "First.Project",
+ "First",
+ "/checkout/first/Code.cs",
+ "public class First;");
+ var second = MappedProject(
+ "duplicate-project",
+ "Second.Project",
+ "Second",
+ "/different/second/Code.cs",
+ "public class Second;");
+ _forwardAdapter = new ModernAdapter(Descriptor("adapter", language: AdapterSourceLanguage.CSharp));
+ _reverseAdapter = new ModernAdapter(Descriptor("adapter", language: AdapterSourceLanguage.CSharp));
+ _forwardIndependent = new ModernAdapter(Descriptor("independent"));
+ _reverseIndependent = new ModernAdapter(Descriptor("independent"));
+ _forward = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(_forwardAdapter), DotNetAdapterRegistration.For(_forwardIndependent)],
+ new DotNetAnalysisContext([first, second]),
+ Options);
+ _reverse = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(_reverseIndependent), DotNetAdapterRegistration.For(_reverseAdapter)],
+ new DotNetAnalysisContext([second, first]),
+ Options);
+ }
+
+ [Fact] void should_read_the_descriptor_without_probing_or_executing_the_source_adapter() => (_forwardAdapter.DescriptorCount, _forwardAdapter.ProbeCount, _forwardAdapter.AnalyzeCount).ShouldEqual((1, 0, 0));
+ [Fact] void should_record_the_source_adapter_as_considered_and_blocked() => SourceRecord().Considered.ShouldBeTrue();
+ [Fact] void should_not_probe_the_source_adapter() => SourceRecord().Probed.ShouldBeFalse();
+ [Fact] void should_not_execute_the_source_adapter() => SourceRecord().Executed.ShouldBeFalse();
+ [Fact] void should_block_the_source_adapter() => SourceRecord().Disposition.ShouldEqual(AdapterRunDisposition.Blocked);
+ [Fact] void should_continue_to_execute_an_unrelated_source_independent_adapter() => (_forwardIndependent.DescriptorCount, _forwardIndependent.ProbeCount, _forwardIndependent.AnalyzeCount).ShouldEqual((1, 1, 1));
+ [Fact] void should_admit_the_unrelated_source_independent_adapter() => _forward.Adapters.Single(record => record.Descriptor.Identity.Id == "independent").Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_report_the_stable_host_project_roster_diagnostic() => Projection(_forward).ShouldEqual("DOTNETADAPTER010:The .NET adapter host rejected the project roster because stable project identity 'duplicate-project' occurs more than once");
+ [Fact] void should_keep_the_diagnostic_identical_under_reversed_project_input() => Projection(_reverse).ShouldEqual(Projection(_forward));
+ [Fact] void should_preserve_the_reversed_source_adapter_record() => (_reverseAdapter.DescriptorCount, _reverseAdapter.ProbeCount, _reverseAdapter.AnalyzeCount).ShouldEqual((1, 0, 0));
+ [Fact] void should_continue_to_execute_the_reversed_source_independent_adapter() => (_reverseIndependent.DescriptorCount, _reverseIndependent.ProbeCount, _reverseIndependent.AnalyzeCount).ShouldEqual((1, 1, 1));
+
+ AdapterRunRecord SourceRecord() => _forward.Adapters.Single(record => record.Descriptor.Identity.Id == "adapter");
+
+ static string Projection(AdapterRunSnapshot snapshot) => string.Join(
+ '|',
+ snapshot.Diagnostics.Select(diagnostic => $"{diagnostic.Code}:{diagnostic.Message}"));
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_registering_invalid_api_capabilities.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_registering_invalid_api_capabilities.cs
new file mode 100644
index 0000000..5b32849
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_registering_invalid_api_capabilities.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_registering_invalid_api_capabilities : given.a_runner_context
+{
+ ModernAdapter _adapter = null!;
+ AdapterRunRecord _record = null!;
+
+ void Establish() => _adapter = new(Descriptor(
+ "invalid-api",
+ apiCapabilities:
+ [
+ new AdapterApiCapability { Id = "framework.api" },
+ new AdapterApiCapability { Id = " malformed " },
+ new AdapterApiCapability { Id = "framework.api" }
+ ]));
+
+ void Because() => _record = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(_adapter)],
+ new DotNetAnalysisContext([]),
+ Options).Adapters.Single();
+
+ [Fact] void should_reject_the_roster_record_before_probe() => _record.Disposition.ShouldEqual(AdapterRunDisposition.RosterRejected);
+ [Fact] void should_not_probe_or_execute_the_adapter() => (_adapter.ProbeCount + _adapter.AnalyzeCount).ShouldEqual(0);
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_required_capabilities_are_missing.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_required_capabilities_are_missing.cs
new file mode 100644
index 0000000..21591d4
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_required_capabilities_are_missing.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_required_capabilities_are_missing : given.a_runner_context
+{
+ ModernAdapter _missingApi = null!;
+ ModernAdapter _missingHost = null!;
+ AdapterRunSnapshot _snapshot = null!;
+
+ void Establish()
+ {
+ _missingApi = new(Descriptor(
+ "missing-api",
+ apiCapabilities: [new AdapterApiCapability { Id = "framework.api" }]));
+ _missingHost = new(Descriptor(
+ "missing-host",
+ language: AdapterSourceLanguage.CSharp,
+ hostCapabilities: [AdapterHostCapability.SemanticAnalysis]));
+ }
+
+ void Because() => _snapshot = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(_missingApi), DotNetAdapterRegistration.For(_missingHost)],
+ new DotNetAnalysisContext([]),
+ Options);
+
+ [Fact] void should_block_before_probe_for_a_missing_host_capability() => _missingHost.ProbeCount.ShouldEqual(0);
+ [Fact] void should_block_after_probe_for_missing_api_capability_evidence() => _missingApi.ProbeCount.ShouldEqual(1);
+ [Fact] void should_not_execute_either_adapter() => (_missingApi.AnalyzeCount + _missingHost.AnalyzeCount).ShouldEqual(0);
+ [Fact] void should_record_both_as_blocked() => _snapshot.Adapters.All(record => record.Disposition == AdapterRunDisposition.Blocked).ShouldBeTrue();
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_requiring_available_project_references.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_requiring_available_project_references.cs
new file mode 100644
index 0000000..e0c175f
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_requiring_available_project_references.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_requiring_available_project_references : given.a_runner_context
+{
+ AdapterRunRecord _withReference = null!;
+ AdapterRunRecord _withoutReferencedProject = null!;
+
+ void Because()
+ {
+ var referencedCompilation = CSharpCompilation.Create("Referenced");
+ var referencingCompilation = CSharpCompilation.Create(
+ "Referencing",
+ references: [referencedCompilation.ToMetadataReference()]);
+ var referenced = Project("Referenced.Project", referencedCompilation);
+ var referencing = Project("Referencing.Project", referencingCompilation);
+ var descriptor = Descriptor(
+ "project-reference",
+ hostCapabilities: [AdapterHostCapability.ProjectReferences]);
+ _withReference = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(new ModernAdapter(descriptor))],
+ new DotNetAnalysisContext([referencing, referenced]),
+ Options).Adapters.Single();
+ _withoutReferencedProject = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(new ModernAdapter(descriptor))],
+ new DotNetAnalysisContext([referencing]),
+ Options).Adapters.Single();
+ }
+
+ [Fact] void should_run_when_the_referenced_source_project_is_available() => _withReference.Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_block_when_the_referenced_source_project_is_absent() => _withoutReferencedProject.Disposition.ShouldEqual(AdapterRunDisposition.Blocked);
+
+ static DotNetProjectCompilation Project(string name, CSharpCompilation compilation) => new()
+ {
+ Name = name,
+ Compilation = compilation,
+ AuthoredSyntaxTrees = compilation.SyntaxTrees.ToHashSet()
+ };
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_reversing_roster_and_project_inputs.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_reversing_roster_and_project_inputs.cs
new file mode 100644
index 0000000..4edda3a
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_reversing_roster_and_project_inputs.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_reversing_roster_and_project_inputs : given.a_runner_context
+{
+ string _forwardCallbacks = null!;
+ string _reverseCallbacks = null!;
+ string _forwardSnapshot = null!;
+ string _reverseSnapshot = null!;
+
+ void Because()
+ {
+ var first = MappedProject("project-a", "Z.Project", "Shared", "/checkout/a/Code.cs", "public class A;");
+ var second = MappedProject("project-b", "A.Project", "Shared", "/checkout/b/Code.cs", "public class B;");
+ var forwardLog = new List();
+ var reverseLog = new List();
+ var forwardAdapters = Adapters(forwardLog);
+ var reverseAdapters = Adapters(reverseLog);
+ var forward = DotNetAdapterRunner.Run(
+ forwardAdapters.Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([first, second]),
+ Options);
+ var reverse = DotNetAdapterRunner.Run(
+ reverseAdapters.AsEnumerable().Reverse().Select(DotNetAdapterRegistration.For),
+ new DotNetAnalysisContext([second, first]),
+ Options);
+ _forwardCallbacks = string.Join('|', forwardLog);
+ _reverseCallbacks = string.Join('|', reverseLog);
+ _forwardSnapshot = SnapshotProjection(forward);
+ _reverseSnapshot = SnapshotProjection(reverse);
+ }
+
+ [Fact] void should_keep_callback_order_equivalent() => _reverseCallbacks.ShouldEqual(_forwardCallbacks);
+ [Fact] void should_order_adapters_by_identity() => _forwardCallbacks.StartsWith("adapter-a:Probe", StringComparison.Ordinal).ShouldBeTrue();
+ [Fact] void should_order_projects_by_stable_project_identity() => _forwardCallbacks.ShouldContain("project-a,project-b");
+ [Fact] void should_keep_snapshot_content_equivalent() => _reverseSnapshot.ShouldEqual(_forwardSnapshot);
+
+ static ModernAdapter[] Adapters(List callbacks) =>
+ [
+ Adapter("adapter-z", callbacks),
+ Adapter("adapter-a", callbacks)
+ ];
+
+ static ModernAdapter Adapter(string id, List callbacks)
+ {
+ return new ModernAdapter(Descriptor(id))
+ {
+ OnProbe = context =>
+ {
+ callbacks.Add($"{id}:Probe:{Projects(context)}");
+ return new AdapterProbeApplicable();
+ },
+ OnAnalyze = (context, _) =>
+ {
+ callbacks.Add($"{id}:Analyze:{Projects(context)}");
+ return EmptyContribution(id);
+ }
+ };
+ }
+
+ static string Projects(DotNetAnalysisContext context) =>
+ string.Join(',', context.Projects.Select(project => project.SourceContext!.ProjectIdentity));
+
+ static string SnapshotProjection(AdapterRunSnapshot snapshot) => string.Join(
+ '|',
+ snapshot.Adapters.Select(record => $"{record.Descriptor.Identity.Id}@{record.Descriptor.Identity.Version}:{record.Disposition}:{record.Considered}:{record.Probed}:{record.Executed}"));
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_a_source_independent_adapter_without_projects.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_a_source_independent_adapter_without_projects.cs
new file mode 100644
index 0000000..6bfd282
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_a_source_independent_adapter_without_projects.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_running_a_source_independent_adapter_without_projects : given.a_runner_context
+{
+ ModernAdapter _adapter = null!;
+ AdapterRunRecord _record = null!;
+
+ void Establish() => _adapter = new(Descriptor("source-independent"));
+
+ void Because() => _record = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(_adapter)],
+ new DotNetAnalysisContext([]),
+ Options).Adapters.Single();
+
+ [Fact] void should_probe_once() => _adapter.ProbeCount.ShouldEqual(1);
+ [Fact] void should_execute_once() => _adapter.AnalyzeCount.ShouldEqual(1);
+ [Fact] void should_admit_the_empty_source_neutral_contribution() => _record.Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_duplicate_adapter_ids.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_duplicate_adapter_ids.cs
new file mode 100644
index 0000000..49a7062
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_duplicate_adapter_ids.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_running_duplicate_adapter_ids : given.a_runner_context
+{
+ ModernAdapter _first = null!;
+ ModernAdapter _second = null!;
+ AdapterRunSnapshot _snapshot = null!;
+
+ void Establish()
+ {
+ _first = new(Descriptor("duplicate", "2.0.0"));
+ _second = new(Descriptor("duplicate", "1.0.0"));
+ }
+
+ void Because() => _snapshot = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(_first), DotNetAdapterRegistration.For(_second)],
+ new DotNetAnalysisContext([]),
+ Options);
+
+ [Fact] void should_not_probe_any_duplicate() => (_first.ProbeCount + _second.ProbeCount).ShouldEqual(0);
+ [Fact] void should_not_execute_any_duplicate() => (_first.AnalyzeCount + _second.AnalyzeCount).ShouldEqual(0);
+ [Fact] void should_reject_every_duplicate_registration() => _snapshot.Adapters.All(record => record.Considered && !record.Probed && !record.Executed && record.Disposition == AdapterRunDisposition.RosterRejected).ShouldBeTrue();
+ [Fact] void should_order_duplicate_versions_ordinally() => _snapshot.Adapters.Select(record => record.Descriptor.Identity.Version).ShouldEqual(["1.0.0", "2.0.0"]);
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_legacy_registrations.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_legacy_registrations.cs
new file mode 100644
index 0000000..4858f94
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_legacy_registrations.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_running_legacy_registrations : given.a_runner_context
+{
+ LegacyAdapter _applicable = null!;
+ LegacyAdapter _notApplicable = null!;
+ AdapterRunSnapshot _snapshot = null!;
+ string _directFactId = null!;
+ AdapterRunRecord _unscopedRecord = null!;
+
+ void Establish()
+ {
+ var applicableIdentity = new AdapterIdentity { Id = "legacy-scoped", Version = "1.0.0" };
+ _applicable = new(applicableIdentity)
+ {
+ Contribution = ArtifactContribution(applicableIdentity, LegacyRange())
+ };
+ _notApplicable = new(new AdapterIdentity { Id = "legacy-not-applicable", Version = "1.0.0" })
+ {
+ IsApplicable = false
+ };
+ }
+
+ void Because()
+ {
+ var project = LegacyProject();
+ _snapshot = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.ForLegacy(_notApplicable), DotNetAdapterRegistration.ForLegacy(_applicable)],
+ new DotNetAnalysisContext([project]),
+ Options);
+
+ var unscopedIdentity = new AdapterIdentity { Id = "legacy-unscoped", Version = "1.0.0" };
+ var unscoped = new LegacyAdapter(unscopedIdentity)
+ {
+ Contribution = ArtifactContribution(unscopedIdentity, LegacyRange(), "artifact")
+ };
+ _directFactId = unscoped.Analyze(new DotNetAnalysisContext([project]), Options).Facts.Single().Id.Value;
+ _unscopedRecord = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.ForLegacy(unscoped)],
+ new DotNetAnalysisContext([project]),
+ Options).Adapters.Single();
+ }
+
+ [Fact] void should_call_legacy_can_analyze_once() => (_applicable.CanAnalyzeCount + _notApplicable.CanAnalyzeCount).ShouldEqual(2);
+ [Fact] void should_execute_only_the_applicable_legacy_adapter_once() => _applicable.AnalyzeCount.ShouldEqual(1);
+ [Fact] void should_not_execute_the_not_applicable_legacy_adapter() => _notApplicable.AnalyzeCount.ShouldEqual(0);
+ [Fact] void should_admit_a_scoped_official_legacy_contribution() => _snapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "legacy-scoped").Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_use_the_legacy_compatibility_descriptor() => _snapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "legacy-scoped").Descriptor.Category.ShouldEqual(AdapterCategory.Legacy);
+ [Fact] void should_accept_exact_legacy_path_only_source() => _snapshot.Facts.Length.ShouldEqual(1);
+ [Fact] void should_reject_unscoped_fact_ids_only_at_the_runner_admission_boundary() => _unscopedRecord.Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_leave_direct_legacy_analysis_behavior_unchanged() => _directFactId.ShouldEqual("artifact");
+
+ static SourceRange LegacyRange() => new()
+ {
+ Path = "Code.cs",
+ StartLine = 1,
+ StartColumn = 1,
+ EndLine = 1,
+ EndColumn = 7
+ };
+
+ static DotNetProjectCompilation LegacyProject()
+ {
+ var tree = CSharpSyntaxTree.ParseText("public class Legacy { }\n", path: "/workspace/Code.cs");
+ return new DotNetProjectCompilation
+ {
+ Name = "Legacy.Project",
+ SourceRoot = "/workspace",
+ Compilation = CSharpCompilation.Create("Legacy", [tree], CompilationFrom().References),
+ AuthoredSyntaxTrees = new HashSet { tree }
+ };
+ }
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_mixed_adapter_outcomes.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_mixed_adapter_outcomes.cs
new file mode 100644
index 0000000..08b3604
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_mixed_adapter_outcomes.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_running_mixed_adapter_outcomes : given.a_runner_context
+{
+ ModernAdapter _applicable = null!;
+ ModernAdapter _blocked = null!;
+ ModernAdapter _notApplicable = null!;
+ ModernAdapter _rejected = null!;
+ ModernAdapter _throwing = null!;
+ AdapterRunSnapshot _snapshot = null!;
+
+ void Establish()
+ {
+ _applicable = new(Descriptor("applicable"));
+ _blocked = new(Descriptor("blocked"))
+ {
+ ProbeResult = new AdapterProbeBlocked
+ {
+ Diagnostics =
+ [
+ new GenerationDiagnostic
+ {
+ Code = "BLOCKED",
+ Severity = GenerationDiagnosticSeverity.Error,
+ Message = "The applicable adapter cannot execute safely"
+ }
+ ]
+ }
+ };
+ _notApplicable = new(Descriptor("not-applicable")) { ProbeResult = new AdapterProbeNotApplicable() };
+ _rejected = new(Descriptor("rejected")) { Contribution = EmptyContribution("another-adapter") };
+ _throwing = new(Descriptor("throwing"))
+ {
+ OnAnalyze = (_, _) => throw new InvalidOperationException("private /checkout/path should not escape")
+ };
+ }
+
+ void Because() => _snapshot = DotNetAdapterRunner.Run(
+ [
+ DotNetAdapterRegistration.For(_throwing),
+ DotNetAdapterRegistration.For(_rejected),
+ DotNetAdapterRegistration.For(_notApplicable),
+ DotNetAdapterRegistration.For(_blocked),
+ DotNetAdapterRegistration.For(_applicable)
+ ],
+ new DotNetAnalysisContext([]),
+ Options);
+
+ [Fact] void should_probe_every_registration_exactly_once() => new[] { _applicable, _blocked, _notApplicable, _rejected, _throwing }.All(adapter => adapter.ProbeCount == 1).ShouldBeTrue();
+ [Fact] void should_execute_only_applicable_registrations_exactly_once() => new[] { _applicable, _rejected, _throwing }.All(adapter => adapter.AnalyzeCount == 1).ShouldBeTrue();
+ [Fact] void should_not_execute_blocked_or_not_applicable_registrations() => (_blocked.AnalyzeCount + _notApplicable.AnalyzeCount).ShouldEqual(0);
+ [Fact] void should_admit_the_unrelated_valid_adapter() => _snapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "applicable").Disposition.ShouldEqual(AdapterRunDisposition.Admitted);
+ [Fact] void should_reject_the_invalid_contribution_atomically() => _snapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "rejected").Disposition.ShouldEqual(AdapterRunDisposition.ContributionRejected);
+ [Fact] void should_continue_after_an_analysis_exception() => _snapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "throwing").Disposition.ShouldEqual(AdapterRunDisposition.ExecutionFailed);
+ [Fact] void should_exclude_exception_messages_and_paths() => string.Join('|', _snapshot.Diagnostics.Select(diagnostic => diagnostic.Message)).ShouldNotContain("checkout");
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_source_ranges_use_nonportable_display_paths.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_source_ranges_use_nonportable_display_paths.cs
new file mode 100644
index 0000000..161f7bb
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_source_ranges_use_nonportable_display_paths.cs
@@ -0,0 +1,210 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAdapterRunner;
+
+public class when_source_ranges_use_nonportable_display_paths : given.a_runner_context
+{
+ static readonly string[] _invalidPaths =
+ [
+ "/checkout/Code.cs",
+ "C:/checkout/Code.cs",
+ "Folder\\Code.cs",
+ "../Code.cs",
+ "./Code.cs",
+ "%2e/Code.cs",
+ "%2e%2e/Code.cs"
+ ];
+
+ AdapterRunRecord[] _modernProbeRecords = null!;
+ AdapterRunRecord[] _legacyAuthorityProbeRecords = null!;
+ AdapterRunRecord[] _modernContributionRecords = null!;
+ AdapterRunRecord[] _legacyContributionRecords = null!;
+ AdapterRunRecord[] _validRecords = null!;
+
+ void Because()
+ {
+ var mappedProject = MappedProject(
+ "project",
+ "Project",
+ "Project",
+ "/checkout/Project/Code.cs",
+ "public class Code { }\n");
+ var mappedFile = mappedProject.SourceContext!.Files.Values.Single();
+ var legacyProject = LegacyProject();
+
+ _modernProbeRecords =
+ [
+ .. _invalidPaths.Select((path, index) => RunModernProbe(mappedProject, mappedFile, path, index))
+ ];
+ _legacyAuthorityProbeRecords =
+ [
+ .. _invalidPaths.Select((path, index) => RunLegacyAuthorityProbe(legacyProject, path, index))
+ ];
+ _modernContributionRecords =
+ [
+ .. _invalidPaths.Select((path, index) => RunModernContribution(mappedProject, mappedFile, path, index))
+ ];
+ _legacyContributionRecords =
+ [
+ .. _invalidPaths.Select((path, index) => RunLegacyContribution(legacyProject, path, index))
+ ];
+ _validRecords =
+ [
+ RunModernProbe(mappedProject, mappedFile, "Code.cs", 100),
+ RunLegacyAuthorityProbe(legacyProject, "Code.cs", 100),
+ RunModernContribution(mappedProject, mappedFile, "Code.cs", 100),
+ RunLegacyContribution(legacyProject, "Code.cs", 100)
+ ];
+ }
+
+ [Fact] void should_reject_every_nonportable_modern_probe_path_as_malformed() => _modernProbeRecords.All(IsRejectedProbe).ShouldBeTrue();
+ [Fact] void should_reject_every_nonportable_path_through_the_legacy_authority_map() => _legacyAuthorityProbeRecords.All(IsRejectedProbe).ShouldBeTrue();
+ [Fact] void should_reject_every_nonportable_modern_contribution_path_structurally() => _modernContributionRecords.All(HasInvalidSourceRangeAdmission).ShouldBeTrue();
+ [Fact] void should_reject_every_nonportable_legacy_contribution_path_structurally() => _legacyContributionRecords.All(HasInvalidSourceRangeAdmission).ShouldBeTrue();
+ [Fact] void should_continue_to_admit_normalized_relative_probe_and_contribution_paths() => _validRecords.All(record => record.Disposition == AdapterRunDisposition.Admitted).ShouldBeTrue();
+
+ static AdapterRunRecord RunModernProbe(
+ DotNetProjectCompilation project,
+ DotNetSourceFile file,
+ string path,
+ int index)
+ {
+ var descriptor = SourceDescriptor($"modern-probe-{index}", stable: true);
+ var adapter = new ModernAdapter(descriptor)
+ {
+ ProbeResult = new AdapterProbeApplicable
+ {
+ Evidence =
+ [
+ new AdapterProbeEvidence
+ {
+ Description = "The exact API is present",
+ Source = Range(path, file.Identity)
+ }
+ ]
+ }
+ };
+ return Run(adapter, project);
+ }
+
+ static AdapterRunRecord RunLegacyAuthorityProbe(
+ DotNetProjectCompilation project,
+ string path,
+ int index)
+ {
+ var descriptor = SourceDescriptor($"legacy-authority-probe-{index}", stable: false);
+ var adapter = new ModernAdapter(descriptor)
+ {
+ ProbeResult = new AdapterProbeApplicable
+ {
+ Evidence =
+ [
+ new AdapterProbeEvidence
+ {
+ Description = "The exact API is present",
+ Source = Range(path, null)
+ }
+ ]
+ }
+ };
+ return Run(adapter, project);
+ }
+
+ static AdapterRunRecord RunModernContribution(
+ DotNetProjectCompilation project,
+ DotNetSourceFile file,
+ string path,
+ int index)
+ {
+ var descriptor = SourceDescriptor(
+ $"modern-contribution-{index}",
+ stable: true,
+ factCapabilities: [GenerationFactCapability.Artifact]);
+ var adapter = new ModernAdapter(descriptor)
+ {
+ Contribution = ArtifactContribution(descriptor.Identity, Range(path, file.Identity))
+ };
+ return Run(adapter, project);
+ }
+
+ static AdapterRunRecord RunLegacyContribution(
+ DotNetProjectCompilation project,
+ string path,
+ int index)
+ {
+ var identity = new AdapterIdentity { Id = $"legacy-contribution-{index}", Version = "1.0.0" };
+ var adapter = new LegacyAdapter(identity)
+ {
+ Contribution = ArtifactContribution(identity, Range(path, null))
+ };
+ return DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.ForLegacy(adapter)],
+ new DotNetAnalysisContext([project]),
+ Options).Adapters.Single();
+ }
+
+ static AdapterDescriptor SourceDescriptor(
+ string id,
+ bool stable,
+ System.Collections.Immutable.ImmutableArray factCapabilities = default) =>
+ Descriptor(
+ id,
+ language: AdapterSourceLanguage.CSharp,
+ hostCapabilities: stable
+ ?
+ [
+ AdapterHostCapability.AuthoredSource,
+ AdapterHostCapability.StableSourceLocations,
+ AdapterHostCapability.SemanticAnalysis
+ ]
+ :
+ [
+ AdapterHostCapability.AuthoredSource,
+ AdapterHostCapability.SemanticAnalysis
+ ],
+ factCapabilities: factCapabilities);
+
+ static AdapterRunRecord Run(ModernAdapter adapter, DotNetProjectCompilation project) =>
+ DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(adapter)],
+ new DotNetAnalysisContext([project]),
+ Options).Adapters.Single();
+
+ static bool IsRejectedProbe(AdapterRunRecord record) =>
+ record.Disposition == AdapterRunDisposition.Blocked &&
+ ((AdapterProbeBlocked)record.Probe).Diagnostics.Single().Code == DotNetAdapterGenerationDiagnosticCodes.ProbeRejected;
+
+ static bool HasInvalidSourceRangeAdmission(AdapterRunRecord record) =>
+ record.Execution is AdapterExecutionRejected rejected &&
+ rejected.AdmissionDiagnostics.Any(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.InvalidSourceRange);
+
+ static SourceRange Range(string path, SourceFileIdentity? identity) => new()
+ {
+ Path = path,
+ FileIdentity = identity,
+ StartLine = 1,
+ StartColumn = 1,
+ EndLine = 1,
+ EndColumn = 7
+ };
+
+ static DotNetProjectCompilation LegacyProject()
+ {
+ var tree = CSharpSyntaxTree.ParseText(
+ "public class Code { }\n",
+ CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview),
+ "/checkout/Project/Code.cs");
+ return new DotNetProjectCompilation
+ {
+ Name = "Project",
+ SourceRoot = "/checkout/Project",
+ Compilation = CSharpCompilation.Create(
+ "Project",
+ [tree],
+ CompilationFrom().References,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)),
+ AuthoredSyntaxTrees = new HashSet { tree }
+ };
+ }
+}
diff --git a/Source/DotNET/Generation.DotNet.Specs/for_DotNetAnalysisContext/when_ordering_legacy_projects_without_source_contexts.cs b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAnalysisContext/when_ordering_legacy_projects_without_source_contexts.cs
new file mode 100644
index 0000000..69d0d96
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Specs/for_DotNetAnalysisContext/when_ordering_legacy_projects_without_source_contexts.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Screenplay.Generation.DotNet.for_DotNetAnalysisContext;
+
+public class when_ordering_legacy_projects_without_source_contexts : DotNet.given.a_compilation
+{
+ IReadOnlyList _order = null!;
+
+ void Because()
+ {
+ DotNetProjectCompilation[] projects =
+ [
+ Project("B", "Assembly", "z/project.csproj"),
+ Project("A", "ZAssembly", "a/project.csproj"),
+ Project("A", "Assembly", "z/project.csproj"),
+ Project("A", "Assembly", "a/project.csproj")
+ ];
+ _order =
+ [
+ .. new DotNetAnalysisContext(projects.AsEnumerable().Reverse()).Projects
+ .Select(project => $"{project.Name}:{project.Compilation.AssemblyName}:{project.ProjectPath}")
+ ];
+ }
+
+ [Fact] void should_preserve_name_assembly_and_portable_relative_path_fallback_order() => string.Join('|', _order).ShouldEqual("A:Assembly:a/project.csproj|A:Assembly:z/project.csproj|A:ZAssembly:a/project.csproj|B:Assembly:z/project.csproj");
+
+ static DotNetProjectCompilation Project(string name, string assembly, string path) => new()
+ {
+ Name = name,
+ ProjectPath = path,
+ Compilation = CSharpCompilation.Create(assembly),
+ AuthoredSyntaxTrees = Enumerable.Empty().ToHashSet()
+ };
+}
diff --git a/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_comparing_modern_and_legacy_execution.cs b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_comparing_modern_and_legacy_execution.cs
new file mode 100644
index 0000000..c734fda
--- /dev/null
+++ b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_comparing_modern_and_legacy_execution.cs
@@ -0,0 +1,73 @@
+// 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.Screenplay.Generation.DotNet.Vogen.for_VogenConceptScreenplayAdapter;
+
+public class when_comparing_modern_and_legacy_execution : given.a_vogen_compilation
+{
+ string _modernFacts = null!;
+ string _legacyFacts = null!;
+ string _modernDiagnostics = null!;
+ string _legacyDiagnostics = null!;
+ byte[] _modernFactBytes = null!;
+ byte[] _legacyFactBytes = null!;
+ byte[] _modernDiagnosticBytes = null!;
+ byte[] _legacyDiagnosticBytes = null!;
+
+ void Because()
+ {
+ var compilation = CompilationFrom(
+ "Concepts",
+ new SourceFile(
+ "/checkout/Concepts/CustomerCode.cs",
+ """
+ namespace Concepts;
+ [Vogen.ValueObject]
+ public partial struct CustomerCode
+ {
+ private static Vogen.Validation Validate(string value) => Vogen.Validation.Invalid("Required");
+ }
+ """));
+ var context = new DotNetAnalysisContext([MappedProject("Concepts.Project", "concepts-project", compilation)]);
+ var modernAdapter = new VogenConceptScreenplayAdapter();
+ var legacyAdapter = new VogenConceptScreenplayAdapter();
+ IDescribedDotNetScreenplayAdapter modernInterface = modernAdapter;
+ IDotNetScreenplayAdapter legacyInterface = legacyAdapter;
+ var rawModern = modernInterface.Analyze(context, new DotNetAdapterOptions());
+ var rawLegacy = legacyInterface.Analyze(context, new DotNetAdapterOptions());
+ var modern = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.For(modernAdapter)],
+ context,
+ new DotNetAdapterOptions());
+ var legacy = DotNetAdapterRunner.Run(
+ [DotNetAdapterRegistration.ForLegacy(legacyAdapter)],
+ context,
+ new DotNetAdapterOptions());
+ _modernFacts = Facts(modern);
+ _legacyFacts = Facts(legacy);
+ _modernDiagnostics = Diagnostics(modern);
+ _legacyDiagnostics = Diagnostics(legacy);
+ _modernFactBytes = JsonSerializer.SerializeToUtf8Bytes(rawModern.Facts.Cast