From d566d36539307e940317f31c65d676795ce5bb5f Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 27 Aug 2026 20:31:25 +0200 Subject: [PATCH 1/4] Add frozen adapter contribution contracts --- .../AdapterContributionAdmission.cs | 42 + .../AdapterContributionAdmissionContext.cs | 97 ++ .../AdapterContributionAdmissionContracts.cs | 174 ++++ .../AdapterContributionAdmissionValidator.cs | 415 +++++++++ .../AdapterContributionFreezer.cs | 837 ++++++++++++++++++ .../AdapterContributionText.cs | 33 + .../AdapterDescriptors.cs | 226 +++++ .../AdapterFactAdmissionValidator.cs | 44 + .../Generation.Contracts/AdapterProbes.cs | 59 ++ .../Generation.Contracts/AdapterRuns.cs | 210 +++++ .../ArtifactFactAdmissionValidator.cs | 116 +++ .../ConceptFactAdmissionValidator.cs | 161 ++++ .../Generation.Contracts/Diagnostics.cs | 5 + .../ISourceAuthorityValidator.cs | 21 + .../RelationshipFactAdmissionValidator.cs | 41 + .../SpecificationFactAdmissionValidator.cs | 357 ++++++++ .../given/a_contribution.cs | 265 ++++++ .../when_admitting_a_valid_contribution.cs | 26 + ...itting_cross_adapter_subject_references.cs | 19 + .../with_a_mismatched_contribution_adapter.cs | 14 + .../with_an_invalid_descriptor_identity.cs | 17 + .../with_an_undeclared_fact_capability.cs | 14 + .../with_inconsistent_ownership.cs | 72 ++ .../with_invalid_fact_identities.cs | 36 + .../with_malformed_subjects.cs | 39 + .../with_mismatched_evidence.cs | 25 + .../with_missing_kind_operands.cs | 37 + .../with_null_or_blank_required_values.cs | 42 + .../with_unknown_or_undefined_enums.cs | 47 + .../when_admitting_reversed_inputs.cs | 52 ++ .../when_mutating_admitted_input.cs | 45 + ...reading_adapter_contract_discriminators.cs | 26 + .../when_validating_source_authority.cs | 63 ++ 33 files changed, 3677 insertions(+) create mode 100644 Source/DotNET/Generation.Contracts/AdapterContributionAdmission.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContext.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterContributionText.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterDescriptors.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterFactAdmissionValidator.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterProbes.cs create mode 100644 Source/DotNET/Generation.Contracts/AdapterRuns.cs create mode 100644 Source/DotNET/Generation.Contracts/ArtifactFactAdmissionValidator.cs create mode 100644 Source/DotNET/Generation.Contracts/ConceptFactAdmissionValidator.cs create mode 100644 Source/DotNET/Generation.Contracts/ISourceAuthorityValidator.cs create mode 100644 Source/DotNET/Generation.Contracts/RelationshipFactAdmissionValidator.cs create mode 100644 Source/DotNET/Generation.Contracts/SpecificationFactAdmissionValidator.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/given/a_contribution.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_a_valid_contribution.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_cross_adapter_subject_references.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_a_mismatched_contribution_adapter.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_invalid_descriptor_identity.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_undeclared_fact_capability.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_inconsistent_ownership.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_invalid_fact_identities.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_malformed_subjects.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_mismatched_evidence.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_missing_kind_operands.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_null_or_blank_required_values.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_unknown_or_undefined_enums.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_reversed_inputs.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_mutating_admitted_input.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_reading_adapter_contract_discriminators.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_validating_source_authority.cs 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..2acda2e --- /dev/null +++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs @@ -0,0 +1,174 @@ +// 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 +} + +/// +/// 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..d7ddf43 --- /dev/null +++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs @@ -0,0 +1,415 @@ +// 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); + } + } + + 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.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 (!IsNormalizedPath(source.Path) || + source.Path.Contains('\\') || + 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 path 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) && + IsNormalizedPath(identity.Path) && + !identity.Path.StartsWith('/') && + !identity.Path.Contains('\\') && + !identity.Path.Split('/').Any(segment => string.Equals(segment, ".", StringComparison.Ordinal) || string.Equals(segment, "..", StringComparison.Ordinal)); + + 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..cf94539 --- /dev/null +++ b/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs @@ -0,0 +1,837 @@ +// 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); + } + + 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), + EmittedFactCapabilities = Canonical(descriptor.EmittedFactCapabilities) + }; + } + + 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/AdapterDescriptors.cs b/Source/DotNET/Generation.Contracts/AdapterDescriptors.cs new file mode 100644 index 0000000..a7ea246 --- /dev/null +++ b/Source/DotNET/Generation.Contracts/AdapterDescriptors.cs @@ -0,0 +1,226 @@ +// 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 +} + +/// +/// 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 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..80cbe3a --- /dev/null +++ b/Source/DotNET/Generation.Contracts/AdapterProbes.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. + +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 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 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 diagnostics explaining why execution is blocked. + /// + public 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..bb2c2e4 --- /dev/null +++ b/Source/DotNET/Generation.Contracts/AdapterRuns.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. + +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 +} + +/// +/// 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 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.Specs/for_AdapterContributionAdmission/given/a_contribution.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/given/a_contribution.cs new file mode 100644 index 0000000..3715649 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/given/a_contribution.cs @@ -0,0 +1,265 @@ +// 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.for_AdapterContributionAdmission.given; + +public class a_contribution : Specification +{ + protected static readonly AdapterIdentity Adapter = new() { Id = "atomic", Version = "1.2.3" }; + protected static readonly SubjectId ArtifactSubject = Subject("artifact"); + protected static readonly SubjectId ScenarioSubject = Subject("scenario"); + protected static readonly SubjectId StepSubject = Subject("scenario/step/0"); + protected static readonly SubjectId ValueSubject = Subject("scenario/step/0/arguments/name"); + protected static readonly SubjectId ExternalSubject = new() { Value = "python://catalog/shared-type" }; + + protected static AdapterDescriptor Descriptor(params GenerationFactCapability[] capabilities) => new() + { + Identity = Adapter, + SourceLanguage = AdapterSourceLanguage.CSharp, + Category = AdapterCategory.ApplicationFramework, + CompatibleGenerationVersions = new GenerationVersionRange + { + MinimumInclusive = new Version(0, 14, 0), + MaximumExclusive = new Version(2, 0, 0) + }, + RequiredHostCapabilities = + [ + AdapterHostCapability.SemanticAnalysis, + AdapterHostCapability.AuthoredSource, + AdapterHostCapability.SemanticAnalysis + ], + EmittedFactCapabilities = capabilities.Length == 0 + ? AllCapabilities() + : [.. capabilities] + }; + + protected static ImmutableArray AllCapabilities() => + [ + GenerationFactCapability.SpecificationValue, + GenerationFactCapability.Relationship, + GenerationFactCapability.Artifact, + GenerationFactCapability.ConceptValidationRule, + GenerationFactCapability.SpecificationScenario, + GenerationFactCapability.ArtifactPlacement, + GenerationFactCapability.ConceptAttribute, + GenerationFactCapability.SpecificationStep, + GenerationFactCapability.ConceptRepresentation, + GenerationFactCapability.Artifact + ]; + + protected static List EveryFact( + IReadOnlyList? properties = null, + IReadOnlyList? scenarioSteps = null, + IReadOnlyList? valuePath = null) + { + var scenarioKey = ScenarioKey(); + var stepKey = StepKey(); + var valueKey = ValueKey(valuePath ?? ["arguments", "name"]); + return + [ + new ArtifactFact + { + Id = Id("artifact"), + Subject = ArtifactSubject, + Evidence = Evidence(), + Definition = new ArtifactDefinition + { + Key = ArtifactKey(ArtifactSubject, ArtifactKind.Command), + Name = "Register", + Properties = properties ?? + [ + new PropertyDefinition + { + Name = "second", + Type = new TypeReferenceDefinition { Name = "External", Subject = ExternalSubject } + }, + new PropertyDefinition + { + Name = "first", + Type = new TypeReferenceDefinition { Name = "String" } + } + ] + } + }, + new ArtifactPlacementFact + { + Id = Id("placement"), + Subject = ArtifactSubject, + Evidence = Evidence(), + Artifact = ArtifactKey(ArtifactSubject, ArtifactKind.Command), + Placement = new ArtifactPlacement + { + Module = "Accounts", + Features = ["Registration", "Commands"], + Slice = "Register", + SliceKind = GenerationSliceKind.StateChange + } + }, + new RelationshipFact + { + Id = Id("relationship"), + Subject = ArtifactSubject, + Evidence = Evidence(), + Definition = new RelationshipDefinition + { + Key = new RelationshipKey + { + Kind = RelationshipKind.Produces, + Source = ArtifactSubject, + Target = ExternalSubject + } + } + }, + new ConceptRepresentationFact + { + Id = Id("concept-representation"), + Subject = ArtifactSubject, + Evidence = Evidence(), + Definition = new ConceptRepresentationDefinition + { + Concept = ArtifactSubject, + Kind = ConceptRepresentationKind.Primitive, + Primitive = GenerationPrimitiveKind.Text + } + }, + new ConceptAttributeFact + { + Id = Id("concept-attribute"), + Subject = ArtifactSubject, + Evidence = Evidence(), + Definition = new ConceptAttributeDefinition + { + Concept = ArtifactSubject, + Kind = ConceptAttributeKind.Named, + Name = "sensitive" + } + }, + new ConceptValidationRuleFact + { + Id = Id("concept-validation"), + Subject = ArtifactSubject, + Evidence = Evidence(), + Definition = new ConceptValidationRuleDefinition + { + Concept = ArtifactSubject, + RuleIdentity = "not-empty", + Kind = ConceptValidationRuleKind.NamedPredicate, + Predicate = "IsNotEmpty" + } + }, + new SpecificationScenarioFact + { + Id = Id("scenario"), + Subject = ScenarioSubject, + Evidence = Evidence(), + Definition = new SpecificationScenarioDefinition + { + Key = scenarioKey, + Name = "registering an account", + TargetArtifact = ArtifactKey(ExternalSubject, ArtifactKind.Command), + Steps = scenarioSteps ?? [stepKey] + } + }, + new SpecificationStepFact + { + Id = Id("step"), + Subject = StepSubject, + Evidence = Evidence(), + Definition = new SpecificationStepDefinition + { + Key = stepKey, + Phase = SpecificationStepPhase.When, + Kind = SpecificationStepKind.Command, + Artifact = ArtifactKey(ExternalSubject, ArtifactKind.Command), + Values = [valueKey] + } + }, + new SpecificationValueFact + { + Id = Id("value"), + Subject = ValueSubject, + Evidence = Evidence(), + Definition = new SpecificationValueDefinition + { + Key = valueKey, + Kind = SpecificationValueKind.Text, + Type = new TypeReferenceDefinition { Name = "External", Subject = ExternalSubject }, + Scalar = "Alice" + } + } + ]; + } + + protected static AdapterContribution Contribution( + IReadOnlyList? facts = null, + IReadOnlyList? diagnostics = null, + AdapterIdentity? adapter = null) => new() + { + Adapter = adapter ?? Adapter, + Facts = facts ?? EveryFact(), + Diagnostics = diagnostics ?? [] + }; + + protected static AdapterContributionAdmissionResult Admit( + AdapterDescriptor? descriptor = null, + AdapterContribution? contribution = null, + ISourceAuthorityValidator? validator = null) => + AdapterContributionAdmission.Admit( + descriptor ?? Descriptor(), + contribution ?? Contribution(), + validator ?? AcceptingSourceAuthorityValidator.Instance); + + protected static FactId Id(string value) => new() { Value = $"{Adapter.Id}:{value}" }; + + sealed class AcceptingSourceAuthorityValidator : ISourceAuthorityValidator + { + public static AcceptingSourceAuthorityValidator Instance { get; } = new(); + + public bool IsAuthoritative(SourceRange source) => true; + } + + protected static Evidence Evidence(int line = 3) => new() + { + Adapter = Adapter, + Strength = EvidenceStrength.Exact, + Source = Source(line) + }; + + protected static SourceRange Source(int line = 3) => new() + { + Path = "Accounts/Register.cs", + FileIdentity = new SourceFileIdentity + { + Project = "Accounts", + Path = "Accounts/Register.cs" + }, + StartLine = line, + StartColumn = 1, + EndLine = line, + EndColumn = 20 + }; + + protected static ArtifactKey ArtifactKey(SubjectId subject, ArtifactKind kind) => new() + { + Subject = subject, + Kind = kind + }; + + protected static SpecificationScenarioKey ScenarioKey() => new() { Scenario = ScenarioSubject }; + + protected static SpecificationStepKey StepKey() => new() + { + Scenario = ScenarioKey(), + Index = 0 + }; + + protected static SpecificationValueKey ValueKey(IReadOnlyList path) => new() + { + Step = StepKey(), + Path = path + }; + + protected static SubjectId Subject(string path) => new() { Value = $"dotnet://Accounts/{path}" }; +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_a_valid_contribution.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_a_valid_contribution.cs new file mode 100644 index 0000000..df5d2c0 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_a_valid_contribution.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission; + +public class when_admitting_a_valid_contribution : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() => _result = Admit(); + + [Fact] void should_admit_the_complete_contribution() => _result.IsAdmitted.ShouldBeTrue(); + [Fact] void should_not_report_admission_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_freeze_every_current_fact_family() => _result.Snapshot!.Facts.Select(fact => fact.GetType()).ShouldContainOnly( + typeof(ArtifactFact), + typeof(ArtifactPlacementFact), + typeof(RelationshipFact), + typeof(ConceptRepresentationFact), + typeof(ConceptAttributeFact), + typeof(ConceptValidationRuleFact), + typeof(SpecificationScenarioFact), + typeof(SpecificationStepFact), + typeof(SpecificationValueFact)); + [Fact] void should_canonicalize_and_deduplicate_required_host_capabilities() => string.Join('|', _result.Snapshot!.Descriptor.RequiredHostCapabilities).ShouldEqual("AuthoredSource|SemanticAnalysis"); + [Fact] void should_canonicalize_and_deduplicate_emitted_fact_capabilities() => _result.Snapshot!.Descriptor.EmittedFactCapabilities.ShouldEqual(Enum.GetValues().Where(value => value != GenerationFactCapability.Unknown)); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_cross_adapter_subject_references.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_cross_adapter_subject_references.cs new file mode 100644 index 0000000..6a47167 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_cross_adapter_subject_references.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission; + +public class when_admitting_cross_adapter_subject_references : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() + { + var facts = EveryFact(); + _result = Admit( + Descriptor(GenerationFactCapability.Artifact, GenerationFactCapability.Relationship), + Contribution([facts[0], facts[2]])); + } + + [Fact] void should_admit_structurally_valid_references_not_declared_by_this_adapter() => _result.IsAdmitted.ShouldBeTrue(); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_a_mismatched_contribution_adapter.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_a_mismatched_contribution_adapter.cs new file mode 100644 index 0000000..5341595 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_a_mismatched_contribution_adapter.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_a_mismatched_contribution_adapter : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() => _result = Admit(contribution: Contribution(adapter: new AdapterIdentity { Id = "other", Version = "1.2.3" })); + + [Fact] void should_reject_the_whole_contribution() => _result.Snapshot.ShouldBeNull(); + [Fact] void should_report_the_typed_adapter_mismatch() => _result.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain(AdapterContributionAdmissionDiagnosticCode.ContributionAdapterMismatch); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_invalid_descriptor_identity.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_invalid_descriptor_identity.cs new file mode 100644 index 0000000..9f58ff8 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_invalid_descriptor_identity.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_an_invalid_descriptor_identity : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() => _result = Admit(Descriptor() with + { + Identity = new AdapterIdentity { Id = "atomic:child", Version = " " } + }); + + [Fact] void should_reject_the_whole_contribution() => _result.Snapshot.ShouldBeNull(); + [Fact] void should_report_each_malformed_identity_part_deterministically() => _result.Diagnostics.Count(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.InvalidDescriptorIdentity).ShouldEqual(2); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_undeclared_fact_capability.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_undeclared_fact_capability.cs new file mode 100644 index 0000000..4fcec5e --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_an_undeclared_fact_capability.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_an_undeclared_fact_capability : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() => _result = Admit(Descriptor(GenerationFactCapability.Artifact)); + + [Fact] void should_reject_the_whole_contribution() => _result.Snapshot.ShouldBeNull(); + [Fact] void should_report_each_undeclared_runtime_fact_family() => _result.Diagnostics.Count(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.UndeclaredFactCapability).ShouldEqual(8); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_inconsistent_ownership.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_inconsistent_ownership.cs new file mode 100644 index 0000000..e56cf9b --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_inconsistent_ownership.cs @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_inconsistent_ownership : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() + { + var facts = EveryFact(); + var artifact = (ArtifactFact)facts[0]; + facts[0] = artifact with + { + Definition = artifact.Definition with + { + Key = artifact.Definition.Key with { Subject = ExternalSubject } + } + }; + var placement = (ArtifactPlacementFact)facts[1]; + facts[1] = placement with { Artifact = placement.Artifact with { Subject = ExternalSubject } }; + var relationship = (RelationshipFact)facts[2]; + facts[2] = relationship with + { + Definition = relationship.Definition with + { + Key = relationship.Definition.Key with { Source = ExternalSubject } + } + }; + var concept = (ConceptRepresentationFact)facts[3]; + facts[3] = concept with + { + Definition = concept.Definition with { Concept = ExternalSubject } + }; + var scenario = (SpecificationScenarioFact)facts[6]; + facts[6] = scenario with + { + Definition = scenario.Definition with + { + Key = new SpecificationScenarioKey { Scenario = ExternalSubject } + } + }; + var step = (SpecificationStepFact)facts[7]; + facts[7] = step with + { + Definition = step.Definition with + { + Values = + [ + new SpecificationValueKey + { + Step = new SpecificationStepKey { Scenario = ScenarioKey(), Index = 1 }, + Path = ["arguments", "name"] + } + ] + } + }; + var value = (SpecificationValueFact)facts[8]; + facts[8] = value with + { + Definition = value.Definition with + { + Children = [ValueKey(["unrelated"])] + } + }; + _result = Admit(contribution: Contribution(facts)); + } + + [Fact] void should_reject_the_whole_contribution() => _result.Snapshot.ShouldBeNull(); + [Fact] void should_report_every_inconsistent_ownership_chain() => _result.Diagnostics.Count(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.OwnershipMismatch).ShouldBeGreaterThan(6); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_invalid_fact_identities.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_invalid_fact_identities.cs new file mode 100644 index 0000000..154c178 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_invalid_fact_identities.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_invalid_fact_identities : given.a_contribution +{ + AdapterContributionAdmissionResult _duplicate = null!; + AdapterContributionAdmissionResult _empty = null!; + AdapterContributionAdmissionResult _unscoped = null!; + + void Because() + { + var duplicateFacts = EveryFact(); + duplicateFacts[1] = duplicateFacts[1] with { Id = duplicateFacts[0].Id }; + _duplicate = Admit(contribution: Contribution(duplicateFacts)); + + var emptyFacts = EveryFact(); + emptyFacts[0] = emptyFacts[0] with { Id = new FactId { Value = string.Empty } }; + _empty = Admit(contribution: Contribution(emptyFacts)); + + var unscopedFacts = EveryFact(); + unscopedFacts[0] = unscopedFacts[0] with { Id = new FactId { Value = "legacy-artifact" } }; + _unscoped = Admit(contribution: Contribution(unscopedFacts)); + } + + [Fact] void should_reject_duplicate_fact_identities_atomically() => AssertRejected(_duplicate, AdapterContributionAdmissionDiagnosticCode.DuplicateFactId); + [Fact] void should_reject_empty_fact_identities_atomically() => AssertRejected(_empty, AdapterContributionAdmissionDiagnosticCode.InvalidFactId); + [Fact] void should_reject_unscoped_fact_identities_without_rewriting_them() => AssertRejected(_unscoped, AdapterContributionAdmissionDiagnosticCode.UnscopedFactId); + + static void AssertRejected(AdapterContributionAdmissionResult result, AdapterContributionAdmissionDiagnosticCode code) + { + result.Snapshot.ShouldBeNull(); + result.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain(code); + } +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_malformed_subjects.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_malformed_subjects.cs new file mode 100644 index 0000000..e03a7d0 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_malformed_subjects.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_malformed_subjects : given.a_contribution +{ + AdapterContributionAdmissionResult _factSubject = null!; + AdapterContributionAdmissionResult _nestedSubject = null!; + + void Because() + { + var malformedFactSubject = EveryFact(); + malformedFactSubject[0] = malformedFactSubject[0] with { Subject = new SubjectId { Value = "relative/artifact" } }; + _factSubject = Admit(contribution: Contribution(malformedFactSubject)); + + var malformedNestedSubject = EveryFact(); + var artifact = (ArtifactFact)malformedNestedSubject[0]; + var properties = artifact.Definition.Properties.ToArray(); + properties[0] = properties[0] with + { + Type = properties[0].Type with { Subject = new SubjectId { Value = "dotnet://Accounts/bad subject" } } + }; + malformedNestedSubject[0] = artifact with + { + Definition = artifact.Definition with { Properties = properties } + }; + _nestedSubject = Admit(contribution: Contribution(malformedNestedSubject)); + } + + [Fact] void should_reject_malformed_fact_subjects_atomically() => AssertRejected(_factSubject); + [Fact] void should_reject_malformed_nested_referenced_subjects_atomically() => AssertRejected(_nestedSubject); + + static void AssertRejected(AdapterContributionAdmissionResult result) + { + result.Snapshot.ShouldBeNull(); + result.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain(AdapterContributionAdmissionDiagnosticCode.InvalidSubject); + } +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_mismatched_evidence.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_mismatched_evidence.cs new file mode 100644 index 0000000..0124044 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_mismatched_evidence.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_mismatched_evidence : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() + { + var facts = EveryFact(); + facts[0] = facts[0] with + { + Evidence = Evidence() with + { + Adapter = new AdapterIdentity { Id = "other", Version = "1.2.3" } + } + }; + _result = Admit(contribution: Contribution(facts)); + } + + [Fact] void should_reject_the_whole_contribution() => _result.Snapshot.ShouldBeNull(); + [Fact] void should_report_the_typed_evidence_mismatch() => _result.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain(AdapterContributionAdmissionDiagnosticCode.EvidenceAdapterMismatch); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_missing_kind_operands.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_missing_kind_operands.cs new file mode 100644 index 0000000..b07a6ae --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_missing_kind_operands.cs @@ -0,0 +1,37 @@ +// 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.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_missing_kind_operands : given.a_contribution +{ + AdapterContributionAdmissionResult _result = null!; + + void Because() + { + var facts = EveryFact(); + var representation = (ConceptRepresentationFact)facts[3]; + facts[3] = representation with + { + Definition = representation.Definition with { Primitive = null } + }; + var step = (SpecificationStepFact)facts[7]; + facts[7] = step with + { + Definition = step.Definition with { Artifact = null } + }; + var value = (SpecificationValueFact)facts[8]; + facts[8] = value with + { + Definition = value.Definition with + { + Kind = SpecificationValueKind.Boolean, + Scalar = "True" + } + }; + _result = Admit(contribution: Contribution(facts)); + } + + [Fact] void should_reject_the_whole_contribution() => _result.Snapshot.ShouldBeNull(); + [Fact] void should_report_each_missing_or_invalid_kind_operand() => _result.Diagnostics.Count(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.InvalidKindOperand).ShouldEqual(3); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_null_or_blank_required_values.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_null_or_blank_required_values.cs new file mode 100644 index 0000000..e22baa0 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_null_or_blank_required_values.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.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_null_or_blank_required_values : given.a_contribution +{ + AdapterContributionAdmissionResult _blank = null!; + AdapterContributionAdmissionResult _nullList = null!; + AdapterContributionAdmissionResult _nullNestedList = null!; + + void Because() + { + var blankFacts = EveryFact(); + var blankArtifact = (ArtifactFact)blankFacts[0]; + blankFacts[0] = blankArtifact with + { + Definition = blankArtifact.Definition with { Name = " " } + }; + _blank = Admit(contribution: Contribution(blankFacts)); + + _nullList = Admit(contribution: Contribution() with { Facts = null! }); + + var nullNestedFacts = EveryFact(); + var nullNestedArtifact = (ArtifactFact)nullNestedFacts[0]; + nullNestedFacts[0] = nullNestedArtifact with + { + Definition = nullNestedArtifact.Definition with { Properties = null! } + }; + _nullNestedList = Admit(contribution: Contribution(nullNestedFacts)); + } + + [Fact] void should_reject_blank_required_names_atomically() => AssertRejected(_blank, AdapterContributionAdmissionDiagnosticCode.MissingRequiredValue); + [Fact] void should_reject_null_required_contribution_lists_atomically() => AssertRejected(_nullList, AdapterContributionAdmissionDiagnosticCode.NullRequiredCollection); + [Fact] void should_reject_null_required_nested_lists_atomically() => AssertRejected(_nullNestedList, AdapterContributionAdmissionDiagnosticCode.NullRequiredCollection); + + static void AssertRejected(AdapterContributionAdmissionResult result, AdapterContributionAdmissionDiagnosticCode code) + { + result.Snapshot.ShouldBeNull(); + result.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain(code); + } +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_unknown_or_undefined_enums.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_unknown_or_undefined_enums.cs new file mode 100644 index 0000000..e45df88 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_unknown_or_undefined_enums.cs @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_unknown_or_undefined_enums : given.a_contribution +{ + AdapterContributionAdmissionResult _descriptorUnknown = null!; + AdapterContributionAdmissionResult _descriptorUndefined = null!; + AdapterContributionAdmissionResult _factUnknown = null!; + AdapterContributionAdmissionResult _evidenceUndefined = null!; + + void Because() + { + _descriptorUnknown = Admit(Descriptor() with { SourceLanguage = AdapterSourceLanguage.Unknown }); + _descriptorUndefined = Admit(Descriptor() with { Category = (AdapterCategory)731 }); + + var unknownFacts = EveryFact(); + var artifact = (ArtifactFact)unknownFacts[0]; + unknownFacts[0] = artifact with + { + Definition = artifact.Definition with + { + Key = artifact.Definition.Key with { Kind = ArtifactKind.Unknown } + } + }; + _factUnknown = Admit(contribution: Contribution(unknownFacts)); + + var undefinedEvidence = EveryFact(); + undefinedEvidence[0] = undefinedEvidence[0] with + { + Evidence = Evidence() with { Strength = (EvidenceStrength)731 } + }; + _evidenceUndefined = Admit(contribution: Contribution(undefinedEvidence)); + } + + [Fact] void should_reject_unknown_descriptor_enums() => AssertRejected(_descriptorUnknown, AdapterContributionAdmissionDiagnosticCode.UnknownEnumValue); + [Fact] void should_reject_undefined_descriptor_enums() => AssertRejected(_descriptorUndefined, AdapterContributionAdmissionDiagnosticCode.UndefinedEnumValue); + [Fact] void should_reject_unknown_fact_enums() => AssertRejected(_factUnknown, AdapterContributionAdmissionDiagnosticCode.UnknownEnumValue); + [Fact] void should_reject_undefined_evidence_enums() => AssertRejected(_evidenceUndefined, AdapterContributionAdmissionDiagnosticCode.UndefinedEnumValue); + + static void AssertRejected(AdapterContributionAdmissionResult result, AdapterContributionAdmissionDiagnosticCode code) + { + result.Snapshot.ShouldBeNull(); + result.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain(code); + } +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_reversed_inputs.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_reversed_inputs.cs new file mode 100644 index 0000000..3d5547e --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_reversed_inputs.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission; + +public class when_admitting_reversed_inputs : given.a_contribution +{ + string _forward = null!; + string _reverse = null!; + AdapterContributionSnapshot _reversedSnapshot = null!; + string _forwardRejected = null!; + string _reverseRejected = null!; + + void Because() + { + var facts = EveryFact(); + var forward = Admit(Descriptor(), Contribution(facts)).Snapshot!; + var reversedDescriptor = Descriptor() with + { + RequiredHostCapabilities = [.. Descriptor().RequiredHostCapabilities.Reverse()], + EmittedFactCapabilities = [.. Descriptor().EmittedFactCapabilities.Reverse()] + }; + _reversedSnapshot = Admit(reversedDescriptor, Contribution([.. facts.AsEnumerable().Reverse()])).Snapshot!; + _forward = Project(forward); + _reverse = Project(_reversedSnapshot); + + var malformed = facts.Select(fact => fact is ArtifactFact artifact + ? artifact with { Definition = artifact.Definition with { Properties = null! } } + : fact).ToArray(); + _forwardRejected = RejectedProjection(Admit(contribution: Contribution(malformed))); + _reverseRejected = RejectedProjection(Admit(contribution: Contribution([.. malformed.AsEnumerable().Reverse()]))); + } + + [Fact] void should_produce_the_same_canonical_snapshot_projection() => _reverse.ShouldEqual(_forward); + [Fact] void should_preserve_authored_property_order() => _reversedSnapshot.Facts.OfType().Single().Definition.Properties.Select(property => property.Name).ShouldEqual(["second", "first"]); + [Fact] void should_preserve_authored_value_path_order() => string.Join('|', _reversedSnapshot.Facts.OfType().Single().Definition.Key.Path).ShouldEqual("arguments|name"); + [Fact] void should_order_rejected_freeze_diagnostics_independently_of_fact_order() => _reverseRejected.ShouldEqual(_forwardRejected); + + static string RejectedProjection(AdapterContributionAdmissionResult result) => string.Join( + '|', + result.Diagnostics.Select(diagnostic => $"{diagnostic.Code}:{diagnostic.Path}:{diagnostic.Fact?.Value}:{diagnostic.Source?.Path}:{diagnostic.Source?.StartLine}:{diagnostic.Source?.StartColumn}")); + + static string Project(AdapterContributionSnapshot snapshot) => string.Join( + '|', + string.Join(',', snapshot.Descriptor.RequiredHostCapabilities.Select(value => ((int)value).ToString(CultureInfo.InvariantCulture))), + string.Join(',', snapshot.Descriptor.EmittedFactCapabilities.Select(value => ((int)value).ToString(CultureInfo.InvariantCulture))), + string.Join(',', snapshot.Facts.Select(fact => $"{fact.Id.Value}:{fact.Subject.Value}:{fact.GetType().Name}")), + string.Join(',', snapshot.Facts.OfType().Single().Definition.Properties.Select(property => property.Name)), + string.Join(',', snapshot.Facts.OfType().Single().Definition.Key.Path)); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_mutating_admitted_input.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_mutating_admitted_input.cs new file mode 100644 index 0000000..5682f1b --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_mutating_admitted_input.cs @@ -0,0 +1,45 @@ +// 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.for_AdapterContributionAdmission; + +public class when_mutating_admitted_input : given.a_contribution +{ + readonly List _properties = + [ + new PropertyDefinition + { + Name = "second", + Type = new TypeReferenceDefinition { Name = "External", Subject = ExternalSubject } + }, + new PropertyDefinition + { + Name = "first", + Type = new TypeReferenceDefinition { Name = "String" } + } + ]; + readonly List _path = ["arguments", "name"]; + List _facts = null!; + GenerationFact _originalFact = null!; + AdapterContributionAdmissionResult _result = null!; + + void Establish() + { + _facts = EveryFact(_properties, valuePath: _path); + _originalFact = _facts[0]; + } + + void Because() + { + _result = Admit(contribution: Contribution(_facts)); + _facts.Clear(); + _properties.Clear(); + _path.Reverse(); + _path.Clear(); + } + + [Fact] void should_keep_the_frozen_fact_list() => _result.Snapshot!.Facts.Length.ShouldEqual(9); + [Fact] void should_deep_copy_fact_records() => ReferenceEquals(_originalFact, _result.Snapshot!.Facts.Single(fact => fact.Id.Value == "atomic:artifact")).ShouldBeFalse(); + [Fact] void should_keep_nested_properties_in_authored_order() => _result.Snapshot!.Facts.OfType().Single().Definition.Properties.Select(property => property.Name).ShouldEqual(["second", "first"]); + [Fact] void should_keep_nested_value_paths_in_authored_order() => string.Join('|', _result.Snapshot!.Facts.OfType().Single().Definition.Key.Path).ShouldEqual("arguments|name"); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_reading_adapter_contract_discriminators.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_reading_adapter_contract_discriminators.cs new file mode 100644 index 0000000..f723c94 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_reading_adapter_contract_discriminators.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission; + +public class when_reading_adapter_contract_discriminators : Specification +{ + [Fact] void should_define_every_new_unknown_discriminator_as_minus_one() + { + ((int)AdapterSourceLanguage.Unknown).ShouldEqual(-1); + ((int)AdapterCategory.Unknown).ShouldEqual(-1); + ((int)AdapterHostCapability.Unknown).ShouldEqual(-1); + ((int)GenerationFactCapability.Unknown).ShouldEqual(-1); + ((int)AdapterRunDisposition.Unknown).ShouldEqual(-1); + ((int)GenerationFactDisposition.Unknown).ShouldEqual(-1); + ((int)AdapterContributionAdmissionDiagnosticCode.Unknown).ShouldEqual(-1); + ((int)GenerationDiagnosticSeverity.Unknown).ShouldEqual(-1); + } + + [Fact] void should_preserve_existing_diagnostic_severity_values() + { + ((int)GenerationDiagnosticSeverity.Information).ShouldEqual(0); + ((int)GenerationDiagnosticSeverity.Warning).ShouldEqual(1); + ((int)GenerationDiagnosticSeverity.Error).ShouldEqual(2); + } +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_validating_source_authority.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_validating_source_authority.cs new file mode 100644 index 0000000..f071a9a --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_validating_source_authority.cs @@ -0,0 +1,63 @@ +// 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.for_AdapterContributionAdmission; + +public class when_validating_source_authority : given.a_contribution +{ + AdapterContributionAdmissionResult _accepted = null!; + AdapterContributionAdmissionResult _rejected = null!; + AdapterContributionAdmissionResult _rejectedReversed = null!; + AdapterContributionAdmissionResult _withoutValidator = null!; + SourceAuthorityValidator _acceptingValidator = null!; + SourceAuthorityValidator _rejectingValidator = null!; + + void Because() + { + var contribution = Contribution( + diagnostics: + [ + new GenerationDiagnostic + { + Code = "ATOMIC0001", + Severity = GenerationDiagnosticSeverity.Warning, + Outcome = GenerationDiagnosticOutcome.Unsupported, + Message = "A source behavior cannot be represented", + Source = Source(20), + Subject = ArtifactSubject + } + ]); + _acceptingValidator = new(true); + _rejectingValidator = new(false); + _accepted = Admit(contribution: contribution, validator: _acceptingValidator); + _rejected = Admit(contribution: contribution, validator: _rejectingValidator); + _rejectedReversed = Admit( + contribution: contribution with { Facts = [.. contribution.Facts.AsEnumerable().Reverse()] }, + validator: new SourceAuthorityValidator(false)); + _withoutValidator = AdapterContributionAdmission.Admit(Descriptor(), contribution); + } + + [Fact] void should_admit_authoritative_fact_and_diagnostic_ranges() => _accepted.IsAdmitted.ShouldBeTrue(); + [Fact] void should_validate_every_fact_and_contribution_diagnostic_range() => _acceptingValidator.Validated.Count.ShouldEqual(10); + [Fact] void should_reject_nonauthoritative_fact_and_diagnostic_ranges_atomically() => _rejected.Snapshot.ShouldBeNull(); + [Fact] void should_report_every_nonauthoritative_range() => _rejected.Diagnostics.Count(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.SourceNotAuthoritative).ShouldEqual(10); + [Fact] void should_reject_source_evidence_when_no_authority_validator_is_supplied() => _withoutValidator.Snapshot.ShouldBeNull(); + [Fact] void should_require_authority_for_every_unvalidated_range() => _withoutValidator.Diagnostics.Count(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.SourceAuthorityRequired).ShouldEqual(10); + [Fact] void should_order_rejected_source_diagnostics_independently_of_fact_order() => Projection(_rejectedReversed).ShouldEqual(Projection(_rejected)); + + static string[] Projection(AdapterContributionAdmissionResult result) => + [ + .. result.Diagnostics.Select(diagnostic => $"{diagnostic.Code}|{diagnostic.Path}|{diagnostic.Fact?.Value}|{diagnostic.Source?.FileIdentity?.Project}|{diagnostic.Source?.FileIdentity?.Path}|{diagnostic.Source?.Path}|{diagnostic.Source?.StartLine}:{diagnostic.Source?.StartColumn}-{diagnostic.Source?.EndLine}:{diagnostic.Source?.EndColumn}") + ]; + + sealed class SourceAuthorityValidator(bool isAuthoritative) : ISourceAuthorityValidator + { + public List Validated { get; } = []; + + public bool IsAuthoritative(SourceRange source) + { + Validated.Add(source); + return isAuthoritative; + } + } +} From a3c849fc52fc0d6ee7128e9ca25aab39c5e095ca Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 27 Aug 2026 22:04:18 +0200 Subject: [PATCH 2/4] Add deterministic .NET adapter runner --- .../AdapterContributionAdmissionContracts.cs | 12 +- .../AdapterContributionAdmissionValidator.cs | 93 ++- .../AdapterContributionFreezer.cs | 28 +- .../AdapterDescriptorAdmission.cs | 50 ++ .../AdapterDescriptors.cs | 16 + .../Generation.Contracts/AdapterProbes.cs | 14 +- .../Generation.Contracts/AdapterRuns.cs | 22 +- .../given/a_runner_context.cs | 164 +++++ ...itting_contributions_with_source_ranges.cs | 116 ++++ ...en_callbacks_throw_under_reversed_input.cs | 50 ++ .../when_descriptor_getters_fail.cs | 58 ++ ...neration_contract_version_compatibility.cs | 77 +++ ..._depends_on_physical_or_ambiguous_paths.cs | 71 ++ .../when_modern_probes_are_malformed.cs | 54 ++ .../when_mutating_adapter_inputs_after_run.cs | 42 ++ ...e_descriptors_with_identical_identities.cs | 64 ++ ...ering_probe_evidence_with_shared_starts.cs | 64 ++ ..._roster_has_duplicate_stable_identities.cs | 60 ++ ...en_registering_invalid_api_capabilities.cs | 27 + .../when_required_capabilities_are_missing.cs | 32 + ..._requiring_available_project_references.cs | 41 ++ ...hen_reversing_roster_and_project_inputs.cs | 69 ++ ...ce_independent_adapter_without_projects.cs | 21 + .../when_running_duplicate_adapter_ids.cs | 27 + .../when_running_legacy_registrations.cs | 76 +++ .../when_running_mixed_adapter_outcomes.cs | 59 ++ ...ce_ranges_use_nonportable_display_paths.cs | 210 ++++++ ...legacy_projects_without_source_contexts.cs | 35 + ...n_comparing_modern_and_legacy_execution.cs | 73 ++ ..._without_safe_applicable_source_mapping.cs | 33 + ...ternative_vogen_declaration_api_subsets.cs | 121 ++++ ...n_using_the_modern_descriptor_and_probe.cs | 43 ++ .../given/a_vogen_compilation.cs | 56 +- .../VogenAdapterApiCapabilities.cs | 20 + .../VogenConceptScreenplayAdapter.cs | 110 ++- .../VogenGenerationDiagnosticCodes.cs | 5 + .../DotNetAdapterDiagnostics.cs | 73 ++ .../DotNetAdapterGenerationDiagnosticCodes.cs | 60 ++ .../DotNetAdapterProbeAdmission.cs | 207 ++++++ .../DotNetAdapterRegistration.cs | 83 +++ .../Generation.DotNet/DotNetAdapterRunner.cs | 639 ++++++++++++++++++ .../DotNetHostCapabilities.cs | 78 +++ .../DotNetProjectCompilation.cs | 5 +- .../DotNetSourceAuthorityValidator.cs | 181 +++++ .../IDescribedDotNetScreenplayAdapter.cs | 30 + .../with_nonportable_source_range_paths.cs | 47 ++ ...alformed_and_duplicate_api_capabilities.cs | 26 + ...hen_admitting_reversed_api_capabilities.cs | 28 + 48 files changed, 3552 insertions(+), 18 deletions(-) create mode 100644 Source/DotNET/Generation.Contracts/AdapterDescriptorAdmission.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/given/a_runner_context.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_admitting_contributions_with_source_ranges.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_callbacks_throw_under_reversed_input.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_descriptor_getters_fail.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_enforcing_generation_contract_version_compatibility.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_legacy_project_order_depends_on_physical_or_ambiguous_paths.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_modern_probes_are_malformed.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_mutating_adapter_inputs_after_run.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_duplicate_descriptors_with_identical_identities.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_ordering_probe_evidence_with_shared_starts.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_project_roster_has_duplicate_stable_identities.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_registering_invalid_api_capabilities.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_required_capabilities_are_missing.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_requiring_available_project_references.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_reversing_roster_and_project_inputs.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_a_source_independent_adapter_without_projects.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_duplicate_adapter_ids.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_legacy_registrations.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_running_mixed_adapter_outcomes.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAdapterRunner/when_source_ranges_use_nonportable_display_paths.cs create mode 100644 Source/DotNET/Generation.DotNet.Specs/for_DotNetAnalysisContext/when_ordering_legacy_projects_without_source_contexts.cs create mode 100644 Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_comparing_modern_and_legacy_execution.cs create mode 100644 Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_probing_without_safe_applicable_source_mapping.cs create mode 100644 Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_alternative_vogen_declaration_api_subsets.cs create mode 100644 Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_the_modern_descriptor_and_probe.cs create mode 100644 Source/DotNET/Generation.DotNet.Vogen/VogenAdapterApiCapabilities.cs create mode 100644 Source/DotNET/Generation.DotNet/DotNetAdapterDiagnostics.cs create mode 100644 Source/DotNET/Generation.DotNet/DotNetAdapterGenerationDiagnosticCodes.cs create mode 100644 Source/DotNET/Generation.DotNet/DotNetAdapterProbeAdmission.cs create mode 100644 Source/DotNET/Generation.DotNet/DotNetAdapterRegistration.cs create mode 100644 Source/DotNET/Generation.DotNet/DotNetAdapterRunner.cs create mode 100644 Source/DotNET/Generation.DotNet/DotNetHostCapabilities.cs create mode 100644 Source/DotNET/Generation.DotNet/DotNetSourceAuthorityValidator.cs create mode 100644 Source/DotNET/Generation.DotNet/IDescribedDotNetScreenplayAdapter.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_nonportable_source_range_paths.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_malformed_and_duplicate_api_capabilities.cs create mode 100644 Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_reversed_api_capabilities.cs diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs index 2acda2e..427237f 100644 --- a/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs +++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionContracts.cs @@ -113,7 +113,17 @@ public enum AdapterContributionAdmissionDiagnosticCode /// /// Source evidence was supplied without a host authority validator. /// - SourceAuthorityRequired = 19 + SourceAuthorityRequired = 19, + + /// + /// A required API capability identity is malformed. + /// + InvalidApiCapability = 20, + + /// + /// A required API capability occurs more than once. + /// + DuplicateApiCapability = 21 } /// diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs index d7ddf43..62c1816 100644 --- a/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs +++ b/Source/DotNET/Generation.Contracts/AdapterContributionAdmissionValidator.cs @@ -91,7 +91,7 @@ public static void ValidateType( } } - static void ValidateDescriptor( + internal static void ValidateDescriptor( AdapterDescriptor descriptor, AdapterContributionAdmissionContext context) { @@ -121,6 +121,29 @@ static void ValidateDescriptor( $"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( @@ -306,8 +329,7 @@ static void ValidateSource( { var isOrdered = source.EndLine > source.StartLine || (source.EndLine == source.StartLine && source.EndColumn >= source.StartColumn); - if (!IsNormalizedPath(source.Path) || - source.Path.Contains('\\') || + if (!IsPortableRelativePath(source.Path) || source.StartLine < 1 || source.StartColumn < 1 || source.EndLine < 1 || @@ -318,7 +340,7 @@ static void ValidateSource( context.Add( AdapterContributionAdmissionDiagnosticCode.InvalidSourceRange, path, - $"{path} must identify a normalized path and an ordered positive 1-based range", + $"{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); @@ -370,10 +392,65 @@ static bool IsNormalizedPath(string? value) static bool IsFileIdentity(SourceFileIdentity identity) => AdapterContributionText.IsNormalized(identity.Project, true) && - IsNormalizedPath(identity.Path) && - !identity.Path.StartsWith('/') && - !identity.Path.Contains('\\') && - !identity.Path.Split('/').Any(segment => string.Equals(segment, ".", StringComparison.Ordinal) || string.Equals(segment, "..", StringComparison.Ordinal)); + 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) { diff --git a/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs b/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs index cf94539..92ef1ef 100644 --- a/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs +++ b/Source/DotNET/Generation.Contracts/AdapterContributionFreezer.cs @@ -32,7 +32,7 @@ public static FrozenAdapterContributionInput Freeze( return new(frozenDescriptor, adapter, facts, diagnostics); } - static AdapterDescriptor FreezeDescriptor( + internal static AdapterDescriptor FreezeDescriptor( AdapterDescriptor? descriptor, AdapterContributionAdmissionContext context) { @@ -71,10 +71,36 @@ static AdapterDescriptor FreezeDescriptor( 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 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 index a7ea246..c8aad06 100644 --- a/Source/DotNET/Generation.Contracts/AdapterDescriptors.cs +++ b/Source/DotNET/Generation.Contracts/AdapterDescriptors.cs @@ -164,6 +164,17 @@ public enum GenerationFactCapability 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. /// @@ -219,6 +230,11 @@ public sealed record AdapterDescriptor /// 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. /// diff --git a/Source/DotNET/Generation.Contracts/AdapterProbes.cs b/Source/DotNET/Generation.Contracts/AdapterProbes.cs index 80cbe3a..6074952 100644 --- a/Source/DotNET/Generation.Contracts/AdapterProbes.cs +++ b/Source/DotNET/Generation.Contracts/AdapterProbes.cs @@ -15,6 +15,11 @@ public sealed record AdapterProbeEvidence /// 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. /// @@ -37,6 +42,11 @@ public abstract record AdapterProbeResult 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. /// @@ -53,7 +63,7 @@ public sealed record AdapterProbeApplicable : AdapterProbeResult; public sealed record AdapterProbeBlocked : AdapterProbeResult { /// - /// Gets diagnostics explaining why execution is blocked. + /// Gets one or more valid diagnostics explaining why execution is blocked. /// - public ImmutableArray Diagnostics { get; init; } = []; + public required ImmutableArray Diagnostics { get; init; } } diff --git a/Source/DotNET/Generation.Contracts/AdapterRuns.cs b/Source/DotNET/Generation.Contracts/AdapterRuns.cs index bb2c2e4..c29484a 100644 --- a/Source/DotNET/Generation.Contracts/AdapterRuns.cs +++ b/Source/DotNET/Generation.Contracts/AdapterRuns.cs @@ -43,7 +43,12 @@ public enum AdapterRunDisposition /// /// The adapter contribution was admitted. /// - Admitted = 5 + Admitted = 5, + + /// + /// The adapter registration was rejected before probing. + /// + RosterRejected = 6 } /// @@ -167,6 +172,21 @@ public sealed record GenerationFactRecord /// 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. /// 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().ToArray()); + _legacyFactBytes = JsonSerializer.SerializeToUtf8Bytes(rawLegacy.Facts.Cast().ToArray()); + _modernDiagnosticBytes = JsonSerializer.SerializeToUtf8Bytes(rawModern.Diagnostics); + _legacyDiagnosticBytes = JsonSerializer.SerializeToUtf8Bytes(rawLegacy.Diagnostics); + } + + [Fact] void should_keep_modern_and_legacy_facts_semantically_identical() => _modernFacts.ShouldEqual(_legacyFacts); + [Fact] void should_keep_modern_and_legacy_contribution_diagnostics_identical() => _modernDiagnostics.ShouldEqual(_legacyDiagnostics); + [Fact] void should_keep_modern_and_legacy_fact_bytes_identical() => _modernFactBytes.SequenceEqual(_legacyFactBytes).ShouldBeTrue(); + [Fact] void should_keep_modern_and_legacy_diagnostic_bytes_identical() => _modernDiagnosticBytes.SequenceEqual(_legacyDiagnosticBytes).ShouldBeTrue(); + + static string Facts(AdapterRunSnapshot snapshot) => string.Join( + '|', + snapshot.Facts.Select(record => $"{record.Fact.GetType().Name}:{record.Fact.Id.Value}:{record.Fact.Subject.Value}:{record.Fact.Evidence.Source!.FileIdentity}")); + + static string Diagnostics(AdapterRunSnapshot snapshot) => string.Join( + '|', + Contribution(snapshot).Diagnostics.Select(diagnostic => $"{diagnostic.Code}:{diagnostic.Message}:{diagnostic.Source?.FileIdentity}")); + + static AdapterContributionSnapshot Contribution(AdapterRunSnapshot snapshot) => + ((AdapterExecutionCompleted)snapshot.Adapters.Single().Execution).Contribution; +} diff --git a/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_probing_without_safe_applicable_source_mapping.cs b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_probing_without_safe_applicable_source_mapping.cs new file mode 100644 index 0000000..04aadc1 --- /dev/null +++ b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_probing_without_safe_applicable_source_mapping.cs @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.DotNet.Vogen.for_VogenConceptScreenplayAdapter; + +public class when_probing_without_safe_applicable_source_mapping : given.a_vogen_compilation +{ + AdapterProbeResult _applicable = null!; + AdapterProbeResult _empty = null!; + + void Because() + { + var applicableCompilation = CompilationFrom( + "Concepts", + new SourceFile( + "/workspace/Concepts/Code.cs", + """ + namespace Concepts; + [Vogen.ValueObject] + public partial struct Code; + """)); + var emptyCompilation = CompilationFrom( + "Empty", + new SourceFile("/workspace/Empty/Code.cs", "namespace Empty; public partial struct Code;")); + var adapter = new VogenConceptScreenplayAdapter(); + _applicable = adapter.Probe(new DotNetAnalysisContext([Project("Concepts.Project", applicableCompilation)])); + _empty = adapter.Probe(new DotNetAnalysisContext([Project("Empty.Project", emptyCompilation)])); + } + + [Fact] void should_block_applicable_vogen_source_without_stable_mapping() => _applicable.ShouldBeOfExactType(); + [Fact] void should_report_the_stable_unsafe_mapping_diagnostic() => ((AdapterProbeBlocked)_applicable).Diagnostics.Single().Code.ShouldEqual(VogenGenerationDiagnosticCodes.UnsafeSourceMapping); + [Fact] void should_remain_not_applicable_without_an_authored_vogen_declaration() => _empty.ShouldBeOfExactType(); +} diff --git a/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_alternative_vogen_declaration_api_subsets.cs b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_alternative_vogen_declaration_api_subsets.cs new file mode 100644 index 0000000..3264033 --- /dev/null +++ b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_alternative_vogen_declaration_api_subsets.cs @@ -0,0 +1,121 @@ +// 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.Vogen.for_VogenConceptScreenplayAdapter; + +public class when_using_alternative_vogen_declaration_api_subsets : given.a_vogen_compilation +{ + AdapterRunSnapshot _genericModern = null!; + AdapterRunSnapshot _genericLegacy = null!; + AdapterRunSnapshot _nonGenericModern = null!; + AdapterRunSnapshot _nonGenericLegacy = null!; + AdapterProbeApplicable _genericProbe = null!; + AdapterProbeApplicable _nonGenericProbe = null!; + string[] _compilationErrors = null!; + + void Because() + { + var genericCompilation = CompilationFromVogenApiSubset( + "GenericConcepts", + """ + namespace Vogen; + [System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)] + public sealed class ValueObjectAttribute : System.Attribute { } + public readonly struct Validation + { + public static Validation Ok => default; + public static Validation Invalid(string message) => default; + } + """, + new SourceFile( + "/checkout/Generic/CustomerCode.cs", + """ + namespace Concepts; + [Vogen.ValueObject] + public partial struct CustomerCode + { + private static Vogen.Validation Validate(string value) => Vogen.Validation.Invalid("Required"); + } + """)); + var nonGenericCompilation = CompilationFromVogenApiSubset( + "NonGenericConcepts", + """ + namespace Vogen; + [System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)] + public sealed class ValueObjectAttribute : System.Attribute + { + public ValueObjectAttribute(System.Type type) { } + } + """, + new SourceFile( + "/checkout/NonGeneric/CustomerNumber.cs", + """ + namespace Concepts; + [Vogen.ValueObject(typeof(int))] + public partial struct CustomerNumber { } + """)); + _compilationErrors = + [ + .. genericCompilation.GetDiagnostics() + .Concat(nonGenericCompilation.GetDiagnostics()) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.Id) + ]; + + var genericContext = new DotNetAnalysisContext( + [MappedProject("Generic.Project", "generic-project", genericCompilation)]); + var nonGenericContext = new DotNetAnalysisContext( + [MappedProject("NonGeneric.Project", "non-generic-project", nonGenericCompilation)]); + _genericProbe = (AdapterProbeApplicable)new VogenConceptScreenplayAdapter().Probe(genericContext); + _nonGenericProbe = (AdapterProbeApplicable)new VogenConceptScreenplayAdapter().Probe(nonGenericContext); + (_genericModern, _genericLegacy) = RunBoth(genericContext); + (_nonGenericModern, _nonGenericLegacy) = RunBoth(nonGenericContext); + } + + [Fact] void should_compile_the_realistic_api_subsets() => _compilationErrors.ShouldBeEmpty(); + [Fact] void should_execute_with_only_the_generic_declaration_api() => Disposition(_genericModern).ShouldEqual(AdapterRunDisposition.Admitted); + [Fact] void should_execute_with_only_the_non_generic_declaration_api() => Disposition(_nonGenericModern).ShouldEqual(AdapterRunDisposition.Admitted); + [Fact] void should_prove_the_same_declaration_capability_from_either_attribute_shape() => new[] { _genericProbe, _nonGenericProbe }.All(probe => Capabilities(probe).Contains("vogen.value-object-declaration", StringComparer.Ordinal)).ShouldBeTrue(); + [Fact] void should_not_require_validation_for_non_generic_concept_applicability() => Capabilities(_nonGenericProbe).ShouldContainOnly("vogen.value-object-declaration"); + [Fact] void should_capture_validation_result_evidence_when_that_optional_api_is_present() => Capabilities(_genericProbe).ShouldContain("vogen.validation-result"); + [Fact] void should_extract_the_validation_message_when_the_optional_api_is_present() => ValidationMessage(_genericModern).ShouldEqual("Required"); + [Fact] void should_keep_generic_modern_and_legacy_contributions_identical() => Facts(_genericModern).ShouldEqual(Facts(_genericLegacy)); + [Fact] void should_keep_non_generic_modern_and_legacy_contributions_identical() => Facts(_nonGenericModern).ShouldEqual(Facts(_nonGenericLegacy)); + + static (AdapterRunSnapshot Modern, AdapterRunSnapshot Legacy) RunBoth(DotNetAnalysisContext context) + { + var modern = DotNetAdapterRunner.Run( + [DotNetAdapterRegistration.For(new VogenConceptScreenplayAdapter())], + context, + new DotNetAdapterOptions()); + var legacy = DotNetAdapterRunner.Run( + [DotNetAdapterRegistration.ForLegacy(new VogenConceptScreenplayAdapter())], + context, + new DotNetAdapterOptions()); + return (modern, legacy); + } + + static AdapterRunDisposition Disposition(AdapterRunSnapshot snapshot) => snapshot.Adapters.Single().Disposition; + + static string[] Capabilities(AdapterProbeResult probe) => + [ + .. probe.Evidence + .Where(evidence => evidence.ApiCapability is not null) + .Select(evidence => evidence.ApiCapability!.Id) + ]; + + static string? ValidationMessage(AdapterRunSnapshot snapshot) => + snapshot.Facts + .Select(record => record.Fact) + .OfType() + .Single() + .Definition.Message; + + static string Facts(AdapterRunSnapshot snapshot) => string.Join( + '|', + snapshot.Facts.Select(record => record.Fact switch + { + ConceptValidationRuleFact validation => $"{validation.GetType().Name}:{validation.Id.Value}:{validation.Subject.Value}:{validation.Definition.Message}", + var fact => $"{fact.GetType().Name}:{fact.Id.Value}:{fact.Subject.Value}" + })); +} diff --git a/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_the_modern_descriptor_and_probe.cs b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_the_modern_descriptor_and_probe.cs new file mode 100644 index 0000000..2c7738a --- /dev/null +++ b/Source/DotNET/Generation.DotNet.Vogen.Specs/for_VogenConceptScreenplayAdapter/when_using_the_modern_descriptor_and_probe.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.DotNet.Vogen.for_VogenConceptScreenplayAdapter; + +public class when_using_the_modern_descriptor_and_probe : given.a_vogen_compilation +{ + VogenConceptScreenplayAdapter _adapter = null!; + AdapterProbeApplicable _probe = null!; + AdapterRunRecord _record = null!; + + void Because() + { + var compilation = CompilationFrom( + "Concepts", + new SourceFile( + "/checkout/Concepts/CustomerId.cs", + """ + namespace Concepts; + [Vogen.ValueObject] + public partial struct CustomerId + { + private static Vogen.Validation Validate(System.Guid value) => Vogen.Validation.Ok; + } + """)); + var context = new DotNetAnalysisContext([MappedProject("Concepts.Project", "concepts-project", compilation)]); + _adapter = new VogenConceptScreenplayAdapter(); + _probe = (AdapterProbeApplicable)_adapter.Probe(context); + _record = DotNetAdapterRunner.Run( + [DotNetAdapterRegistration.For(_adapter)], + context, + new DotNetAdapterOptions()).Adapters.Single(); + } + + [Fact] void should_describe_the_exact_identity_category_and_language() => $"{_adapter.Descriptor.Identity.Id}:{_adapter.Descriptor.Identity.Version}:{_adapter.Descriptor.Category}:{_adapter.Descriptor.SourceLanguage}".ShouldEqual("vogen:1.0.0:Concepts:CSharp"); + [Fact] void should_require_authored_stable_semantic_host_capabilities() => string.Join(',', _adapter.Descriptor.RequiredHostCapabilities).ShouldEqual("AuthoredSource,StableSourceLocations,SemanticAnalysis"); + [Fact] void should_declare_exact_emitted_fact_capabilities() => string.Join(',', _adapter.Descriptor.EmittedFactCapabilities).ShouldEqual("Artifact,ConceptRepresentation,ConceptValidationRule"); + [Fact] void should_require_only_the_alternative_vogen_declaration_capability() => _adapter.Descriptor.RequiredApiCapabilities.Select(capability => capability.Id).ShouldEqual(["vogen.value-object-declaration"]); + [Fact] void should_prove_the_required_declaration_capability() => _probe.Evidence.Where(evidence => evidence.ApiCapability is not null).Select(evidence => evidence.ApiCapability!.Id).ShouldContain("vogen.value-object-declaration"); + [Fact] void should_capture_optional_validation_result_api_evidence() => _probe.Evidence.Where(evidence => evidence.ApiCapability is not null).Select(evidence => evidence.ApiCapability!.Id).ShouldContain("vogen.validation-result"); + [Fact] void should_anchor_the_authored_declaration_probe_evidence_to_stable_source() => _probe.Evidence.Single(evidence => evidence.Source is not null).Source!.FileIdentity.ShouldEqual(new SourceFileIdentity { Project = "concepts-project", Path = "CustomerId.cs" }); + [Fact] void should_admit_the_modern_vogen_contribution() => _record.Disposition.ShouldEqual(AdapterRunDisposition.Admitted); +} diff --git a/Source/DotNET/Generation.DotNet.Vogen.Specs/given/a_vogen_compilation.cs b/Source/DotNET/Generation.DotNet.Vogen.Specs/given/a_vogen_compilation.cs index 0573bf0..afd9261 100644 --- a/Source/DotNET/Generation.DotNet.Vogen.Specs/given/a_vogen_compilation.cs +++ b/Source/DotNET/Generation.DotNet.Vogen.Specs/given/a_vogen_compilation.cs @@ -5,15 +5,24 @@ namespace Cratis.Screenplay.Generation.DotNet.Vogen.given; public class a_vogen_compilation : Specification { - static readonly IReadOnlyList _references = + static readonly IReadOnlyList _platformReferences = [ .. ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")) .Split(Path.PathSeparator) - .Append(typeof(global::Vogen.ValueObjectAttribute).Assembly.Location) + .Where(path => !string.Equals( + Path.GetFileNameWithoutExtension(path), + typeof(global::Vogen.ValueObjectAttribute).Assembly.GetName().Name, + StringComparison.Ordinal)) .Distinct(StringComparer.Ordinal) .Select(_ => MetadataReference.CreateFromFile(_)) ]; + static readonly IReadOnlyList _references = + [ + .. _platformReferences, + MetadataReference.CreateFromFile(typeof(global::Vogen.ValueObjectAttribute).Assembly.Location) + ]; + protected static CSharpCompilation CompilationFrom(string assemblyName, params SourceFile[] sources) => CSharpCompilation.Create( assemblyName, @@ -24,6 +33,27 @@ protected static CSharpCompilation CompilationFrom(string assemblyName, params S _references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + protected static CSharpCompilation CompilationFromVogenApiSubset( + string assemblyName, + string vogenApi, + params SourceFile[] sources) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + var compilationOptions = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable); + var vogen = CSharpCompilation.Create( + "Vogen.SharedTypes", + [CSharpSyntaxTree.ParseText(vogenApi, parseOptions, "VogenApi.cs")], + _platformReferences, + compilationOptions); + return CSharpCompilation.Create( + assemblyName, + sources.Select(source => CSharpSyntaxTree.ParseText(source.Content, parseOptions, source.Path)), + [.. _platformReferences, vogen.ToMetadataReference()], + compilationOptions); + } + protected static AdapterContribution Analyze(params DotNetProjectCompilation[] projects) => new VogenConceptScreenplayAdapter().Analyze(new DotNetAnalysisContext(projects), new DotNetAdapterOptions()); @@ -41,6 +71,28 @@ protected static DotNetProjectCompilation Project( AuthoredSyntaxTrees = (authoredSyntaxTrees ?? compilation.SyntaxTrees.Where(_ => !DotNetGeneratedSource.IsGenerated(_))).ToHashSet() }; + protected static DotNetProjectCompilation MappedProject( + string name, + string projectIdentity, + CSharpCompilation compilation) + { + var authored = compilation.SyntaxTrees.Where(tree => !DotNetGeneratedSource.IsGenerated(tree)).ToArray(); + var sourceContext = DotNetSourcePaths.Create( + projectIdentity, + new DotNetSourcePathPolicy + { + DisplayRoot = DotNetSourceDisplayRoot.Project, + CasePolicy = DotNetSourcePathCasePolicy.Ordinal + }, + authored.Select(tree => new DotNetSourceDocument + { + SyntaxTree = tree, + ProjectRelativePath = Path.GetFileName(tree.FilePath), + WorkspaceRelativePath = Path.GetFileName(tree.FilePath) + })); + return Project(name, compilation, authoredSyntaxTrees: authored, sourceContext: sourceContext); + } + protected static ArtifactFact ConceptNamed(AdapterContribution contribution, string name) => contribution.Facts.OfType().Single(_ => _.Definition.Name == name); diff --git a/Source/DotNET/Generation.DotNet.Vogen/VogenAdapterApiCapabilities.cs b/Source/DotNET/Generation.DotNet.Vogen/VogenAdapterApiCapabilities.cs new file mode 100644 index 0000000..c02b4d2 --- /dev/null +++ b/Source/DotNET/Generation.DotNet.Vogen/VogenAdapterApiCapabilities.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.DotNet.Vogen; + +/// +/// Defines the exact Vogen API capabilities understood by the Vogen concept adapter. +/// +public static class VogenAdapterApiCapabilities +{ + /// + /// A Vogen value-object declaration API, proven by either the generic or non-generic declaration attribute. + /// + public static AdapterApiCapability ValueObjectDeclaration { get; } = new() { Id = "vogen.value-object-declaration" }; + + /// + /// The optional Vogen validation-result API used to extract authored validation messages. + /// + public static AdapterApiCapability ValidationResult { get; } = new() { Id = "vogen.validation-result" }; +} diff --git a/Source/DotNET/Generation.DotNet.Vogen/VogenConceptScreenplayAdapter.cs b/Source/DotNET/Generation.DotNet.Vogen/VogenConceptScreenplayAdapter.cs index b264314..6a634ea 100644 --- a/Source/DotNET/Generation.DotNet.Vogen/VogenConceptScreenplayAdapter.cs +++ b/Source/DotNET/Generation.DotNet.Vogen/VogenConceptScreenplayAdapter.cs @@ -13,7 +13,7 @@ namespace Cratis.Screenplay.Generation.DotNet.Vogen; /// Recognition uses Roslyn metadata names and authoritative authored-source evidence. Generated members can corroborate /// a declaration but never originate concept, identity, validation, normalization, named-instance, or representation evidence. /// -public sealed class VogenConceptScreenplayAdapter : IDotNetScreenplayAdapter +public sealed class VogenConceptScreenplayAdapter : IDotNetScreenplayAdapter, IDescribedDotNetScreenplayAdapter { const string AdapterId = "vogen"; const string AdapterVersion = "1.0.0"; @@ -23,10 +23,62 @@ public sealed class VogenConceptScreenplayAdapter : IDotNetScreenplayAdapter /// public AdapterIdentity Identity { get; } = new() { Id = AdapterId, Version = AdapterVersion }; + /// + public AdapterDescriptor Descriptor { get; } = new() + { + Identity = new AdapterIdentity { Id = AdapterId, Version = AdapterVersion }, + SourceLanguage = AdapterSourceLanguage.CSharp, + Category = AdapterCategory.Concepts, + RequiredHostCapabilities = + [ + AdapterHostCapability.AuthoredSource, + AdapterHostCapability.StableSourceLocations, + AdapterHostCapability.SemanticAnalysis + ], + RequiredApiCapabilities = + [ + VogenAdapterApiCapabilities.ValueObjectDeclaration + ], + EmittedFactCapabilities = + [ + GenerationFactCapability.Artifact, + GenerationFactCapability.ConceptRepresentation, + GenerationFactCapability.ConceptValidationRule + ] + }; + /// public bool CanAnalyze(DotNetAnalysisContext context) => context.Projects.Any(project => DeclarationsIn(project).Any()); + /// + public AdapterProbeResult Probe(DotNetAnalysisContext context) + { + var declarations = context.Projects + .SelectMany(project => DeclarationsIn(project).Select(declaration => new ProjectDeclaration(project, declaration))) + .ToArray(); + if (declarations.Length == 0) + { + return new AdapterProbeNotApplicable(); + } + + try + { + var evidence = declarations.Select(DeclarationEvidence).ToList(); + if (evidence.Exists(item => item.Source?.FileIdentity is null)) + { + return UnsafeSourceMapping(); + } + + evidence.AddRange(ApiEvidence(declarations)); + return new AdapterProbeApplicable { Evidence = [.. evidence] }; + } + catch (DotNetSourceTreeNotMapped) + { + return UnsafeSourceMapping(); + } + } + /// public AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterOptions options) { @@ -55,6 +107,60 @@ public AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterO }; } + static AdapterProbeEvidence DeclarationEvidence(ProjectDeclaration item) => new() + { + Description = $"The authored type has the exact '{MetadataName(item.Declaration.Attribute)}' Vogen declaration API", + Source = DotNetSource.RangeForProject(item.Declaration.Attribute.ApplicationSyntaxReference!.GetSyntax().GetLocation(), item.Project), + Subject = item.Project.SubjectForType(item.Declaration.Type) + }; + + static IEnumerable ApiEvidence(IEnumerable declarations) + { + var declarationArray = declarations.ToArray(); + var exactDeclarationApis = declarationArray + .Select(item => item.Declaration.Attribute.AttributeClass) + .OfType() + .Where(IsExactVogenApi) + .Select(DotNetSubjectIds.MetadataName) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + if (exactDeclarationApis.Length > 0) + { + yield return new AdapterProbeEvidence + { + Description = $"The exact Vogen value-object declaration API is available through '{string.Join("' or '", exactDeclarationApis)}'", + ApiCapability = VogenAdapterApiCapabilities.ValueObjectDeclaration + }; + } + + var projects = declarationArray.Select(item => item.Project).Distinct().ToArray(); + if (projects.Any(project => IsExactVogenApi(project.Compilation.GetTypeByMetadataName(VogenMetadataNames.Validation)))) + { + yield return new AdapterProbeEvidence + { + Description = $"The exact '{VogenMetadataNames.Validation}' Vogen validation-result API is available", + ApiCapability = VogenAdapterApiCapabilities.ValidationResult + }; + } + } + + static bool IsExactVogenApi(INamedTypeSymbol? type) => + type is not null && string.Equals(type.ContainingAssembly.Name, "Vogen.SharedTypes", StringComparison.Ordinal); + + static AdapterProbeBlocked UnsafeSourceMapping() => new() + { + Diagnostics = + [ + new GenerationDiagnostic + { + Code = VogenGenerationDiagnosticCodes.UnsafeSourceMapping, + Severity = GenerationDiagnosticSeverity.Error, + Message = "Applicable authored Vogen declarations do not have authoritative stable source mappings" + } + ] + }; + static void AddConcept( DotNetProjectCompilation project, INamedTypeSymbol type, @@ -404,5 +510,7 @@ sealed record AuthoredMethod(IMethodSymbol Method, SyntaxReference Reference); sealed record VogenDeclaration(INamedTypeSymbol Type, AttributeData Attribute); + sealed record ProjectDeclaration(DotNetProjectCompilation Project, VogenDeclaration Declaration); + sealed record VogenBackingType(ITypeSymbol Type, AttributeData Evidence); } diff --git a/Source/DotNET/Generation.DotNet.Vogen/VogenGenerationDiagnosticCodes.cs b/Source/DotNET/Generation.DotNet.Vogen/VogenGenerationDiagnosticCodes.cs index 5505e9c..2c1c895 100644 --- a/Source/DotNET/Generation.DotNet.Vogen/VogenGenerationDiagnosticCodes.cs +++ b/Source/DotNET/Generation.DotNet.Vogen/VogenGenerationDiagnosticCodes.cs @@ -22,4 +22,9 @@ public static class VogenGenerationDiagnosticCodes /// A Vogen concept declares a named instance that Screenplay concepts cannot preserve. /// public const string NamedInstanceNotRepresented = "VOG0003"; + + /// + /// Applicable Vogen source lacks an authoritative stable source mapping. + /// + public const string UnsafeSourceMapping = "VOG0004"; } diff --git a/Source/DotNET/Generation.DotNet/DotNetAdapterDiagnostics.cs b/Source/DotNET/Generation.DotNet/DotNetAdapterDiagnostics.cs new file mode 100644 index 0000000..c77a2e8 --- /dev/null +++ b/Source/DotNET/Generation.DotNet/DotNetAdapterDiagnostics.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.Collections.Immutable; + +namespace Cratis.Screenplay.Generation.DotNet; + +static class DotNetAdapterDiagnostics +{ + public static GenerationDiagnostic Error(string code, string adapterId, string detail) => new() + { + Code = code, + Severity = GenerationDiagnosticSeverity.Error, + Message = $"Adapter '{adapterId}' {detail}" + }; + + public static GenerationDiagnostic HostError(string code, string detail) => new() + { + Code = code, + Severity = GenerationDiagnosticSeverity.Error, + Message = $"The .NET adapter host {detail}" + }; + + public static GenerationDiagnostic OperationFailed(string adapterId, string operation, Exception exception) => + Error( + DotNetAdapterGenerationDiagnosticCodes.OperationFailed, + adapterId, + $"operation '{operation}' failed with exception type '{exception.GetType().FullName ?? exception.GetType().Name}'"); + + public static ImmutableArray Canonical(IEnumerable diagnostics) => + [ + .. diagnostics.Select(Freeze) + .Distinct() + .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 GenerationDiagnostic Freeze(GenerationDiagnostic diagnostic) => new() + { + Code = diagnostic.Code, + Severity = diagnostic.Severity, + Message = diagnostic.Message, + Outcome = diagnostic.Outcome, + Source = diagnostic.Source is null ? null : Freeze(diagnostic.Source), + Subject = diagnostic.Subject is null ? null : new SubjectId { Value = diagnostic.Subject.Value } + }; + + static SourceRange Freeze(SourceRange source) => new() + { + 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 + }; +} diff --git a/Source/DotNET/Generation.DotNet/DotNetAdapterGenerationDiagnosticCodes.cs b/Source/DotNET/Generation.DotNet/DotNetAdapterGenerationDiagnosticCodes.cs new file mode 100644 index 0000000..c0171a2 --- /dev/null +++ b/Source/DotNET/Generation.DotNet/DotNetAdapterGenerationDiagnosticCodes.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; + +/// +/// Defines stable diagnostics produced by the deterministic .NET adapter boundary. +/// +public static class DotNetAdapterGenerationDiagnosticCodes +{ + /// + /// An adapter descriptor was rejected before probing. + /// + public const string DescriptorRejected = "DOTNETADAPTER001"; + + /// + /// An adapter identity was duplicated in the roster. + /// + public const string DuplicateAdapterId = "DOTNETADAPTER002"; + + /// + /// The .NET host cannot enforce a required adapter capability. + /// + public const string MissingHostCapability = "DOTNETADAPTER003"; + + /// + /// A modern adapter returned a malformed structured probe result. + /// + public const string ProbeRejected = "DOTNETADAPTER004"; + + /// + /// An applicable probe did not prove every required API capability. + /// + public const string MissingApiCapabilityEvidence = "DOTNETADAPTER005"; + + /// + /// An adapter callback threw an unexpected exception. + /// + public const string OperationFailed = "DOTNETADAPTER006"; + + /// + /// Atomic contribution admission rejected an adapter result. + /// + public const string ContributionRejected = "DOTNETADAPTER007"; + + /// + /// The registered source language cannot execute in the .NET runner. + /// + public const string UnsupportedSourceLanguage = "DOTNETADAPTER008"; + + /// + /// The adapter does not support the Generation.Contracts version loaded by the runner host. + /// + public const string IncompatibleGenerationVersion = "DOTNETADAPTER009"; + + /// + /// The project roster cannot be ordered without ambiguous or machine-specific identity. + /// + public const string InvalidProjectRoster = "DOTNETADAPTER010"; +} diff --git a/Source/DotNET/Generation.DotNet/DotNetAdapterProbeAdmission.cs b/Source/DotNET/Generation.DotNet/DotNetAdapterProbeAdmission.cs new file mode 100644 index 0000000..a538dc8 --- /dev/null +++ b/Source/DotNET/Generation.DotNet/DotNetAdapterProbeAdmission.cs @@ -0,0 +1,207 @@ +// 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; +using System.Text; + +namespace Cratis.Screenplay.Generation.DotNet; + +static class DotNetAdapterProbeAdmission +{ + public static AdapterProbeResult Admit( + AdapterDescriptor descriptor, + AdapterProbeResult? probe, + ISourceAuthorityValidator sourceAuthorityValidator) + { + if (probe is not AdapterProbeApplicable and not AdapterProbeNotApplicable and not AdapterProbeBlocked) + { + return Malformed(descriptor.Identity.Id); + } + + if (!TryFreezeEvidence(probe.Evidence, sourceAuthorityValidator, out var evidence)) + { + return Malformed(descriptor.Identity.Id); + } + + if (probe is AdapterProbeBlocked blocked) + { + return TryFreezeDiagnostics(descriptor, blocked.Diagnostics, sourceAuthorityValidator, out var diagnostics) && + !diagnostics.IsEmpty + ? new AdapterProbeBlocked { Evidence = evidence, Diagnostics = diagnostics } + : Malformed(descriptor.Identity.Id); + } + + if (probe is AdapterProbeNotApplicable) + { + return new AdapterProbeNotApplicable { Evidence = evidence }; + } + + var missing = descriptor.RequiredApiCapabilities + .Where(required => !evidence.Any(item => item.ApiCapability == required)) + .ToArray(); + if (missing.Length == 0) + { + return new AdapterProbeApplicable { Evidence = evidence }; + } + + return new AdapterProbeBlocked + { + Evidence = evidence, + Diagnostics = + [ + .. missing.Select(capability => DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.MissingApiCapabilityEvidence, + descriptor.Identity.Id, + $"applicable probe did not prove required API capability '{capability.Id}'")) + ] + }; + } + + public static AdapterProbeBlocked Malformed(string adapterId) => new() + { + Diagnostics = + [ + DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.ProbeRejected, + adapterId, + "returned a malformed structured probe result") + ] + }; + + static bool TryFreezeEvidence( + ImmutableArray source, + ISourceAuthorityValidator validator, + out ImmutableArray evidence) + { + evidence = []; + if (source.IsDefault) + { + return true; + } + + var frozen = ImmutableArray.CreateBuilder(); + foreach (var item in source) + { + if (item is null || + !IsNormalized(item.Description, true) || + (item.ApiCapability is not null && !IsNormalized(item.ApiCapability.Id, false)) || + (item.Subject is not null && !IsSubject(item.Subject.Value)) || + (item.Source is not null && !validator.IsAuthoritative(item.Source))) + { + return false; + } + + frozen.Add(new AdapterProbeEvidence + { + Description = item.Description, + ApiCapability = item.ApiCapability is null + ? null + : new AdapterApiCapability { Id = item.ApiCapability.Id }, + Source = item.Source is null ? null : Freeze(item.Source), + Subject = item.Subject is null ? null : new SubjectId { Value = item.Subject.Value } + }); + } + + evidence = + [ + .. frozen + .OrderBy(item => item.ApiCapability?.Id, StringComparer.Ordinal) + .ThenBy(item => item.Source?.FileIdentity?.Project, StringComparer.Ordinal) + .ThenBy(item => item.Source?.FileIdentity?.Path, StringComparer.Ordinal) + .ThenBy(item => item.Source?.Path, StringComparer.Ordinal) + .ThenBy(item => item.Source?.StartLine) + .ThenBy(item => item.Source?.StartColumn) + .ThenBy(item => item.Source?.EndLine) + .ThenBy(item => item.Source?.EndColumn) + .ThenBy(item => item.Subject?.Value, StringComparer.Ordinal) + .ThenBy(item => item.Description, StringComparer.Ordinal) + ]; + return true; + } + + static bool TryFreezeDiagnostics( + AdapterDescriptor descriptor, + ImmutableArray diagnostics, + ISourceAuthorityValidator validator, + out ImmutableArray frozen) + { + var admission = AdapterContributionAdmission.Admit( + descriptor, + new AdapterContribution + { + Adapter = descriptor.Identity, + Diagnostics = diagnostics.IsDefault ? [] : diagnostics + }, + validator); + frozen = admission.Snapshot?.Diagnostics ?? []; + return admission.IsAdmitted; + } + + static bool IsNormalized(string? value, bool allowWhitespace) + { + if (string.IsNullOrEmpty(value) || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + 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; + } + } + + static bool IsSubject(string? value) + { + if (!IsNormalized(value, false) || + value!.Contains('\\') || + !Uri.TryCreate(value, UriKind.Absolute, out var uri) || + string.IsNullOrWhiteSpace(uri.Scheme)) + { + return false; + } + + 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 true; + } + } + + 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 SourceRange Freeze(SourceRange source) => new() + { + 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 + }; +} diff --git a/Source/DotNET/Generation.DotNet/DotNetAdapterRegistration.cs b/Source/DotNET/Generation.DotNet/DotNetAdapterRegistration.cs new file mode 100644 index 0000000..02ea72d --- /dev/null +++ b/Source/DotNET/Generation.DotNet/DotNetAdapterRegistration.cs @@ -0,0 +1,83 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; + +namespace Cratis.Screenplay.Generation.DotNet; + +/// +/// Represents one opaque modern or legacy .NET adapter registration. +/// +public sealed class DotNetAdapterRegistration +{ + static readonly ImmutableArray _legacyHostCapabilities = + [ + AdapterHostCapability.AuthoredSource, + AdapterHostCapability.SemanticAnalysis + ]; + + static readonly ImmutableArray _legacyFactCapabilities = + [ + .. Enum.GetValues() + .Where(capability => capability != GenerationFactCapability.Unknown) + .OrderBy(capability => (int)capability) + ]; + + readonly IDescribedDotNetScreenplayAdapter? _modern; + readonly IDotNetScreenplayAdapter? _legacy; + + DotNetAdapterRegistration(IDescribedDotNetScreenplayAdapter modern) => _modern = modern; + + DotNetAdapterRegistration(IDotNetScreenplayAdapter legacy) => _legacy = legacy; + + internal bool IsLegacy => _legacy is not null; + + /// + /// Creates a registration for a described adapter. + /// + /// The described adapter. + /// An opaque adapter registration. + public static DotNetAdapterRegistration For(IDescribedDotNetScreenplayAdapter adapter) => new(adapter); + + /// + /// Creates a compatibility registration for an adapter implementing the original .NET adapter interface. + /// + /// The legacy adapter. + /// An opaque adapter registration. + public static DotNetAdapterRegistration ForLegacy(IDotNetScreenplayAdapter adapter) => new(adapter); + + internal AdapterDescriptor Describe() + { + if (_modern is not null) + { + return _modern.Descriptor; + } + + var identity = _legacy!.Identity; + return new AdapterDescriptor + { + Identity = new AdapterIdentity { Id = identity.Id, Version = identity.Version }, + SourceLanguage = AdapterSourceLanguage.CSharp, + Category = AdapterCategory.Legacy, + RequiredHostCapabilities = _legacyHostCapabilities, + EmittedFactCapabilities = _legacyFactCapabilities + }; + } + + internal AdapterProbeResult Probe(DotNetAnalysisContext context) + { + if (_modern is not null) + { + return _modern.Probe(context); + } + + return _legacy!.CanAnalyze(context) + ? new AdapterProbeApplicable() + : new AdapterProbeNotApplicable(); + } + + internal AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterOptions options) => + _modern is not null + ? _modern.Analyze(context, options) + : _legacy!.Analyze(context, options); +} diff --git a/Source/DotNET/Generation.DotNet/DotNetAdapterRunner.cs b/Source/DotNET/Generation.DotNet/DotNetAdapterRunner.cs new file mode 100644 index 0000000..2ddb613 --- /dev/null +++ b/Source/DotNET/Generation.DotNet/DotNetAdapterRunner.cs @@ -0,0 +1,639 @@ +// 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; + +/// +/// Executes a deterministic roster of modern and legacy .NET adapters through atomic contribution admission. +/// +public static class DotNetAdapterRunner +{ + /// + /// Executes an adapter roster using the loaded Generation.Contracts assembly version and returns a deeply frozen canonical snapshot. + /// + /// The modern and legacy adapter registrations. + /// The canonical .NET analysis context. + /// The adapter analysis options. + /// The immutable adapter run snapshot. + public static AdapterRunSnapshot Run( + IEnumerable roster, + DotNetAnalysisContext context, + DotNetAdapterOptions options) => + Run(roster, context, options, CurrentGenerationContractsVersion()); + + /// + /// Executes an adapter roster using an explicit Generation.Contracts host version and returns a deeply frozen canonical snapshot. + /// + /// The modern and legacy adapter registrations. + /// The canonical .NET analysis context. + /// The adapter analysis options. + /// The Generation.Contracts package or assembly version enforced by the host. + /// The immutable adapter run snapshot. + public static AdapterRunSnapshot Run( + IEnumerable roster, + DotNetAnalysisContext context, + DotNetAdapterOptions options, + Version generationContractsVersion) + { + var registrations = roster.ToArray(); + var prepared = registrations + .Select(Prepare) + .Order(PreparedRegistrationComparer.Instance) + .ToArray(); + var duplicateIds = prepared + .Where(item => item.Failure is null) + .GroupBy(item => item.Descriptor.Identity.Id, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + var projectDiagnostics = ProjectDiagnostics(context); + var hostCapabilities = DotNetHostCapabilities.Determine(context); + var records = prepared + .Select(item => Run( + item, + duplicateIds.Contains(item.Descriptor.Identity.Id), + projectDiagnostics, + hostCapabilities, + generationContractsVersion, + context, + options)) + .ToImmutableArray(); + var facts = records + .SelectMany(record => record.Execution is AdapterExecutionCompleted completed + ? completed.Contribution.Facts + : []) + .OrderBy(fact => fact.Id.Value, StringComparer.Ordinal) + .ThenBy(fact => fact.Subject.Value, StringComparer.Ordinal) + .ThenBy(fact => fact.GetType().FullName, StringComparer.Ordinal) + .Select(fact => new GenerationFactRecord { Fact = fact }) + .ToImmutableArray(); + var diagnostics = DotNetAdapterDiagnostics.Canonical(projectDiagnostics.Concat(DiagnosticsFrom(records))); + return new AdapterRunSnapshot + { + Adapters = records, + Facts = facts, + Diagnostics = [.. diagnostics] + }; + } + + static PreparedRegistration Prepare(DotNetAdapterRegistration registration) + { + try + { + var admission = AdapterDescriptorAdmission.Admit(registration.Describe()); + return new PreparedRegistration(registration, admission.Descriptor, admission.Diagnostics, null); + } + catch (Exception exception) + { + var descriptor = AdapterDescriptorAdmission.Admit(FallbackDescriptor()).Descriptor; + return new PreparedRegistration( + registration, + descriptor, + [], + DotNetAdapterDiagnostics.OperationFailed(descriptor.Identity.Id, "Descriptor", exception)); + } + } + + static AdapterRunRecord Run( + PreparedRegistration item, + bool isDuplicate, + ImmutableArray projectDiagnostics, + ImmutableArray hostCapabilities, + Version generationContractsVersion, + DotNetAnalysisContext context, + DotNetAdapterOptions options) + { + if (item.Failure is not null) + { + return RejectedRosterRecord(item.Descriptor, [item.Failure]); + } + + if (isDuplicate) + { + var diagnostic = DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.DuplicateAdapterId, + item.Descriptor.Identity.Id, + "was rejected because its identity occurs more than once in the roster"); + return RejectedRosterRecord(item.Descriptor, [diagnostic]); + } + + if (!item.DescriptorDiagnostics.IsEmpty) + { + return RejectedRosterRecord( + item.Descriptor, + [.. item.DescriptorDiagnostics.Select(diagnostic => DescriptorDiagnostic(item.Descriptor, diagnostic))]); + } + + if (!projectDiagnostics.IsEmpty && RequiresProjectRoster(item.Descriptor)) + { + return BlockedBeforeProbe(item.Descriptor, projectDiagnostics); + } + + var compatibilityDiagnostics = GenerationCompatibilityDiagnostics(item.Descriptor, generationContractsVersion); + if (!compatibilityDiagnostics.IsEmpty) + { + return BlockedBeforeProbe(item.Descriptor, compatibilityDiagnostics); + } + + var hostDiagnostics = HostDiagnostics(item.Descriptor, hostCapabilities); + if (!hostDiagnostics.IsEmpty) + { + return BlockedBeforeProbe(item.Descriptor, hostDiagnostics); + } + + return ProbeAndRun(item, context, options); + } + + static AdapterRunRecord ProbeAndRun( + PreparedRegistration item, + DotNetAnalysisContext context, + DotNetAdapterOptions options) + { + var requiresStableIdentity = item.Descriptor.RequiredHostCapabilities.Contains(AdapterHostCapability.StableSourceLocations); + var sourceValidator = new DotNetSourceAuthorityValidator(context, requiresStableIdentity); + AdapterProbeResult rawProbe; + try + { + rawProbe = item.Registration.Probe(context); + } + catch (Exception exception) + { + var operation = item.Registration.IsLegacy ? "CanAnalyze" : "Probe"; + var diagnostic = DotNetAdapterDiagnostics.OperationFailed(item.Descriptor.Identity.Id, operation, exception); + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Descriptor = item.Descriptor, + Probe = new AdapterProbeBlocked { Diagnostics = [diagnostic] }, + Execution = new AdapterExecutionFailed { Diagnostics = [diagnostic] }, + Disposition = AdapterRunDisposition.ExecutionFailed + }; + } + + var probe = DotNetAdapterProbeAdmission.Admit(item.Descriptor, rawProbe, sourceValidator); + return probe switch + { + AdapterProbeNotApplicable => NotApplicable(item.Descriptor, probe), + AdapterProbeBlocked blocked => BlockedAfterProbe(item.Descriptor, blocked), + AdapterProbeApplicable => Analyze(item, probe, sourceValidator, context, options), + _ => BlockedAfterProbe( + item.Descriptor, + DotNetAdapterProbeAdmission.Malformed(item.Descriptor.Identity.Id)) + }; + } + + static AdapterRunRecord Analyze( + PreparedRegistration item, + AdapterProbeResult probe, + ISourceAuthorityValidator sourceValidator, + DotNetAnalysisContext context, + DotNetAdapterOptions options) + { + AdapterContribution contribution; + try + { + contribution = item.Registration.Analyze(context, options); + } + catch (Exception exception) + { + var diagnostic = DotNetAdapterDiagnostics.OperationFailed(item.Descriptor.Identity.Id, "Analyze", exception); + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Executed = true, + Descriptor = item.Descriptor, + Probe = probe, + Execution = new AdapterExecutionFailed { Diagnostics = [diagnostic] }, + Disposition = AdapterRunDisposition.ExecutionFailed + }; + } + + AdapterContributionAdmissionResult admission; + try + { + admission = AdapterContributionAdmission.Admit(item.Descriptor, contribution, sourceValidator); + } + catch (Exception exception) + { + var diagnostic = DotNetAdapterDiagnostics.OperationFailed(item.Descriptor.Identity.Id, "ContributionAdmission", exception); + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Executed = true, + Descriptor = item.Descriptor, + Probe = probe, + Execution = new AdapterExecutionFailed { Diagnostics = [diagnostic] }, + Disposition = AdapterRunDisposition.ExecutionFailed + }; + } + + if (!admission.IsAdmitted) + { + var diagnostic = DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.ContributionRejected, + item.Descriptor.Identity.Id, + "contribution was rejected atomically"); + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Executed = true, + Descriptor = item.Descriptor, + Probe = probe, + Execution = new AdapterExecutionRejected + { + Diagnostics = [diagnostic], + AdmissionDiagnostics = admission.Diagnostics + }, + Disposition = AdapterRunDisposition.ContributionRejected + }; + } + + var snapshot = admission.Snapshot!; + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Executed = true, + Descriptor = item.Descriptor, + Probe = probe, + Execution = new AdapterExecutionCompleted + { + Diagnostics = snapshot.Diagnostics, + Contribution = snapshot + }, + Disposition = AdapterRunDisposition.Admitted + }; + } + + static AdapterRunRecord RejectedRosterRecord( + AdapterDescriptor descriptor, + ImmutableArray diagnostics) => new() + { + Considered = true, + Descriptor = descriptor, + Probe = new AdapterProbeNotRun(), + Execution = new AdapterExecutionNotRun { Diagnostics = diagnostics }, + Disposition = AdapterRunDisposition.RosterRejected + }; + + static AdapterRunRecord BlockedBeforeProbe( + AdapterDescriptor descriptor, + ImmutableArray diagnostics) => new() + { + Considered = true, + Descriptor = descriptor, + Probe = new AdapterProbeBlocked { Diagnostics = diagnostics }, + Execution = new AdapterExecutionNotRun { Diagnostics = diagnostics }, + Disposition = AdapterRunDisposition.Blocked + }; + + static AdapterRunRecord BlockedAfterProbe(AdapterDescriptor descriptor, AdapterProbeBlocked probe) => new() + { + Considered = true, + Probed = true, + Descriptor = descriptor, + Probe = probe, + Execution = new AdapterExecutionNotRun { Diagnostics = probe.Diagnostics }, + Disposition = AdapterRunDisposition.Blocked + }; + + static AdapterRunRecord NotApplicable(AdapterDescriptor descriptor, AdapterProbeResult probe) => new() + { + Considered = true, + Probed = true, + Descriptor = descriptor, + Probe = probe, + Disposition = AdapterRunDisposition.NotApplicable + }; + + static bool RequiresProjectRoster(AdapterDescriptor descriptor) => + descriptor.SourceLanguage != AdapterSourceLanguage.SourceIndependent || + !descriptor.RequiredHostCapabilities.IsEmpty; + + static ImmutableArray ProjectDiagnostics(DotNetAnalysisContext context) + { + var diagnostics = ImmutableArray.CreateBuilder(); + foreach (var duplicate in context.Projects + .Where(project => project.SourceContext is not null) + .GroupBy(project => project.SourceContext!.ProjectIdentity, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .OrderBy(group => group.Key, StringComparer.Ordinal)) + { + diagnostics.Add(DotNetAdapterDiagnostics.HostError( + DotNetAdapterGenerationDiagnosticCodes.InvalidProjectRoster, + $"rejected the project roster because stable project identity '{duplicate.Key}' occurs more than once")); + } + + foreach (var duplicate in context.Projects + .Where(project => project.SourceContext is null) + .GroupBy(project => (project.Name, project.Compilation.AssemblyName)) + .Where(group => group.Count() > 1) + .OrderBy(group => group.Key.Name, StringComparer.Ordinal) + .ThenBy(group => group.Key.AssemblyName, StringComparer.Ordinal)) + { + var projectPaths = duplicate.Select(project => project.ProjectPath).ToArray(); + if (projectPaths.All(IsPortableRelativeProjectPath) && + projectPaths.Distinct(StringComparer.Ordinal).Count() == projectPaths.Length) + { + continue; + } + + diagnostics.Add(DotNetAdapterDiagnostics.HostError( + DotNetAdapterGenerationDiagnosticCodes.InvalidProjectRoster, + $"rejected the project roster because legacy project name '{duplicate.Key.Name}' and assembly '{duplicate.Key.AssemblyName}' are not disambiguated by unique portable relative project paths")); + } + + return DotNetAdapterDiagnostics.Canonical(diagnostics); + } + + static ImmutableArray GenerationCompatibilityDiagnostics( + AdapterDescriptor descriptor, + Version generationContractsVersion) + { + var range = descriptor.CompatibleGenerationVersions; + if (generationContractsVersion.CompareTo(range.MinimumInclusive) >= 0 && + (range.MaximumExclusive is null || generationContractsVersion.CompareTo(range.MaximumExclusive) < 0)) + { + return []; + } + + var maximum = range.MaximumExclusive is null ? "unbounded" : $"'{range.MaximumExclusive}' exclusive"; + return + [ + DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.IncompatibleGenerationVersion, + descriptor.Identity.Id, + $"supports Generation.Contracts versions from '{range.MinimumInclusive}' inclusive through {maximum}, but the runner host version is '{generationContractsVersion}'") + ]; + } + + static bool IsPortableRelativeProjectPath(string? path) + { + if (string.IsNullOrEmpty(path) || + !string.Equals(path, path.Trim(), StringComparison.Ordinal) || + path.Any(char.IsControl) || + path[0] == '/' || + path.Contains('\\') || + (path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':')) + { + return false; + } + + try + { + if (!string.Equals(path, path.Normalize(), StringComparison.Ordinal)) + { + return false; + } + } + catch (ArgumentException) + { + return false; + } + + var segments = path.Split('/'); + for (var index = 0; index < segments.Length; index++) + { + if (string.IsNullOrEmpty(segments[index]) || + !TryDecodeProjectPathSegment(segments[index], out var decoded) || + string.Equals(decoded, ".", StringComparison.Ordinal) || + string.Equals(decoded, "..", StringComparison.Ordinal) || + decoded.Contains('/') || + decoded.Contains('\\') || + (index == 0 && decoded.Length >= 2 && char.IsAsciiLetter(decoded[0]) && decoded[1] == ':')) + { + return false; + } + } + + return true; + } + + static bool TryDecodeProjectPathSegment(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 ImmutableArray HostDiagnostics( + AdapterDescriptor descriptor, + ImmutableArray available) + { + var diagnostics = ImmutableArray.CreateBuilder(); + if (descriptor.SourceLanguage is not AdapterSourceLanguage.CSharp and not AdapterSourceLanguage.SourceIndependent) + { + diagnostics.Add(DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.UnsupportedSourceLanguage, + descriptor.Identity.Id, + $"requires unsupported source language '{descriptor.SourceLanguage}'")); + } + + diagnostics.AddRange(descriptor.RequiredHostCapabilities + .Where(capability => !available.Contains(capability)) + .Select(capability => DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.MissingHostCapability, + descriptor.Identity.Id, + $"requires unavailable host capability '{capability}'"))); + return diagnostics.ToImmutable(); + } + + static GenerationDiagnostic DescriptorDiagnostic( + AdapterDescriptor descriptor, + AdapterContributionAdmissionDiagnostic diagnostic) => + DotNetAdapterDiagnostics.Error( + DotNetAdapterGenerationDiagnosticCodes.DescriptorRejected, + descriptor.Identity.Id, + $"descriptor was rejected with admission code '{diagnostic.Code}' at '{diagnostic.Path}'"); + + static IEnumerable DiagnosticsFrom(IEnumerable records) + { + foreach (var record in records) + { + foreach (var diagnostic in record.Probe is AdapterProbeBlocked blocked ? blocked.Diagnostics : []) + { + yield return diagnostic; + } + + foreach (var diagnostic in record.Execution.Diagnostics) + { + yield return diagnostic; + } + } + } + + static AdapterDescriptor FallbackDescriptor() => new() + { + Identity = new AdapterIdentity { Id = "runner:descriptor-failure", Version = "unavailable" }, + SourceLanguage = AdapterSourceLanguage.SourceIndependent, + Category = AdapterCategory.Legacy + }; + + static Version CurrentGenerationContractsVersion() => + typeof(AdapterDescriptor).Assembly.GetName().Version ?? new Version(0, 0); + + sealed record PreparedRegistration( + DotNetAdapterRegistration Registration, + AdapterDescriptor Descriptor, + ImmutableArray DescriptorDiagnostics, + GenerationDiagnostic? Failure); + + sealed class PreparedRegistrationComparer : IComparer + { + public static PreparedRegistrationComparer Instance { get; } = new(); + + public int Compare(PreparedRegistration? x, PreparedRegistration? y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + if (x is null) + { + return -1; + } + + if (y is null) + { + return 1; + } + + var descriptorComparison = Compare(x.Descriptor, y.Descriptor); + if (descriptorComparison != 0) + { + return descriptorComparison; + } + + return StringComparer.Ordinal.Compare(x.Failure?.Message, y.Failure?.Message); + } + + static int Compare(AdapterDescriptor left, AdapterDescriptor right) + { + var comparison = StringComparer.Ordinal.Compare(left.Identity.Id, right.Identity.Id); + if (comparison != 0) + { + return comparison; + } + + comparison = StringComparer.Ordinal.Compare(left.Identity.Version, right.Identity.Version); + if (comparison != 0) + { + return comparison; + } + + comparison = left.SourceLanguage.CompareTo(right.SourceLanguage); + if (comparison != 0) + { + return comparison; + } + + comparison = left.Category.CompareTo(right.Category); + if (comparison != 0) + { + return comparison; + } + + comparison = CompareMinimumVersion( + left.CompatibleGenerationVersions.MinimumInclusive, + right.CompatibleGenerationVersions.MinimumInclusive); + if (comparison != 0) + { + return comparison; + } + + comparison = CompareMaximumVersion( + left.CompatibleGenerationVersions.MaximumExclusive, + right.CompatibleGenerationVersions.MaximumExclusive); + if (comparison != 0) + { + return comparison; + } + + comparison = CompareEnums(left.RequiredHostCapabilities, right.RequiredHostCapabilities); + if (comparison != 0) + { + return comparison; + } + + comparison = CompareApiCapabilities(left.RequiredApiCapabilities, right.RequiredApiCapabilities); + return comparison != 0 + ? comparison + : CompareEnums(left.EmittedFactCapabilities, right.EmittedFactCapabilities); + } + + static int CompareMinimumVersion(Version? left, Version? right) + { + if (left is null) + { + return right is null ? 0 : -1; + } + + return right is null ? 1 : left.CompareTo(right); + } + + static int CompareMaximumVersion(Version? left, Version? right) + { + if (left is null) + { + return right is null ? 0 : 1; + } + + return right is null ? -1 : left.CompareTo(right); + } + + static int CompareEnums(ImmutableArray left, ImmutableArray right) + where T : struct, Enum + { + var count = Math.Min(left.Length, right.Length); + for (var index = 0; index < count; index++) + { + var comparison = Comparer.Default.Compare(left[index], right[index]); + if (comparison != 0) + { + return comparison; + } + } + + return left.Length.CompareTo(right.Length); + } + + static int CompareApiCapabilities( + ImmutableArray left, + ImmutableArray right) + { + var count = Math.Min(left.Length, right.Length); + for (var index = 0; index < count; index++) + { + var comparison = StringComparer.Ordinal.Compare(left[index].Id, right[index].Id); + if (comparison != 0) + { + return comparison; + } + } + + return left.Length.CompareTo(right.Length); + } + } +} diff --git a/Source/DotNET/Generation.DotNet/DotNetHostCapabilities.cs b/Source/DotNET/Generation.DotNet/DotNetHostCapabilities.cs new file mode 100644 index 0000000..82d5dfc --- /dev/null +++ b/Source/DotNET/Generation.DotNet/DotNetHostCapabilities.cs @@ -0,0 +1,78 @@ +// 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; +using Microsoft.CodeAnalysis; + +namespace Cratis.Screenplay.Generation.DotNet; + +static class DotNetHostCapabilities +{ + public static ImmutableArray Determine(DotNetAnalysisContext context) + { + if (context.Projects.Count == 0) + { + return []; + } + + var capabilities = ImmutableArray.CreateBuilder(); + if (context.Projects.All(HasAuthoritativeAuthoredSource)) + { + capabilities.Add(AdapterHostCapability.AuthoredSource); + } + + if (context.Projects.All(project => + project.Compilation is not null && + string.Equals(project.Compilation.Language, LanguageNames.CSharp, StringComparison.Ordinal))) + { + capabilities.Add(AdapterHostCapability.SemanticAnalysis); + } + + if (context.Projects.All(HasStableSourceIdentity)) + { + capabilities.Add(AdapterHostCapability.StableSourceLocations); + } + + if (HasAvailableProjectReferences(context.Projects)) + { + capabilities.Add(AdapterHostCapability.ProjectReferences); + } + + return capabilities.ToImmutable(); + } + + static bool HasAuthoritativeAuthoredSource(DotNetProjectCompilation project) + { + if (project.Compilation is null || project.AuthoredSyntaxTrees is null) + { + return false; + } + + var compilationTrees = project.Compilation.SyntaxTrees.ToHashSet(); + return project.AuthoredSyntaxTrees.All(tree => + compilationTrees.Contains(tree) && + !DotNetGeneratedSource.IsGenerated(tree)); + } + + static bool HasStableSourceIdentity(DotNetProjectCompilation project) + { + if (!HasAuthoritativeAuthoredSource(project) || project.SourceContext is null) + { + return false; + } + + return project.AuthoredSyntaxTrees.All(tree => + project.SourceContext.Files.TryGetValue(tree, out var file) && + file.Identity.Project == project.SourceContext.ProjectIdentity && + !string.IsNullOrWhiteSpace(file.Identity.Path) && + !string.IsNullOrWhiteSpace(file.DisplayPath)); + } + + static bool HasAvailableProjectReferences(IReadOnlyList projects) + { + var assemblies = projects + .Select(project => project.Compilation.Assembly.Identity) + .ToHashSet(); + return projects.Any(project => project.Compilation.ReferencedAssemblyNames.Any(assemblies.Contains)); + } +} diff --git a/Source/DotNET/Generation.DotNet/DotNetProjectCompilation.cs b/Source/DotNET/Generation.DotNet/DotNetProjectCompilation.cs index 0a82908..e2fa0a8 100644 --- a/Source/DotNET/Generation.DotNet/DotNetProjectCompilation.cs +++ b/Source/DotNET/Generation.DotNet/DotNetProjectCompilation.cs @@ -80,9 +80,10 @@ public sealed class DotNetAnalysisContext(IEnumerable public IReadOnlyList Projects { get; } = [ .. projects - .OrderBy(_ => _.Name, StringComparer.Ordinal) + .OrderBy(_ => _.SourceContext?.ProjectIdentity ?? _.Name, StringComparer.Ordinal) + .ThenBy(_ => _.Name, StringComparer.Ordinal) .ThenBy(_ => _.Compilation.AssemblyName, StringComparer.Ordinal) - .ThenBy(_ => _.ProjectPath, StringComparer.Ordinal) + .ThenBy(_ => _.SourceContext is null ? _.ProjectPath : null, StringComparer.Ordinal) ]; /// diff --git a/Source/DotNET/Generation.DotNet/DotNetSourceAuthorityValidator.cs b/Source/DotNET/Generation.DotNet/DotNetSourceAuthorityValidator.cs new file mode 100644 index 0000000..fff036a --- /dev/null +++ b/Source/DotNET/Generation.DotNet/DotNetSourceAuthorityValidator.cs @@ -0,0 +1,181 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Screenplay.Generation.DotNet; + +sealed class DotNetSourceAuthorityValidator(DotNetAnalysisContext context, bool requiresStableIdentity) : ISourceAuthorityValidator +{ + readonly bool _requiresStableIdentity = requiresStableIdentity; + readonly IReadOnlyList _sources = + [ + .. context.Projects.SelectMany(SourcesIn) + .OrderBy(source => source.Identity?.Project, StringComparer.Ordinal) + .ThenBy(source => source.Identity?.Path, StringComparer.Ordinal) + .ThenBy(source => source.DisplayPath, StringComparer.Ordinal) + ]; + + public bool IsAuthoritative(SourceRange source) + { + var isOrdered = source.EndLine > source.StartLine || + (source.EndLine == source.StartLine && source.EndColumn >= source.StartColumn); + if ((_requiresStableIdentity && source.FileIdentity is null) || + !IsPortableRelativePath(source.Path) || + !isOrdered) + { + return false; + } + + var candidates = _sources.Where(candidate => Matches(candidate, source)).ToArray(); + return candidates.Length == 1 && CoordinatesAreValid(source, candidates[0].SyntaxTree); + } + + static IEnumerable SourcesIn(DotNetProjectCompilation project) + { + if (project.AuthoredSyntaxTrees is null) + { + yield break; + } + + var compilationTrees = project.Compilation.SyntaxTrees.ToHashSet(); + foreach (var tree in project.AuthoredSyntaxTrees.Where(compilationTrees.Contains)) + { + if (DotNetGeneratedSource.IsGenerated(tree)) + { + continue; + } + + if (project.SourceContext is not null) + { + if (project.SourceContext.Files.TryGetValue(tree, out var file)) + { + yield return new AuthoritativeSource(tree, file.DisplayPath, file.Identity); + } + + continue; + } + + yield return new AuthoritativeSource(tree, LegacyDisplayPath(project, tree), null); + } + } + + static bool Matches(AuthoritativeSource candidate, SourceRange source) + { + if (!string.Equals(candidate.DisplayPath, source.Path, StringComparison.Ordinal)) + { + return false; + } + + return source.FileIdentity is null || candidate.Identity == source.FileIdentity; + } + + static bool CoordinatesAreValid(SourceRange source, SyntaxTree tree) + { + var lines = tree.GetText().Lines; + return PositionIsValid(source.StartLine, source.StartColumn, lines) && + PositionIsValid(source.EndLine, source.EndColumn, lines); + } + + static bool PositionIsValid(int line, int column, Microsoft.CodeAnalysis.Text.TextLineCollection lines) => + line >= 1 && + line <= lines.Count && + column >= 1 && + column <= lines[line - 1].Span.Length + 1; + + static string LegacyDisplayPath(DotNetProjectCompilation project, SyntaxTree tree) + { + var path = tree.FilePath; + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + string displayPath; + if (string.IsNullOrWhiteSpace(project.SourceRoot) || !Path.IsPathFullyQualified(path)) + { + displayPath = path.Replace('\\', '/'); + } + else + { + var relative = Path.GetRelativePath(project.SourceRoot, path).Replace('\\', '/'); + displayPath = relative == ".." || relative.StartsWith("../", StringComparison.Ordinal) + ? Path.GetFileName(path) + : relative; + } + + return IsPortableRelativePath(displayPath) ? displayPath : string.Empty; + } + + static bool IsPortableRelativePath(string? value) + { + if (string.IsNullOrEmpty(value) || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + value.Any(char.IsControl) || + value[0] == '/' || + value.Contains('\\') || + IsDriveRooted(value)) + { + return false; + } + + try + { + if (!string.Equals(value, value.Normalize(), StringComparison.Ordinal)) + { + return false; + } + } + catch (ArgumentException) + { + return false; + } + + var segments = value.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] == ':'; + + sealed record AuthoritativeSource( + SyntaxTree SyntaxTree, + string DisplayPath, + SourceFileIdentity? Identity); +} diff --git a/Source/DotNET/Generation.DotNet/IDescribedDotNetScreenplayAdapter.cs b/Source/DotNET/Generation.DotNet/IDescribedDotNetScreenplayAdapter.cs new file mode 100644 index 0000000..3b40c24 --- /dev/null +++ b/Source/DotNET/Generation.DotNet/IDescribedDotNetScreenplayAdapter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.DotNet; + +/// +/// Defines a described .NET Screenplay adapter with structured applicability probing. +/// +public interface IDescribedDotNetScreenplayAdapter +{ + /// + /// Gets the source-neutral adapter descriptor. + /// + AdapterDescriptor Descriptor { get; } + + /// + /// Probes the analysis context for exact applicability and API capability evidence. + /// + /// The .NET analysis context. + /// The structured probe result. + AdapterProbeResult Probe(DotNetAnalysisContext context); + + /// + /// Analyzes source and returns a raw contribution for atomic host admission. + /// + /// The .NET analysis context. + /// Options controlling artifact placement. + /// The raw adapter contribution. + AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterOptions options); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_nonportable_source_range_paths.cs b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_nonportable_source_range_paths.cs new file mode 100644 index 0000000..a49c3e3 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterContributionAdmission/when_admitting_malformed_contributions/with_nonportable_source_range_paths.cs @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterContributionAdmission.when_admitting_malformed_contributions; + +public class with_nonportable_source_range_paths : given.a_contribution +{ + static readonly string[] _invalidPaths = + [ + "/checkout/Code.cs", + "C:/checkout/Code.cs", + "Folder\\Code.cs", + "../Code.cs", + "./Code.cs", + "Folder//Code.cs", + "%2e/Code.cs", + "%2e%2e/Code.cs" + ]; + + IReadOnlyDictionary _results = null!; + AdapterContributionAdmissionResult _valid = null!; + + void Because() + { + _results = _invalidPaths.ToDictionary(path => path, AdmitWithPath, StringComparer.Ordinal); + _valid = AdmitWithPath("Accounts/Registration/Register.cs"); + } + + [Fact] void should_reject_every_nonportable_display_path() => _results.Values.All(result => !result.IsAdmitted).ShouldBeTrue(); + [Fact] void should_report_every_nonportable_display_path_as_an_invalid_source_range() => _results.Values.All(result => result.Diagnostics.Any(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.InvalidSourceRange)).ShouldBeTrue(); + [Fact] void should_continue_to_admit_a_normalized_relative_display_path() => _valid.IsAdmitted.ShouldBeTrue(); + + static AdapterContributionAdmissionResult AdmitWithPath(string path) + { + var fact = EveryFact().OfType().Single(); + var source = fact.Evidence.Source! with { Path = path }; + var contribution = Contribution( + facts: + [ + fact with + { + Evidence = fact.Evidence with { Source = source } + } + ]); + return Admit(Descriptor(GenerationFactCapability.Artifact), contribution); + } +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_malformed_and_duplicate_api_capabilities.cs b/Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_malformed_and_duplicate_api_capabilities.cs new file mode 100644 index 0000000..229dfe9 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_malformed_and_duplicate_api_capabilities.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterDescriptorAdmission; + +public class when_admitting_malformed_and_duplicate_api_capabilities : Specification +{ + AdapterDescriptorAdmissionResult _result = null!; + + void Because() => _result = AdapterDescriptorAdmission.Admit(new AdapterDescriptor + { + Identity = new AdapterIdentity { Id = "adapter", Version = "1.0.0" }, + SourceLanguage = AdapterSourceLanguage.SourceIndependent, + Category = AdapterCategory.Concepts, + RequiredApiCapabilities = + [ + new AdapterApiCapability { Id = "framework.api" }, + new AdapterApiCapability { Id = " malformed " }, + new AdapterApiCapability { Id = "framework.api" } + ] + }); + + [Fact] void should_reject_the_descriptor() => _result.IsAdmitted.ShouldBeFalse(); + [Fact] void should_report_the_malformed_capability() => _result.Diagnostics.Any(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.InvalidApiCapability).ShouldBeTrue(); + [Fact] void should_report_the_duplicate_capability() => _result.Diagnostics.Any(diagnostic => diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.DuplicateApiCapability).ShouldBeTrue(); +} diff --git a/Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_reversed_api_capabilities.cs b/Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_reversed_api_capabilities.cs new file mode 100644 index 0000000..7a30762 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_AdapterDescriptorAdmission/when_admitting_reversed_api_capabilities.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_AdapterDescriptorAdmission; + +public class when_admitting_reversed_api_capabilities : Specification +{ + AdapterDescriptor _input = null!; + AdapterDescriptorAdmissionResult _result = null!; + + void Establish() => _input = new AdapterDescriptor + { + Identity = new AdapterIdentity { Id = "adapter", Version = "1.0.0" }, + SourceLanguage = AdapterSourceLanguage.SourceIndependent, + Category = AdapterCategory.Concepts, + RequiredApiCapabilities = + [ + new AdapterApiCapability { Id = "framework.zeta" }, + new AdapterApiCapability { Id = "framework.alpha" } + ] + }; + + void Because() => _result = AdapterDescriptorAdmission.Admit(_input); + + [Fact] void should_admit_the_descriptor() => _result.IsAdmitted.ShouldBeTrue(); + [Fact] void should_canonicalize_capabilities_by_stable_identity() => _result.Descriptor.RequiredApiCapabilities.Select(capability => capability.Id).ShouldEqual(["framework.alpha", "framework.zeta"]); + [Fact] void should_deeply_freeze_capability_records() => ReferenceEquals(_input.RequiredApiCapabilities[0], _result.Descriptor.RequiredApiCapabilities[1]).ShouldBeFalse(); +} From f6d208aaa56493715e9a6bbf87c2cccbfefb1589 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 27 Aug 2026 23:17:24 +0200 Subject: [PATCH 3/4] Generate Screenplay from frozen adapter runs --- ...olving_null_and_empty_structural_fields.cs | 46 ++ ...ing_separator_bearing_structural_fields.cs | 74 ++++ .../given/a_generator.cs | 97 +++++ ...onship_shares_a_subject_with_a_conflict.cs | 50 +++ ...placement_and_relationship_dispositions.cs | 157 +++++++ ...lating_collision_safe_fact_dispositions.cs | 78 ++++ ...n_calculating_concept_fact_dispositions.cs | 97 +++++ ...ting_equally_strong_placement_conflicts.cs | 31 ++ ...ting_relationship_conflict_dispositions.cs | 70 +++ ...ulating_specification_fact_dispositions.cs | 173 ++++++++ ...calizing_complete_adapter_run_snapshots.cs | 277 ++++++++++++ ...apter_run_snapshots_in_different_orders.cs | 97 +++++ ...sifiable_and_nonexecuted_adapter_inputs.cs | 88 ++++ ...ification_fails_after_snapshot_lowering.cs | 69 +++ .../Generation/AdapterRunCanonicalizer.cs | 398 ++++++++++++++++++ .../Generation/GenerationDiagnosticCodes.cs | 20 + .../GenerationFactDispositionCalculator.cs | 339 +++++++++++++++ .../DotNET/Generation/GenerationResolver.cs | 69 ++- .../Generation/ResolverDiagnosticCoverage.cs | 238 +++++++++++ .../ScreenplayDefinitionGenerator.cs | 136 ++++++ Source/DotNET/Generation/ScreenplayLowerer.cs | 382 +++++++++++------ .../Generation/ScreenplayLoweringCoverage.cs | 104 +++++ .../Generation/SpecificationAdmission.cs | 18 +- .../Generation/SpecificationFactResolver.cs | 30 +- .../Generation/SpecificationSyntaxLowerer.cs | 66 ++- .../Generation/SpecificationValueAdmission.cs | 8 +- Source/DotNET/Generation/Structural.cs | 266 ++++++++++++ 27 files changed, 3294 insertions(+), 184 deletions(-) create mode 100644 Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_null_and_empty_structural_fields.cs create mode 100644 Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_separator_bearing_structural_fields.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_an_omitted_relationship_shares_a_subject_with_a_conflict.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_artifact_placement_and_relationship_dispositions.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_collision_safe_fact_dispositions.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_concept_fact_dispositions.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_equally_strong_placement_conflicts.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_relationship_conflict_dispositions.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_specification_fact_dispositions.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_canonicalizing_complete_adapter_run_snapshots.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generating_from_adapter_run_snapshots_in_different_orders.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generation_encounters_unclassifiable_and_nonexecuted_adapter_inputs.cs create mode 100644 Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_verification_fails_after_snapshot_lowering.cs create mode 100644 Source/DotNET/Generation/AdapterRunCanonicalizer.cs create mode 100644 Source/DotNET/Generation/GenerationFactDispositionCalculator.cs create mode 100644 Source/DotNET/Generation/ResolverDiagnosticCoverage.cs create mode 100644 Source/DotNET/Generation/ScreenplayLoweringCoverage.cs create mode 100644 Source/DotNET/Generation/Structural.cs diff --git a/Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_null_and_empty_structural_fields.cs b/Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_null_and_empty_structural_fields.cs new file mode 100644 index 0000000..b7e1c2c --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_null_and_empty_structural_fields.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_GenerationResolver; + +public class when_resolving_null_and_empty_structural_fields : given.facts +{ + ResolvedApplicationGraph _result = null!; + + void Because() + { + var artifactWithNullFile = EventDefinition() with { File = null }; + var artifactWithEmptyFile = EventDefinition() with { File = string.Empty }; + var relationshipWithNullMembers = Relationship("relationship:null", FirstAdapter).Definition; + var relationshipWithEmptySource = relationshipWithNullMembers with { SourceMember = string.Empty }; + var relationshipWithEmptyTarget = relationshipWithNullMembers with { TargetMember = string.Empty }; + _result = new GenerationResolver().Resolve( + [ + Contribution( + FirstAdapter, + Fact("artifact:null", FirstAdapter, artifactWithNullFile), + RelationshipFact("relationship:null", FirstAdapter, relationshipWithNullMembers)), + Contribution( + SecondAdapter, + Fact("artifact:empty", SecondAdapter, artifactWithEmptyFile), + RelationshipFact("relationship:empty-source", SecondAdapter, relationshipWithEmptySource), + RelationshipFact("relationship:empty-target", SecondAdapter, relationshipWithEmptyTarget)) + ]); + } + + [Fact] void should_retain_null_and_empty_artifact_files_as_distinct_variants() => _result.Artifacts.Single().Variants.Count.ShouldEqual(2); + [Fact] void should_conflict_null_and_empty_artifact_files() => _result.Artifacts.Single().IsConflicted.ShouldBeTrue(); + [Fact] void should_retain_null_and_empty_relationship_members_as_distinct_variants() => _result.Relationships.Single().Definitions.Count.ShouldEqual(3); + [Fact] void should_conflict_null_and_empty_relationship_members() => _result.Relationships.Single().IsConflicted.ShouldBeTrue(); + + static RelationshipFact RelationshipFact( + string id, + AdapterIdentity adapter, + RelationshipDefinition definition) => new() + { + Id = new FactId { Value = id }, + Subject = definition.Key.Source, + Definition = definition, + Evidence = new Evidence { Adapter = adapter, Strength = EvidenceStrength.Exact } + }; +} diff --git a/Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_separator_bearing_structural_fields.cs b/Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_separator_bearing_structural_fields.cs new file mode 100644 index 0000000..bec5087 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_GenerationResolver/when_resolving_separator_bearing_structural_fields.cs @@ -0,0 +1,74 @@ +// 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.for_GenerationResolver; + +public class when_resolving_separator_bearing_structural_fields : given.facts +{ + ResolvedApplicationGraph _result = null!; + + void Because() + { + var artifact = EventDefinition().Key; + var firstRelationship = new RelationshipKey + { + Kind = RelationshipKind.Reads, + Source = CommandSubject, + Target = new SubjectId { Value = $"{EventSubject.Value}\u001fmember" } + }; + var secondRelationship = new RelationshipKey + { + Kind = RelationshipKind.Reads, + Source = CommandSubject, + Target = EventSubject, + Discriminator = "member" + }; + _result = new GenerationResolver().Resolve( + [ + Contribution( + FirstAdapter, + Placement("placement:first", FirstAdapter, artifact, "Accounts\u001fRegistration", []), + Relationship("relationship:first", FirstAdapter, firstRelationship)), + Contribution( + SecondAdapter, + Placement("placement:second", SecondAdapter, artifact, "Accounts", ["Registration"]), + Relationship("relationship:second", SecondAdapter, secondRelationship)) + ]); + } + + [Fact] void should_retain_separator_bearing_placements_as_distinct_variants() => _result.Placements.Single().Variants.Count.ShouldEqual(2); + [Fact] void should_conflict_the_structurally_different_placements() => _result.Placements.Single().IsConflicted.ShouldBeTrue(); + [Fact] void should_resolve_separator_bearing_relationship_keys_independently() => _result.Relationships.Count.ShouldEqual(2); + [Fact] void should_not_conflict_the_independent_relationships() => _result.Relationships.Any(_ => _.IsConflicted).ShouldBeFalse(); + + static ArtifactPlacementFact Placement( + string id, + AdapterIdentity adapter, + ArtifactKey artifact, + string module, + IReadOnlyList features) => new() + { + Id = new FactId { Value = id }, + Subject = artifact.Subject, + Artifact = artifact, + Placement = new ArtifactPlacement + { + Module = module, + Features = features, + Slice = "Open", + SliceKind = GenerationSliceKind.StateChange + }, + Evidence = new Evidence { Adapter = adapter, Strength = EvidenceStrength.Exact } + }; + + static RelationshipFact Relationship( + string id, + AdapterIdentity adapter, + RelationshipKey key) => new() + { + Id = new FactId { Value = id }, + Subject = key.Source, + Definition = new RelationshipDefinition { Key = key }, + Evidence = new Evidence { Adapter = adapter, Strength = EvidenceStrength.Exact } + }; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/given/a_generator.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/given/a_generator.cs index 374e74d..760c1be 100644 --- a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/given/a_generator.cs +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/given/a_generator.cs @@ -1,6 +1,10 @@ // 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; +using System.Globalization; +using System.Reflection; + namespace Cratis.Screenplay.Generation.for_ScreenplayDefinitionGenerator.given; public class a_generator : Specification @@ -141,4 +145,97 @@ protected ConceptValidationRuleFact Validation( Adapter = Adapter, Facts = facts }; + + protected static AdapterRunRecord Completed( + AdapterIdentity adapter, + IReadOnlyList facts, + IReadOnlyList? diagnostics = null) + { + var descriptor = new AdapterDescriptor + { + Identity = adapter, + SourceLanguage = AdapterSourceLanguage.SourceIndependent, + Category = AdapterCategory.ApplicationFramework + }; + var contribution = new AdapterContributionSnapshot + { + Descriptor = descriptor, + Facts = [.. facts], + Diagnostics = diagnostics is null ? [] : [.. diagnostics] + }; + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Executed = true, + Descriptor = descriptor, + Probe = new AdapterProbeApplicable(), + Execution = new AdapterExecutionCompleted + { + Contribution = contribution, + Diagnostics = contribution.Diagnostics + }, + Disposition = AdapterRunDisposition.Admitted + }; + } + + protected static AdapterRunSnapshot Snapshot(params AdapterRunRecord[] adapters) => new() + { + Adapters = [.. adapters], + Facts = + [ + .. adapters + .SelectMany(record => record.Execution is AdapterExecutionCompleted completed + ? completed.Contribution.Facts + : []) + .Select(fact => new GenerationFactRecord { Fact = fact }) + ] + }; + + protected static string AdapterRunProjection(object? value) + { + if (value is null) + { + return ProjectionNode([null]); + } + + if (value is string text) + { + return ProjectionNode([typeof(string).FullName, text]); + } + + if (value is Version version) + { + return ProjectionNode([typeof(Version).FullName, version.ToString()]); + } + + var type = value.GetType(); + if (type.IsEnum || type.IsPrimitive || value is decimal) + { + return ProjectionNode([type.FullName, Convert.ToString(value, CultureInfo.InvariantCulture)]); + } + + if (value is IEnumerable enumerable) + { + return ProjectionNode( + [ + type.FullName, + .. enumerable.Cast().Select(AdapterRunProjection) + ]); + } + + var properties = type + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.GetIndexParameters().Length == 0) + .OrderBy(property => property.Name, StringComparer.Ordinal); + return ProjectionNode( + [ + type.FullName, + .. properties.Select(property => ProjectionNode([property.Name, AdapterRunProjection(property.GetValue(value))])) + ]); + } + + static string ProjectionNode(IEnumerable values) => string.Concat(values.Select(value => value is null + ? "-1:" + : $"{value.Length.ToString(CultureInfo.InvariantCulture)}:{value}")); } diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_an_omitted_relationship_shares_a_subject_with_a_conflict.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_an_omitted_relationship_shares_a_subject_with_a_conflict.cs new file mode 100644 index 0000000..22ff439 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_an_omitted_relationship_shares_a_subject_with_a_conflict.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.for_ScreenplayDefinitionGenerator; + +public class when_an_omitted_relationship_shares_a_subject_with_a_conflict : given.a_generator +{ + GeneratedScreenplayDefinition _result = null!; + + void Because() + { + var source = new SubjectId { Value = "dotnet://Banking/Handlers.AccountHandler" }; + var firstTarget = new SubjectId { Value = "dotnet://Banking/Commands.OpenAccount" }; + var secondTarget = new SubjectId { Value = "dotnet://Banking/ReadModels.Account" }; + _result = Generator.Generate( + Snapshot(Completed( + Adapter, + [ + Relationship("handles", RelationshipKind.Handles, source, firstTarget, null), + Relationship("reads:first", RelationshipKind.Reads, source, secondTarget, "first"), + Relationship("reads:second", RelationshipKind.Reads, source, secondTarget, "second") + ])), + new ScreenplayGenerationOptions { Domain = "Banking" }); + } + + [Fact] void should_omit_the_unconsumed_handles_relationship() => Record("handles").Disposition.ShouldEqual(GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_report_the_stable_unsupported_relationship_diagnostic() => Record("handles").Diagnostics.Select(_ => _.Code).ShouldContain(GenerationDiagnosticCodes.UnsupportedRelationship); + [Fact] void should_not_borrow_the_reads_conflict_diagnostic() => Record("handles").Diagnostics.Select(_ => _.Code).ShouldNotContain(GenerationDiagnosticCodes.ConflictingRelationship); + [Fact] void should_classify_both_reads_variants_as_conflicted() => new[] { Record("reads:first"), Record("reads:second") }.All(_ => _.Disposition == GenerationFactDisposition.Conflicted).ShouldBeTrue(); + + RelationshipFact Relationship( + string id, + RelationshipKind kind, + SubjectId source, + SubjectId target, + string? sourceMember) => new() + { + Id = new FactId { Value = id }, + Subject = source, + Evidence = new Evidence { Adapter = Adapter, Strength = EvidenceStrength.Exact }, + Definition = new RelationshipDefinition + { + Key = new RelationshipKey { Kind = kind, Source = source, Target = target }, + SourceMember = sourceMember + } + }; + + GenerationFactRecord Record(string id) => + _result.AdapterRun!.Facts.Single(_ => _.Fact.Id.Value == id); +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_artifact_placement_and_relationship_dispositions.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_artifact_placement_and_relationship_dispositions.cs new file mode 100644 index 0000000..c9d5f66 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_artifact_placement_and_relationship_dispositions.cs @@ -0,0 +1,157 @@ +// 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.for_ScreenplayDefinitionGenerator; + +public class when_calculating_artifact_placement_and_relationship_dispositions : given.a_generator +{ + AdapterRunSnapshot _input = null!; + AdapterRunSnapshot _copiedAfterGeneration = null!; + GeneratedScreenplayDefinition _result = null!; + + void Because() + { + var emitted = Event("AccountOpened", "Open"); + var artifact = (ArtifactFact)emitted[0]; + var effectivePlacement = (ArtifactPlacementFact)emitted[1]; + var weakerPlacement = effectivePlacement with + { + Id = new FactId { Value = "placement:AccountOpened:weaker" }, + Placement = effectivePlacement.Placement with { Slice = "Events" }, + Evidence = effectivePlacement.Evidence with { Strength = EvidenceStrength.Heuristic } + }; + var duplicateArtifact = artifact with { Id = new FactId { Value = "event:AccountOpened:duplicate" } }; + var unsupportedSubject = new SubjectId { Value = "dotnet://Banking/Handlers.OpenAccount" }; + var unsupportedKey = new ArtifactKey { Subject = unsupportedSubject, Kind = ArtifactKind.Handler }; + var unsupportedArtifact = new ArtifactFact + { + Id = new FactId { Value = "handler:unsupported" }, + Subject = unsupportedSubject, + Evidence = Evidence("Handlers/OpenAccount.cs"), + Definition = new ArtifactDefinition { Key = unsupportedKey, Name = "OpenAccountHandler" } + }; + var unsupportedPlacement = new ArtifactPlacementFact + { + Id = new FactId { Value = "handler:unsupported:placement" }, + Subject = unsupportedSubject, + Evidence = Evidence("Handlers/OpenAccount.cs"), + Artifact = unsupportedKey, + Placement = effectivePlacement.Placement with { Slice = "Handle" } + }; + var unplacedSubject = new SubjectId { Value = "dotnet://Banking/Events.AccountClosed" }; + var unplaced = new ArtifactFact + { + Id = new FactId { Value = "event:unplaced" }, + Subject = unplacedSubject, + Evidence = Evidence("Events/AccountClosed.cs"), + Definition = new ArtifactDefinition + { + Key = new ArtifactKey { Subject = unplacedSubject, Kind = ArtifactKind.Event }, + Name = "AccountClosed" + } + }; + var commandSubject = new SubjectId { Value = "dotnet://Banking/Commands.OpenAccount" }; + var commandKey = new ArtifactKey { Subject = commandSubject, Kind = ArtifactKind.Command }; + var commandArtifact = new ArtifactFact + { + Id = new FactId { Value = "command:open" }, + Subject = commandSubject, + Evidence = Evidence("Commands/OpenAccount.cs"), + Definition = new ArtifactDefinition { Key = commandKey, Name = "OpenAccount" } + }; + var commandPlacement = new ArtifactPlacementFact + { + Id = new FactId { Value = "command:open:placement" }, + Subject = commandSubject, + Evidence = Evidence("Commands/OpenAccount.cs"), + Artifact = commandKey, + Placement = effectivePlacement.Placement + }; + var loweredRelationship = new RelationshipFact + { + Id = new FactId { Value = "relationship:produces" }, + Subject = commandSubject, + Evidence = Evidence("Commands/OpenAccount.cs"), + Definition = new RelationshipDefinition + { + Key = new RelationshipKey + { + Kind = RelationshipKind.Produces, + Source = commandSubject, + Target = artifact.Subject + } + } + }; + var unsupportedRelationship = new RelationshipFact + { + Id = new FactId { Value = "relationship:handles" }, + Subject = artifact.Subject, + Evidence = Evidence("Handlers/OpenAccount.cs"), + Definition = new RelationshipDefinition + { + Key = new RelationshipKey + { + Kind = RelationshipKind.Handles, + Source = artifact.Subject, + Target = unsupportedSubject + } + } + }; + _input = Snapshot(Completed( + Adapter, + [ + artifact, + effectivePlacement, + weakerPlacement, + duplicateArtifact, + unsupportedArtifact, + unsupportedPlacement, + unplaced, + commandArtifact, + commandPlacement, + loweredRelationship, + unsupportedRelationship + ])); + + _result = Generator.Generate(_input, new ScreenplayGenerationOptions { Domain = "Banking" }); + _copiedAfterGeneration = _input with { Adapters = [], Facts = [], Diagnostics = [] }; + } + + [Fact] void should_lower_both_equivalent_artifact_assertions() => Dispositions("event:AccountOpened", "event:AccountOpened:duplicate").ShouldContainOnly(GenerationFactDisposition.Lowered, GenerationFactDisposition.Lowered); + [Fact] void should_lower_only_the_effective_placement() => Disposition("placement:AccountOpened").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_retain_the_weaker_placement_as_provenance() => Disposition("placement:AccountOpened:weaker").ShouldEqual(GenerationFactDisposition.ProvenanceOnly); + [Fact] void should_omit_the_unsupported_artifact_with_its_stable_diagnostic() => OmittedCode("handler:unsupported").ShouldEqual(GenerationDiagnosticCodes.UnsupportedArtifact); + [Fact] void should_omit_the_unsupported_artifact_placement() => Disposition("handler:unsupported:placement").ShouldEqual(GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_omit_the_unplaced_artifact_with_its_stable_diagnostic() => OmittedCode("event:unplaced").ShouldEqual(GenerationDiagnosticCodes.IncompleteArtifact); + [Fact] void should_lower_the_consumed_relationship() => Disposition("relationship:produces").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_omit_the_unconsumed_relationship_with_its_stable_diagnostic() => OmittedCode("relationship:handles").ShouldEqual(GenerationDiagnosticCodes.UnsupportedRelationship); + [Fact] void should_not_leave_any_fact_unknown() => _result.AdapterRun!.Facts.Any(record => record.Disposition == GenerationFactDisposition.Unknown).ShouldBeFalse(); + [Fact] void should_leave_input_dispositions_unknown() => _input.Facts.All(record => record.Disposition == GenerationFactDisposition.Unknown).ShouldBeTrue(); + [Fact] void should_return_a_new_snapshot() => ReferenceEquals(_result.AdapterRun, _input).ShouldBeFalse(); + [Fact] void should_not_change_the_result_after_with_copying_the_input() => _result.AdapterRun!.Facts.Length.ShouldEqual(11); + [Fact] void should_allow_the_input_copy_to_diverge() => _copiedAfterGeneration.Facts.ShouldBeEmpty(); + + GenerationFactDisposition Disposition(string id) => _result.AdapterRun!.Facts.Single(record => record.Fact.Id.Value == id).Disposition; + + GenerationFactDisposition[] Dispositions(params string[] ids) => + [.. ids.Select(Disposition)]; + + string OmittedCode(string id) => _result.AdapterRun!.Facts + .Single(record => record.Fact.Id.Value == id) + .Diagnostics[0] + .Code; + + Evidence Evidence(string path) => new() + { + Adapter = Adapter, + Strength = EvidenceStrength.Exact, + Source = new SourceRange + { + Path = path, + StartLine = 1, + StartColumn = 1, + EndLine = 1, + EndColumn = 1 + } + }; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_collision_safe_fact_dispositions.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_collision_safe_fact_dispositions.cs new file mode 100644 index 0000000..bd1ab87 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_collision_safe_fact_dispositions.cs @@ -0,0 +1,78 @@ +// 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.for_ScreenplayDefinitionGenerator; + +public class when_calculating_collision_safe_fact_dispositions : given.a_generator +{ + GeneratedScreenplayDefinition _result = null!; + + void Because() + { + var artifactSubject = new SubjectId { Value = "dotnet://Banking/Events.AccountOpened" }; + var artifactKey = new ArtifactKey { Subject = artifactSubject, Kind = ArtifactKind.Event }; + var source = new SubjectId { Value = "dotnet://Banking/Handlers.AccountHandler" }; + var target = new SubjectId { Value = "dotnet://Banking/ReadModels.Account" }; + _result = Generator.Generate( + Snapshot(Completed( + Adapter, + [ + Artifact("artifact:null", artifactKey, null), + Artifact("artifact:empty", artifactKey, string.Empty), + Relationship("relationship:null", source, target, null, null), + Relationship("relationship:empty-source", source, target, string.Empty, null), + Relationship( + "relationship:separator-target", + source, + new SubjectId { Value = $"{target.Value}\u001fmember" }, + null, + null), + Relationship("relationship:separator-discriminator", source, target, null, "member") + ])), + new ScreenplayGenerationOptions { Domain = "Banking" }); + } + + [Fact] void should_conflict_null_and_empty_artifact_files_independently() => Dispositions("artifact:null", "artifact:empty").ShouldContainOnly(GenerationFactDisposition.Conflicted, GenerationFactDisposition.Conflicted); + [Fact] void should_conflict_null_and_empty_relationship_members_independently() => Dispositions("relationship:null", "relationship:empty-source").ShouldContainOnly(GenerationFactDisposition.Conflicted, GenerationFactDisposition.Conflicted); + [Fact] void should_omit_separator_bearing_relationship_keys_independently() => Dispositions("relationship:separator-target", "relationship:separator-discriminator").ShouldContainOnly(GenerationFactDisposition.OmittedWithDiagnostic, GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_not_report_a_conflict_for_separator_bearing_relationship_keys() => Records("relationship:separator-target", "relationship:separator-discriminator").SelectMany(_ => _.Diagnostics).Any(_ => _.Outcome == GenerationDiagnosticOutcome.Conflict).ShouldBeFalse(); + + ArtifactFact Artifact(string id, ArtifactKey key, string? file) => new() + { + Id = new FactId { Value = id }, + Subject = key.Subject, + Evidence = Exact(), + Definition = new ArtifactDefinition { Key = key, Name = "AccountOpened", File = file } + }; + + RelationshipFact Relationship( + string id, + SubjectId source, + SubjectId target, + string? sourceMember, + string? discriminator) => new() + { + Id = new FactId { Value = id }, + Subject = source, + Evidence = Exact(), + Definition = new RelationshipDefinition + { + Key = new RelationshipKey + { + Kind = RelationshipKind.Reads, + Source = source, + Target = target, + Discriminator = discriminator + }, + SourceMember = sourceMember + } + }; + + Evidence Exact() => new() { Adapter = Adapter, Strength = EvidenceStrength.Exact }; + + GenerationFactRecord[] Records(params string[] ids) => + [.. ids.Select(id => _result.AdapterRun!.Facts.Single(_ => _.Fact.Id.Value == id))]; + + GenerationFactDisposition[] Dispositions(params string[] ids) => + [.. Records(ids).Select(_ => _.Disposition)]; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_concept_fact_dispositions.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_concept_fact_dispositions.cs new file mode 100644 index 0000000..273454b --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_concept_fact_dispositions.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. + +namespace Cratis.Screenplay.Generation.for_ScreenplayDefinitionGenerator; + +public class when_calculating_concept_fact_dispositions : given.a_generator +{ + GeneratedScreenplayDefinition _result = null!; + + void Because() + { + var accountNumber = Concept("AccountNumber", GenerationPrimitiveKind.Text); + var attribute = new ConceptAttributeFact + { + Id = new FactId { Value = "attribute:account-number" }, + Subject = accountNumber.Subject, + Evidence = Exact(), + Definition = new ConceptAttributeDefinition + { + Concept = accountNumber.Subject, + Name = "sensitive", + Reason = "Routes payments" + } + }; + var validation = Validation( + "validation:account-number", + accountNumber.Subject, + "format", + "BeValidAccountNumber"); + var invalidAttribute = attribute with + { + Id = new FactId { Value = "attribute:invalid" }, + Definition = attribute.Definition with { Name = "Not Valid" } + }; + var conflictedSubject = new SubjectId { Value = "dotnet://Banking/Concepts.ExternalCode" }; + var conflictedArtifact = new ArtifactFact + { + Id = new FactId { Value = "concept:conflicted" }, + Subject = conflictedSubject, + Evidence = Exact(), + Definition = new ArtifactDefinition + { + Key = new ArtifactKey { Subject = conflictedSubject, Kind = ArtifactKind.Concept }, + Name = "ExternalCode" + } + }; + var firstRepresentation = new ConceptRepresentationFact + { + Id = new FactId { Value = "representation:first" }, + Subject = conflictedSubject, + Evidence = Exact(), + Definition = new ConceptRepresentationDefinition + { + Concept = conflictedSubject, + Kind = ConceptRepresentationKind.Primitive, + Primitive = GenerationPrimitiveKind.Text + } + }; + var secondRepresentation = firstRepresentation with + { + Id = new FactId { Value = "representation:second" }, + Definition = firstRepresentation.Definition with { Primitive = GenerationPrimitiveKind.Uuid } + }; + + _result = Generator.Generate( + Snapshot(Completed( + Adapter, + [ + .. accountNumber.Facts, + attribute, + validation, + invalidAttribute, + conflictedArtifact, + firstRepresentation, + secondRepresentation + ])), + new ScreenplayGenerationOptions { Domain = "Banking" }); + } + + [Fact] void should_lower_the_concept_artifact() => Disposition("concept:AccountNumber").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_lower_the_concept_representation() => Disposition("concept-representation:AccountNumber").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_lower_the_emitted_attribute() => Disposition("attribute:account-number").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_lower_the_emitted_validation() => Disposition("validation:account-number").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_omit_the_invalid_attribute_with_its_stable_diagnostic() => DiagnosticCode("attribute:invalid").ShouldEqual(GenerationDiagnosticCodes.UnsupportedConceptAttribute); + [Fact] void should_classify_the_first_conflicting_representation() => Disposition("representation:first").ShouldEqual(GenerationFactDisposition.Conflicted); + [Fact] void should_classify_the_second_conflicting_representation() => Disposition("representation:second").ShouldEqual(GenerationFactDisposition.Conflicted); + [Fact] void should_omit_the_concept_whose_representation_conflicted() => Disposition("concept:conflicted").ShouldEqual(GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_not_leave_any_fact_unknown() => _result.AdapterRun!.Facts.Any(record => record.Disposition == GenerationFactDisposition.Unknown).ShouldBeFalse(); + + GenerationFactDisposition Disposition(string id) => Record(id).Disposition; + + string DiagnosticCode(string id) => Record(id).Diagnostics[0].Code; + + GenerationFactRecord Record(string id) => _result.AdapterRun!.Facts.Single(record => record.Fact.Id.Value == id); + + Evidence Exact() => new() { Adapter = Adapter, Strength = EvidenceStrength.Exact }; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_equally_strong_placement_conflicts.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_equally_strong_placement_conflicts.cs new file mode 100644 index 0000000..8be6c4f --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_equally_strong_placement_conflicts.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Generation.for_ScreenplayDefinitionGenerator; + +public class when_calculating_equally_strong_placement_conflicts : given.a_generator +{ + GeneratedScreenplayDefinition _result = null!; + + void Because() + { + var facts = Event("AccountOpened", "Open"); + var first = (ArtifactPlacementFact)facts[1]; + var second = first with + { + Id = new FactId { Value = "placement:AccountOpened:other" }, + Placement = first.Placement with { Slice = "Other" } + }; + _result = Generator.Generate( + Snapshot(Completed(Adapter, [facts[0], first, second])), + new ScreenplayGenerationOptions { Domain = "Banking" }); + } + + [Fact] void should_classify_the_first_conflict_variant_independently() => Disposition("placement:AccountOpened").ShouldEqual(GenerationFactDisposition.Conflicted); + [Fact] void should_classify_the_second_conflict_variant_independently() => Disposition("placement:AccountOpened:other").ShouldEqual(GenerationFactDisposition.Conflicted); + [Fact] void should_associate_the_stable_conflict_diagnostic_with_both_variants() => _result.AdapterRun!.Facts.Where(record => record.Disposition == GenerationFactDisposition.Conflicted).All(record => record.Diagnostics.Any(diagnostic => diagnostic.Code == GenerationDiagnosticCodes.ConflictingPlacement)).ShouldBeTrue(); + [Fact] void should_omit_the_artifact_that_could_not_be_placed() => Disposition("event:AccountOpened").ShouldEqual(GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_not_leave_any_fact_unknown() => _result.AdapterRun!.Facts.Any(record => record.Disposition == GenerationFactDisposition.Unknown).ShouldBeFalse(); + + GenerationFactDisposition Disposition(string id) => _result.AdapterRun!.Facts.Single(record => record.Fact.Id.Value == id).Disposition; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_relationship_conflict_dispositions.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_relationship_conflict_dispositions.cs new file mode 100644 index 0000000..c4ba456 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_relationship_conflict_dispositions.cs @@ -0,0 +1,70 @@ +// 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.for_ScreenplayDefinitionGenerator; + +public class when_calculating_relationship_conflict_dispositions : given.a_generator +{ + GeneratedScreenplayDefinition _result = null!; + + void Because() + { + var commandSubject = new SubjectId { Value = "dotnet://Banking/Commands.OpenAccount" }; + var eventFacts = Event("AccountOpened", "Open"); + var eventArtifact = (ArtifactFact)eventFacts[0]; + var commandKey = new ArtifactKey { Subject = commandSubject, Kind = ArtifactKind.Command }; + var placement = ((ArtifactPlacementFact)eventFacts[1]).Placement; + var first = Relationship("relationship:first", commandSubject, eventArtifact.Subject, "result"); + var second = Relationship("relationship:second", commandSubject, eventArtifact.Subject, "events"); + _result = Generator.Generate( + Snapshot(Completed( + Adapter, + [ + .. eventFacts, + new ArtifactFact + { + Id = new FactId { Value = "command" }, + Subject = commandSubject, + Evidence = Exact(), + Definition = new ArtifactDefinition { Key = commandKey, Name = "OpenAccount" } + }, + new ArtifactPlacementFact + { + Id = new FactId { Value = "command:placement" }, + Subject = commandSubject, + Evidence = Exact(), + Artifact = commandKey, + Placement = placement + }, + first, + second + ])), + new ScreenplayGenerationOptions { Domain = "Banking" }); + } + + [Fact] void should_classify_the_first_relationship_variant_as_conflicted() => Disposition("relationship:first").ShouldEqual(GenerationFactDisposition.Conflicted); + [Fact] void should_classify_the_second_relationship_variant_as_conflicted() => Disposition("relationship:second").ShouldEqual(GenerationFactDisposition.Conflicted); + [Fact] void should_associate_the_relationship_conflict_diagnostic_with_both_variants() => _result.AdapterRun!.Facts.Where(record => record.Fact is RelationshipFact).All(record => record.Diagnostics.Any(diagnostic => diagnostic.Code == GenerationDiagnosticCodes.ConflictingRelationship)).ShouldBeTrue(); + [Fact] void should_not_leave_any_fact_unknown() => _result.AdapterRun!.Facts.Any(record => record.Disposition == GenerationFactDisposition.Unknown).ShouldBeFalse(); + + RelationshipFact Relationship(string id, SubjectId source, SubjectId target, string sourceMember) => new() + { + Id = new FactId { Value = id }, + Subject = source, + Evidence = Exact(), + Definition = new RelationshipDefinition + { + Key = new RelationshipKey + { + Kind = RelationshipKind.Produces, + Source = source, + Target = target + }, + SourceMember = sourceMember + } + }; + + Evidence Exact() => new() { Adapter = Adapter, Strength = EvidenceStrength.Exact }; + + GenerationFactDisposition Disposition(string id) => _result.AdapterRun!.Facts.Single(record => record.Fact.Id.Value == id).Disposition; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_specification_fact_dispositions.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_specification_fact_dispositions.cs new file mode 100644 index 0000000..b4e782b --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_calculating_specification_fact_dispositions.cs @@ -0,0 +1,173 @@ +// 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.for_ScreenplayDefinitionGenerator; + +public class when_calculating_specification_fact_dispositions : given.a_generator +{ + readonly SubjectId _commandSubject = new() { Value = "dotnet://Banking/Commands.RegisterAccount" }; + readonly SubjectId _eventSubject = new() { Value = "dotnet://Banking/Events.AccountRegistered" }; + readonly SubjectId _scenarioSubject = new() { Value = "dotnet://Banking/Specs.RegisteringAccount" }; + GeneratedScreenplayDefinition _accepted = null!; + GeneratedScreenplayDefinition _conflicted = null!; + GeneratedScreenplayDefinition _rejected = null!; + + void Because() + { + var common = CommonFacts(); + var specification = SpecificationFacts(); + var options = new ScreenplayGenerationOptions { Domain = "Banking" }; + _accepted = Generator.Generate( + Snapshot(Completed(Adapter, [.. common, .. specification])), + options); + _rejected = Generator.Generate( + Snapshot(Completed(Adapter, [.. common, .. specification.Where(fact => fact.Id.Value != "spec:value:then")])), + options); + var scenario = (SpecificationScenarioFact)specification.Single(fact => fact.Id.Value == "spec:scenario"); + var value = (SpecificationValueFact)specification.Single(fact => fact.Id.Value == "spec:value:then"); + _conflicted = Generator.Generate( + Snapshot(Completed( + Adapter, + [ + .. common, + .. specification, + scenario with + { + Id = new FactId { Value = "spec:scenario:conflict" }, + Definition = scenario.Definition with { Name = "Registering another account" } + }, + value with + { + Id = new FactId { Value = "spec:value:then:conflict" }, + Definition = value.Definition with { Scalar = "Other" } + } + ])), + options); + } + + [Fact] void should_lower_the_accepted_scenario() => Disposition(_accepted, "spec:scenario").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_lower_every_accepted_step() => Dispositions(_accepted, "spec:step:when", "spec:step:then").ShouldContainOnly(GenerationFactDisposition.Lowered, GenerationFactDisposition.Lowered); + [Fact] void should_lower_every_accepted_value() => Dispositions(_accepted, "spec:value:when", "spec:value:then").ShouldContainOnly(GenerationFactDisposition.Lowered, GenerationFactDisposition.Lowered); + [Fact] void should_omit_the_rejected_scenario() => Disposition(_rejected, "spec:scenario").ShouldEqual(GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_omit_every_step_of_the_rejected_scenario() => Dispositions(_rejected, "spec:step:when", "spec:step:then").ShouldContainOnly(GenerationFactDisposition.OmittedWithDiagnostic, GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_omit_the_unconsumed_value_of_the_rejected_scenario() => Disposition(_rejected, "spec:value:when").ShouldEqual(GenerationFactDisposition.OmittedWithDiagnostic); + [Fact] void should_associate_the_incomplete_scenario_diagnostic_with_rejected_steps_and_values() => DiagnosticCodes(_rejected, "spec:scenario", "spec:step:when", "spec:step:then", "spec:value:when").All(code => code == GenerationDiagnosticCodes.IncompleteSpecificationScenario).ShouldBeTrue(); + [Fact] void should_classify_both_scenario_conflict_variants_independently() => Dispositions(_conflicted, "spec:scenario", "spec:scenario:conflict").ShouldContainOnly(GenerationFactDisposition.Conflicted, GenerationFactDisposition.Conflicted); + [Fact] void should_classify_both_value_conflict_variants_independently() => Dispositions(_conflicted, "spec:value:then", "spec:value:then:conflict").ShouldContainOnly(GenerationFactDisposition.Conflicted, GenerationFactDisposition.Conflicted); + [Fact] void should_not_leave_accepted_rejected_or_conflicted_specification_facts_unknown() => _accepted.AdapterRun!.Facts.Concat(_rejected.AdapterRun!.Facts).Concat(_conflicted.AdapterRun!.Facts).Any(record => record.Disposition == GenerationFactDisposition.Unknown).ShouldBeFalse(); + + GenerationFact[] CommonFacts() + { + var commandKey = new ArtifactKey { Subject = _commandSubject, Kind = ArtifactKind.Command }; + var eventKey = new ArtifactKey { Subject = _eventSubject, Kind = ArtifactKind.Event }; + return + [ + Artifact("artifact:command", commandKey, "RegisterAccount"), + Placement("placement:command", commandKey), + Artifact("artifact:event", eventKey, "AccountRegistered"), + Placement("placement:event", eventKey) + ]; + } + + GenerationFact[] SpecificationFacts() + { + var scenario = new SpecificationScenarioKey { Scenario = _scenarioSubject }; + var whenKey = new SpecificationStepKey { Scenario = scenario, Index = 0 }; + var thenKey = new SpecificationStepKey { Scenario = scenario, Index = 1 }; + var whenValue = new SpecificationValueKey { Step = whenKey, Path = ["name"] }; + var thenValue = new SpecificationValueKey { Step = thenKey, Path = ["name"] }; + return + [ + new SpecificationScenarioFact + { + Id = new FactId { Value = "spec:scenario" }, + Subject = _scenarioSubject, + Evidence = Exact(), + Definition = new SpecificationScenarioDefinition + { + Key = scenario, + Name = "Registering account", + TargetArtifact = new ArtifactKey { Subject = _commandSubject, Kind = ArtifactKind.Command }, + Steps = [whenKey, thenKey] + } + }, + new SpecificationStepFact + { + Id = new FactId { Value = "spec:step:when" }, + Subject = new SubjectId { Value = $"{_scenarioSubject.Value}/step/0" }, + Evidence = Exact(), + Definition = new SpecificationStepDefinition + { + Key = whenKey, + Phase = SpecificationStepPhase.When, + Kind = SpecificationStepKind.Command, + Artifact = new ArtifactKey { Subject = _commandSubject, Kind = ArtifactKind.Command }, + Values = [whenValue] + } + }, + new SpecificationStepFact + { + Id = new FactId { Value = "spec:step:then" }, + Subject = new SubjectId { Value = $"{_scenarioSubject.Value}/step/1" }, + Evidence = Exact(), + Definition = new SpecificationStepDefinition + { + Key = thenKey, + Phase = SpecificationStepPhase.Then, + Kind = SpecificationStepKind.Event, + Artifact = new ArtifactKey { Subject = _eventSubject, Kind = ArtifactKind.Event }, + Values = [thenValue] + } + }, + Value("spec:value:when", whenValue), + Value("spec:value:then", thenValue) + ]; + } + + ArtifactFact Artifact(string id, ArtifactKey key, string name) => new() + { + Id = new FactId { Value = id }, + Subject = key.Subject, + Evidence = Exact(), + Definition = new ArtifactDefinition { Key = key, Name = name } + }; + + ArtifactPlacementFact Placement(string id, ArtifactKey key) => new() + { + Id = new FactId { Value = id }, + Subject = key.Subject, + Evidence = Exact(), + Artifact = key, + Placement = new ArtifactPlacement + { + Module = "Accounts", + Features = ["Registration"], + Slice = "Register", + SliceKind = GenerationSliceKind.StateChange + } + }; + + SpecificationValueFact Value(string id, SpecificationValueKey key) => new() + { + Id = new FactId { Value = id }, + Subject = new SubjectId { Value = $"{_scenarioSubject.Value}/value/{id}" }, + Evidence = Exact(), + Definition = new SpecificationValueDefinition + { + Key = key, + Kind = SpecificationValueKind.Text, + Scalar = "Cratis" + } + }; + + Evidence Exact() => new() { Adapter = Adapter, Strength = EvidenceStrength.Exact }; + + static GenerationFactDisposition Disposition(GeneratedScreenplayDefinition result, string id) => + result.AdapterRun!.Facts.Single(record => record.Fact.Id.Value == id).Disposition; + + static GenerationFactDisposition[] Dispositions(GeneratedScreenplayDefinition result, params string[] ids) => + [.. ids.Select(id => Disposition(result, id))]; + + static string[] DiagnosticCodes(GeneratedScreenplayDefinition result, params string[] ids) => + [.. ids.Select(id => result.AdapterRun!.Facts.Single(record => record.Fact.Id.Value == id).Diagnostics[0].Code)]; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_canonicalizing_complete_adapter_run_snapshots.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_canonicalizing_complete_adapter_run_snapshots.cs new file mode 100644 index 0000000..52f9f88 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_canonicalizing_complete_adapter_run_snapshots.cs @@ -0,0 +1,277 @@ +// 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.for_ScreenplayDefinitionGenerator; + +public class when_canonicalizing_complete_adapter_run_snapshots : given.a_generator +{ + readonly AdapterIdentity _completedAdapter = new() { Id = "adapter-completed", Version = "2.0.0" }; + readonly AdapterIdentity _rejectedAdapter = new() { Id = "adapter-rejected", Version = "3.0.0" }; + readonly List _features = ["Registration", "Opening"]; + readonly List _properties = []; + AdapterRunRecord _completedInput = null!; + GeneratedScreenplayDefinition _forward = null!; + GeneratedScreenplayDefinition _reverse = null!; + + void Because() + { + var subject = new SubjectId { Value = "dotnet://Banking/Events.AccountOpened" }; + var artifact = new ArtifactKey { Subject = subject, Kind = ArtifactKind.Event }; + _properties.Add(new PropertyDefinition + { + Name = "accountId", + Type = new TypeReferenceDefinition + { + Name = "AccountId", + Subject = new SubjectId { Value = "dotnet://Banking/Concepts.AccountId" }, + IsOptional = true + }, + IsIdentifier = true + }); + var facts = new GenerationFact[] + { + new ArtifactPlacementFact + { + Id = new FactId { Value = "adapter-completed:placement" }, + Subject = subject, + Evidence = FactEvidence(), + Artifact = artifact, + Placement = new ArtifactPlacement + { + Module = "Accounts", + Features = _features, + Slice = "Open", + SliceKind = GenerationSliceKind.StateChange + } + }, + new ArtifactFact + { + Id = new FactId { Value = "adapter-completed:artifact" }, + Subject = subject, + Evidence = FactEvidence(), + Definition = new ArtifactDefinition + { + Key = artifact, + Name = "AccountOpened", + Description = "An account was opened", + File = "Accounts/Open/AccountOpened.cs", + Properties = _properties + } + } + }; + var contributionDiagnostics = GenerationDiagnostics("CONTRIBUTION", subject); + var executionDiagnostics = GenerationDiagnostics("EXECUTION", subject); + _completedInput = CompletedRecord( + facts, + contributionDiagnostics, + executionDiagnostics, + reverse: false); + var completedReverse = CompletedRecord( + Reversed(facts), + Reversed(contributionDiagnostics), + Reversed(executionDiagnostics), + reverse: true); + var rejected = RejectedRecord(reverse: false); + var rejectedReverse = RejectedRecord(reverse: true); + var topDiagnostics = GenerationDiagnostics("TOP", subject); + var options = new ScreenplayGenerationOptions { Domain = "Banking" }; + _forward = Generator.Generate( + Snapshot(_completedInput, rejected) with { Diagnostics = [.. topDiagnostics] }, + options); + _reverse = Generator.Generate( + Snapshot(rejectedReverse, completedReverse) with + { + Diagnostics = [.. Reversed(topDiagnostics)] + }, + options); + + _features.Add("Mutated"); + _properties.Clear(); + } + + [Fact] void should_return_recursively_identical_canonical_adapter_runs() => AdapterRunProjection(_reverse.AdapterRun).ShouldEqual(AdapterRunProjection(_forward.AdapterRun)); + [Fact] void should_return_a_new_adapter_record() => ReferenceEquals(_forward.AdapterRun!.Adapters[0], _completedInput).ShouldBeFalse(); + [Fact] void should_deep_clone_the_descriptor() => ReferenceEquals(_forward.AdapterRun!.Adapters[0].Descriptor, _completedInput.Descriptor).ShouldBeFalse(); + [Fact] void should_canonicalize_host_capabilities() => _forward.AdapterRun!.Adapters[0].Descriptor.RequiredHostCapabilities.ShouldContainOnly(AdapterHostCapability.AuthoredSource, AdapterHostCapability.SemanticAnalysis); + [Fact] void should_canonicalize_api_capabilities() => _forward.AdapterRun!.Adapters[0].Descriptor.RequiredApiCapabilities.Select(_ => _.Id).ShouldEqual(["api.alpha", "api.zeta"]); + [Fact] void should_canonicalize_fact_capabilities() => _forward.AdapterRun!.Adapters[0].Descriptor.EmittedFactCapabilities.ShouldContainOnly(GenerationFactCapability.Artifact, GenerationFactCapability.ArtifactPlacement); + [Fact] void should_canonicalize_probe_evidence() => _forward.AdapterRun!.Adapters[0].Probe.Evidence.Select(_ => _.Description).ShouldEqual(["Subject", "API"]); + [Fact] void should_preserve_the_complete_probe_source_range() => _forward.AdapterRun!.Adapters[0].Probe.Evidence[0].Source!.EndColumn.ShouldEqual(9); + [Fact] void should_canonicalize_completed_contribution_facts() => CompletedContribution().Facts.Select(_ => _.Id.Value).ShouldEqual(["adapter-completed:artifact", "adapter-completed:placement"]); + [Fact] void should_preserve_authored_property_order_by_deep_clone() => ((ArtifactFact)CompletedContribution().Facts[0]).Definition.Properties.Select(_ => _.Name).ShouldEqual(["accountId"]); + [Fact] void should_preserve_authored_feature_order_by_deep_clone() => ((ArtifactPlacementFact)CompletedContribution().Facts[1]).Placement.Features.ShouldEqual("Registration", "Opening"); + [Fact] void should_preserve_completed_contribution_diagnostics() => CompletedContribution().Diagnostics.Select(_ => _.Message).ShouldContainOnly("alpha", "zeta"); + [Fact] void should_canonicalize_rejected_admission_diagnostics() => RejectedExecution().AdmissionDiagnostics[0].Path.ShouldEqual("z.path"); + [Fact] void should_deep_clone_fact_records() => ReferenceEquals(_forward.AdapterRun!.Facts[0].Fact, CompletedContribution().Facts[0]).ShouldBeFalse(); + [Fact] void should_deep_clone_run_diagnostics() => ReferenceEquals(_forward.AdapterRun!.Diagnostics[0], _completedInput.Execution.Diagnostics[0]).ShouldBeFalse(); + + AdapterRunRecord CompletedRecord( + IReadOnlyList facts, + IReadOnlyList contributionDiagnostics, + IReadOnlyList executionDiagnostics, + bool reverse) + { + var descriptor = Descriptor(_completedAdapter, reverse); + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Executed = true, + Descriptor = descriptor, + Probe = new AdapterProbeApplicable + { + Evidence = ProbeEvidence(reverse) + }, + Execution = new AdapterExecutionCompleted + { + Diagnostics = [.. executionDiagnostics], + Contribution = new AdapterContributionSnapshot + { + Descriptor = descriptor, + Facts = [.. facts], + Diagnostics = [.. contributionDiagnostics] + } + }, + Disposition = AdapterRunDisposition.Admitted + }; + } + + AdapterRunRecord RejectedRecord(bool reverse) + { + var descriptor = Descriptor(_rejectedAdapter, reverse); + var diagnostics = new[] + { + new AdapterContributionAdmissionDiagnostic + { + Code = AdapterContributionAdmissionDiagnosticCode.InvalidFactId, + Path = "z.path", + Message = "zeta", + Fact = new FactId { Value = "adapter-rejected:zeta" }, + Subject = new SubjectId { Value = "dotnet://Banking/Zeta" }, + Source = Source("Rejected/Zeta.cs", 7) + }, + new AdapterContributionAdmissionDiagnostic + { + Code = AdapterContributionAdmissionDiagnosticCode.InvalidSubject, + Path = "a.path", + Message = "alpha", + Fact = new FactId { Value = "adapter-rejected:alpha" }, + Subject = new SubjectId { Value = "dotnet://Banking/Alpha" }, + Source = Source("Rejected/Alpha.cs", 3) + } + }; + return new AdapterRunRecord + { + Considered = true, + Probed = true, + Executed = true, + Descriptor = descriptor, + Probe = new AdapterProbeApplicable { Evidence = ProbeEvidence(reverse) }, + Execution = new AdapterExecutionRejected + { + Diagnostics = [.. GenerationDiagnostics("REJECTED", diagnostics[0].Subject!)], + AdmissionDiagnostics = reverse + ? [.. Reversed(diagnostics)] + : [.. diagnostics] + }, + Disposition = AdapterRunDisposition.ContributionRejected + }; + } + + AdapterDescriptor Descriptor(AdapterIdentity identity, bool reverse) + { + var host = new[] { AdapterHostCapability.SemanticAnalysis, AdapterHostCapability.AuthoredSource }; + var api = new[] { new AdapterApiCapability { Id = "api.zeta" }, new AdapterApiCapability { Id = "api.alpha" } }; + var facts = new[] { GenerationFactCapability.ArtifactPlacement, GenerationFactCapability.Artifact }; + return new AdapterDescriptor + { + Identity = identity, + SourceLanguage = AdapterSourceLanguage.CSharp, + Category = AdapterCategory.EventSourcing, + CompatibleGenerationVersions = new GenerationVersionRange + { + MinimumInclusive = new Version(1, 2, 3), + MaximumExclusive = new Version(4, 5, 6) + }, + RequiredHostCapabilities = Values(host, reverse), + RequiredApiCapabilities = Values(api, reverse), + EmittedFactCapabilities = Values(facts, reverse) + }; + } + + ImmutableArray ProbeEvidence(bool reverse) + { + var evidence = new[] + { + new AdapterProbeEvidence + { + Description = "Subject", + Subject = new SubjectId { Value = "dotnet://Banking/Events.AccountOpened" }, + Source = Source("Probe/Subject.cs", 5) + }, + new AdapterProbeEvidence + { + Description = "API", + ApiCapability = new AdapterApiCapability { Id = "api.alpha" }, + Source = Source("Probe/Api.cs", 2) + } + }; + return reverse ? [.. Reversed(evidence)] : [.. evidence]; + } + + Evidence FactEvidence() => new() + { + Adapter = _completedAdapter, + Strength = EvidenceStrength.Exact, + Explanation = "Authored declaration", + Source = Source("Accounts/Open/AccountOpened.cs", 11) + }; + + static GenerationDiagnostic[] GenerationDiagnostics(string code, SubjectId subject) => + [ + new GenerationDiagnostic + { + Code = code, + Severity = GenerationDiagnosticSeverity.Information, + Message = "zeta", + Source = Source("Diagnostics/Zeta.cs", 8), + Subject = subject + }, + new GenerationDiagnostic + { + Code = code, + Severity = GenerationDiagnosticSeverity.Information, + Message = "alpha", + Source = Source("Diagnostics/Alpha.cs", 4), + Subject = subject + } + ]; + + static SourceRange Source(string path, int start) => new() + { + Path = path, + FileIdentity = new SourceFileIdentity { Project = "Banking", Path = path }, + StartLine = start, + StartColumn = 2, + EndLine = start + 1, + EndColumn = 9 + }; + + static ImmutableArray Values(IEnumerable values, bool reverse) => + reverse ? [.. Reversed(values)] : [.. values]; + + static T[] Reversed(IEnumerable values) + { + var reversed = values.ToArray(); + Array.Reverse(reversed); + return reversed; + } + + AdapterContributionSnapshot CompletedContribution() => + ((AdapterExecutionCompleted)_forward.AdapterRun!.Adapters.Single(_ => _.Descriptor.Identity == _completedAdapter).Execution).Contribution; + + AdapterExecutionRejected RejectedExecution() => + (AdapterExecutionRejected)_forward.AdapterRun!.Adapters.Single(_ => _.Descriptor.Identity == _rejectedAdapter).Execution; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generating_from_adapter_run_snapshots_in_different_orders.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generating_from_adapter_run_snapshots_in_different_orders.cs new file mode 100644 index 0000000..a0f67c8 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generating_from_adapter_run_snapshots_in_different_orders.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.Security.Cryptography; +using System.Text; + +namespace Cratis.Screenplay.Generation.for_ScreenplayDefinitionGenerator; + +public class when_generating_from_adapter_run_snapshots_in_different_orders : given.a_generator +{ + const string ExpectedContributionHash = "A063FD97809E3FA5B3A0539E12077C6F04B73F2292705510CA457FF7BFD7228E"; + readonly AdapterIdentity _firstAdapter = new() { Id = "adapter:first", Version = "1.0.0" }; + readonly AdapterIdentity _secondAdapter = new() { Id = "adapter:second", Version = "1.0.0" }; + GeneratedScreenplayDefinition _contribution = null!; + GeneratedScreenplayDefinition _forward = null!; + GeneratedScreenplayDefinition _reverse = null!; + + void Because() + { + var opened = ProducedBy(Event("AccountOpened", "Open"), _firstAdapter); + var deposited = ProducedBy(Event("FundsDeposited", "Deposit", Property("amount", "Decimal")), _secondAdapter); + var firstDiagnostics = Diagnostics("FIRST", opened[0].Subject, _firstAdapter); + var secondDiagnostics = Diagnostics("SECOND", deposited[0].Subject, _secondAdapter); + var options = new ScreenplayGenerationOptions { Domain = "Banking" }; + _contribution = Generator.Generate( + [ + ContributionFrom(_secondAdapter, deposited, secondDiagnostics), + ContributionFrom(_firstAdapter, opened, firstDiagnostics) + ], + options); + _forward = Generator.Generate( + Snapshot( + Completed(_secondAdapter, deposited, secondDiagnostics), + Completed(_firstAdapter, opened, firstDiagnostics)), + options); + _reverse = Generator.Generate( + Snapshot( + Completed( + _firstAdapter, + [.. opened.AsEnumerable().Reverse()], + [.. firstDiagnostics.AsEnumerable().Reverse()]), + Completed( + _secondAdapter, + [.. deposited.AsEnumerable().Reverse()], + [.. secondDiagnostics.AsEnumerable().Reverse()])), + options); + } + + [Fact] void should_preserve_the_existing_contribution_source_hash() => Hash(_contribution.Source).ShouldEqual(ExpectedContributionHash); + [Fact] void should_leave_the_contribution_result_without_an_adapter_run() => _contribution.AdapterRun.ShouldBeNull(); + [Fact] void should_generate_the_same_source_as_the_contribution_overload() => _forward.Source.ShouldEqual(_contribution.Source); + [Fact] void should_preserve_the_contribution_diagnostics() => _forward.Diagnostics.ShouldContainOnly(_contribution.Diagnostics); + [Fact] void should_generate_identical_source_after_reversing_adapters_and_facts() => _reverse.Source.ShouldEqual(_forward.Source); + [Fact] void should_generate_identical_diagnostics_after_reversing_adapters_and_facts() => _reverse.Diagnostics.ShouldContainOnly(_forward.Diagnostics); + [Fact] void should_return_recursively_identical_canonical_adapter_runs() => AdapterRunProjection(_reverse.AdapterRun).ShouldEqual(AdapterRunProjection(_forward.AdapterRun)); + [Fact] void should_classify_every_admitted_fact_as_lowered() => _forward.AdapterRun!.Facts.All(record => record.Disposition == GenerationFactDisposition.Lowered).ShouldBeTrue(); + [Fact] void should_have_no_unknown_fact_dispositions() => _forward.AdapterRun!.Facts.Any(record => record.Disposition == GenerationFactDisposition.Unknown).ShouldBeFalse(); + + static GenerationFact[] ProducedBy(IEnumerable facts, AdapterIdentity adapter) => + [.. facts.Select(fact => fact with { Evidence = fact.Evidence with { Adapter = adapter } })]; + + static AdapterContribution ContributionFrom( + AdapterIdentity adapter, + IReadOnlyList facts, + IReadOnlyList diagnostics) => new() + { + Adapter = adapter, + Facts = facts, + Diagnostics = diagnostics + }; + + static GenerationDiagnostic[] Diagnostics(string code, SubjectId subject, AdapterIdentity adapter) => + [ + new GenerationDiagnostic + { + Code = code, + Severity = GenerationDiagnosticSeverity.Information, + Message = $"Produced by {adapter.Id}", + Source = new SourceRange + { + Path = $"Diagnostics/{adapter.Id}.cs", + FileIdentity = new SourceFileIdentity + { + Project = "Banking", + Path = $"Diagnostics/{adapter.Id}.cs" + }, + StartLine = 2, + StartColumn = 3, + EndLine = 4, + EndColumn = 5 + }, + Subject = subject + } + ]; + + static string Hash(string source) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))); +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generation_encounters_unclassifiable_and_nonexecuted_adapter_inputs.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generation_encounters_unclassifiable_and_nonexecuted_adapter_inputs.cs new file mode 100644 index 0000000..ca5d287 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_generation_encounters_unclassifiable_and_nonexecuted_adapter_inputs.cs @@ -0,0 +1,88 @@ +// 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.for_ScreenplayDefinitionGenerator; + +public class when_generation_encounters_unclassifiable_and_nonexecuted_adapter_inputs : given.a_generator +{ + GeneratedScreenplayDefinition _unclassifiable = null!; + GeneratedScreenplayDefinition _nonExecuted = null!; + + void Because() + { + var fact = new UnclassifiableFact + { + Id = new FactId { Value = "custom:fact" }, + Subject = new SubjectId { Value = "custom://facts/unclassifiable" }, + Evidence = new Evidence { Adapter = Adapter, Strength = EvidenceStrength.Exact } + }; + _unclassifiable = Generator.Generate( + Snapshot(Completed(Adapter, [fact])), + new ScreenplayGenerationOptions { Domain = "Unknowns" }); + + var blockedDiagnostic = Diagnostic("RUN-BLOCKED", "Adapter was blocked"); + var rejectedDiagnostic = Diagnostic("RUN-REJECTED", "Adapter contribution was rejected"); + var blocked = Record( + "adapter:blocked", + new AdapterProbeBlocked { Diagnostics = [blockedDiagnostic] }, + new AdapterExecutionNotRun { Diagnostics = [blockedDiagnostic] }, + AdapterRunDisposition.Blocked); + var rejected = Record( + "adapter:rejected", + new AdapterProbeApplicable(), + new AdapterExecutionRejected { Diagnostics = [rejectedDiagnostic] }, + AdapterRunDisposition.ContributionRejected, + executed: true); + _nonExecuted = Generator.Generate( + new AdapterRunSnapshot + { + Adapters = [rejected, blocked], + Facts = [new GenerationFactRecord { Fact = fact }], + Diagnostics = [rejectedDiagnostic, blockedDiagnostic] + }, + new ScreenplayGenerationOptions { Domain = "Empty" }); + } + + [Fact] void should_fail_closed_for_the_unclassifiable_fact() => _unclassifiable.AdapterRun!.Facts.Single().Disposition.ShouldEqual(GenerationFactDisposition.Unknown); + [Fact] void should_report_a_stable_error_for_the_unclassifiable_fact() => _unclassifiable.AdapterRun!.Facts.Single().Diagnostics.Single().Code.ShouldEqual(GenerationDiagnosticCodes.UnclassifiedGenerationFact); + [Fact] void should_not_report_success_for_the_unclassifiable_fact() => _unclassifiable.IsSuccess.ShouldBeFalse(); + [Fact] void should_not_resolve_facts_from_the_top_level_input_fact_records() => _nonExecuted.AdapterRun!.Facts.ShouldBeEmpty(); + [Fact] void should_preserve_blocked_and_rejected_adapter_records() => _nonExecuted.AdapterRun!.Adapters.Select(record => record.Disposition).ShouldContainOnly(AdapterRunDisposition.Blocked, AdapterRunDisposition.ContributionRejected); + [Fact] void should_preserve_the_blocked_diagnostic() => _nonExecuted.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain("RUN-BLOCKED"); + [Fact] void should_preserve_the_rejected_diagnostic() => _nonExecuted.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain("RUN-REJECTED"); + [Fact] void should_keep_runner_diagnostics_canonical_without_duplicates() => _nonExecuted.AdapterRun!.Diagnostics.Length.ShouldEqual(2); + + static AdapterRunRecord Record( + string id, + AdapterProbeResult probe, + AdapterExecutionResult execution, + AdapterRunDisposition disposition, + bool executed = false) + { + var descriptor = new AdapterDescriptor + { + Identity = new AdapterIdentity { Id = id, Version = "1.0.0" }, + SourceLanguage = AdapterSourceLanguage.SourceIndependent, + Category = AdapterCategory.ApplicationFramework + }; + return new AdapterRunRecord + { + Considered = true, + Probed = probe is not AdapterProbeNotRun, + Executed = executed, + Descriptor = descriptor, + Probe = probe, + Execution = execution, + Disposition = disposition + }; + } + + static GenerationDiagnostic Diagnostic(string code, string message) => new() + { + Code = code, + Severity = GenerationDiagnosticSeverity.Error, + Message = message + }; + + sealed record UnclassifiableFact : GenerationFact; +} diff --git a/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_verification_fails_after_snapshot_lowering.cs b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_verification_fails_after_snapshot_lowering.cs new file mode 100644 index 0000000..d4c37d6 --- /dev/null +++ b/Source/DotNET/Generation.Specs/for_ScreenplayDefinitionGenerator/when_verification_fails_after_snapshot_lowering.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 Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Printing; +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Captures; +using Cratis.Screenplay.Syntax.Projections; +using Cratis.Screenplay.Syntax.Specifications; + +namespace Cratis.Screenplay.Generation.for_ScreenplayDefinitionGenerator; + +public class when_verification_fails_after_snapshot_lowering : given.a_generator +{ + GeneratedScreenplayDefinition _result = null!; + + void Because() + { + var generator = new ScreenplayDefinitionGenerator( + new GenerationResolver(), + new ScreenplayLowerer(), + new ScreenplayPrinter(), + new FailingCompiler()); + _result = generator.Generate( + Snapshot(Completed(Adapter, Event("AccountOpened", "Open"))), + new ScreenplayGenerationOptions { Domain = "Banking" }); + } + + [Fact] void should_report_the_verification_failure() => _result.Diagnostics.Select(diagnostic => diagnostic.Code).ShouldContain(GenerationDiagnosticCodes.DocumentDidNotCompile); + [Fact] void should_mark_generation_as_unsuccessful() => _result.IsSuccess.ShouldBeFalse(); + [Fact] void should_keep_the_lowered_artifact_disposition() => Disposition("event:AccountOpened").ShouldEqual(GenerationFactDisposition.Lowered); + [Fact] void should_keep_the_lowered_placement_disposition() => Disposition("placement:AccountOpened").ShouldEqual(GenerationFactDisposition.Lowered); + + GenerationFactDisposition Disposition(string id) => _result.AdapterRun!.Facts.Single(record => record.Fact.Id.Value == id).Disposition; + + sealed class FailingCompiler : IScreenplayCompiler + { + readonly IScreenplayCompiler _compiler = new ScreenplayCompiler(); + + public CompilationResult Compile(string source) => CompilationResult.Failed( + [ + Diagnostic.Error("PLAY-FAILED", "Verification failed", SourceLocation.Start) + ]); + + public CompilationResult Compile(string source, IApplicationSyntaxVisitor visitor) => + _compiler.Compile(source, visitor); + + public CompilationResult Parse(string source, string? path = null) => + _compiler.Parse(source, path); + + public CompilationResult CompileProjection(string source) => + _compiler.CompileProjection(source); + + public CompilationResult CompileProjection(string source, IProjectionSyntaxVisitor visitor) => + _compiler.CompileProjection(source, visitor); + + public CompilationResult CompileSpecification(string source) => + _compiler.CompileSpecification(source); + + public CompilationResult CompileSpecification(string source, ISpecificationSyntaxVisitor visitor) => + _compiler.CompileSpecification(source, visitor); + + public CompilationResult CompileCapture(string source) => + _compiler.CompileCapture(source); + + public CompilationResult CompileCapture(string source, ICaptureSyntaxVisitor visitor) => + _compiler.CompileCapture(source, visitor); + } +} diff --git a/Source/DotNET/Generation/AdapterRunCanonicalizer.cs b/Source/DotNET/Generation/AdapterRunCanonicalizer.cs new file mode 100644 index 0000000..e0cbbab --- /dev/null +++ b/Source/DotNET/Generation/AdapterRunCanonicalizer.cs @@ -0,0 +1,398 @@ +// 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 AdapterRunCanonicalizer +{ + public static ImmutableArray FactRecords(IEnumerable records) => + [ + .. records.Select(record => new GenerationFactRecord + { + Fact = Fact(record.Fact), + Disposition = record.Disposition, + Diagnostics = Diagnostics(record.Diagnostics) + }) + ]; + + public static ImmutableArray Diagnostics(IEnumerable diagnostics) => + [ + .. diagnostics + .Select(Diagnostic) + .GroupBy(Structural.Diagnostic, StringComparer.Ordinal) + .OrderBy(group => Canonical.Diagnostic(group.First()), StringComparer.Ordinal) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.First()) + ]; + + public static GenerationFact Fact(GenerationFact fact) + { + var id = new FactId { Value = fact.Id.Value }; + var subject = Subject(fact.Subject); + var evidence = Evidence(fact.Evidence); + return fact switch + { + ArtifactFact artifact => new ArtifactFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = Artifact(artifact.Definition) + }, + ArtifactPlacementFact placement => new ArtifactPlacementFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Artifact = ArtifactKey(placement.Artifact), + Placement = Placement(placement.Placement) + }, + RelationshipFact relationship => new RelationshipFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = Relationship(relationship.Definition) + }, + ConceptRepresentationFact representation => new ConceptRepresentationFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = ConceptRepresentation(representation.Definition) + }, + ConceptAttributeFact attribute => new ConceptAttributeFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = ConceptAttribute(attribute.Definition) + }, + ConceptValidationRuleFact validation => new ConceptValidationRuleFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = ConceptValidationRule(validation.Definition) + }, + SpecificationScenarioFact scenario => new SpecificationScenarioFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = SpecificationScenario(scenario.Definition) + }, + SpecificationStepFact step => new SpecificationStepFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = SpecificationStep(step.Definition) + }, + SpecificationValueFact value => new SpecificationValueFact + { + Id = id, + Subject = subject, + Evidence = evidence, + Definition = SpecificationValue(value.Definition) + }, + _ => fact with { Id = id, Subject = subject, Evidence = evidence } + }; + } + + public static AdapterRunRecord Adapter(AdapterRunRecord record) + { + var descriptor = Descriptor(record.Descriptor); + return new AdapterRunRecord + { + Considered = record.Considered, + Probed = record.Probed, + Executed = record.Executed, + Descriptor = descriptor, + Probe = Probe(record.Probe), + Execution = Execution(record.Execution), + Disposition = record.Disposition + }; + } + + static AdapterDescriptor Descriptor(AdapterDescriptor descriptor) => + AdapterDescriptorAdmission.Admit(descriptor).Descriptor; + + static AdapterProbeResult Probe(AdapterProbeResult probe) + { + var evidence = ProbeEvidence(probe.Evidence); + return probe switch + { + AdapterProbeNotRun => new AdapterProbeNotRun { Evidence = evidence }, + AdapterProbeNotApplicable => new AdapterProbeNotApplicable { Evidence = evidence }, + AdapterProbeApplicable => new AdapterProbeApplicable { Evidence = evidence }, + AdapterProbeBlocked blocked => new AdapterProbeBlocked + { + Evidence = evidence, + Diagnostics = Diagnostics(blocked.Diagnostics) + }, + _ => probe with { Evidence = evidence } + }; + } + + static AdapterExecutionResult Execution(AdapterExecutionResult execution) + { + var diagnostics = Diagnostics(execution.Diagnostics); + return execution switch + { + AdapterExecutionNotRun => new AdapterExecutionNotRun { Diagnostics = diagnostics }, + AdapterExecutionFailed => new AdapterExecutionFailed { Diagnostics = diagnostics }, + AdapterExecutionRejected rejected => new AdapterExecutionRejected + { + Diagnostics = diagnostics, + AdmissionDiagnostics = AdmissionDiagnostics(rejected.AdmissionDiagnostics) + }, + AdapterExecutionCompleted completed => new AdapterExecutionCompleted + { + Diagnostics = diagnostics, + Contribution = Contribution(completed.Contribution) + }, + _ => execution with { Diagnostics = diagnostics } + }; + } + + static AdapterContributionSnapshot Contribution(AdapterContributionSnapshot contribution) + { + var descriptor = Descriptor(contribution.Descriptor); + var producer = descriptor.Identity; + var facts = contribution.Facts.Select(Fact); + return new AdapterContributionSnapshot + { + Descriptor = descriptor, + Facts = + [ + .. facts + .OrderBy(_ => producer.Id, StringComparer.Ordinal) + .ThenBy(_ => producer.Version, StringComparer.Ordinal) + .ThenBy(fact => fact.Id.Value, StringComparer.Ordinal) + .ThenBy(fact => fact.Subject.Value, StringComparer.Ordinal) + .ThenBy(Structural.FactFamily) + .ThenBy(Structural.FactDefinition, StringComparer.Ordinal) + .ThenBy(fact => Structural.Evidence(fact.Evidence), StringComparer.Ordinal) + ], + Diagnostics = Diagnostics(contribution.Diagnostics) + }; + } + + static ImmutableArray ProbeEvidence(IEnumerable evidence) => + [ + .. evidence + .Select(item => new AdapterProbeEvidence + { + Description = item.Description, + ApiCapability = item.ApiCapability is null ? null : new AdapterApiCapability { Id = item.ApiCapability.Id }, + Source = item.Source is null ? null : Source(item.Source), + Subject = item.Subject is null ? null : Subject(item.Subject) + }) + .OrderBy(item => item.ApiCapability?.Id, StringComparer.Ordinal) + .ThenBy(item => item.Source?.FileIdentity?.Project, StringComparer.Ordinal) + .ThenBy(item => item.Source?.FileIdentity?.Path, StringComparer.Ordinal) + .ThenBy(item => item.Source?.Path, StringComparer.Ordinal) + .ThenBy(item => item.Source?.StartLine) + .ThenBy(item => item.Source?.StartColumn) + .ThenBy(item => item.Source?.EndLine) + .ThenBy(item => item.Source?.EndColumn) + .ThenBy(item => item.Subject?.Value, StringComparer.Ordinal) + .ThenBy(item => item.Description, StringComparer.Ordinal) + .ThenBy(Structural.ProbeEvidence, StringComparer.Ordinal) + ]; + + static ImmutableArray AdmissionDiagnostics( + IEnumerable diagnostics) => + [ + .. diagnostics + .Select(diagnostic => new AdapterContributionAdmissionDiagnostic + { + Code = diagnostic.Code, + Path = diagnostic.Path, + Message = diagnostic.Message, + Fact = diagnostic.Fact is null ? null : new FactId { Value = diagnostic.Fact.Value }, + Subject = diagnostic.Subject is null ? null : Subject(diagnostic.Subject), + Source = diagnostic.Source is null ? null : Source(diagnostic.Source) + }) + .GroupBy(Structural.AdmissionDiagnostic, StringComparer.Ordinal) + .Select(group => group.First()) + .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) + .ThenBy(Structural.AdmissionDiagnostic, StringComparer.Ordinal) + ]; + + static GenerationDiagnostic Diagnostic(GenerationDiagnostic diagnostic) => new() + { + Code = diagnostic.Code, + Severity = diagnostic.Severity, + Message = diagnostic.Message, + Outcome = diagnostic.Outcome, + Source = diagnostic.Source is null ? null : Source(diagnostic.Source), + Subject = diagnostic.Subject is null ? null : Subject(diagnostic.Subject) + }; + + static Evidence Evidence(Evidence evidence) => new() + { + Adapter = new AdapterIdentity { Id = evidence.Adapter.Id, Version = evidence.Adapter.Version }, + Strength = evidence.Strength, + Source = evidence.Source is null ? null : Source(evidence.Source), + Explanation = evidence.Explanation + }; + + static SourceRange Source(SourceRange source) => new() + { + 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 + }; + + static SubjectId Subject(SubjectId subject) => new() { Value = subject.Value }; + + static ArtifactKey ArtifactKey(ArtifactKey key) => new() + { + Subject = Subject(key.Subject), + Kind = key.Kind + }; + + static TypeReferenceDefinition TypeReference(TypeReferenceDefinition type) => new() + { + Name = type.Name, + Subject = type.Subject is null ? null : Subject(type.Subject), + IsCollection = type.IsCollection, + IsOptional = type.IsOptional + }; + + static ArtifactDefinition Artifact(ArtifactDefinition definition) => new() + { + Key = ArtifactKey(definition.Key), + Name = definition.Name, + Description = definition.Description, + File = definition.File, + Properties = + [ + .. definition.Properties.Select(property => new PropertyDefinition + { + Name = property.Name, + Type = TypeReference(property.Type), + IsIdentifier = property.IsIdentifier + }) + ] + }; + + static ArtifactPlacement Placement(ArtifactPlacement placement) => new() + { + Module = placement.Module, + Features = [.. placement.Features], + Slice = placement.Slice, + SliceKind = placement.SliceKind + }; + + static RelationshipDefinition Relationship(RelationshipDefinition definition) => new() + { + Key = new RelationshipKey + { + Kind = definition.Key.Kind, + Source = Subject(definition.Key.Source), + Target = Subject(definition.Key.Target), + Discriminator = definition.Key.Discriminator + }, + SourceMember = definition.SourceMember, + TargetMember = definition.TargetMember, + IsCollection = definition.IsCollection, + IsOptional = definition.IsOptional + }; + + static ConceptRepresentationDefinition ConceptRepresentation(ConceptRepresentationDefinition definition) => new() + { + Concept = Subject(definition.Concept), + Kind = definition.Kind, + Primitive = definition.Primitive, + EnumerationValues = [.. definition.EnumerationValues] + }; + + static ConceptAttributeDefinition ConceptAttribute(ConceptAttributeDefinition definition) => new() + { + Concept = Subject(definition.Concept), + Kind = definition.Kind, + Name = definition.Name, + Reason = definition.Reason + }; + + static ConceptValidationRuleDefinition ConceptValidationRule(ConceptValidationRuleDefinition definition) => new() + { + Concept = Subject(definition.Concept), + RuleIdentity = definition.RuleIdentity, + Kind = definition.Kind, + Predicate = definition.Predicate, + Message = definition.Message, + ImplementationFile = definition.ImplementationFile + }; + + static SpecificationScenarioKey SpecificationScenarioKey(SpecificationScenarioKey key) => new() + { + Scenario = Subject(key.Scenario) + }; + + static SpecificationStepKey SpecificationStepKey(SpecificationStepKey key) => new() + { + Scenario = SpecificationScenarioKey(key.Scenario), + Index = key.Index + }; + + static SpecificationValueKey SpecificationValueKey(SpecificationValueKey key) => new() + { + Step = SpecificationStepKey(key.Step), + Path = [.. key.Path] + }; + + static SpecificationScenarioDefinition SpecificationScenario(SpecificationScenarioDefinition definition) => new() + { + Key = SpecificationScenarioKey(definition.Key), + Name = definition.Name, + TargetArtifact = ArtifactKey(definition.TargetArtifact), + Steps = [.. definition.Steps.Select(SpecificationStepKey)] + }; + + static SpecificationStepDefinition SpecificationStep(SpecificationStepDefinition definition) => new() + { + Key = SpecificationStepKey(definition.Key), + Phase = definition.Phase, + Kind = definition.Kind, + Artifact = definition.Artifact is null ? null : ArtifactKey(definition.Artifact), + ErrorCode = definition.ErrorCode, + ErrorMessage = definition.ErrorMessage, + Values = [.. definition.Values.Select(SpecificationValueKey)] + }; + + static SpecificationValueDefinition SpecificationValue(SpecificationValueDefinition definition) => new() + { + Key = SpecificationValueKey(definition.Key), + Kind = definition.Kind, + Type = definition.Type is null ? null : TypeReference(definition.Type), + Scalar = definition.Scalar, + Children = [.. definition.Children.Select(SpecificationValueKey)] + }; +} diff --git a/Source/DotNET/Generation/GenerationDiagnosticCodes.cs b/Source/DotNET/Generation/GenerationDiagnosticCodes.cs index 0290ca7..29f589b 100644 --- a/Source/DotNET/Generation/GenerationDiagnosticCodes.cs +++ b/Source/DotNET/Generation/GenerationDiagnosticCodes.cs @@ -187,4 +187,24 @@ public static class GenerationDiagnosticCodes /// A complete neutral specification uses behavior the current Screenplay syntax cannot represent exactly. /// public const string UnsupportedSpecificationLowering = "GEN0038"; + + /// + /// A recognized relationship did not contribute to emitted Screenplay syntax. + /// + public const string UnsupportedRelationship = "GEN0039"; + + /// + /// An admitted fact was omitted without a more specific pipeline diagnostic. + /// + public const string OmittedGenerationFact = "GEN0040"; + + /// + /// An admitted fact could not be classified by the generation pipeline. + /// + public const string UnclassifiedGenerationFact = "GEN0041"; + + /// + /// An admitted fact participated in a conflict without a more specific pipeline diagnostic. + /// + public const string ConflictingGenerationFact = "GEN0042"; } diff --git a/Source/DotNET/Generation/GenerationFactDispositionCalculator.cs b/Source/DotNET/Generation/GenerationFactDispositionCalculator.cs new file mode 100644 index 0000000..715eb1d --- /dev/null +++ b/Source/DotNET/Generation/GenerationFactDispositionCalculator.cs @@ -0,0 +1,339 @@ +// 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; + +internal static class GenerationFactDispositionCalculator +{ + public static ImmutableArray Calculate( + IEnumerable facts, + ResolvedApplicationGraph graph, + ScreenplayLoweringCoverage coverage, + IReadOnlyList diagnostics) + { + var admitted = facts.ToArray(); + var conflictingIdentities = admitted + .Where(HasSupportedDiscriminators) + .GroupBy(_ => _.Id.Value, StringComparer.Ordinal) + .Where(group => group + .Select(SemanticIdentity) + .Distinct(StringComparer.Ordinal) + .Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + + return + [ + .. admitted.Select(fact => Calculate( + fact, + conflictingIdentities.Contains(fact.Id.Value), + graph, + coverage, + diagnostics)) + ]; + } + + static GenerationFactRecord Calculate( + GenerationFact fact, + bool hasConflictingIdentity, + ResolvedApplicationGraph graph, + ScreenplayLoweringCoverage coverage, + IReadOnlyList diagnostics) + { + if (!HasSupportedDiscriminators(fact)) + { + return Omitted(fact, coverage, diagnostics); + } + + if (hasConflictingIdentity || IsConflicted(fact, graph, coverage)) + { + return Conflicted(fact, coverage, diagnostics); + } + + var key = GenerationFactSemanticKey.For(fact); + if (key is not null && coverage.Lowered.Contains(key)) + { + return new GenerationFactRecord + { + Fact = fact, + Disposition = GenerationFactDisposition.Lowered + }; + } + + if (fact is ArtifactPlacementFact placement && IsWeakerPlacement(placement, graph)) + { + return new GenerationFactRecord + { + Fact = fact, + Disposition = GenerationFactDisposition.ProvenanceOnly + }; + } + + if (key is not null) + { + return Omitted(fact, coverage, diagnostics); + } + + var diagnostic = new GenerationDiagnostic + { + Code = GenerationDiagnosticCodes.UnclassifiedGenerationFact, + Severity = GenerationDiagnosticSeverity.Error, + Outcome = GenerationDiagnosticOutcome.Unknown, + Message = $"Admitted fact '{fact.Id.Value}' uses unclassifiable fact type '{fact.GetType().FullName}'", + Source = fact.Evidence.Source, + Subject = fact.Subject + }; + return new GenerationFactRecord + { + Fact = fact, + Disposition = GenerationFactDisposition.Unknown, + Diagnostics = [diagnostic] + }; + } + + static GenerationFactRecord Conflicted( + GenerationFact fact, + ScreenplayLoweringCoverage coverage, + IReadOnlyList diagnostics) + { + var associated = AssociatedDiagnostics(fact, coverage, diagnostics) + .Where(_ => _.Outcome == GenerationDiagnosticOutcome.Conflict) + .ToArray(); + if (associated.Length == 0) + { + associated = + [ + new GenerationDiagnostic + { + Code = GenerationDiagnosticCodes.ConflictingGenerationFact, + Severity = GenerationDiagnosticSeverity.Error, + Outcome = GenerationDiagnosticOutcome.Conflict, + Message = $"Admitted fact '{fact.Id.Value}' participated in an unresolved semantic conflict", + Source = fact.Evidence.Source, + Subject = DiagnosticSubject(fact) + } + ]; + } + + return new GenerationFactRecord + { + Fact = fact, + Disposition = GenerationFactDisposition.Conflicted, + Diagnostics = CanonicalDiagnostics(associated) + }; + } + + static GenerationFactRecord Omitted( + GenerationFact fact, + ScreenplayLoweringCoverage coverage, + IReadOnlyList diagnostics) + { + var associated = AssociatedDiagnostics(fact, coverage, diagnostics).ToList(); + if (fact is RelationshipFact relationship && HasSupportedDiscriminators(fact)) + { + associated.Add(new GenerationDiagnostic + { + Code = GenerationDiagnosticCodes.UnsupportedRelationship, + Severity = GenerationDiagnosticSeverity.Warning, + Outcome = GenerationDiagnosticOutcome.Unsupported, + Message = $"Relationship '{relationship.Definition.Key.Kind}' from '{relationship.Definition.Key.Source.Value}' to '{relationship.Definition.Key.Target.Value}' did not contribute to emitted Screenplay syntax and was omitted", + Source = fact.Evidence.Source, + Subject = relationship.Definition.Key.Source + }); + } + + if (associated.Count == 0) + { + associated.Add(new GenerationDiagnostic + { + Code = GenerationDiagnosticCodes.OmittedGenerationFact, + Severity = GenerationDiagnosticSeverity.Warning, + Outcome = GenerationDiagnosticOutcome.Unsupported, + Message = $"Admitted {FactFamily(fact)} fact '{fact.Id.Value}' did not contribute to emitted Screenplay syntax and was omitted", + Source = fact.Evidence.Source, + Subject = DiagnosticSubject(fact) + }); + } + + return new GenerationFactRecord + { + Fact = fact, + Disposition = GenerationFactDisposition.OmittedWithDiagnostic, + Diagnostics = CanonicalDiagnostics(associated) + }; + } + + static IEnumerable AssociatedDiagnostics( + GenerationFact fact, + ScreenplayLoweringCoverage coverage, + IReadOnlyList diagnostics) + { + var key = GenerationFactSemanticKey.For(fact); + if (key is not null && coverage.Diagnostics.TryGetValue(key, out var loweringDiagnostics)) + { + foreach (var diagnostic in loweringDiagnostics) + { + yield return diagnostic; + } + } + + foreach (var diagnostic in diagnostics.Where(_ => + _.Outcome is not null && HasExactFactIdentity(_.Message, fact.Id.Value))) + { + yield return diagnostic; + } + } + + static bool IsConflicted( + GenerationFact fact, + ResolvedApplicationGraph graph, + ScreenplayLoweringCoverage coverage) + { + var semanticKey = GenerationFactSemanticKey.For(fact); + if (semanticKey is not null && coverage.Conflicted.Contains(semanticKey)) + { + return true; + } + + return fact switch + { + ArtifactFact artifact => graph.Artifacts.Any(resolved => + resolved.IsConflicted && + Structural.ArtifactKey(resolved.Key) == Structural.ArtifactKey(artifact.Definition.Key) && + resolved.Variants.Any(variant => Structural.Artifact(variant.Definition) == Structural.Artifact(artifact.Definition))), + ArtifactPlacementFact placement => ConflictingPlacement(placement, graph), + RelationshipFact relationship => graph.Relationships.Any(resolved => + resolved.IsConflicted && + Structural.RelationshipKey(resolved.Key) == Structural.RelationshipKey(relationship.Definition.Key) && + resolved.Definitions.Any(definition => Structural.Relationship(definition) == Structural.Relationship(relationship.Definition))), + ConceptRepresentationFact representation => graph.ConceptRepresentations.Any(resolved => + resolved.IsConflicted && + resolved.Concept == representation.Definition.Concept && + resolved.Variants.Any(variant => Structural.ConceptRepresentation(variant.Definition) == Structural.ConceptRepresentation(representation.Definition))), + ConceptAttributeFact attribute => graph.ConceptAttributes.Any(resolved => + resolved.IsConflicted && + resolved.Variants.Any(variant => Structural.ConceptAttribute(variant.Definition) == Structural.ConceptAttribute(attribute.Definition))), + ConceptValidationRuleFact validation => graph.ConceptValidationRules.Any(resolved => + resolved.IsConflicted && + resolved.Variants.Any(variant => Structural.ConceptValidationRule(variant.Definition) == Structural.ConceptValidationRule(validation.Definition))), + SpecificationScenarioFact scenario => graph.SpecificationScenarios.Any(resolved => + resolved.IsConflicted && + Structural.SpecificationScenarioKey(resolved.Key) == Structural.SpecificationScenarioKey(scenario.Definition.Key) && + resolved.Variants.Any(variant => Structural.SpecificationScenario(variant.Definition) == Structural.SpecificationScenario(scenario.Definition))), + SpecificationStepFact step => graph.SpecificationSteps.Any(resolved => + resolved.IsConflicted && + Structural.SpecificationStepKey(resolved.Key) == Structural.SpecificationStepKey(step.Definition.Key) && + resolved.Variants.Any(variant => Structural.SpecificationStep(variant.Definition) == Structural.SpecificationStep(step.Definition))), + SpecificationValueFact value => graph.SpecificationValues.Any(resolved => + resolved.IsConflicted && + Structural.SpecificationValueKey(resolved.Key) == Structural.SpecificationValueKey(value.Definition.Key) && + resolved.Variants.Any(variant => Structural.SpecificationValue(variant.Definition) == Structural.SpecificationValue(value.Definition))), + _ => false + }; + } + + static bool ConflictingPlacement(ArtifactPlacementFact fact, ResolvedApplicationGraph graph) + { + var resolved = graph.Placements.FirstOrDefault(_ => + Structural.ArtifactKey(_.Artifact) == Structural.ArtifactKey(fact.Artifact)); + if (resolved?.IsConflicted != true) + { + return false; + } + + var placement = Structural.Placement(fact.Placement); + return resolved.EffectiveVariants.Any(_ => Structural.Placement(_.Placement) == placement); + } + + static bool IsWeakerPlacement(ArtifactPlacementFact fact, ResolvedApplicationGraph graph) + { + var resolved = graph.Placements.FirstOrDefault(_ => + Structural.ArtifactKey(_.Artifact) == Structural.ArtifactKey(fact.Artifact)); + if (resolved?.Variants.Any(_ => Structural.Placement(_.Placement) == Structural.Placement(fact.Placement)) != true) + { + return false; + } + + var placement = Structural.Placement(fact.Placement); + return resolved.EffectiveVariants.All(_ => Structural.Placement(_.Placement) != placement); + } + + static bool HasExactFactIdentity(string message, string factId) => + message.Contains($"Fact '{factId}'", StringComparison.Ordinal) || + message.Contains($"fact '{factId}'", StringComparison.Ordinal) || + message.Contains($"Fact identity '{factId}'", StringComparison.Ordinal); + + static bool HasSupportedDiscriminators(GenerationFact fact) + { + if (!Supported(fact.Evidence.Strength, EvidenceStrength.Unknown)) + { + return false; + } + + return fact switch + { + ArtifactFact artifact => Supported(artifact.Definition.Key.Kind, ArtifactKind.Unknown), + ArtifactPlacementFact placement => + Supported(placement.Artifact.Kind, ArtifactKind.Unknown) && + Supported(placement.Placement.SliceKind, GenerationSliceKind.Unknown), + RelationshipFact relationship => Supported(relationship.Definition.Key.Kind, RelationshipKind.Unknown), + ConceptRepresentationFact representation => + Supported(representation.Definition.Kind, ConceptRepresentationKind.Unknown) && + (representation.Definition.Primitive is not { } primitive || Supported(primitive, GenerationPrimitiveKind.Unknown)), + ConceptAttributeFact attribute => Supported(attribute.Definition.Kind, ConceptAttributeKind.Unknown), + ConceptValidationRuleFact validation => Supported(validation.Definition.Kind, ConceptValidationRuleKind.Unknown), + SpecificationScenarioFact scenario => Supported(scenario.Definition.TargetArtifact.Kind, ArtifactKind.Unknown), + SpecificationStepFact step => + Supported(step.Definition.Phase, SpecificationStepPhase.Unknown) && + Supported(step.Definition.Kind, SpecificationStepKind.Unknown) && + (step.Definition.Artifact is not { } artifact || Supported(artifact.Kind, ArtifactKind.Unknown)), + SpecificationValueFact value => Supported(value.Definition.Kind, SpecificationValueKind.Unknown), + _ => true + }; + } + + static bool Supported(TEnum value, TEnum unknown) + where TEnum : struct, Enum => + !EqualityComparer.Default.Equals(value, unknown) && Enum.IsDefined(value); + + static SubjectId DiagnosticSubject(GenerationFact fact) => fact switch + { + ArtifactFact artifact => artifact.Definition.Key.Subject, + ArtifactPlacementFact placement => placement.Artifact.Subject, + RelationshipFact relationship => relationship.Definition.Key.Source, + ConceptRepresentationFact representation => representation.Definition.Concept, + ConceptAttributeFact attribute => attribute.Definition.Concept, + ConceptValidationRuleFact validation => validation.Definition.Concept, + SpecificationScenarioFact scenario => scenario.Definition.Key.Scenario, + SpecificationStepFact step => step.Definition.Key.Scenario.Scenario, + SpecificationValueFact value => value.Definition.Key.Step.Scenario.Scenario, + _ => fact.Subject + }; + + static string SemanticIdentity(GenerationFact fact) => + GenerationFactSemanticKey.For(fact) ?? fact.GetType().FullName ?? fact.GetType().Name; + + static string FactFamily(GenerationFact fact) => fact switch + { + ArtifactFact => "artifact", + ArtifactPlacementFact => "artifact placement", + RelationshipFact => "relationship", + ConceptRepresentationFact => "concept representation", + ConceptAttributeFact => "concept attribute", + ConceptValidationRuleFact => "concept validation rule", + SpecificationScenarioFact => "specification scenario", + SpecificationStepFact => "specification step", + SpecificationValueFact => "specification value", + _ => "generation" + }; + + static ImmutableArray CanonicalDiagnostics(IEnumerable diagnostics) => + [ + .. diagnostics + .GroupBy(Canonical.Diagnostic, StringComparer.Ordinal) + .OrderBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.First()) + ]; +} diff --git a/Source/DotNET/Generation/GenerationResolver.cs b/Source/DotNET/Generation/GenerationResolver.cs index f960eeb..a4c2215 100644 --- a/Source/DotNET/Generation/GenerationResolver.cs +++ b/Source/DotNET/Generation/GenerationResolver.cs @@ -77,13 +77,15 @@ static ResolvedArtifact[] ResolveArtifacts( List diagnostics) => [ .. facts - .GroupBy(_ => Canonical.ArtifactKey(_.Definition.Key), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.ArtifactKey(_.Definition.Key), StringComparer.Ordinal) + .OrderBy(_ => Canonical.ArtifactKey(_.First().Definition.Key), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(group => { var variants = group - .GroupBy(_ => Canonical.Artifact(_.Definition), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.Artifact(_.Definition), StringComparer.Ordinal) + .OrderBy(_ => Canonical.Artifact(_.First().Definition), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(_ => new ResolvedArtifactVariant { Definition = _.First().Definition, @@ -116,8 +118,9 @@ .. facts .Select(group => { var variants = group - .GroupBy(_ => Canonical.ConceptRepresentation(_.Definition), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.ConceptRepresentation(_.Definition), StringComparer.Ordinal) + .OrderBy(_ => Canonical.ConceptRepresentation(_.First().Definition), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(_ => new ResolvedConceptRepresentationVariant { Definition = _.First().Definition, @@ -144,13 +147,15 @@ static ResolvedConceptAttribute[] ResolveConceptAttributes( List diagnostics) => [ .. facts - .GroupBy(_ => Canonical.ConceptAttributeKey(_.Definition), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.ConceptAttributeKey(_.Definition), StringComparer.Ordinal) + .OrderBy(_ => Canonical.ConceptAttributeKey(_.First().Definition), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(group => { var variants = group - .GroupBy(_ => Canonical.ConceptAttribute(_.Definition), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.ConceptAttribute(_.Definition), StringComparer.Ordinal) + .OrderBy(_ => Canonical.ConceptAttribute(_.First().Definition), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(_ => new ResolvedConceptAttributeVariant { Definition = _.First().Definition, @@ -178,13 +183,15 @@ static ResolvedConceptValidationRule[] ResolveConceptValidationRules( List diagnostics) => [ .. facts - .GroupBy(_ => Canonical.ConceptValidationRuleKey(_.Definition), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.ConceptValidationRuleKey(_.Definition), StringComparer.Ordinal) + .OrderBy(_ => Canonical.ConceptValidationRuleKey(_.First().Definition), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(group => { var variants = group - .GroupBy(_ => Canonical.ConceptValidationRule(_.Definition), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.ConceptValidationRule(_.Definition), StringComparer.Ordinal) + .OrderBy(_ => Canonical.ConceptValidationRule(_.First().Definition), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(_ => new ResolvedConceptValidationRuleVariant { Definition = _.First().Definition, @@ -212,13 +219,15 @@ static ResolvedArtifactPlacement[] ResolvePlacements( List diagnostics) => [ .. facts - .GroupBy(_ => Canonical.ArtifactKey(_.Artifact), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.ArtifactKey(_.Artifact), StringComparer.Ordinal) + .OrderBy(_ => Canonical.ArtifactKey(_.First().Artifact), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(group => { var variants = group - .GroupBy(_ => Canonical.Placement(_.Placement), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.Placement(_.Placement), StringComparer.Ordinal) + .OrderBy(_ => Canonical.Placement(_.First().Placement), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(_ => new ResolvedArtifactPlacementVariant { Placement = _.First().Placement, @@ -245,13 +254,15 @@ static ResolvedRelationship[] ResolveRelationships( List diagnostics) => [ .. facts - .GroupBy(_ => Canonical.RelationshipKey(_.Definition.Key), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.RelationshipKey(_.Definition.Key), StringComparer.Ordinal) + .OrderBy(_ => Canonical.RelationshipKey(_.First().Definition.Key), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(group => { var definitions = group - .GroupBy(_ => Canonical.Relationship(_.Definition), StringComparer.Ordinal) - .OrderBy(_ => _.Key, StringComparer.Ordinal) + .GroupBy(_ => Structural.Relationship(_.Definition), StringComparer.Ordinal) + .OrderBy(_ => Canonical.Relationship(_.First().Definition), StringComparer.Ordinal) + .ThenBy(_ => _.Key, StringComparer.Ordinal) .Select(_ => _.First().Definition) .ToArray(); var relationship = new ResolvedRelationship @@ -335,19 +346,7 @@ static IEnumerable ConflictingFactIdentityDiagnostics(IEnu Subject = _.Facts.OrderBy(fact => fact.Subject.Value, StringComparer.Ordinal).First().Subject }); - static string FactDefinition(GenerationFact fact) => fact switch - { - ArtifactFact artifact => $"artifact:{Canonical.Artifact(artifact.Definition)}", - ConceptRepresentationFact representation => $"concept-representation:{Canonical.ConceptRepresentation(representation.Definition)}", - ConceptAttributeFact attribute => $"concept-attribute:{Canonical.ConceptAttribute(attribute.Definition)}", - ConceptValidationRuleFact validationRule => $"concept-validation-rule:{Canonical.ConceptValidationRule(validationRule.Definition)}", - ArtifactPlacementFact placement => $"placement:{Canonical.ArtifactKey(placement.Artifact)}:{Canonical.Placement(placement.Placement)}", - RelationshipFact relationship => $"relationship:{Canonical.Relationship(relationship.Definition)}", - SpecificationScenarioFact scenario => $"specification-scenario:{Canonical.SpecificationScenario(scenario.Definition)}", - SpecificationStepFact step => $"specification-step:{Canonical.SpecificationStep(step.Definition)}", - SpecificationValueFact value => $"specification-value:{Canonical.SpecificationValue(value.Definition)}", - _ => fact.GetType().FullName ?? fact.GetType().Name - }; + static string FactDefinition(GenerationFact fact) => Structural.FactDefinition(fact); static GenerationDiagnostic ConflictFor(ResolvedArtifact artifact) => new() { diff --git a/Source/DotNET/Generation/ResolverDiagnosticCoverage.cs b/Source/DotNET/Generation/ResolverDiagnosticCoverage.cs new file mode 100644 index 0000000..c4f446b --- /dev/null +++ b/Source/DotNET/Generation/ResolverDiagnosticCoverage.cs @@ -0,0 +1,238 @@ +// 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 ResolverDiagnosticCoverage +{ + public static void Propagate( + ResolvedApplicationGraph graph, + ScreenplayLoweringCoverageBuilder coverage) + { + PropagateConflicts(graph, coverage); + PropagateRejectedSpecifications(graph, coverage); + } + + static void PropagateConflicts( + ResolvedApplicationGraph graph, + ScreenplayLoweringCoverageBuilder coverage) + { + foreach (var artifact in graph.Artifacts.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingArtifact, + $"Artifact '{artifact.Key.Subject.Value}' has {artifact.Variants.Count} incompatible {artifact.Key.Kind} definitions", + artifact.Key.Subject); + AddConflict(artifact.Variants.Select(_ => GenerationFactSemanticKey.Artifact(_.Definition)), diagnostic, coverage); + } + + foreach (var representation in graph.ConceptRepresentations.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingConceptRepresentation, + $"Concept '{representation.Concept.Value}' has {representation.Variants.Count} incompatible representations", + representation.Concept); + AddConflict( + representation.Variants.Select(_ => GenerationFactSemanticKey.ConceptRepresentation(_.Definition)), + diagnostic, + coverage); + } + + foreach (var attribute in graph.ConceptAttributes.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingConceptAttribute, + $"Concept '{attribute.Concept.Value}' has {attribute.Variants.Count} incompatible '{attribute.Name}' attribute definitions", + attribute.Concept); + AddConflict( + attribute.Variants.Select(_ => GenerationFactSemanticKey.ConceptAttribute(_.Definition)), + diagnostic, + coverage); + } + + foreach (var rule in graph.ConceptValidationRules.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingConceptValidationRule, + $"Concept '{rule.Concept.Value}' has {rule.Variants.Count} incompatible validation definitions for rule identity '{rule.RuleIdentity}'", + rule.Concept); + AddConflict( + rule.Variants.Select(_ => GenerationFactSemanticKey.ConceptValidationRule(_.Definition)), + diagnostic, + coverage); + } + + foreach (var placement in graph.Placements.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingPlacement, + $"Artifact '{placement.Artifact.Subject.Value}' has {placement.EffectiveVariants.Count} equally strong incompatible {placement.Artifact.Kind} placements", + placement.Artifact.Subject); + AddConflict( + placement.EffectiveVariants.Select(_ => GenerationFactSemanticKey.Placement(placement.Artifact, _.Placement)), + diagnostic, + coverage); + } + + foreach (var relationship in graph.Relationships.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingRelationship, + $"Relationship '{relationship.Key.Kind}' from '{relationship.Key.Source.Value}' to '{relationship.Key.Target.Value}' has {relationship.Definitions.Count} incompatible definitions", + relationship.Key.Source); + AddConflict( + relationship.Definitions.Select(GenerationFactSemanticKey.Relationship), + diagnostic, + coverage); + } + + foreach (var scenario in graph.SpecificationScenarios.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingSpecificationScenario, + $"Specification scenario '{scenario.Key.Scenario.Value}' has {scenario.Variants.Count} incompatible definitions", + scenario.Key.Scenario); + AddConflict( + scenario.Variants.Select(_ => GenerationFactSemanticKey.SpecificationScenario(_.Definition)), + diagnostic, + coverage); + } + + foreach (var step in graph.SpecificationSteps.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingSpecificationStep, + $"Specification step '{step.Key.Index}' in '{step.Key.Scenario.Scenario.Value}' has {step.Variants.Count} incompatible definitions", + step.Key.Scenario.Scenario); + AddConflict( + step.Variants.Select(_ => GenerationFactSemanticKey.SpecificationStep(_.Definition)), + diagnostic, + coverage); + } + + foreach (var value in graph.SpecificationValues.Where(_ => _.IsConflicted)) + { + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.ConflictingSpecificationValue, + $"Specification value '{string.Join('.', value.Key.Path)}' in step '{value.Key.Step.Index}' has {value.Variants.Count} incompatible definitions", + value.Key.Step.Scenario.Scenario); + AddConflict( + value.Variants.Select(_ => GenerationFactSemanticKey.SpecificationValue(_.Definition)), + diagnostic, + coverage); + } + } + + static void PropagateRejectedSpecifications( + ResolvedApplicationGraph graph, + ScreenplayLoweringCoverageBuilder coverage) + { + var admitted = graph.Specifications + .Select(_ => Structural.SpecificationScenario(_.Definition)) + .ToHashSet(StringComparer.Ordinal); + var steps = graph.SpecificationSteps.ToDictionary( + _ => Structural.SpecificationStepKey(_.Key), + StringComparer.Ordinal); + var values = graph.SpecificationValues.ToDictionary( + _ => Structural.SpecificationValueKey(_.Key), + StringComparer.Ordinal); + + foreach (var scenario in graph.SpecificationScenarios.Where(_ => !_.IsConflicted)) + { + var definition = scenario.Variants.Single().Definition; + if (admitted.Contains(Structural.SpecificationScenario(definition))) + { + continue; + } + + var diagnostic = Find( + graph, + GenerationDiagnosticCodes.IncompleteSpecificationScenario, + $"Specification scenario '{scenario.Key.Scenario.Value}' could not be represented completely; no partial scenario was admitted", + scenario.Key.Scenario); + if (diagnostic is null) + { + continue; + } + + coverage.Omitted(GenerationFactSemanticKey.SpecificationScenario(definition), diagnostic); + foreach (var stepKey in definition.Steps) + { + if (!steps.TryGetValue(Structural.SpecificationStepKey(stepKey), out var step)) + { + continue; + } + + foreach (var stepVariant in step.Variants) + { + coverage.Omitted(GenerationFactSemanticKey.SpecificationStep(stepVariant.Definition), diagnostic); + foreach (var valueKey in stepVariant.Definition.Values) + { + PropagateValue(valueKey, diagnostic, values, coverage, []); + } + } + } + } + } + + static void PropagateValue( + SpecificationValueKey key, + GenerationDiagnostic diagnostic, + IReadOnlyDictionary values, + ScreenplayLoweringCoverageBuilder coverage, + HashSet visited) + { + var keyIdentity = Structural.SpecificationValueKey(key); + if (!visited.Add(keyIdentity) || !values.TryGetValue(keyIdentity, out var value)) + { + return; + } + + foreach (var variant in value.Variants) + { + coverage.Omitted(GenerationFactSemanticKey.SpecificationValue(variant.Definition), diagnostic); + foreach (var child in variant.Definition.Children) + { + PropagateValue(child, diagnostic, values, coverage, visited); + } + } + } + + static void AddConflict( + IEnumerable keys, + GenerationDiagnostic? diagnostic, + ScreenplayLoweringCoverageBuilder coverage) + { + if (diagnostic is null) + { + return; + } + + foreach (var key in keys) + { + coverage.Conflicted(key, diagnostic); + } + } + + static GenerationDiagnostic? Find( + ResolvedApplicationGraph graph, + string code, + string message, + SubjectId subject) => + graph.Diagnostics + .Where(_ => + _.Code == code && + _.Message == message && + _.Subject == subject) + .OrderBy(Canonical.Diagnostic, StringComparer.Ordinal) + .FirstOrDefault(); +} diff --git a/Source/DotNET/Generation/ScreenplayDefinitionGenerator.cs b/Source/DotNET/Generation/ScreenplayDefinitionGenerator.cs index 428c28c..3eae2ea 100644 --- a/Source/DotNET/Generation/ScreenplayDefinitionGenerator.cs +++ b/Source/DotNET/Generation/ScreenplayDefinitionGenerator.cs @@ -1,6 +1,7 @@ // 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; using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Printing; using Cratis.Screenplay.Syntax; @@ -43,6 +44,11 @@ public sealed record GeneratedScreenplayDefinition /// public IReadOnlyList Diagnostics { get; init; } = []; + /// + /// Gets the immutable adapter run with final fact dispositions when generation started from a run snapshot. + /// + public AdapterRunSnapshot? AdapterRun { get; init; } + /// /// Gets whether generation completed without errors. /// @@ -111,6 +117,134 @@ public GeneratedScreenplayDefinition Generate( }; } + /// + /// Generates one Screenplay document from an immutable adapter run snapshot. + /// + /// The immutable source-adapter run snapshot. + /// Options controlling the generated document. + /// The generated and verified definition with final fact dispositions. + public GeneratedScreenplayDefinition Generate( + AdapterRunSnapshot snapshot, + ScreenplayGenerationOptions options) + { + var canonicalAdapters = CanonicalAdapters(snapshot.Adapters); + var completed = canonicalAdapters + .Select(record => record.Execution) + .OfType() + .Select(execution => execution.Contribution) + .OrderBy(contribution => contribution.Descriptor.Identity.Id, StringComparer.Ordinal) + .ThenBy(contribution => contribution.Descriptor.Identity.Version, StringComparer.Ordinal) + .ToArray(); + var contributions = completed.Select(contribution => new AdapterContribution + { + Adapter = contribution.Descriptor.Identity, + Facts = contribution.Facts, + Diagnostics = contribution.Diagnostics + }); + var graph = resolver.Resolve(contributions); + var lowering = lowerer.Lower(graph, options.Domain); + var source = printer.Print(lowering.Application); + var verification = compiler.Compile(source); + var verificationDiagnostics = VerificationDiagnostics(verification).ToList(); + if (verification.Success && printer.Print(verification.Value!) != source) + { + verificationDiagnostics.Add(new GenerationDiagnostic + { + Code = GenerationDiagnosticCodes.UnstableRoundTrip, + Severity = GenerationDiagnosticSeverity.Error, + Outcome = GenerationDiagnosticOutcome.Unsupported, + Message = "The generated Screenplay document changed after compile and canonical reprint" + }); + } + + var pipelineDiagnostics = graph.Diagnostics + .Concat(lowering.Diagnostics) + .Concat(verificationDiagnostics) + .OrderBy(Canonical.Diagnostic, StringComparer.Ordinal) + .ToArray(); + var facts = completed + .SelectMany(contribution => contribution.Facts.Select(fact => new ProducedFact(contribution.Descriptor.Identity, fact))) + .OrderBy(item => item.Producer.Id, StringComparer.Ordinal) + .ThenBy(item => item.Producer.Version, StringComparer.Ordinal) + .ThenBy(item => item.Fact.Id.Value, StringComparer.Ordinal) + .ThenBy(item => item.Fact.Subject.Value, StringComparer.Ordinal) + .ThenBy(item => Structural.FactFamily(item.Fact)) + .ThenBy(item => Structural.FactDefinition(item.Fact), StringComparer.Ordinal) + .ThenBy(item => Structural.Evidence(item.Fact.Evidence), StringComparer.Ordinal) + .Select(item => item.Fact) + .ToArray(); + var factRecords = GenerationFactDispositionCalculator.Calculate( + facts, + graph, + lowering.Coverage, + pipelineDiagnostics); + var canonicalFactRecords = AdapterRunCanonicalizer.FactRecords(factRecords); + var runnerDiagnostics = RunnerDiagnostics(snapshot.Diagnostics, canonicalAdapters); + var dispositionDiagnostics = canonicalFactRecords.SelectMany(record => record.Diagnostics).ToArray(); + var adapterRun = new AdapterRunSnapshot + { + Adapters = canonicalAdapters, + Facts = canonicalFactRecords, + Diagnostics = CanonicalDiagnostics(runnerDiagnostics.Concat(dispositionDiagnostics)) + }; + var diagnostics = CanonicalDiagnostics( + pipelineDiagnostics + .Concat(runnerDiagnostics) + .Concat(dispositionDiagnostics)); + + return new() + { + Source = source, + Application = lowering.Application, + Graph = graph, + Diagnostics = diagnostics, + AdapterRun = adapterRun + }; + } + + static ImmutableArray CanonicalAdapters(IEnumerable adapters) => + [ + .. adapters + .Select(AdapterRunCanonicalizer.Adapter) + .OrderBy(record => record.Descriptor.Identity.Id, StringComparer.Ordinal) + .ThenBy(record => record.Descriptor.Identity.Version, StringComparer.Ordinal) + .ThenBy(AdapterRecordKey, StringComparer.Ordinal) + ]; + + static string AdapterRecordKey(AdapterRunRecord record) => Structural.AdapterRecord(record); + + static ImmutableArray RunnerDiagnostics( + IEnumerable diagnostics, + IEnumerable adapters) => + CanonicalDiagnostics(diagnostics.Concat(adapters.SelectMany(DiagnosticsFrom))); + + static IEnumerable DiagnosticsFrom(AdapterRunRecord record) + { + if (record.Probe is AdapterProbeBlocked blocked) + { + foreach (var diagnostic in blocked.Diagnostics) + { + yield return diagnostic; + } + } + + foreach (var diagnostic in record.Execution.Diagnostics) + { + yield return diagnostic; + } + + if (record.Execution is AdapterExecutionCompleted completed) + { + foreach (var diagnostic in completed.Contribution.Diagnostics) + { + yield return diagnostic; + } + } + } + + static ImmutableArray CanonicalDiagnostics(IEnumerable diagnostics) => + AdapterRunCanonicalizer.Diagnostics(diagnostics); + static IEnumerable VerificationDiagnostics(CompilationResult result) { if (result.Success) @@ -143,4 +277,6 @@ static IEnumerable VerificationDiagnostics(CompilationResu } ]; } + + sealed record ProducedFact(AdapterIdentity Producer, GenerationFact Fact); } diff --git a/Source/DotNET/Generation/ScreenplayLowerer.cs b/Source/DotNET/Generation/ScreenplayLowerer.cs index 8cf242b..6fcfbc3 100644 --- a/Source/DotNET/Generation/ScreenplayLowerer.cs +++ b/Source/DotNET/Generation/ScreenplayLowerer.cs @@ -21,6 +21,8 @@ public sealed record ScreenplayLoweringResult /// Gets diagnostics produced while lowering the graph. /// public IReadOnlyList Diagnostics { get; init; } = []; + + internal ScreenplayLoweringCoverage Coverage { get; init; } = ScreenplayLoweringCoverage.Empty; } /// @@ -39,30 +41,34 @@ public sealed class ScreenplayLowerer public ScreenplayLoweringResult Lower(ResolvedApplicationGraph graph, string domain) { var diagnostics = new List(); + var coverage = new ScreenplayLoweringCoverageBuilder(); + ResolverDiagnosticCoverage.Propagate(graph, coverage); ReportUnsupportedRelationships(graph, diagnostics); var placements = graph.Placements .Where(_ => !_.IsConflicted) - .ToDictionary(_ => Canonical.ArtifactKey(_.Artifact), _ => _.EffectiveVariants.Single().Placement, StringComparer.Ordinal); + .ToDictionary(_ => Structural.ArtifactKey(_.Artifact), _ => _.EffectiveVariants.Single().Placement, StringComparer.Ordinal); var definitions = graph.Artifacts .Where(_ => !_.IsConflicted) .Select(_ => _.Variants.Single().Definition) .ToArray(); - var (concepts, conceptNames) = BuildConcepts(graph, definitions, diagnostics); - var artifactsWithMissingConceptReferences = ReportMissingConceptReferences(definitions, conceptNames, diagnostics); + var (concepts, conceptNames) = BuildConcepts(graph, definitions, diagnostics, coverage); + var artifactsWithMissingConceptReferences = ReportMissingConceptReferences(definitions, conceptNames, diagnostics, coverage); var context = new LoweringContext(graph, conceptNames); foreach (var unplaced in definitions.Where(_ => _.Key.Kind != ArtifactKind.Concept && - !placements.ContainsKey(Canonical.ArtifactKey(_.Key)))) + !placements.ContainsKey(Structural.ArtifactKey(_.Key)))) { - diagnostics.Add(UnplacedArtifactDiagnostic(unplaced, SourceForArtifact(graph, unplaced.Key))); + var diagnostic = UnplacedArtifactDiagnostic(unplaced, SourceForArtifact(graph, unplaced.Key)); + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(unplaced), diagnostic); } var artifacts = definitions - .Where(_ => placements.ContainsKey(Canonical.ArtifactKey(_.Key)) && - !artifactsWithMissingConceptReferences.Contains(Canonical.ArtifactKey(_.Key))) + .Where(_ => placements.ContainsKey(Structural.ArtifactKey(_.Key)) && + !artifactsWithMissingConceptReferences.Contains(Structural.ArtifactKey(_.Key))) .Select(_ => new PlacedArtifact( _, - placements[Canonical.ArtifactKey(_.Key)], + placements[Structural.ArtifactKey(_.Key)], SourceForPlacement(graph, _.Key))) .OrderBy(_ => Canonical.Artifact(_.Definition), StringComparer.Ordinal) .ToArray(); @@ -72,7 +78,7 @@ public ScreenplayLoweringResult Lower(ResolvedApplicationGraph graph, string dom !CanLower(_.Definition.Key.Kind))) { var isKnownKind = IsKnownArtifactKind(unsupported.Definition.Key.Kind); - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = isKnownKind ? GenerationDiagnosticCodes.UnsupportedArtifact : GenerationDiagnosticCodes.UnsupportedArtifactKind, Severity = GenerationDiagnosticSeverity.Warning, @@ -84,7 +90,10 @@ public ScreenplayLoweringResult Lower(ResolvedApplicationGraph graph, string dom : $"Artifact '{unsupported.Definition.Name}' uses unknown or undefined ArtifactKind value '{(int)unsupported.Definition.Key.Kind}' and was omitted", Source = SourceForArtifact(graph, unsupported.Definition.Key), Subject = unsupported.Definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(unsupported.Definition), diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Placement(unsupported.Definition.Key, unsupported.Placement), diagnostic); } ModuleSyntax[] modules = @@ -93,7 +102,7 @@ .. artifacts .Where(_ => CanLower(_.Definition.Key.Kind)) .GroupBy(_ => _.Placement.Module, StringComparer.Ordinal) .OrderBy(_ => _.Key, StringComparer.Ordinal) - .Select(_ => BuildModule(_.Key, _, context, diagnostics)) + .Select(_ => BuildModule(_.Key, _, context, diagnostics, coverage)) ]; return new() @@ -105,14 +114,16 @@ .. artifacts modules, _generated, new DomainSyntax(domain, _generated)), - Diagnostics = [.. diagnostics.OrderBy(Canonical.Diagnostic, StringComparer.Ordinal)] + Diagnostics = [.. diagnostics.OrderBy(Canonical.Diagnostic, StringComparer.Ordinal)], + Coverage = coverage.Build() }; } static (ConceptSyntax[] Concepts, IReadOnlyDictionary Names) BuildConcepts( ResolvedApplicationGraph graph, IReadOnlyList definitions, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var conceptDefinitions = definitions.Where(_ => _.Key.Kind == ArtifactKind.Concept).ToArray(); var conflictingNames = conceptDefinitions @@ -121,14 +132,19 @@ .. artifacts .ToDictionary(_ => _.Key, _ => _.ToArray(), StringComparer.Ordinal); foreach (var conflict in conflictingNames.OrderBy(_ => _.Key, StringComparer.Ordinal)) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.ConflictingConceptName, Severity = GenerationDiagnosticSeverity.Error, Outcome = GenerationDiagnosticOutcome.Conflict, Message = $"Concept name '{conflict.Key}' is required by {conflict.Value.Length} distinct source subjects", Subject = conflict.Value.OrderBy(_ => _.Key.Subject.Value, StringComparer.Ordinal).First().Key.Subject - }); + }; + diagnostics.Add(diagnostic); + foreach (var definition in conflict.Value) + { + coverage.Conflicted(GenerationFactSemanticKey.Artifact(definition), diagnostic); + } } var concepts = new List(); @@ -141,14 +157,16 @@ .. artifacts var resolved = graph.ConceptRepresentations.FirstOrDefault(_ => _.Concept == definition.Key.Subject); if (resolved is null) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.MissingConceptRepresentation, Severity = GenerationDiagnosticSeverity.Warning, Outcome = GenerationDiagnosticOutcome.Unknown, Message = $"Concept '{definition.Name}' has no proven representation and was omitted", Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); continue; } @@ -161,7 +179,7 @@ .. artifacts var representation = representationVariant.Definition; if (representation.Kind == ConceptRepresentationKind.Unknown || !Enum.IsDefined(representation.Kind)) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedConceptRepresentationKind, Severity = GenerationDiagnosticSeverity.Warning, @@ -169,14 +187,17 @@ .. artifacts Message = $"Concept '{definition.Name}' uses unknown or undefined ConceptRepresentationKind value '{(int)representation.Kind}' and was omitted", Source = FirstSource(representationVariant.Evidence), Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptRepresentation(representation), diagnostic); continue; } if (representation.Primitive is { } primitive && (primitive == GenerationPrimitiveKind.Unknown || !Enum.IsDefined(primitive))) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedPrimitiveKind, Severity = GenerationDiagnosticSeverity.Warning, @@ -184,13 +205,16 @@ .. artifacts Message = $"Concept '{definition.Name}' uses unknown or undefined GenerationPrimitiveKind value '{(int)primitive}' and was omitted", Source = FirstSource(representationVariant.Evidence), Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptRepresentation(representation), diagnostic); continue; } if (TypeOf(representation) is not { } type) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedConceptRepresentation, Severity = GenerationDiagnosticSeverity.Warning, @@ -198,7 +222,10 @@ .. artifacts Message = $"Concept '{definition.Name}' has an invalid or unsupported {representation.Kind} representation and was omitted", Source = FirstSource(representationVariant.Evidence), Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptRepresentation(representation), diagnostic); continue; } @@ -207,7 +234,7 @@ .. artifacts : []; if (values.Any(_ => !IsEnumValue(_)) || values.Distinct(StringComparer.Ordinal).Count() != values.Count) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedConceptRepresentation, Severity = GenerationDiagnosticSeverity.Warning, @@ -215,17 +242,22 @@ .. artifacts Message = $"Concept '{definition.Name}' has empty or duplicate enumeration values after Screenplay naming and was omitted", Source = FirstSource(representationVariant.Evidence), Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptRepresentation(representation), diagnostic); continue; } - var attributes = BuildConceptAttributes(graph, definition, diagnostics); - var validations = BuildConceptValidations(graph, definition, diagnostics); + var attributes = BuildConceptAttributes(graph, definition, diagnostics, coverage); + var validations = BuildConceptValidations(graph, definition, diagnostics, coverage); concepts.Add(new ConceptSyntax(definition.Name, type, attributes, values, _generated, validations) { File = FileFrom(definition.File) }); names[definition.Key.Subject.Value] = definition.Name; + coverage.Lowered(GenerationFactSemanticKey.Artifact(definition)); + coverage.Lowered(GenerationFactSemanticKey.ConceptRepresentation(representation)); } return ([.. concepts], names); @@ -234,7 +266,8 @@ .. artifacts static ConceptAttributeSyntax[] BuildConceptAttributes( ResolvedApplicationGraph graph, ArtifactDefinition concept, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var attributes = new List(); foreach (var resolved in graph.ConceptAttributes @@ -244,7 +277,7 @@ static ConceptAttributeSyntax[] BuildConceptAttributes( var definition = resolved.Variants.Single().Definition; if (definition.Kind != ConceptAttributeKind.Named) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedConceptAttributeKind, Severity = GenerationDiagnosticSeverity.Warning, @@ -252,13 +285,15 @@ static ConceptAttributeSyntax[] BuildConceptAttributes( Message = $"Concept '{concept.Name}' has unknown or undefined ConceptAttributeKind value '{(int)definition.Kind}', which was omitted", Source = FirstSource(resolved.Variants.Single().Evidence), Subject = concept.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptAttribute(definition), diagnostic); continue; } if (!IsIdentifier(definition.Name)) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedConceptAttribute, Severity = GenerationDiagnosticSeverity.Warning, @@ -266,11 +301,14 @@ static ConceptAttributeSyntax[] BuildConceptAttributes( Message = $"Concept '{concept.Name}' has invalid attribute name '{definition.Name}', which was omitted", Source = FirstSource(resolved.Variants.Single().Evidence), Subject = concept.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptAttribute(definition), diagnostic); continue; } attributes.Add(new ConceptAttributeSyntax(definition.Name, _generated, definition.Reason)); + coverage.Lowered(GenerationFactSemanticKey.ConceptAttribute(definition)); } return [.. attributes]; @@ -279,7 +317,8 @@ static ConceptAttributeSyntax[] BuildConceptAttributes( static ValidateSyntax[] BuildConceptValidations( ResolvedApplicationGraph graph, ArtifactDefinition concept, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var rules = new List(); foreach (var resolved in graph.ConceptValidationRules @@ -288,12 +327,16 @@ static ValidateSyntax[] BuildConceptValidations( { if (string.IsNullOrWhiteSpace(resolved.RuleIdentity)) { - ReportUnsupportedValidation( + var diagnostic = UnsupportedValidation( concept, resolved.RuleIdentity, "has no rule identity", - FirstSource(resolved.Variants.SelectMany(_ => _.Evidence)), - diagnostics); + FirstSource(resolved.Variants.SelectMany(_ => _.Evidence))); + diagnostics.Add(diagnostic); + foreach (var variant in resolved.Variants) + { + coverage.Omitted(GenerationFactSemanticKey.ConceptValidationRule(variant.Definition), diagnostic); + } continue; } @@ -306,7 +349,7 @@ static ValidateSyntax[] BuildConceptValidations( var definition = validationVariant.Definition; if (definition.Kind == ConceptValidationRuleKind.Unknown || !Enum.IsDefined(definition.Kind)) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedConceptValidationRuleKind, Severity = GenerationDiagnosticSeverity.Warning, @@ -314,7 +357,9 @@ static ValidateSyntax[] BuildConceptValidations( Message = $"Concept '{concept.Name}' validation rule '{definition.RuleIdentity}' uses unknown or undefined ConceptValidationRuleKind value '{(int)definition.Kind}' and was omitted", Source = FirstSource(validationVariant.Evidence), Subject = concept.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptValidationRule(definition), diagnostic); continue; } @@ -323,12 +368,13 @@ definition.Predicate is not { } predicate || !IsRuleIdentifier(predicate) || !IsValidImplementationFile(definition.ImplementationFile)) { - ReportUnsupportedValidation( + var diagnostic = UnsupportedValidation( concept, definition.RuleIdentity, "has invalid or missing required data", - FirstSource(validationVariant.Evidence), - diagnostics); + FirstSource(validationVariant.Evidence)); + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.ConceptValidationRule(definition), diagnostic); continue; } @@ -339,6 +385,7 @@ definition.Predicate is not { } predicate || definition.Message, _generated, FileFrom(definition.ImplementationFile))); + coverage.Lowered(GenerationFactSemanticKey.ConceptValidationRule(definition)); } return rules.Count == 0 @@ -346,13 +393,11 @@ definition.Predicate is not { } predicate || : [new DeclarativeValidateSyntax(rules, _generated)]; } - static void ReportUnsupportedValidation( + static GenerationDiagnostic UnsupportedValidation( ArtifactDefinition concept, string? ruleIdentity, string reason, - SourceRange? source, - List diagnostics) => - diagnostics.Add(new GenerationDiagnostic + SourceRange? source) => new() { Code = GenerationDiagnosticCodes.UnsupportedConceptValidationRule, Severity = GenerationDiagnosticSeverity.Warning, @@ -360,17 +405,18 @@ static void ReportUnsupportedValidation( Message = $"Concept '{concept.Name}' validation rule '{ruleIdentity ?? string.Empty}' {reason} and was omitted", Source = source, Subject = concept.Key.Subject - }); + }; static HashSet ReportMissingConceptReferences( - IEnumerable definitions, + IReadOnlyList definitions, IReadOnlyDictionary conceptNames, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var missing = definitions .SelectMany(definition => definition.Properties .Where(property => property.Type.Subject is not null && !conceptNames.ContainsKey(property.Type.Subject.Value)) - .Select(property => new { Artifact = definition.Key, property.Type })) + .Select(property => new { Artifact = definition, property.Type })) .ToArray(); foreach (var type in missing .GroupBy(_ => _.Type.Subject!.Value, StringComparer.Ordinal) @@ -387,7 +433,17 @@ static HashSet ReportMissingConceptReferences( }); } - return [.. missing.Select(_ => Canonical.ArtifactKey(_.Artifact))]; + foreach (var definition in missing + .Select(_ => _.Artifact) + .DistinctBy(Canonical.Artifact, StringComparer.Ordinal)) + { + var diagnostic = diagnostics.First(_ => + _.Code == GenerationDiagnosticCodes.MissingConceptReference && + definition.Properties.Any(property => property.Type.Subject == _.Subject)); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); + } + + return [.. missing.Select(_ => Structural.ArtifactKey(_.Artifact.Key))]; } static string EnumValue(string value) => value.Length == 0 @@ -495,13 +551,13 @@ static GenerationDiagnostic UnplacedArtifactDiagnostic(ArtifactDefinition artifa static SourceRange? SourceForArtifact(ResolvedApplicationGraph graph, ArtifactKey key) => FirstSource(graph.Artifacts - .Where(_ => Canonical.ArtifactKey(_.Key) == Canonical.ArtifactKey(key)) + .Where(_ => Structural.ArtifactKey(_.Key) == Structural.ArtifactKey(key)) .SelectMany(_ => _.Variants) .SelectMany(_ => _.Evidence)); static SourceRange? SourceForPlacement(ResolvedApplicationGraph graph, ArtifactKey key) => FirstSource(graph.Placements - .Where(_ => Canonical.ArtifactKey(_.Artifact) == Canonical.ArtifactKey(key)) + .Where(_ => Structural.ArtifactKey(_.Artifact) == Structural.ArtifactKey(key)) .SelectMany(_ => _.EffectiveVariants) .SelectMany(_ => _.Evidence)); @@ -526,7 +582,8 @@ static ModuleSyntax BuildModule( string name, IEnumerable artifacts, LoweringContext context, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var root = new FeatureNode(string.Empty); foreach (var artifact in artifacts) @@ -543,7 +600,7 @@ static ModuleSyntax BuildModule( feature.Add(artifact); } - return new(name, [], [.. root.Children.Values.Select(_ => _.Build(context, diagnostics))], _generated); + return new(name, [], [.. root.Children.Values.Select(_ => _.Build(context, diagnostics, coverage))], _generated); } static SliceSyntax BuildSlice( @@ -551,43 +608,50 @@ static SliceSyntax BuildSlice( GenerationSliceKind kind, IReadOnlyList artifacts, LoweringContext context, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { - var commands = artifacts - .Select(_ => _.Definition) - .Where(_ => _.Key.Kind == ArtifactKind.Command) - .OrderBy(_ => _.Name, StringComparer.Ordinal) - .Select(_ => BuildCommand(_, context)) - .ToArray(); - var events = artifacts - .Select(_ => _.Definition) - .Where(_ => _.Key.Kind == ArtifactKind.Event) - .OrderBy(_ => _.Name, StringComparer.Ordinal) - .Select(_ => BuildEvent(_, context)) - .ToArray(); - var queries = artifacts - .Select(_ => _.Definition) - .Where(_ => _.Key.Kind == ArtifactKind.Query) - .OrderBy(_ => _.Name, StringComparer.Ordinal) - .Select(_ => BuildQuery(_, context, diagnostics)) - .Where(_ => _ is not null) - .Cast() + var commandArtifacts = ArtifactsOfKind(artifacts, ArtifactKind.Command); + var commands = commandArtifacts + .Select(_ => BuildCommand(_.Definition, context, coverage)) .ToArray(); - var readModels = artifacts - .Select(_ => _.Definition) - .Where(_ => _.Key.Kind == ArtifactKind.ReadModel) - .OrderBy(_ => _.Name, StringComparer.Ordinal) - .Select(_ => BuildReadModel(_, context)) + MarkLowered(commandArtifacts, coverage); + + var eventArtifacts = ArtifactsOfKind(artifacts, ArtifactKind.Event); + var events = eventArtifacts + .Select(_ => BuildEvent(_.Definition, context)) .ToArray(); - var reducers = artifacts - .Select(_ => _.Definition) - .Where(_ => _.Key.Kind == ArtifactKind.Reducer) - .OrderBy(_ => _.Name, StringComparer.Ordinal) - .Select(_ => BuildReducer(_, context, diagnostics)) - .Where(_ => _ is not null) - .Cast() + MarkLowered(eventArtifacts, coverage); + + var queries = new List(); + foreach (var artifact in ArtifactsOfKind(artifacts, ArtifactKind.Query)) + { + var query = BuildQuery(artifact.Definition, context, diagnostics, coverage); + if (query is not null) + { + queries.Add(query); + MarkLowered(artifact, coverage); + } + } + + var readModelArtifacts = ArtifactsOfKind(artifacts, ArtifactKind.ReadModel); + var readModels = readModelArtifacts + .Select(_ => BuildReadModel(_.Definition, context)) .ToArray(); - var specifications = context.SpecificationsFor(artifacts[0].Placement, diagnostics); + MarkLowered(readModelArtifacts, coverage); + + var reducers = new List(); + foreach (var artifact in ArtifactsOfKind(artifacts, ArtifactKind.Reducer)) + { + var reducer = BuildReducer(artifact.Definition, context, diagnostics, coverage); + if (reducer is not null) + { + reducers.Add(reducer); + MarkLowered(artifact, coverage); + } + } + + var specifications = context.SpecificationsFor(artifacts[0].Placement, diagnostics, coverage); return new SliceSyntax( SliceTypeFrom(kind), @@ -606,31 +670,75 @@ static SliceSyntax BuildSlice( Reducers: reducers); } - static CommandSyntax BuildCommand(ArtifactDefinition definition, LoweringContext context) + static PlacedArtifact[] ArtifactsOfKind(IReadOnlyList artifacts, ArtifactKind kind) => + [.. artifacts.Where(_ => _.Definition.Key.Kind == kind).OrderBy(_ => _.Definition.Name, StringComparer.Ordinal)]; + + static void MarkLowered(IEnumerable artifacts, ScreenplayLoweringCoverageBuilder coverage) + { + foreach (var artifact in artifacts) + { + MarkLowered(artifact, coverage); + } + } + + static void MarkLowered(PlacedArtifact artifact, ScreenplayLoweringCoverageBuilder coverage) + { + coverage.Lowered(GenerationFactSemanticKey.Artifact(artifact.Definition)); + coverage.Lowered(GenerationFactSemanticKey.Placement(artifact.Definition.Key, artifact.Placement)); + } + + static CommandSyntax BuildCommand( + ArtifactDefinition definition, + LoweringContext context, + ScreenplayLoweringCoverageBuilder coverage) { var productionRelationships = context.RelationshipsFrom(definition.Key.Subject, RelationshipKind.Produces); - var hasImperativeProduction = productionRelationships.Any(_ => _.Key.Discriminator == "imperative"); - var produces = productionRelationships + var imperativeProductionRelationships = productionRelationships + .Where(_ => _.Key.Discriminator == "imperative") + .ToArray(); + var hasImperativeProduction = imperativeProductionRelationships.Length > 0; + var loweredProductionRelationships = productionRelationships .Where(_ => !hasImperativeProduction && _.Key.Discriminator != "imperative") - .Select(_ => context.ArtifactName(_.Key.Target, ArtifactKind.Event)) - .Where(_ => _ is not null) - .Cast() + .Select(_ => new + { + Relationship = _, + Name = context.ArtifactName(_.Key.Target, ArtifactKind.Event) + }) + .Where(_ => _.Name is not null) + .ToArray(); + var produces = loweredProductionRelationships + .Select(_ => _.Name!) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .Select(_ => new ProducesSyntax(_, null, [], _generated)) .ToArray(); - var reads = context.RelationshipsFrom(definition.Key.Subject, RelationshipKind.Reads) + var loweredReadRelationships = context.RelationshipsFrom(definition.Key.Subject, RelationshipKind.Reads) .Select(_ => new { - Name = context.ArtifactName(_.Key.Target, ArtifactKind.ReadModel), - By = _.SourceMember + Relationship = _, + Name = context.ArtifactName(_.Key.Target, ArtifactKind.ReadModel) }) .Where(_ => _.Name is not null) - .Select(_ => new ReadsSyntax(_.Name!, _.By, _generated)) .ToArray(); + var reads = loweredReadRelationships + .Select(_ => new ReadsSyntax(_.Name!, _.Relationship.SourceMember, _generated)) + .ToArray(); + foreach (var relationship in loweredProductionRelationships.Select(_ => _.Relationship) + .Concat(loweredReadRelationships.Select(_ => _.Relationship))) + { + coverage.Lowered(GenerationFactSemanticKey.Relationship(relationship)); + } + var handler = (produces.Length == 0 || hasImperativeProduction) && definition.File is not null ? new HandlerSyntax(FileFrom(definition.File), null, _generated) : null; + if (handler is not null) + { + foreach (var relationship in imperativeProductionRelationships) + { + coverage.Lowered(GenerationFactSemanticKey.Relationship(relationship)); + } + } return new( definition.Name, @@ -647,7 +755,8 @@ static CommandSyntax BuildCommand(ArtifactDefinition definition, LoweringContext static QuerySyntax? BuildQuery( ArtifactDefinition definition, LoweringContext context, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var returns = context.RelationshipsFrom(definition.Key.Subject, RelationshipKind.Returns); var returnType = returns.Length == 1 @@ -655,14 +764,16 @@ static CommandSyntax BuildCommand(ArtifactDefinition definition, LoweringContext : null; if (returnType is null) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.IncompleteArtifact, Severity = GenerationDiagnosticSeverity.Warning, Outcome = GenerationDiagnosticOutcome.Unknown, Message = $"Query '{definition.Name}' was omitted because it does not return exactly one known read model", Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); return null; } @@ -675,6 +786,7 @@ static CommandSyntax BuildCommand(ArtifactDefinition definition, LoweringContext .Select(_ => new QueryParameterSyntax(_.Name, BuildType(_.Type, context), _generated)) .ToArray(); var relationship = returns[0]; + coverage.Lowered(GenerationFactSemanticKey.Relationship(relationship)); return new( definition.Name, @@ -707,7 +819,8 @@ static CommandSyntax BuildCommand(ArtifactDefinition definition, LoweringContext static ReducerSyntax? BuildReducer( ArtifactDefinition definition, LoweringContext context, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var builds = context.RelationshipsFrom(definition.Key.Subject, RelationshipKind.Builds); var consumes = context.RelationshipsFrom(definition.Key.Subject, RelationshipKind.Consumes); @@ -716,38 +829,49 @@ static CommandSyntax BuildCommand(ArtifactDefinition definition, LoweringContext : null; if (readModel is null || consumes.Length == 0) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.IncompleteArtifact, Severity = GenerationDiagnosticSeverity.Warning, Outcome = GenerationDiagnosticOutcome.Unknown, Message = $"Reducer '{definition.Name}' was omitted because it does not identify exactly one read model and at least one consumed event", Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); return null; } - var rules = consumes - .Select(_ => context.ArtifactName(_.Key.Target, ArtifactKind.Event)) - .Where(_ => _ is not null) - .Cast() + var loweredConsumes = consumes + .Where(_ => context.ArtifactName(_.Key.Target, ArtifactKind.Event) is not null) + .ToArray(); + var rules = loweredConsumes + .Select(_ => context.ArtifactName(_.Key.Target, ArtifactKind.Event)!) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .Select(_ => new ReducerRuleSyntax(_, FileFrom(definition.File), null, _generated)) .ToArray(); if (rules.Length == 0) { - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.IncompleteArtifact, Severity = GenerationDiagnosticSeverity.Warning, Outcome = GenerationDiagnosticOutcome.Unknown, Message = $"Reducer '{definition.Name}' was omitted because none of its consumed event subjects resolve to event artifacts", Subject = definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Artifact(definition), diagnostic); return null; } + coverage.Lowered(GenerationFactSemanticKey.Relationship(builds[0])); + foreach (var relationship in loweredConsumes) + { + coverage.Lowered(GenerationFactSemanticKey.Relationship(relationship)); + } + return new(definition.Name, readModel, rules, _generated, definition.Description); } @@ -811,12 +935,14 @@ public string TypeName(TypeReferenceDefinition type) => public SpecificationSyntax[] SpecificationsFor( ArtifactPlacement placement, - ICollection diagnostics) => + ICollection diagnostics, + ScreenplayLoweringCoverageBuilder coverage) => SpecificationSyntaxLowerer.Lower( graph, placement, artifact => ArtifactName(artifact.Subject, artifact.Kind), - diagnostics); + diagnostics, + coverage); } sealed record PlacedArtifact( @@ -854,18 +980,21 @@ public void Add(PlacedArtifact artifact) artifacts.Add(artifact); } - public FeatureSyntax Build(LoweringContext context, List diagnostics) + public FeatureSyntax Build( + LoweringContext context, + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var slices = _slices .OrderBy(_ => _.Key, StringComparer.Ordinal) - .Select(_ => BuildSliceGroup(_.Key, _.Value, context, diagnostics)) + .Select(_ => BuildSliceGroup(_.Key, _.Value, context, diagnostics, coverage)) .Where(_ => _ is not null) .Cast() .ToArray(); return new( Name, - [.. Children.Values.Select(_ => _.Build(context, diagnostics))], + [.. Children.Values.Select(_ => _.Build(context, diagnostics, coverage))], slices, _generated); } @@ -874,7 +1003,8 @@ [.. Children.Values.Select(_ => _.Build(context, diagnostics))], string name, IReadOnlyList artifacts, LoweringContext context, - List diagnostics) + List diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { var kinds = artifacts .Select(_ => _.Placement.SliceKind) @@ -887,7 +1017,7 @@ [.. Children.Values.Select(_ => _.Build(context, diagnostics))], var firstArtifact = artifacts .OrderBy(_ => Canonical.Artifact(_.Definition), StringComparer.Ordinal) .First(); - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.UnsupportedSliceKind, Severity = GenerationDiagnosticSeverity.Warning, @@ -895,7 +1025,13 @@ [.. Children.Values.Select(_ => _.Build(context, diagnostics))], Message = $"Slice '{name}' uses unknown or undefined GenerationSliceKind value '{(int)unsupportedKind}' and was omitted", Source = firstArtifact.Source, Subject = firstArtifact.Definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + foreach (var artifact in artifacts) + { + coverage.Omitted(GenerationFactSemanticKey.Artifact(artifact.Definition), diagnostic); + coverage.Omitted(GenerationFactSemanticKey.Placement(artifact.Definition.Key, artifact.Placement), diagnostic); + } return null; } @@ -904,7 +1040,7 @@ [.. Children.Values.Select(_ => _.Build(context, diagnostics))], var firstArtifact = artifacts .OrderBy(_ => Canonical.Artifact(_.Definition), StringComparer.Ordinal) .First(); - diagnostics.Add(new GenerationDiagnostic + var diagnostic = new GenerationDiagnostic { Code = GenerationDiagnosticCodes.ConflictingSliceKind, Severity = GenerationDiagnosticSeverity.Error, @@ -912,11 +1048,17 @@ [.. Children.Values.Select(_ => _.Build(context, diagnostics))], Message = $"Slice '{name}' was assigned incompatible kinds: {string.Join(", ", kinds)}", Source = firstArtifact.Source, Subject = firstArtifact.Definition.Key.Subject - }); + }; + diagnostics.Add(diagnostic); + foreach (var artifact in artifacts) + { + coverage.Omitted(GenerationFactSemanticKey.Artifact(artifact.Definition), diagnostic); + coverage.Conflicted(GenerationFactSemanticKey.Placement(artifact.Definition.Key, artifact.Placement), diagnostic); + } return null; } - return BuildSlice(name, kinds[0], artifacts, context, diagnostics); + return BuildSlice(name, kinds[0], artifacts, context, diagnostics, coverage); } } } diff --git a/Source/DotNET/Generation/ScreenplayLoweringCoverage.cs b/Source/DotNET/Generation/ScreenplayLoweringCoverage.cs new file mode 100644 index 0000000..d091361 --- /dev/null +++ b/Source/DotNET/Generation/ScreenplayLoweringCoverage.cs @@ -0,0 +1,104 @@ +// 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; + +internal static class GenerationFactSemanticKey +{ + public static string? For(GenerationFact fact) => fact switch + { + ArtifactFact artifact => Artifact(artifact.Definition), + ArtifactPlacementFact placement => Placement(placement.Artifact, placement.Placement), + RelationshipFact relationship => Relationship(relationship.Definition), + ConceptRepresentationFact representation => ConceptRepresentation(representation.Definition), + ConceptAttributeFact attribute => ConceptAttribute(attribute.Definition), + ConceptValidationRuleFact validation => ConceptValidationRule(validation.Definition), + SpecificationScenarioFact scenario => SpecificationScenario(scenario.Definition), + SpecificationStepFact step => SpecificationStep(step.Definition), + SpecificationValueFact value => SpecificationValue(value.Definition), + _ => null + }; + + public static string Artifact(ArtifactDefinition definition) => + Structural.SemanticKey("artifact", Structural.Artifact(definition)); + + public static string Placement(ArtifactKey artifact, ArtifactPlacement placement) => + Structural.SemanticKey("placement", Structural.ArtifactKey(artifact), Structural.Placement(placement)); + + public static string Relationship(RelationshipDefinition definition) => + Structural.SemanticKey("relationship", Structural.Relationship(definition)); + + public static string ConceptRepresentation(ConceptRepresentationDefinition definition) => + Structural.SemanticKey("concept-representation", Structural.ConceptRepresentation(definition)); + + public static string ConceptAttribute(ConceptAttributeDefinition definition) => + Structural.SemanticKey("concept-attribute", Structural.ConceptAttribute(definition)); + + public static string ConceptValidationRule(ConceptValidationRuleDefinition definition) => + Structural.SemanticKey("concept-validation-rule", Structural.ConceptValidationRule(definition)); + + public static string SpecificationScenario(SpecificationScenarioDefinition definition) => + Structural.SemanticKey("specification-scenario", Structural.SpecificationScenario(definition)); + + public static string SpecificationStep(SpecificationStepDefinition definition) => + Structural.SemanticKey("specification-step", Structural.SpecificationStep(definition)); + + public static string SpecificationValue(SpecificationValueDefinition definition) => + Structural.SemanticKey("specification-value", Structural.SpecificationValue(definition)); +} + +internal sealed record ScreenplayLoweringCoverage +{ + public static ScreenplayLoweringCoverage Empty { get; } = new(); + + public ImmutableHashSet Lowered { get; init; } = ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); + + public ImmutableHashSet Conflicted { get; init; } = ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); + + public ImmutableDictionary> Diagnostics { get; init; } = + ImmutableDictionary>.Empty.WithComparers(StringComparer.Ordinal); +} + +internal sealed class ScreenplayLoweringCoverageBuilder +{ + readonly Dictionary> _diagnostics = new(StringComparer.Ordinal); + readonly HashSet _lowered = new(StringComparer.Ordinal); + readonly HashSet _conflicted = new(StringComparer.Ordinal); + + public void Lowered(string key) => _lowered.Add(key); + + public void Omitted(string key, GenerationDiagnostic diagnostic) => AddDiagnostic(key, diagnostic); + + public void Conflicted(string key, GenerationDiagnostic diagnostic) + { + _conflicted.Add(key); + AddDiagnostic(key, diagnostic); + } + + public ScreenplayLoweringCoverage Build() => new() + { + Lowered = _lowered.ToImmutableHashSet(StringComparer.Ordinal), + Conflicted = _conflicted.ToImmutableHashSet(StringComparer.Ordinal), + Diagnostics = _diagnostics.ToImmutableDictionary( + item => item.Key, + item => item.Value + .GroupBy(Canonical.Diagnostic, StringComparer.Ordinal) + .OrderBy(group => group.Key, StringComparer.Ordinal) + .Select(group => group.First()) + .ToImmutableArray(), + StringComparer.Ordinal) + }; + + void AddDiagnostic(string key, GenerationDiagnostic diagnostic) + { + if (!_diagnostics.TryGetValue(key, out var diagnostics)) + { + diagnostics = []; + _diagnostics.Add(key, diagnostics); + } + + diagnostics.Add(diagnostic); + } +} diff --git a/Source/DotNET/Generation/SpecificationAdmission.cs b/Source/DotNET/Generation/SpecificationAdmission.cs index b187d17..80f0962 100644 --- a/Source/DotNET/Generation/SpecificationAdmission.cs +++ b/Source/DotNET/Generation/SpecificationAdmission.cs @@ -13,11 +13,11 @@ public static AdmittedSpecificationScenario[] Admit( { var artifactsByKey = artifacts .Where(artifact => !artifact.IsConflicted) - .ToDictionary(artifact => Canonical.ArtifactKey(artifact.Key), StringComparer.Ordinal); + .ToDictionary(artifact => Structural.ArtifactKey(artifact.Key), StringComparer.Ordinal); var placementsByKey = placements .Where(placement => !placement.IsConflicted) - .ToDictionary(placement => Canonical.ArtifactKey(placement.Artifact), StringComparer.Ordinal); - var stepsByKey = facts.Steps.ToDictionary(step => Canonical.SpecificationStepKey(step.Key), StringComparer.Ordinal); + .ToDictionary(placement => Structural.ArtifactKey(placement.Artifact), StringComparer.Ordinal); + var stepsByKey = facts.Steps.ToDictionary(step => Structural.SpecificationStepKey(step.Key), StringComparer.Ordinal); var valueAdmission = new SpecificationValueAdmission(facts.Values); var admitted = new List(); @@ -57,7 +57,7 @@ static bool TryAdmit( var variant = scenario.Variants.Single(); var definition = variant.Definition; - var targetKey = Canonical.ArtifactKey(definition.TargetArtifact); + var targetKey = Structural.ArtifactKey(definition.TargetArtifact); if (!artifacts.ContainsKey(targetKey) || !placements.TryGetValue(targetKey, out var placement) || placement.EffectiveVariants.Count != 1 || !ValidStepKeys(definition)) { @@ -67,7 +67,7 @@ static bool TryAdmit( var admittedSteps = new List(); foreach (var stepKey in definition.Steps) { - if (!steps.TryGetValue(Canonical.SpecificationStepKey(stepKey), out var step) || + if (!steps.TryGetValue(Structural.SpecificationStepKey(stepKey), out var step) || !TryAdmitStep(step, values, artifacts, out var admittedStep)) { return false; @@ -106,7 +106,7 @@ static bool TryAdmitStep( var variant = step.Variants.Single(); var definition = variant.Definition; if (!ValidStepShape(definition, artifacts) || - definition.Values.Select(Canonical.SpecificationValueKey).Distinct(StringComparer.Ordinal).Count() != definition.Values.Count) + definition.Values.Select(Structural.SpecificationValueKey).Distinct(StringComparer.Ordinal).Count() != definition.Values.Count) { return false; } @@ -133,9 +133,9 @@ static bool TryAdmitStep( static bool ValidStepKeys(SpecificationScenarioDefinition definition) => definition.Steps.Count > 0 && - definition.Steps.Select(Canonical.SpecificationStepKey).Distinct(StringComparer.Ordinal).Count() == definition.Steps.Count && + definition.Steps.Select(Structural.SpecificationStepKey).Distinct(StringComparer.Ordinal).Count() == definition.Steps.Count && definition.Steps.Select((step, index) => step.Index == index && - Canonical.SpecificationScenarioKey(step.Scenario) == Canonical.SpecificationScenarioKey(definition.Key)).All(valid => valid); + Structural.SpecificationScenarioKey(step.Scenario) == Structural.SpecificationScenarioKey(definition.Key)).All(valid => valid); static bool ValidStepShape( SpecificationStepDefinition step, @@ -147,7 +147,7 @@ static bool ValidStepShape( } if (step.Artifact is null || step.ErrorCode is not null || step.ErrorMessage is not null || - !artifacts.TryGetValue(Canonical.ArtifactKey(step.Artifact), out var artifact)) + !artifacts.TryGetValue(Structural.ArtifactKey(step.Artifact), out var artifact)) { return false; } diff --git a/Source/DotNET/Generation/SpecificationFactResolver.cs b/Source/DotNET/Generation/SpecificationFactResolver.cs index 9485a33..3125cd0 100644 --- a/Source/DotNET/Generation/SpecificationFactResolver.cs +++ b/Source/DotNET/Generation/SpecificationFactResolver.cs @@ -29,13 +29,15 @@ static ResolvedSpecificationScenario[] ResolveScenarios( List diagnostics) => [ .. facts - .GroupBy(fact => Canonical.SpecificationScenarioKey(fact.Definition.Key), StringComparer.Ordinal) - .OrderBy(group => group.Key, StringComparer.Ordinal) + .GroupBy(fact => Structural.SpecificationScenarioKey(fact.Definition.Key), StringComparer.Ordinal) + .OrderBy(group => Canonical.SpecificationScenarioKey(group.First().Definition.Key), StringComparer.Ordinal) + .ThenBy(group => group.Key, StringComparer.Ordinal) .Select(group => { var variants = group - .GroupBy(fact => Canonical.SpecificationScenario(fact.Definition), StringComparer.Ordinal) - .OrderBy(variant => variant.Key, StringComparer.Ordinal) + .GroupBy(fact => Structural.SpecificationScenario(fact.Definition), StringComparer.Ordinal) + .OrderBy(variant => Canonical.SpecificationScenario(variant.First().Definition), StringComparer.Ordinal) + .ThenBy(variant => variant.Key, StringComparer.Ordinal) .Select(variant => new ResolvedSpecificationScenarioVariant { Definition = variant.First().Definition, @@ -61,13 +63,15 @@ static ResolvedSpecificationStep[] ResolveSteps( List diagnostics) => [ .. facts - .GroupBy(fact => Canonical.SpecificationStepKey(fact.Definition.Key), StringComparer.Ordinal) - .OrderBy(group => group.Key, StringComparer.Ordinal) + .GroupBy(fact => Structural.SpecificationStepKey(fact.Definition.Key), StringComparer.Ordinal) + .OrderBy(group => Canonical.SpecificationStepKey(group.First().Definition.Key), StringComparer.Ordinal) + .ThenBy(group => group.Key, StringComparer.Ordinal) .Select(group => { var variants = group - .GroupBy(fact => Canonical.SpecificationStep(fact.Definition), StringComparer.Ordinal) - .OrderBy(variant => variant.Key, StringComparer.Ordinal) + .GroupBy(fact => Structural.SpecificationStep(fact.Definition), StringComparer.Ordinal) + .OrderBy(variant => Canonical.SpecificationStep(variant.First().Definition), StringComparer.Ordinal) + .ThenBy(variant => variant.Key, StringComparer.Ordinal) .Select(variant => new ResolvedSpecificationStepVariant { Definition = variant.First().Definition, @@ -93,13 +97,15 @@ static ResolvedSpecificationValue[] ResolveValues( List diagnostics) => [ .. facts - .GroupBy(fact => Canonical.SpecificationValueKey(fact.Definition.Key), StringComparer.Ordinal) - .OrderBy(group => group.Key, StringComparer.Ordinal) + .GroupBy(fact => Structural.SpecificationValueKey(fact.Definition.Key), StringComparer.Ordinal) + .OrderBy(group => Canonical.SpecificationValueKey(group.First().Definition.Key), StringComparer.Ordinal) + .ThenBy(group => group.Key, StringComparer.Ordinal) .Select(group => { var variants = group - .GroupBy(fact => Canonical.SpecificationValue(fact.Definition), StringComparer.Ordinal) - .OrderBy(variant => variant.Key, StringComparer.Ordinal) + .GroupBy(fact => Structural.SpecificationValue(fact.Definition), StringComparer.Ordinal) + .OrderBy(variant => Canonical.SpecificationValue(variant.First().Definition), StringComparer.Ordinal) + .ThenBy(variant => variant.Key, StringComparer.Ordinal) .Select(variant => new ResolvedSpecificationValueVariant { Definition = variant.First().Definition, diff --git a/Source/DotNET/Generation/SpecificationSyntaxLowerer.cs b/Source/DotNET/Generation/SpecificationSyntaxLowerer.cs index e053589..c67c4ee 100644 --- a/Source/DotNET/Generation/SpecificationSyntaxLowerer.cs +++ b/Source/DotNET/Generation/SpecificationSyntaxLowerer.cs @@ -15,26 +15,84 @@ public static SpecificationSyntax[] Lower( ResolvedApplicationGraph graph, ArtifactPlacement placement, Func artifactName, - ICollection diagnostics) + ICollection diagnostics, + ScreenplayLoweringCoverageBuilder coverage) { - var canonicalPlacement = Canonical.Placement(placement); + var structuralPlacement = Structural.Placement(placement); var lowered = new List(); foreach (var scenario in graph.Specifications.Where(item => - Canonical.Placement(item.Placement) == canonicalPlacement)) + Structural.Placement(item.Placement) == structuralPlacement)) { if (TryLower(scenario, artifactName, out var specification)) { lowered.Add(specification!); + MarkLowered(scenario, coverage); } else { - diagnostics.Add(Unsupported(scenario)); + var diagnostic = Unsupported(scenario); + diagnostics.Add(diagnostic); + MarkOmitted(scenario, diagnostic, coverage); } } return [.. lowered.OrderBy(item => item.Name, StringComparer.Ordinal)]; } + static void MarkLowered( + AdmittedSpecificationScenario scenario, + ScreenplayLoweringCoverageBuilder coverage) + { + coverage.Lowered(GenerationFactSemanticKey.SpecificationScenario(scenario.Definition)); + foreach (var step in scenario.Steps) + { + coverage.Lowered(GenerationFactSemanticKey.SpecificationStep(step.Definition)); + foreach (var value in step.Values) + { + MarkLowered(value, coverage); + } + } + } + + static void MarkLowered( + AdmittedSpecificationValue value, + ScreenplayLoweringCoverageBuilder coverage) + { + coverage.Lowered(GenerationFactSemanticKey.SpecificationValue(value.Definition)); + foreach (var child in value.Children) + { + MarkLowered(child, coverage); + } + } + + static void MarkOmitted( + AdmittedSpecificationScenario scenario, + GenerationDiagnostic diagnostic, + ScreenplayLoweringCoverageBuilder coverage) + { + coverage.Omitted(GenerationFactSemanticKey.SpecificationScenario(scenario.Definition), diagnostic); + foreach (var step in scenario.Steps) + { + coverage.Omitted(GenerationFactSemanticKey.SpecificationStep(step.Definition), diagnostic); + foreach (var value in step.Values) + { + MarkOmitted(value, diagnostic, coverage); + } + } + } + + static void MarkOmitted( + AdmittedSpecificationValue value, + GenerationDiagnostic diagnostic, + ScreenplayLoweringCoverageBuilder coverage) + { + coverage.Omitted(GenerationFactSemanticKey.SpecificationValue(value.Definition), diagnostic); + foreach (var child in value.Children) + { + MarkOmitted(child, diagnostic, coverage); + } + } + static bool TryLower( AdmittedSpecificationScenario scenario, Func artifactName, diff --git a/Source/DotNET/Generation/SpecificationValueAdmission.cs b/Source/DotNET/Generation/SpecificationValueAdmission.cs index 3f39061..8abfcaf 100644 --- a/Source/DotNET/Generation/SpecificationValueAdmission.cs +++ b/Source/DotNET/Generation/SpecificationValueAdmission.cs @@ -6,7 +6,7 @@ namespace Cratis.Screenplay.Generation; internal sealed class SpecificationValueAdmission(IEnumerable values) { readonly Dictionary _values = values.ToDictionary( - value => Canonical.SpecificationValueKey(value.Key), + value => Structural.SpecificationValueKey(value.Key), StringComparer.Ordinal); public bool TryAdmit( @@ -16,7 +16,7 @@ public bool TryAdmit( TryAdmit(key, step, new HashSet(StringComparer.Ordinal), out admitted); static bool SameStep(SpecificationStepKey left, SpecificationStepKey right) => - Canonical.SpecificationStepKey(left) == Canonical.SpecificationStepKey(right); + Structural.SpecificationStepKey(left) == Structural.SpecificationStepKey(right); static bool IsValidShape(SpecificationValueDefinition definition) { @@ -41,7 +41,7 @@ bool TryAdmit( out AdmittedSpecificationValue? admitted) { admitted = null; - var canonicalKey = Canonical.SpecificationValueKey(key); + var canonicalKey = Structural.SpecificationValueKey(key); if (!SameStep(key.Step, step) || !visiting.Add(canonicalKey) || !_values.TryGetValue(canonicalKey, out var resolved) || resolved.IsConflicted) { @@ -51,7 +51,7 @@ bool TryAdmit( var variant = resolved.Variants.Single(); var definition = variant.Definition; if (!IsValidShape(definition) || - definition.Children.Select(Canonical.SpecificationValueKey).Distinct(StringComparer.Ordinal).Count() != definition.Children.Count) + definition.Children.Select(Structural.SpecificationValueKey).Distinct(StringComparer.Ordinal).Count() != definition.Children.Count) { return false; } diff --git a/Source/DotNET/Generation/Structural.cs b/Source/DotNET/Generation/Structural.cs new file mode 100644 index 0000000..e54bb70 --- /dev/null +++ b/Source/DotNET/Generation/Structural.cs @@ -0,0 +1,266 @@ +// 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 Structural +{ + public static string SemanticKey(string family, params string?[] parts) => Node([family, .. parts]); + + public static string ArtifactKey(ArtifactKey key) => + Node(key.Subject.Value, Integer((int)key.Kind)); + + public static string Artifact(ArtifactDefinition definition) => + Node( + ArtifactKey(definition.Key), + definition.Name, + definition.Description, + definition.File, + Sequence(definition.Properties, Property)); + + public static string Placement(ArtifactPlacement placement) => + Node( + placement.Module, + Sequence(placement.Features, value => value), + placement.Slice, + Integer((int)placement.SliceKind)); + + public static string RelationshipKey(RelationshipKey key) => + Node(Integer((int)key.Kind), key.Source.Value, key.Target.Value, key.Discriminator); + + public static string Relationship(RelationshipDefinition definition) => + Node( + RelationshipKey(definition.Key), + definition.SourceMember, + definition.TargetMember, + Boolean(definition.IsCollection), + Boolean(definition.IsOptional)); + + public static string ConceptRepresentation(ConceptRepresentationDefinition definition) => + Node( + definition.Concept.Value, + Integer((int)definition.Kind), + NullableInteger(definition.Primitive is null ? null : (int)definition.Primitive.Value), + Sequence(definition.EnumerationValues, value => value)); + + public static string ConceptAttributeKey(ConceptAttributeDefinition definition) => + Node(definition.Concept.Value, Integer((int)definition.Kind), definition.Name); + + public static string ConceptAttribute(ConceptAttributeDefinition definition) => + Node(ConceptAttributeKey(definition), definition.Reason); + + public static string ConceptValidationRuleKey(ConceptValidationRuleDefinition definition) => + Node(definition.Concept.Value, definition.RuleIdentity); + + public static string ConceptValidationRule(ConceptValidationRuleDefinition definition) => + Node( + ConceptValidationRuleKey(definition), + Integer((int)definition.Kind), + definition.Predicate, + definition.Message, + definition.ImplementationFile); + + public static string SpecificationScenarioKey(SpecificationScenarioKey key) => + Node(key.Scenario.Value); + + public static string SpecificationStepKey(SpecificationStepKey key) => + Node(SpecificationScenarioKey(key.Scenario), Integer(key.Index)); + + public static string SpecificationValueKey(SpecificationValueKey key) => + Node(SpecificationStepKey(key.Step), Sequence(key.Path, value => value)); + + public static string SpecificationScenario(SpecificationScenarioDefinition definition) => + Node( + SpecificationScenarioKey(definition.Key), + definition.Name, + ArtifactKey(definition.TargetArtifact), + Sequence(definition.Steps, SpecificationStepKey)); + + public static string SpecificationStep(SpecificationStepDefinition definition) => + Node( + SpecificationStepKey(definition.Key), + Integer((int)definition.Phase), + Integer((int)definition.Kind), + definition.Artifact is null ? null : ArtifactKey(definition.Artifact), + definition.ErrorCode, + definition.ErrorMessage, + Sequence(definition.Values, SpecificationValueKey)); + + public static string SpecificationValue(SpecificationValueDefinition definition) => + Node( + SpecificationValueKey(definition.Key), + Integer((int)definition.Kind), + definition.Type is null ? null : TypeReference(definition.Type), + definition.Scalar, + Sequence(definition.Children, SpecificationValueKey)); + + public static string FactDefinition(GenerationFact fact) => fact switch + { + ArtifactFact artifact => Node("artifact", Artifact(artifact.Definition)), + ArtifactPlacementFact placement => Node("placement", ArtifactKey(placement.Artifact), Placement(placement.Placement)), + RelationshipFact relationship => Node("relationship", Relationship(relationship.Definition)), + ConceptRepresentationFact representation => Node("concept-representation", ConceptRepresentation(representation.Definition)), + ConceptAttributeFact attribute => Node("concept-attribute", ConceptAttribute(attribute.Definition)), + ConceptValidationRuleFact validation => Node("concept-validation-rule", ConceptValidationRule(validation.Definition)), + SpecificationScenarioFact scenario => Node("specification-scenario", SpecificationScenario(scenario.Definition)), + SpecificationStepFact step => Node("specification-step", SpecificationStep(step.Definition)), + SpecificationValueFact value => Node("specification-value", SpecificationValue(value.Definition)), + _ => Node("unknown", fact.GetType().FullName ?? fact.GetType().Name) + }; + + public static string Fact(GenerationFact fact) => + Node( + fact.Id.Value, + fact.Subject.Value, + Integer(FactFamily(fact)), + FactDefinition(fact), + Evidence(fact.Evidence)); + + public static string Evidence(Evidence evidence) => + Node( + Identity(evidence.Adapter), + Integer((int)evidence.Strength), + evidence.Source is null ? null : Source(evidence.Source), + evidence.Explanation); + + public static string Diagnostic(GenerationDiagnostic diagnostic) => + Node( + diagnostic.Code, + Integer((int)diagnostic.Severity), + diagnostic.Message, + NullableInteger(diagnostic.Outcome is null ? null : (int)diagnostic.Outcome.Value), + diagnostic.Source is null ? null : Source(diagnostic.Source), + diagnostic.Subject?.Value); + + public static string AdmissionDiagnostic(AdapterContributionAdmissionDiagnostic diagnostic) => + Node( + Integer((int)diagnostic.Code), + diagnostic.Path, + diagnostic.Message, + diagnostic.Fact?.Value, + diagnostic.Subject?.Value, + diagnostic.Source is null ? null : Source(diagnostic.Source)); + + public static string ProbeEvidence(AdapterProbeEvidence evidence) => + Node( + evidence.Description, + evidence.ApiCapability?.Id, + evidence.Source is null ? null : Source(evidence.Source), + evidence.Subject?.Value); + + public static string Descriptor(AdapterDescriptor descriptor) => + Node( + Identity(descriptor.Identity), + Integer((int)descriptor.SourceLanguage), + Integer((int)descriptor.Category), + VersionRange(descriptor.CompatibleGenerationVersions), + Sequence(descriptor.RequiredHostCapabilities, capability => Integer((int)capability)), + Sequence(descriptor.RequiredApiCapabilities, capability => capability.Id), + Sequence(descriptor.EmittedFactCapabilities, capability => Integer((int)capability))); + + public static string AdapterRecord(AdapterRunRecord record) => + Node( + Boolean(record.Considered), + Boolean(record.Probed), + Boolean(record.Executed), + Descriptor(record.Descriptor), + Probe(record.Probe), + Execution(record.Execution), + Integer((int)record.Disposition)); + + internal 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 + }; + + static string Property(PropertyDefinition property) => + Node(property.Name, TypeReference(property.Type), Boolean(property.IsIdentifier)); + + static string TypeReference(TypeReferenceDefinition type) => + Node( + type.Name, + type.Subject?.Value, + Boolean(type.IsCollection), + Boolean(type.IsOptional)); + + static string Identity(AdapterIdentity identity) => Node(identity.Id, identity.Version); + + static string VersionRange(GenerationVersionRange range) => + Node(range.MinimumInclusive?.ToString(), range.MaximumExclusive?.ToString()); + + static string Source(SourceRange source) => + Node( + source.Path, + source.FileIdentity is null ? null : Node(source.FileIdentity.Project, source.FileIdentity.Path), + Integer(source.StartLine), + Integer(source.StartColumn), + Integer(source.EndLine), + Integer(source.EndColumn)); + + static string Probe(AdapterProbeResult probe) + { + var type = probe switch + { + AdapterProbeNotRun => nameof(AdapterProbeNotRun), + AdapterProbeNotApplicable => nameof(AdapterProbeNotApplicable), + AdapterProbeApplicable => nameof(AdapterProbeApplicable), + AdapterProbeBlocked => nameof(AdapterProbeBlocked), + _ => probe.GetType().FullName ?? probe.GetType().Name + }; + var diagnostics = probe is AdapterProbeBlocked blocked + ? Sequence(blocked.Diagnostics, Diagnostic) + : Sequence(Array.Empty(), Diagnostic); + return Node(type, Sequence(probe.Evidence, ProbeEvidence), diagnostics); + } + + static string Execution(AdapterExecutionResult execution) + { + var diagnostics = Sequence(execution.Diagnostics, Diagnostic); + return execution switch + { + AdapterExecutionNotRun => Node(nameof(AdapterExecutionNotRun), diagnostics), + AdapterExecutionFailed => Node(nameof(AdapterExecutionFailed), diagnostics), + AdapterExecutionRejected rejected => Node( + nameof(AdapterExecutionRejected), + diagnostics, + Sequence(rejected.AdmissionDiagnostics, AdmissionDiagnostic)), + AdapterExecutionCompleted completed => Node( + nameof(AdapterExecutionCompleted), + diagnostics, + Contribution(completed.Contribution)), + _ => Node(execution.GetType().FullName ?? execution.GetType().Name, diagnostics) + }; + } + + static string Contribution(AdapterContributionSnapshot contribution) => + Node( + Descriptor(contribution.Descriptor), + Sequence(contribution.Facts, Fact), + Sequence(contribution.Diagnostics, Diagnostic)); + + static string Sequence(IEnumerable values, Func value) => + Node([Integer(values.Count()), .. values.Select(value)]); + + static string Node(params string?[] values) => string.Concat(values.Select(Encode)); + + static string Encode(string? value) => value is null + ? "-1:" + : $"{value.Length.ToString(CultureInfo.InvariantCulture)}:{value}"; + + static string Integer(int value) => value.ToString(CultureInfo.InvariantCulture); + + static string? NullableInteger(int? value) => value?.ToString(CultureInfo.InvariantCulture); + + static string Boolean(bool value) => value ? "1" : "0"; +} From 9a6898b1d11af97260e0b5c9092c025aa31e0f21 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 27 Aug 2026 23:34:32 +0200 Subject: [PATCH 4/4] Document deterministic adapter execution --- Directory.Build.props | 4 +- Documentation/guides/build-source-adapter.md | 166 ++++-- README.md | 49 +- scripts/verify-package-consumers.sh | 547 ++++++++++++++++++- 4 files changed, 720 insertions(+), 46 deletions(-) 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/scripts/verify-package-consumers.sh b/scripts/verify-package-consumers.sh index 1ff72b5..2bd2502 100755 --- a/scripts/verify-package-consumers.sh +++ b/scripts/verify-package-consumers.sh @@ -4,7 +4,7 @@ # Compiles consumers against the compatibility ancestry, then runs those unchanged binaries # beside the packages being validated. A separate source consumer compiles only against the -# current candidate packages and exercises the public 0.7+ composition surface. Together these +# current candidate packages and exercises the public 0.15+ composition and adapter-run surface. Together these # catch binary breaks that a source rebuild hides and ensure the current package set is usable. # # Usage: verify-package-consumers.sh [current-version] [local-package-feed] @@ -292,6 +292,9 @@ cat >"$CURRENT_SOURCE_DIR/CurrentSourceConsumer.csproj" <net10.0 enable enable + true + true + true @@ -387,6 +390,8 @@ internal static class Program unknown.Failures.SequenceEqual([failure]), "CSC0037", "The named Failures constructor argument did not retain an immutable failure snapshot."); + ExercisePublicAdapterContracts(); + ExerciseCandidatePackageClosure(); var authoredTree = CSharpSyntaxTree.ParseText( """ @@ -741,6 +746,8 @@ internal static class Program "The shared .NET invocation helpers did not preserve exact method, formal-argument, or receiver-root semantics."); var customerRegistered = compilation.GetTypeByMetadataName("Ordering.CustomerRegistered")!; + ExerciseAdapterRunnerContracts(context, project, customerRegistered); + ExerciseVogenModernAndLegacyParity(); var batchElement = DotNetSymbols.ElementTypeOf(compilation.GetTypeByMetadataName("Ordering.CustomerBatch")!); var endpointPolicy = customerRegistered.GetAttributes().Single(); var handlerType = compilation.GetTypeByMetadataName("Ordering.CustomerHandler")!; @@ -1076,6 +1083,466 @@ internal static class Program "The typed fail-closed Unknown outcome API did not omit and diagnose the affected fact exactly."); } + static void ExercisePublicAdapterContracts() + { + var descriptor = new AdapterDescriptor + { + Identity = new AdapterIdentity { Id = "contract-smoke", Version = "1.0.0" }, + SourceLanguage = AdapterSourceLanguage.SourceIndependent, + Category = AdapterCategory.Integration, + RequiredApiCapabilities = + [ + new AdapterApiCapability { Id = "contract.api.z" }, + new AdapterApiCapability { Id = "contract.api.a" } + ], + EmittedFactCapabilities = + [ + GenerationFactCapability.Relationship, + GenerationFactCapability.Artifact + ] + }; + Require( + (int)AdapterSourceLanguage.Unknown == -1 && + (int)AdapterCategory.Unknown == -1 && + (int)AdapterHostCapability.Unknown == -1 && + (int)GenerationFactCapability.Unknown == -1 && + (int)AdapterRunDisposition.Unknown == -1 && + (int)GenerationFactDisposition.Unknown == -1 && + (int)AdapterContributionAdmissionDiagnosticCode.Unknown == -1 && + (int)GenerationDiagnosticSeverity.Unknown == -1 && + (int)GenerationDiagnosticOutcome.Unknown == -1, + "CSC0041", + "A new adapter-run or admission discriminator lost its explicit Unknown = -1 sentinel."); + + var descriptorAdmission = AdapterDescriptorAdmission.Admit(descriptor); + Require( + descriptorAdmission.IsAdmitted && + !ReferenceEquals(descriptorAdmission.Descriptor, descriptor) && + descriptorAdmission.Descriptor.RequiredApiCapabilities.Select(capability => capability.Id) + .SequenceEqual(["contract.api.a", "contract.api.z"], StringComparer.Ordinal) && + descriptorAdmission.Descriptor.EmittedFactCapabilities.SequenceEqual( + [GenerationFactCapability.Artifact, GenerationFactCapability.Relationship]), + "CSC0042", + "Public descriptor admission did not deeply freeze and canonicalize the descriptor."); + + var invalidDescriptor = AdapterDescriptorAdmission.Admit(descriptor with + { + SourceLanguage = AdapterSourceLanguage.Unknown + }); + Require( + !invalidDescriptor.IsAdmitted && + invalidDescriptor.Diagnostics.Any(diagnostic => + diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.UnknownEnumValue), + "CSC0043", + "Public descriptor admission did not reject an explicit Unknown discriminator."); + + var admittedContribution = AdapterContributionAdmission.Admit( + descriptorAdmission.Descriptor, + new AdapterContribution { Adapter = descriptorAdmission.Descriptor.Identity }); + Require( + admittedContribution.IsAdmitted && + admittedContribution.Snapshot is { Facts.Length: 0, Diagnostics.Length: 0 } && + !ReferenceEquals(admittedContribution.Snapshot.Descriptor, descriptorAdmission.Descriptor), + "CSC0044", + "Public contribution admission did not return an immutable admitted snapshot."); + + var rejectedContribution = AdapterContributionAdmission.Admit( + descriptorAdmission.Descriptor, + new AdapterContribution + { + Adapter = new AdapterIdentity { Id = "another-adapter", Version = "1.0.0" } + }); + Require( + !rejectedContribution.IsAdmitted && + rejectedContribution.Snapshot is null && + rejectedContribution.Diagnostics.Any(diagnostic => + diagnostic.Code == AdapterContributionAdmissionDiagnosticCode.ContributionAdapterMismatch), + "CSC0045", + "Public contribution admission did not reject an identity mismatch atomically."); + } + + static void ExerciseCandidatePackageClosure() + { + var assemblyNames = new[] + { + typeof(AdapterDescriptor).Assembly.GetName().Name, + typeof(ScreenplayDefinitionGenerator).Assembly.GetName().Name, + typeof(DotNetAdapterRunner).Assembly.GetName().Name, + typeof(VogenConceptScreenplayAdapter).Assembly.GetName().Name + }; + Require( + assemblyNames.Length == 4 && + assemblyNames.Distinct(StringComparer.Ordinal).Count() == 4 && + assemblyNames.ToHashSet(StringComparer.Ordinal).SetEquals( + [ + "Cratis.Screenplay.Generation.Contracts", + "Cratis.Screenplay.Generation", + "Cratis.Screenplay.Generation.DotNet", + "Cratis.Screenplay.Generation.DotNet.Vogen" + ]), + "CSC0046", + "The runtime dependency closure did not load all four candidate package assemblies independently."); + } + + static void ExerciseAdapterRunnerContracts( + DotNetAnalysisContext context, + DotNetProjectCompilation project, + INamedTypeSymbol mappedType) + { + var duplicateFirst = new DescribedFakeAdapter(Descriptor("duplicate-smoke", "2.0.0")); + var duplicateSecond = new DescribedFakeAdapter(Descriptor("duplicate-smoke", "1.0.0")); + var duplicateSnapshot = DotNetAdapterRunner.Run( + [ + DotNetAdapterRegistration.For(duplicateFirst), + DotNetAdapterRegistration.For(duplicateSecond) + ], + new DotNetAnalysisContext([]), + new DotNetAdapterOptions()); + Require( + duplicateFirst.ProbeCount + duplicateSecond.ProbeCount == 0 && + duplicateFirst.AnalyzeCount + duplicateSecond.AnalyzeCount == 0 && + duplicateSnapshot.Adapters.All(record => + record.Disposition == AdapterRunDisposition.RosterRejected && + !record.Probed && + !record.Executed) && + duplicateSnapshot.Diagnostics.All(diagnostic => + diagnostic.Code == DotNetAdapterGenerationDiagnosticCodes.DuplicateAdapterId), + "CSC0047", + "Duplicate adapter IDs were not rejected deterministically before callbacks."); + + var independent = new DescribedFakeAdapter(Descriptor("source-independent")); + var independentSnapshot = DotNetAdapterRunner.Run( + [DotNetAdapterRegistration.For(independent)], + new DotNetAnalysisContext([]), + new DotNetAdapterOptions()); + Require( + independent.ProbeCount == 1 && + independent.AnalyzeCount == 1 && + independentSnapshot.Adapters.Single().Disposition == AdapterRunDisposition.Admitted, + "CSC0048", + "A host-free source-independent adapter did not execute exactly once against an empty context."); + + var applicable = new DescribedFakeAdapter(Descriptor("applicable-smoke")); + var notApplicable = new DescribedFakeAdapter(Descriptor("not-applicable-smoke")) + { + ProbeResult = new AdapterProbeNotApplicable() + }; + var blocked = new DescribedFakeAdapter(Descriptor("blocked-smoke")) + { + ProbeResult = new AdapterProbeBlocked + { + Diagnostics = + [ + new GenerationDiagnostic + { + Code = "PACKAGEBLOCK001", + Severity = GenerationDiagnosticSeverity.Error, + Message = "The adapter recognized source but cannot analyze it safely" + } + ] + } + }; + var legacy = new LegacyFakeAdapter( + new AdapterIdentity { Id = "legacy-smoke", Version = "1.0.0" }); + var mixedSnapshot = DotNetAdapterRunner.Run( + [ + DotNetAdapterRegistration.For(blocked), + DotNetAdapterRegistration.ForLegacy(legacy), + DotNetAdapterRegistration.For(notApplicable), + DotNetAdapterRegistration.For(applicable) + ], + context, + new DotNetAdapterOptions()); + Require( + applicable.ProbeCount == 1 && applicable.AnalyzeCount == 1 && + notApplicable.ProbeCount == 1 && notApplicable.AnalyzeCount == 0 && + blocked.ProbeCount == 1 && blocked.AnalyzeCount == 0 && + legacy.CanAnalyzeCount == 1 && legacy.AnalyzeCount == 1 && + mixedSnapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "applicable-smoke").Disposition == AdapterRunDisposition.Admitted && + mixedSnapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "not-applicable-smoke").Disposition == AdapterRunDisposition.NotApplicable && + mixedSnapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "blocked-smoke").Disposition == AdapterRunDisposition.Blocked && + mixedSnapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "legacy-smoke").Descriptor.Category == AdapterCategory.Legacy && + mixedSnapshot.Adapters.Single(record => record.Descriptor.Identity.Id == "legacy-smoke").Disposition == AdapterRunDisposition.Admitted, + "CSC0049", + "Modern applicable, not-applicable, blocked, or legacy callbacks violated exactly-once runner semantics."); + + var mappedIdentity = new AdapterIdentity { Id = "runner-mapped", Version = "1.0.0" }; + var mappedApi = new AdapterApiCapability { Id = "runner-mapped.customer-event" }; + var mappedEvidence = DotNetSource.EvidenceFor( + mappedType, + mappedIdentity, + project, + EvidenceStrength.Exact, + "The mapped authored event declaration is exact"); + var mappedSubject = project.SubjectForType(mappedType); + var mappedKey = new ArtifactKey { Subject = mappedSubject, Kind = ArtifactKind.Event }; + var mutableFacts = new List + { + new ArtifactFact + { + Id = new FactId { Value = "runner-mapped:artifact:customer-registered" }, + Subject = mappedSubject, + Evidence = mappedEvidence, + Definition = new ArtifactDefinition + { + Key = mappedKey, + Name = "CustomerRegistered", + File = mappedEvidence.Source?.Path + } + }, + new ArtifactPlacementFact + { + Id = new FactId { Value = "runner-mapped:placement:customer-registered" }, + Subject = mappedSubject, + Evidence = mappedEvidence, + Artifact = mappedKey, + Placement = new ArtifactPlacement + { + Module = "Package", + Features = ["Adapters"], + Slice = "Run", + SliceKind = GenerationSliceKind.StateChange + } + } + }; + var mutableDiagnostics = new List + { + new() + { + Code = "RUNNERSMOKE001", + Severity = GenerationDiagnosticSeverity.Information, + Message = "The package consumer admitted a stable mapped contribution", + Source = mappedEvidence.Source, + Subject = mappedSubject + } + }; + var mappedContribution = new AdapterContribution + { + Adapter = mappedIdentity, + Facts = mutableFacts, + Diagnostics = mutableDiagnostics + }; + var mappedDescriptor = Descriptor( + mappedIdentity.Id, + mappedIdentity.Version, + AdapterSourceLanguage.CSharp, + AdapterCategory.ApplicationFramework, + [ + AdapterHostCapability.AuthoredSource, + AdapterHostCapability.StableSourceLocations, + AdapterHostCapability.SemanticAnalysis + ], + [mappedApi], + [GenerationFactCapability.Artifact, GenerationFactCapability.ArtifactPlacement]); + var probeEvidence = new AdapterProbeEvidence + { + Description = "The stable mapped customer event API is available", + ApiCapability = mappedApi, + Source = mappedEvidence.Source, + Subject = mappedSubject + }; + var mappedAdapter = new DescribedFakeAdapter(mappedDescriptor) + { + ProbeResult = new AdapterProbeApplicable { Evidence = [probeEvidence] }, + Contribution = mappedContribution + }; + var mappedSnapshot = DotNetAdapterRunner.Run( + [DotNetAdapterRegistration.For(mappedAdapter)], + context, + new DotNetAdapterOptions()); + Require( + mappedAdapter.ProbeCount == 1 && + mappedAdapter.AnalyzeCount == 1 && + mappedSnapshot.Adapters.Single().Disposition == AdapterRunDisposition.Admitted && + mappedSnapshot.Facts.Length == 2 && + mappedSnapshot.Facts.All(record => record.Disposition == GenerationFactDisposition.Unknown) && + mappedSnapshot.Facts.All(record => record.Fact.Evidence.Source?.FileIdentity is not null), + "CSC0050", + "The deterministic runner did not admit a stable mapped .NET contribution exactly once."); + + var generationOptions = new ScreenplayGenerationOptions { Domain = "PackageConsumer" }; + var generator = new ScreenplayDefinitionGenerator(); + var generatedFromContribution = generator.Generate([mappedContribution], generationOptions); + var originalFact = mutableFacts[0]; + var completed = (AdapterExecutionCompleted)mappedSnapshot.Adapters.Single().Execution; + mutableFacts.Clear(); + mutableDiagnostics.Clear(); + Require( + mappedSnapshot.Facts.Length == 2 && + completed.Contribution.Facts.Length == 2 && + completed.Contribution.Diagnostics.Length == 1 && + !ReferenceEquals(originalFact, mappedSnapshot.Facts[0].Fact) && + !ReferenceEquals(mappedDescriptor, mappedSnapshot.Adapters.Single().Descriptor) && + !ReferenceEquals(probeEvidence, mappedSnapshot.Adapters.Single().Probe.Evidence.Single()), + "CSC0051", + "Mutating adapter-owned inputs changed the deeply frozen adapter-run snapshot."); + + var generatedFromSnapshot = generator.Generate(mappedSnapshot, generationOptions); + Require( + generatedFromSnapshot.IsSuccess && + generatedFromSnapshot.Source.Contains("event CustomerRegistered", StringComparison.Ordinal) && + generatedFromSnapshot.Diagnostics.Any(diagnostic => diagnostic.Code == "RUNNERSMOKE001") && + Encoding.UTF8.GetBytes(generatedFromSnapshot.Source) + .SequenceEqual(Encoding.UTF8.GetBytes(generatedFromContribution.Source)) && + JsonSerializer.SerializeToUtf8Bytes(generatedFromSnapshot.Diagnostics, _serializerOptions) + .SequenceEqual(JsonSerializer.SerializeToUtf8Bytes(generatedFromContribution.Diagnostics, _serializerOptions)), + "CSC0052", + "Generate(snapshot) changed output or diagnostics compared with the original contribution overload."); + Require( + generatedFromSnapshot.AdapterRun is not null && + generatedFromSnapshot.AdapterRun.Facts.Length == 2 && + generatedFromSnapshot.AdapterRun.Facts.All(record => + record.Disposition == GenerationFactDisposition.Lowered && + record.Diagnostics.Length == 0) && + generatedFromSnapshot.AdapterRun.Diagnostics.Any(diagnostic => diagnostic.Code == "RUNNERSMOKE001"), + "CSC0053", + "Generate(snapshot) did not return final lowered fact dispositions and runner diagnostics."); + } + + static void ExerciseVogenModernAndLegacyParity() + { + var apiTree = CSharpSyntaxTree.ParseText( + """ + namespace Vogen; + + [System.AttributeUsage(System.AttributeTargets.Struct)] + public sealed class ValueObjectAttribute : System.Attribute; + + public sealed class Validation + { + public static Validation Ok { get; } = new(); + public static Validation Invalid(string message) + { + _ = message; + return new(); + } + } + """, + path: "/api/Vogen.SharedTypes.cs"); + var apiCompilation = CSharpCompilation.Create( + "Vogen.SharedTypes", + [apiTree], + TrustedPlatformReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var apiImage = new MemoryStream(); + var apiEmit = apiCompilation.Emit(apiImage); + Require( + apiEmit.Success, + "CSC0054", + $"The in-memory exact Vogen API failed to compile: {string.Join(" | ", apiEmit.Diagnostics)}"); + + var authoredTree = CSharpSyntaxTree.ParseText( + """ + namespace ModernOrdering; + + [Vogen.ValueObject] + public readonly partial record struct CustomerCode + { + private static Vogen.Validation Validate(string value) => + string.IsNullOrWhiteSpace(value) + ? Vogen.Validation.Invalid("Required") + : Vogen.Validation.Ok; + } + """, + path: "/consumer/Concepts/CustomerCode.cs"); + var compilation = CSharpCompilation.Create( + "ModernOrdering", + [authoredTree], + TrustedPlatformReferences().Append(MetadataReference.CreateFromImage(apiImage.ToArray())), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var errors = compilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Require( + errors.Length == 0, + "CSC0055", + $"The modern Vogen package-consumer compilation was invalid: {string.Join(" | ", errors.Select(error => error.ToString()))}"); + + var sourceContext = DotNetSourcePaths.Create( + "ModernOrdering/ModernOrdering", + new DotNetSourcePathPolicy + { + DisplayRoot = DotNetSourceDisplayRoot.Workspace, + CasePolicy = DotNetSourcePathCasePolicy.Ordinal + }, + [ + new DotNetSourceDocument + { + SyntaxTree = authoredTree, + ProjectRelativePath = "Concepts/CustomerCode.cs", + WorkspaceRelativePath = "Concepts/CustomerCode.cs" + } + ]); + var context = new DotNetAnalysisContext( + [ + new DotNetProjectCompilation + { + Name = "ModernOrdering", + Compilation = compilation, + SourceContext = sourceContext, + AuthoredSyntaxTrees = new HashSet { authoredTree } + } + ]); + var modernAdapter = new VogenConceptScreenplayAdapter(); + IDescribedDotNetScreenplayAdapter modernContract = modernAdapter; + var probe = modernContract.Probe(context); + Require( + modernContract.Descriptor.Category == AdapterCategory.Concepts && + modernContract.Descriptor.SourceLanguage == AdapterSourceLanguage.CSharp && + modernContract.Descriptor.RequiredHostCapabilities.Contains(AdapterHostCapability.StableSourceLocations) && + modernContract.Descriptor.RequiredApiCapabilities.Contains(VogenAdapterApiCapabilities.ValueObjectDeclaration) && + modernContract.Descriptor.EmittedFactCapabilities.SequenceEqual( + [ + GenerationFactCapability.Artifact, + GenerationFactCapability.ConceptRepresentation, + GenerationFactCapability.ConceptValidationRule + ]) && + probe is AdapterProbeApplicable && + probe.Evidence.Any(evidence => evidence.ApiCapability == VogenAdapterApiCapabilities.ValueObjectDeclaration) && + probe.Evidence.Any(evidence => evidence.Source?.FileIdentity is not null), + "CSC0056", + "The Vogen modern descriptor or structured probe lost its declared capabilities or stable evidence."); + + var modernSnapshot = DotNetAdapterRunner.Run( + [DotNetAdapterRegistration.For(modernAdapter)], + context, + new DotNetAdapterOptions()); + var legacyAdapter = new VogenConceptScreenplayAdapter(); + IDotNetScreenplayAdapter legacyContract = legacyAdapter; + var legacySnapshot = DotNetAdapterRunner.Run( + [DotNetAdapterRegistration.ForLegacy(legacyContract)], + context, + new DotNetAdapterOptions()); + var modernContribution = ((AdapterExecutionCompleted)modernSnapshot.Adapters.Single().Execution).Contribution; + var legacyContribution = ((AdapterExecutionCompleted)legacySnapshot.Adapters.Single().Execution).Contribution; + Require( + modernSnapshot.Adapters.Single().Disposition == AdapterRunDisposition.Admitted && + legacySnapshot.Adapters.Single().Disposition == AdapterRunDisposition.Admitted && + JsonSerializer.SerializeToUtf8Bytes(modernContribution.Facts.Cast().ToArray(), _serializerOptions) + .SequenceEqual(JsonSerializer.SerializeToUtf8Bytes(legacyContribution.Facts.Cast().ToArray(), _serializerOptions)) && + JsonSerializer.SerializeToUtf8Bytes(modernContribution.Diagnostics, _serializerOptions) + .SequenceEqual(JsonSerializer.SerializeToUtf8Bytes(legacyContribution.Diagnostics, _serializerOptions)), + "CSC0057", + "The Vogen modern and legacy registrations did not produce byte-identical contributions."); + } + + static AdapterDescriptor Descriptor( + string id, + string version = "1.0.0", + AdapterSourceLanguage language = AdapterSourceLanguage.SourceIndependent, + AdapterCategory category = AdapterCategory.Integration, + IEnumerable? hostCapabilities = null, + IEnumerable? apiCapabilities = null, + IEnumerable? factCapabilities = null) => new() + { + Identity = new AdapterIdentity { Id = id, Version = version }, + SourceLanguage = language, + Category = category, + RequiredHostCapabilities = hostCapabilities is null ? [] : [.. hostCapabilities], + RequiredApiCapabilities = apiCapabilities is null ? [] : [.. apiCapabilities], + EmittedFactCapabilities = factCapabilities is null ? [] : [.. factCapabilities] + }; + static IReadOnlyList TrustedPlatformReferences() => ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) .Split(Path.PathSeparator) @@ -1161,6 +1628,52 @@ internal static class Program } } + sealed class DescribedFakeAdapter(AdapterDescriptor descriptor) : IDescribedDotNetScreenplayAdapter + { + public AdapterDescriptor Descriptor { get; } = descriptor; + public AdapterProbeResult ProbeResult { get; set; } = new AdapterProbeApplicable(); + public AdapterContribution Contribution { get; set; } = new() { Adapter = descriptor.Identity }; + public int ProbeCount { get; private set; } + public int AnalyzeCount { get; private set; } + + public AdapterProbeResult Probe(DotNetAnalysisContext context) + { + _ = context; + ProbeCount++; + return ProbeResult; + } + + public AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterOptions options) + { + _ = context; + _ = options; + AnalyzeCount++; + return Contribution; + } + } + + sealed class LegacyFakeAdapter(AdapterIdentity identity) : IDotNetScreenplayAdapter + { + public AdapterIdentity Identity { get; } = identity; + public int CanAnalyzeCount { get; private set; } + public int AnalyzeCount { get; private set; } + + public bool CanAnalyze(DotNetAnalysisContext context) + { + _ = context; + CanAnalyzeCount++; + return true; + } + + public AdapterContribution Analyze(DotNetAnalysisContext context, DotNetAdapterOptions options) + { + _ = context; + _ = options; + AnalyzeCount++; + return new AdapterContribution { Adapter = Identity }; + } + } + sealed class CurrentSourceConsumerFailure(string code, string message) : Exception(message) { public string Code { get; } = code; @@ -1179,6 +1692,38 @@ dotnet build "$VOGEN_DIR/VogenBaseline.csproj" --no-restore --configuration Rele echo "Compiling the current-source consumer only against candidate package version $CURRENT_VERSION..." dotnet restore "$CURRENT_SOURCE_DIR/CurrentSourceConsumer.csproj" --configfile "$WORK_DIR/nuget.config" --nologo +python3 - "$CURRENT_SOURCE_DIR/obj/project.assets.json" "$CURRENT_SOURCE_DIR/CurrentSourceConsumer.csproj" "$CURRENT_VERSION" <<'PYTHON' +import json +import pathlib +import sys + +assets_path = pathlib.Path(sys.argv[1]) +project_path = pathlib.Path(sys.argv[2]) +version = sys.argv[3] +expected = { + "Cratis.Screenplay.Generation.Contracts", + "Cratis.Screenplay.Generation", + "Cratis.Screenplay.Generation.DotNet", + "Cratis.Screenplay.Generation.DotNet.Vogen", +} +data = json.loads(assets_path.read_text(encoding="utf-8")) +frameworks = list(data["project"]["frameworks"].values()) +direct = set().union(*(framework["dependencies"].keys() for framework in frameworks)) +if direct != expected: + raise SystemExit(f"CSC0058: Current-source direct package references were {sorted(direct)}, expected only {sorted(expected)}") + +libraries = data["libraries"] +for package in expected: + key = f"{package}/{version}" + if key not in libraries or libraries[key].get("type") != "package": + raise SystemExit(f"CSC0059: Candidate package dependency '{key}' is absent or is not a package") + +if any(library.get("type") == "project" for library in libraries.values()): + raise SystemExit("CSC0060: The current-source dependency closure contains a project reference") +project_text = project_path.read_text(encoding="utf-8") +if "