From b7c741307bfeb92b654f605611b3cf0458075c0c Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 20:36:57 +0300 Subject: [PATCH 01/45] feat: conformance kit runner generic over the suite context, TransportFace.None (phase 10, #99) --- .../PublicAPI.Unshipped.txt | 4 + .../Reporting/ConformanceAssertion.cs | 50 +++- .../Reporting/ConformanceReport.cs | 68 +++++- .../Reporting/TransportFace.cs | 12 +- .../Suite/SuiteIdentity.cs | 16 ++ .../Suite/SuiteRunner.cs | 202 +++------------- .../Suite/SuiteRunnerCore.cs | 220 ++++++++++++++++++ .../Reporting/ConformanceAssertionTests.cs | 29 +++ .../Suite/GenericRunnerTests.cs | 159 +++++++++++++ .../Suite/SuiteRunnerTests.cs | 11 + 10 files changed, 584 insertions(+), 187 deletions(-) create mode 100644 src/Dexpace.Sdk.Conformance/Suite/SuiteIdentity.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Suite/GenericRunnerTests.cs diff --git a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt index 73b14996..79b48d8c 100644 --- a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt @@ -23,8 +23,10 @@ Dexpace.Sdk.Conformance.ConformanceReport Dexpace.Sdk.Conformance.ConformanceReport.ByAppendixBItem.get -> System.Collections.Generic.IReadOnlyDictionary! Dexpace.Sdk.Conformance.ConformanceReport.ByRequirement.get -> System.Collections.Generic.IReadOnlyDictionary! Dexpace.Sdk.Conformance.ConformanceReport.IsGreen.get -> bool +Dexpace.Sdk.Conformance.ConformanceReport.PreambleText.get -> string! Dexpace.Sdk.Conformance.ConformanceReport.Results.get -> System.Collections.Generic.IReadOnlyList! Dexpace.Sdk.Conformance.ConformanceReport.SubjectName.get -> string! +Dexpace.Sdk.Conformance.ConformanceReport.Suite.get -> string! Dexpace.Sdk.Conformance.ConformanceResult Dexpace.Sdk.Conformance.ConformanceResult.$() -> Dexpace.Sdk.Conformance.ConformanceResult! Dexpace.Sdk.Conformance.ConformanceResult.Assertion.get -> Dexpace.Sdk.Conformance.ConformanceAssertion! @@ -77,6 +79,7 @@ Dexpace.Sdk.Conformance.RequirementLevel.Should = 1 -> Dexpace.Sdk.Conformance.R Dexpace.Sdk.Conformance.TransportFace Dexpace.Sdk.Conformance.TransportFace.Async = 0 -> Dexpace.Sdk.Conformance.TransportFace Dexpace.Sdk.Conformance.TransportFace.Blocking = 1 -> Dexpace.Sdk.Conformance.TransportFace +Dexpace.Sdk.Conformance.TransportFace.None = 2 -> Dexpace.Sdk.Conformance.TransportFace Dexpace.Sdk.Conformance.TransportSettings Dexpace.Sdk.Conformance.TransportSettings.$() -> Dexpace.Sdk.Conformance.TransportSettings! Dexpace.Sdk.Conformance.TransportSettings.Equals(Dexpace.Sdk.Conformance.TransportSettings? other) -> bool @@ -156,6 +159,7 @@ override Dexpace.Sdk.Conformance.ConformanceWaiver.ToString() -> string! override Dexpace.Sdk.Conformance.TransportSettings.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.TransportSettings.GetHashCode() -> int override Dexpace.Sdk.Conformance.TransportSettings.ToString() -> string! +static Dexpace.Sdk.Conformance.ConformanceReport.Combine(string! subjectName, System.Collections.Generic.IEnumerable! reports) -> Dexpace.Sdk.Conformance.ConformanceReport! static Dexpace.Sdk.Conformance.ConformanceReport.Create(string! subjectName, System.Collections.Generic.IEnumerable! results) -> Dexpace.Sdk.Conformance.ConformanceReport! static Dexpace.Sdk.Conformance.ConformanceReport.Preamble.get -> string! static Dexpace.Sdk.Conformance.ConformanceResult.operator !=(Dexpace.Sdk.Conformance.ConformanceResult? left, Dexpace.Sdk.Conformance.ConformanceResult? right) -> bool diff --git a/src/Dexpace.Sdk.Conformance/Reporting/ConformanceAssertion.cs b/src/Dexpace.Sdk.Conformance/Reporting/ConformanceAssertion.cs index 79bfce58..ae9ba404 100644 --- a/src/Dexpace.Sdk.Conformance/Reporting/ConformanceAssertion.cs +++ b/src/Dexpace.Sdk.Conformance/Reporting/ConformanceAssertion.cs @@ -4,18 +4,30 @@ namespace Dexpace.Sdk.Conformance; /// -/// One named conformance check of a transport (design "The assertion catalogue"). A value, not a callable: it is run by -/// TransportSuite.RunAsync, and its body stays internal so a later release can repair an assertion without a -/// public-surface change. Names are stable public identifiers; a consumer's waiver list refers to them. +/// One named conformance check of a subject (design "The assertion catalogue"). A value, not a callable: it is run by a suite +/// (TransportSuite, InvariantSuite, CodecSuite or PackagingSuite), and its body stays internal so a +/// later release can repair an assertion without a public-surface change. Names are stable public identifiers; a consumer's +/// waiver list refers to them. /// public sealed class ConformanceAssertion { + /// A transport assertion: its body takes the transport suite's . internal ConformanceAssertion( string name, IReadOnlyList requirementIds, RequirementLevel level, IReadOnlyList faces, Func body) + : this(name, requirementIds, level, faces, Erase(body)) + { + } + + private ConformanceAssertion( + string name, + IReadOnlyList requirementIds, + RequirementLevel level, + IReadOnlyList faces, + Func body) { ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(requirementIds); @@ -43,8 +55,11 @@ internal ConformanceAssertion( Body = body; } - /// The assertion's check: throws when the transport breaks the clause. - internal Func Body { get; } + /// + /// The assertion's check, type-erased so every suite shares one runner (P10-7, plan reading R1): throws + /// when the subject breaks the clause. The runner passes the context of its own suite. + /// + internal Func Body { get; } /// The stable name, for example transport-24.vendor-status-readable. public string Name { get; } @@ -64,4 +79,29 @@ internal ConformanceAssertion( /// The assertion's . public override string ToString() => Name; + + /// Declares an assertion whose body takes the context of another suite (the invariant, codec or packaging suite). + /// The suite's context type. + /// The stable name. + /// The requirements it is evidence for. + /// The strength of the clause. + /// The faces it runs on. + /// The check, given the suite's context. + internal static ConformanceAssertion Create( + string name, + IReadOnlyList requirementIds, + RequirementLevel level, + IReadOnlyList faces, + Func body) + where TContext : class + { + ArgumentNullException.ThrowIfNull(body); + return new(name, requirementIds, level, faces, (context, token) => body((TContext)context, token)); + } + + private static Func Erase(Func body) + { + ArgumentNullException.ThrowIfNull(body); + return (context, token) => body((SuiteContext)context, token); + } } diff --git a/src/Dexpace.Sdk.Conformance/Reporting/ConformanceReport.cs b/src/Dexpace.Sdk.Conformance/Reporting/ConformanceReport.cs index f9cb253b..a670a956 100644 --- a/src/Dexpace.Sdk.Conformance/Reporting/ConformanceReport.cs +++ b/src/Dexpace.Sdk.Conformance/Reporting/ConformanceReport.cs @@ -15,9 +15,11 @@ public sealed class ConformanceReport { private readonly IReadOnlyList _waivers; - private ConformanceReport(string subjectName, IReadOnlyList results, IReadOnlyList waivers) + private ConformanceReport(string subjectName, IReadOnlyList results, IReadOnlyList waivers, SuiteIdentity suite) { SubjectName = subjectName; + Suite = suite.Name; + PreambleText = suite.Preamble; Results = results; _waivers = waivers; IsGreen = results.All(result => result.Status is not (ConformanceStatus.Failed or ConformanceStatus.Errored)); @@ -38,6 +40,18 @@ private ConformanceReport(string subjectName, IReadOnlyList r /// The name of the subject the run exercised. public string SubjectName { get; } + /// + /// The suite that produced the report: transport, invariant, codec or packaging, or + /// combined for a report merged by . + /// + public string Suite { get; } + + /// + /// What a green run of does not prove, as the report renders it. The static is the + /// transport suite's text; each other suite carries its own, and a combined report carries each of its inputs'. + /// + public string PreambleText { get; } + /// Every result of the run, in the order the assertions ran. public IReadOnlyList Results { get; } @@ -62,19 +76,63 @@ public static ConformanceReport Create(string subjectName, IEnumerable result.Waiver).OfType().Distinct()]); + return new(subjectName, copy, [.. copy.Select(result => result.Waiver).OfType().Distinct()], SuiteIdentity.Transport); + } + + /// + /// Merges the reports of several suites run against one subject into one, so the per-requirement and appendix B views read + /// across suites (P10-6, P10-7): the results are concatenated, the worst status wins per requirement and per appendix B + /// item (8a's rule), and the waivers are the union. + /// + /// The name of the combined subject. + /// The reports to merge; at least one. + /// The merged report; its is combined and its carries each input's. + /// , or an element is null. + /// is empty. + public static ConformanceReport Combine(string subjectName, IEnumerable reports) + { + ArgumentNullException.ThrowIfNull(subjectName); + ArgumentNullException.ThrowIfNull(reports); + var all = reports.ToArray(); + if (all.Length == 0) + { + throw new ArgumentException("Combine needs at least one report.", nameof(reports)); + } + + foreach (var report in all) + { + ArgumentNullException.ThrowIfNull(report, nameof(reports)); + } + + var preamble = string.Join( + Environment.NewLine + Environment.NewLine, + all.Select(report => report.PreambleText).Distinct(StringComparer.Ordinal)); + return new( + subjectName, + [.. all.SelectMany(report => report.Results)], + [.. all.SelectMany(report => report._waivers).Distinct()], + new SuiteIdentity("combined", preamble)); } /// Builds the report of a run that was configured with , listing every one of them. /// The subject's name. /// The results; copied. /// Every waiver the run was configured with, applied or not. - internal static ConformanceReport Create(string subjectName, IEnumerable results, IEnumerable waivers) + internal static ConformanceReport Create(string subjectName, IEnumerable results, IEnumerable waivers) => + Create(subjectName, results, waivers, SuiteIdentity.Transport); + + /// The overload every suite uses: it also records which suite produced the report and what its green run does not prove. + /// The subject's name. + /// The results; copied. + /// Every waiver the run was configured with, applied or not. + /// The suite that ran. + internal static ConformanceReport Create(string subjectName, IEnumerable results, IEnumerable waivers, SuiteIdentity suite) { ArgumentNullException.ThrowIfNull(subjectName); ArgumentNullException.ThrowIfNull(results); ArgumentNullException.ThrowIfNull(waivers); - return new(subjectName, results.ToArray(), [.. waivers]); + ArgumentNullException.ThrowIfNull(suite); + return new(subjectName, results.ToArray(), [.. waivers], suite); } /// @@ -83,7 +141,7 @@ internal static ConformanceReport Create(string subjectName, IEnumerable public override string ToString() { - var text = new StringBuilder(Preamble).AppendLine().AppendLine(); + var text = new StringBuilder(PreambleText).AppendLine().AppendLine(); text.AppendLine(CultureInfo.InvariantCulture, $"Subject: {SubjectName}"); text.AppendLine(CultureInfo.InvariantCulture, $"Verdict: {(IsGreen ? "GREEN" : "RED")} ({Count(ConformanceStatus.Failed)} failed, {Count(ConformanceStatus.Errored)} errored, {Count(ConformanceStatus.Waived)} waived, {Count(ConformanceStatus.NotExercised)} not exercised, {Count(ConformanceStatus.Vacuous)} vacuous, {Count(ConformanceStatus.Passed)} passed)"); AppendTable(text, "Requirements", ByRequirement, NaturalOrder); diff --git a/src/Dexpace.Sdk.Conformance/Reporting/TransportFace.cs b/src/Dexpace.Sdk.Conformance/Reporting/TransportFace.cs index 4e90a58c..b6e63e1d 100644 --- a/src/Dexpace.Sdk.Conformance/Reporting/TransportFace.cs +++ b/src/Dexpace.Sdk.Conformance/Reporting/TransportFace.cs @@ -4,8 +4,9 @@ namespace Dexpace.Sdk.Conformance; /// -/// The shape in which a transport is driven: the asynchronous IAsyncHttpClient face or the blocking -/// IHttpClient face. The suite runs an assertion once per face it declares and the subject supplies (P8a-10). +/// The shape in which a subject is driven: the asynchronous IAsyncHttpClient face, the blocking IHttpClient +/// face, or no call surface at all. The suite runs an assertion once per face it declares and the subject supplies +/// (P8a-10). The name is the transport suite's; the invariant, codec and packaging suites reuse it (phase 10, P10-7). /// public enum TransportFace { @@ -14,4 +15,11 @@ public enum TransportFace /// The blocking face: IHttpClient.Execute, called on a dedicated thread. Blocking = 1, + + /// + /// No call surface: the assertion judges a value (a model, a classifier, a bounded map, a package) rather than driving a + /// request, so it runs once and is supplied by definition. Added by phase 10 (P10-7); an exhaustive switch over the + /// enum in a third-party driver must handle it. The transport suite rejects it. + /// + None = 2, } diff --git a/src/Dexpace.Sdk.Conformance/Suite/SuiteIdentity.cs b/src/Dexpace.Sdk.Conformance/Suite/SuiteIdentity.cs new file mode 100644 index 00000000..eeb729f7 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/SuiteIdentity.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// Which of the kit's suites produced a report, and what a green run of that suite does not prove (design section B, P10-7). +/// The text is the suite's own, so a report never claims more than its subject was asked. +/// +/// The suite's name: transport, invariant, codec or packaging; combined for a merged report. +/// What a green run of the suite does not prove. +internal sealed record SuiteIdentity(string Name, string Preamble) +{ + /// The transport suite: the text has always carried. + internal static SuiteIdentity Transport { get; } = new("transport", ConformanceReport.Preamble); +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/SuiteRunner.cs b/src/Dexpace.Sdk.Conformance/Suite/SuiteRunner.cs index 428f1e5f..ee882191 100644 --- a/src/Dexpace.Sdk.Conformance/Suite/SuiteRunner.cs +++ b/src/Dexpace.Sdk.Conformance/Suite/SuiteRunner.cs @@ -1,18 +1,17 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. -using System.Globalization; - namespace Dexpace.Sdk.Conformance; /// -/// Runs assertions against a subject: one fresh context per (assertion, face), a bounded body, the six statuses and the -/// waiver rules (design section C). The heart of the kit: every assertion depends on it. +/// Runs transport assertions against a : one fresh per +/// (assertion, face). The status, waiver and bound logic is 's, shared with the other suites +/// (design section C, P10-7). /// internal static class SuiteRunner { /// Runs one assertion on one face and applies the waivers. - /// does not declare . + /// does not declare , or is . /// was signalled: the run is abandoned, never reported as an error. internal static async Task RunAsync( TransportSubject subject, @@ -21,189 +20,42 @@ internal static async Task RunAsync( TransportSuiteOptions options, CancellationToken cancellationToken) { - if (!assertion.Faces.Contains(face)) + if (face == TransportFace.None) { - throw new ArgumentException($"Assertion '{assertion.Name}' does not declare the {face} face.", nameof(face)); + throw new ArgumentException("The transport suite has no None face: every transport assertion drives a call surface.", nameof(face)); } - if (!SuiteContext.Supplies(subject, face)) + if (!assertion.Faces.Contains(face)) { - return ApplyWaivers(Result(assertion, face, ConformanceStatus.NotExercised, $"the subject supplies no {face} factory"), options.Waivers); + throw new ArgumentException($"Assertion '{assertion.Name}' does not declare the {face} face.", nameof(face)); } -#pragma warning disable CA2000 // Disposed on every path below: DisposeAsync(context, ...) on completion, DisposeQuietlyAsync on cancellation. - var context = new SuiteContext(subject, face, options); -#pragma warning restore CA2000 - ConformanceResult outcome; - try - { - outcome = await ExecuteBodyAsync(assertion, context, options, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) + if (!SuiteContext.Supplies(subject, face)) { - await DisposeQuietlyAsync(context).ConfigureAwait(false); - throw; + return SuiteRunnerCore.ApplyWaivers( + SuiteRunnerCore.Result(assertion, face, ConformanceStatus.NotExercised, $"the subject supplies no {face} factory"), + options.Waivers); } - return ApplyWaivers(await DisposeAsync(context, assertion, face, outcome).ConfigureAwait(false), options.Waivers); + return await SuiteRunnerCore.RunAsync( + assertion, + face, + () => new SuiteContext(subject, face, options), + options.AssertionTimeout, + options.Waivers, + cancellationToken).ConfigureAwait(false); } /// Runs every assertion on every face it declares, sequentially in the given order, and builds the report. - internal static async Task RunAllAsync( + internal static Task RunAllAsync( TransportSubject subject, IReadOnlyList assertions, TransportSuiteOptions options, - CancellationToken cancellationToken) - { - var results = new List(); - foreach (var assertion in assertions) - { - foreach (var face in assertion.Faces) - { - results.Add(await RunAsync(subject, assertion, face, options, cancellationToken).ConfigureAwait(false)); - } - } - - results.AddRange(UnknownWaiverTargets(assertions, options.Waivers)); - return ConformanceReport.Create(subject.Name, results, options.Waivers); - } - - private static async Task ExecuteBodyAsync( - ConformanceAssertion assertion, - SuiteContext context, - TransportSuiteOptions options, - CancellationToken cancellationToken) - { - var face = context.Face; - try - { - await Bounded.RunAsync(token => assertion.Body(context, token), options.AssertionTimeout, assertion.Name, cancellationToken).ConfigureAwait(false); - return Result(assertion, face, ConformanceStatus.Passed, "passed"); - } - catch (NotExercisedException ex) - { - return Result(assertion, face, ConformanceStatus.NotExercised, ex.Message); - } - catch (ConformanceVacuousException ex) - { - return Result(assertion, face, ConformanceStatus.Vacuous, ex.Message); - } - catch (ConformanceException ex) - { - return Result(assertion, face, ConformanceStatus.Failed, Describe(ex)) with { Exception = ex }; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } -#pragma warning disable CA1031 // A transport crash or a kit bug is a result (Errored), never an unhandled exception that ends the run. - catch (Exception ex) -#pragma warning restore CA1031 - { - return Result(assertion, face, ConformanceStatus.Errored, $"{ex.GetType().Name}: {ex.Message}") with { Exception = ex }; - } - } - - // The context is disposed after the body, on every path. A failure to dispose is a result too, but never replaces a - // result that is already a failure or an error: the first problem is the one the report shows. - private static async Task DisposeAsync(SuiteContext context, ConformanceAssertion assertion, TransportFace face, ConformanceResult outcome) - { - try - { - await context.DisposeAsync().ConfigureAwait(false); - return outcome; - } - catch (ConformanceException ex) when (outcome.Status is not (ConformanceStatus.Failed or ConformanceStatus.Errored)) - { - return Result(assertion, face, ConformanceStatus.Failed, Describe(ex)) with { Exception = ex }; - } -#pragma warning disable CA1031 // See ExecuteBodyAsync: a crash is a result. - catch (Exception ex) when (outcome.Status is not (ConformanceStatus.Failed or ConformanceStatus.Errored)) -#pragma warning restore CA1031 - { - return Result(assertion, face, ConformanceStatus.Errored, $"disposing the run threw {ex.GetType().Name}: {ex.Message}") with { Exception = ex }; - } -#pragma warning disable CA1031 // The outcome is already a failure or an error; this one is dropped in its favour. - catch (Exception) -#pragma warning restore CA1031 - { - return outcome; - } - } - - private static async Task DisposeQuietlyAsync(SuiteContext context) - { - try - { - await context.DisposeAsync().ConfigureAwait(false); - } -#pragma warning disable CA1031 // The run is being abandoned for the caller's cancellation; a disposal failure must not replace it. - catch (Exception) -#pragma warning restore CA1031 - { - } - } - - /// - /// A waiver turns Failed and Errored into Waived; it turns a Passed result it covers into a Failed "no longer needed" - /// one (P8a-8: a fixed row must lose its waiver); it never touches Vacuous or NotExercised. - /// - private static ConformanceResult ApplyWaivers(ConformanceResult result, IReadOnlyList waivers) - { - if (result.Status is not (ConformanceStatus.Failed or ConformanceStatus.Errored or ConformanceStatus.Passed)) - { - return result; - } - - var waiver = waivers.FirstOrDefault(w => Covers(w, result)); - if (waiver is null) - { - return result; - } - - return result.Status == ConformanceStatus.Passed - ? result with - { - Status = ConformanceStatus.Failed, - Detail = $"waiver for {waiver.RequirementId} is no longer needed: {result.Assertion.Name} passes on {result.Face} (waiver reason: {waiver.Reason})", - Waiver = waiver, - } - : result with - { - Status = ConformanceStatus.Waived, - Detail = $"waived ({(waiver.Owner is null ? "permanent" : "owner " + waiver.Owner)}): {waiver.Reason} | {result.Detail}", - Waiver = waiver, - }; - } - - private static bool Covers(ConformanceWaiver waiver, ConformanceResult result) => - result.Assertion.RequirementIds.Contains(waiver.RequirementId, StringComparer.Ordinal) - && (waiver.Assertion is null || string.Equals(waiver.Assertion, result.Assertion.Name, StringComparison.Ordinal)) - && (waiver.Face is null || waiver.Face == result.Face); - - // A waiver aimed at an assertion that does not run is a typo that would otherwise silently waive nothing. - private static IEnumerable UnknownWaiverTargets(IReadOnlyList assertions, IReadOnlyList waivers) - { - var names = assertions.Select(a => a.Name).ToHashSet(StringComparer.Ordinal); - foreach (var waiver in waivers.Where(w => w.Assertion is not null && !names.Contains(w.Assertion))) - { - var synthetic = new ConformanceAssertion("waiver", [waiver.RequirementId], RequirementLevel.Must, [TransportFace.Async], static (_, _) => Task.CompletedTask); - yield return new ConformanceResult( - synthetic, - TransportFace.Async, - ConformanceStatus.Failed, - $"unknown waiver target '{Text.Truncate(waiver.Assertion!)}'") - { - Waiver = waiver, - }; - } - } - - private static ConformanceResult Result(ConformanceAssertion assertion, TransportFace face, ConformanceStatus status, string detail) => - new(assertion, face, status, detail); - - private static string Describe(ConformanceException ex) => - ex.Expected is null && ex.Actual is null - ? ex.Message - : string.Create(CultureInfo.InvariantCulture, $"{ex.Message} (expected: {ex.Expected}; actual: {ex.Actual})"); + CancellationToken cancellationToken) => + SuiteRunnerCore.RunAllAsync( + subject.Name, + SuiteIdentity.Transport, + assertions, + (assertion, face) => RunAsync(subject, assertion, face, options, cancellationToken), + options.Waivers); } diff --git a/src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs b/src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs new file mode 100644 index 00000000..ac9daaa1 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs @@ -0,0 +1,220 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; + +namespace Dexpace.Sdk.Conformance; + +/// +/// The status, waiver and bound logic every suite shares (design section C, P10-7): one fresh context per (assertion, face), a +/// bounded body, the six statuses and the rule that a waiver must stay needed. Generic over the suite's context, so the transport, +/// invariant and codec runners are thin callers and a status means the same thing in all of them. +/// +internal static class SuiteRunnerCore +{ + /// Runs one assertion on one face against a fresh context and applies the waivers. + /// What one run owns; disposed after the body on every path. + /// The assertion. + /// The face; the assertion must declare it. + /// Builds the run's context. Called once. + /// The bound on the body. + /// The waivers in force. + /// Abandons the run with . + /// does not declare . + internal static async Task RunAsync( + ConformanceAssertion assertion, + TransportFace face, + Func create, + TimeSpan assertionTimeout, + IReadOnlyList waivers, + CancellationToken cancellationToken) + where TContext : class, IAsyncDisposable + { + if (!assertion.Faces.Contains(face)) + { + throw new ArgumentException($"Assertion '{assertion.Name}' does not declare the {face} face.", nameof(face)); + } + + var context = create(); + ConformanceResult outcome; + try + { + outcome = await ExecuteBodyAsync(assertion, face, context, assertionTimeout, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await DisposeQuietlyAsync(context).ConfigureAwait(false); + throw; + } + + return ApplyWaivers(await DisposeAsync(context, assertion, face, outcome).ConfigureAwait(false), waivers); + } + + /// Runs every assertion on every face it declares, sequentially in the given order, and builds the report. + /// The subject's name. + /// The suite that produced the report; selects the report's preamble. + /// The assertions, in order. + /// Runs one assertion on one face. + /// The waivers in force. + internal static async Task RunAllAsync( + string subjectName, + SuiteIdentity suite, + IReadOnlyList assertions, + Func> runOne, + IReadOnlyList waivers) + { + var results = new List(); + foreach (var assertion in assertions) + { + foreach (var face in assertion.Faces) + { + results.Add(await runOne(assertion, face).ConfigureAwait(false)); + } + } + + results.AddRange(UnknownWaiverTargets(assertions, waivers)); + return ConformanceReport.Create(subjectName, results, waivers, suite); + } + + /// + /// A waiver turns Failed and Errored into Waived; it turns a Passed result it covers into a Failed "no longer needed" + /// one (P8a-8: a fixed row must lose its waiver); it never touches Vacuous or NotExercised. + /// + /// The result. + /// The waivers in force. + internal static ConformanceResult ApplyWaivers(ConformanceResult result, IReadOnlyList waivers) + { + if (result.Status is not (ConformanceStatus.Failed or ConformanceStatus.Errored or ConformanceStatus.Passed)) + { + return result; + } + + var waiver = waivers.FirstOrDefault(w => Covers(w, result)); + if (waiver is null) + { + return result; + } + + return result.Status == ConformanceStatus.Passed + ? result with + { + Status = ConformanceStatus.Failed, + Detail = $"waiver for {waiver.RequirementId} is no longer needed: {result.Assertion.Name} passes on {result.Face} (waiver reason: {waiver.Reason})", + Waiver = waiver, + } + : result with + { + Status = ConformanceStatus.Waived, + Detail = $"waived ({(waiver.Owner is null ? "permanent" : "owner " + waiver.Owner)}): {waiver.Reason} | {result.Detail}", + Waiver = waiver, + }; + } + + /// A result row for on . + internal static ConformanceResult Result(ConformanceAssertion assertion, TransportFace face, ConformanceStatus status, string detail) => + new(assertion, face, status, detail); + + private static async Task ExecuteBodyAsync( + ConformanceAssertion assertion, + TransportFace face, + object context, + TimeSpan assertionTimeout, + CancellationToken cancellationToken) + { + try + { + await Bounded.RunAsync(token => assertion.Body(context, token), assertionTimeout, assertion.Name, cancellationToken).ConfigureAwait(false); + return Result(assertion, face, ConformanceStatus.Passed, "passed"); + } + catch (NotExercisedException ex) + { + return Result(assertion, face, ConformanceStatus.NotExercised, ex.Message); + } + catch (ConformanceVacuousException ex) + { + return Result(assertion, face, ConformanceStatus.Vacuous, ex.Message); + } + catch (ConformanceException ex) + { + return Result(assertion, face, ConformanceStatus.Failed, Describe(ex)) with { Exception = ex }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } +#pragma warning disable CA1031 // A subject crash or a kit bug is a result (Errored), never an unhandled exception that ends the run. + catch (Exception ex) +#pragma warning restore CA1031 + { + return Result(assertion, face, ConformanceStatus.Errored, $"{ex.GetType().Name}: {ex.Message}") with { Exception = ex }; + } + } + + // The context is disposed after the body, on every path. A failure to dispose is a result too, but never replaces a + // result that is already a failure or an error: the first problem is the one the report shows. + private static async Task DisposeAsync(IAsyncDisposable context, ConformanceAssertion assertion, TransportFace face, ConformanceResult outcome) + { + try + { + await context.DisposeAsync().ConfigureAwait(false); + return outcome; + } + catch (ConformanceException ex) when (outcome.Status is not (ConformanceStatus.Failed or ConformanceStatus.Errored)) + { + return Result(assertion, face, ConformanceStatus.Failed, Describe(ex)) with { Exception = ex }; + } +#pragma warning disable CA1031 // See ExecuteBodyAsync: a crash is a result. + catch (Exception ex) when (outcome.Status is not (ConformanceStatus.Failed or ConformanceStatus.Errored)) +#pragma warning restore CA1031 + { + return Result(assertion, face, ConformanceStatus.Errored, $"disposing the run threw {ex.GetType().Name}: {ex.Message}") with { Exception = ex }; + } +#pragma warning disable CA1031 // The outcome is already a failure or an error; this one is dropped in its favour. + catch (Exception) +#pragma warning restore CA1031 + { + return outcome; + } + } + + private static async Task DisposeQuietlyAsync(IAsyncDisposable context) + { + try + { + await context.DisposeAsync().ConfigureAwait(false); + } +#pragma warning disable CA1031 // The run is being abandoned for the caller's cancellation; a disposal failure must not replace it. + catch (Exception) +#pragma warning restore CA1031 + { + } + } + + private static bool Covers(ConformanceWaiver waiver, ConformanceResult result) => + result.Assertion.RequirementIds.Contains(waiver.RequirementId, StringComparer.Ordinal) + && (waiver.Assertion is null || string.Equals(waiver.Assertion, result.Assertion.Name, StringComparison.Ordinal)) + && (waiver.Face is null || waiver.Face == result.Face); + + // A waiver aimed at an assertion that does not run is a typo that would otherwise silently waive nothing. + private static IEnumerable UnknownWaiverTargets(IReadOnlyList assertions, IReadOnlyList waivers) + { + var names = assertions.Select(a => a.Name).ToHashSet(StringComparer.Ordinal); + foreach (var waiver in waivers.Where(w => w.Assertion is not null && !names.Contains(w.Assertion))) + { + var synthetic = new ConformanceAssertion("waiver", [waiver.RequirementId], RequirementLevel.Must, [TransportFace.Async], static (_, _) => Task.CompletedTask); + yield return new ConformanceResult( + synthetic, + TransportFace.Async, + ConformanceStatus.Failed, + $"unknown waiver target '{Text.Truncate(waiver.Assertion!)}'") + { + Waiver = waiver, + }; + } + } + + private static string Describe(ConformanceException ex) => + ex.Expected is null && ex.Actual is null + ? ex.Message + : string.Create(CultureInfo.InvariantCulture, $"{ex.Message} (expected: {ex.Expected}; actual: {ex.Actual})"); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceAssertionTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceAssertionTests.cs index 5e0e920b..89ed8ff4 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceAssertionTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceAssertionTests.cs @@ -53,4 +53,33 @@ public void An_assertion_cannot_cite_a_requirement_the_catalogue_does_not_hold() { Assert.Throws(() => new ConformanceAssertion("a.b", ["TRANSPORT-99"], RequirementLevel.Must, [TransportFace.Async], (_, _) => Task.CompletedTask)); } + + [Fact] + public async Task A_generic_assertion_hands_its_body_the_context_of_its_own_suite() + { + var seen = new List(); + var assertion = ConformanceAssertion.Create>( + "xcut-1.generic", + ["XCUT-1"], + RequirementLevel.Must, + [TransportFace.None], + (context, _) => + { + context.Add("ran"); + return Task.CompletedTask; + }); + + await assertion.Body(seen, TestContext.Current.CancellationToken); + + Assert.Equal(["ran"], seen); + Assert.Equal([TransportFace.None], assertion.Faces); + } + + [Fact] + public void A_generic_assertion_needs_a_body_and_applies_the_same_checks() + { + Assert.Throws(() => ConformanceAssertion.Create("a.b", ["XCUT-1"], RequirementLevel.Must, [TransportFace.None], null!)); + Assert.Throws(() => ConformanceAssertion.Create("a.b", ["XCUT-99"], RequirementLevel.Must, [TransportFace.None], (_, _) => Task.CompletedTask)); + Assert.Throws(() => ConformanceAssertion.Create("a.b", ["XCUT-1"], RequirementLevel.Must, [], (_, _) => Task.CompletedTask)); + } } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Suite/GenericRunnerTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Suite/GenericRunnerTests.cs new file mode 100644 index 00000000..3f3b0c1e --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Suite/GenericRunnerTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Suite; + +/// +/// Plan 1.1 (P10-7, R1): the six statuses, the bound and the waiver rule come out of the generic core, so the invariant, codec +/// and packaging suites mean the same by a status as the transport suite does. The context here is a trivial one. +/// +[Trait("Category", "Unit")] +public sealed class GenericRunnerTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static ConformanceAssertion Asserting(Func body, TransportFace[]? faces = null, string name = "xcut-1.probe", string id = "XCUT-1") => + ConformanceAssertion.Create(name, [id], RequirementLevel.Must, faces ?? [TransportFace.None], body); + + private static Task RunAsync(ConformanceAssertion assertion, TransportFace face = TransportFace.None, TimeSpan? timeout = null, IReadOnlyList? waivers = null, Probe? probe = null) => + SuiteRunnerCore.RunAsync(assertion, face, () => probe ?? new Probe(), timeout ?? TimeSpan.FromSeconds(10), waivers ?? [], Ct); + + [Fact] + public async Task A_none_face_assertion_runs_without_a_factory() + { + var result = await RunAsync(Asserting((_, _) => Task.CompletedTask)); + + Assert.Equal(ConformanceStatus.Passed, result.Status); + Assert.Equal(TransportFace.None, result.Face); + } + + [Fact] + public async Task The_body_is_given_the_context_the_runner_built() + { + await using var probe = new Probe(); + ConformanceAssertion assertion = Asserting((context, _) => + { + context.Seen = true; + return Task.CompletedTask; + }); + + await RunAsync(assertion, probe: probe); + + Assert.True(probe.Seen); + Assert.Equal(1, probe.Disposed); + } + + [Fact] + public async Task The_six_statuses_come_out_of_the_generic_core() + { + var failed = await RunAsync(Asserting((_, _) => throw new ConformanceException("broken", "a", "b"))); + var vacuous = await RunAsync(Asserting((_, _) => throw new ConformanceVacuousException("no antecedent"))); + var notExercised = await RunAsync(Asserting((_, _) => throw new NotExercisedException("needs the hook"))); + var errored = await RunAsync(Asserting((_, _) => throw new InvalidOperationException("kit bug"))); + var passed = await RunAsync(Asserting((_, _) => Task.CompletedTask)); + + Assert.Equal(ConformanceStatus.Failed, failed.Status); + Assert.Contains("expected: a; actual: b", failed.Detail, StringComparison.Ordinal); + Assert.Equal(ConformanceStatus.Vacuous, vacuous.Status); + Assert.Equal(ConformanceStatus.NotExercised, notExercised.Status); + Assert.Equal("needs the hook", notExercised.Detail); + Assert.Equal(ConformanceStatus.Errored, errored.Status); + Assert.Equal(ConformanceStatus.Passed, passed.Status); + } + + [Fact] + public async Task A_body_exceeding_the_assertion_timeout_is_Failed_naming_the_bound_and_the_assertion() + { + var never = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var result = await RunAsync(Asserting((_, _) => never.Task, name: "xcut-1.hangs"), timeout: TimeSpan.FromMilliseconds(200)); + + Assert.Equal(ConformanceStatus.Failed, result.Status); + Assert.Contains("200 ms", result.Detail, StringComparison.Ordinal); + Assert.Contains("xcut-1.hangs", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public async Task A_waiver_turns_failed_into_waived_and_a_passing_covered_assertion_into_a_stale_waiver_failure() + { + var waiver = new ConformanceWaiver("XCUT-1", "known gap") { Face = TransportFace.None }; + + var waived = await RunAsync(Asserting((_, _) => throw new ConformanceException("x")), waivers: [waiver]); + var stale = await RunAsync(Asserting((_, _) => Task.CompletedTask), waivers: [waiver]); + + Assert.Equal(ConformanceStatus.Waived, waived.Status); + Assert.Same(waiver, waived.Waiver); + Assert.Equal(ConformanceStatus.Failed, stale.Status); + Assert.Contains("no longer needed", stale.Detail, StringComparison.Ordinal); + } + + [Fact] + public async Task An_unknown_waiver_target_is_a_failed_synthetic_row() + { + var waiver = new ConformanceWaiver("XCUT-1", "typo") { Assertion = "xcut-1.does-not-exist" }; + var assertion = Asserting((_, _) => Task.CompletedTask); + var identity = new SuiteIdentity("invariant", "preamble"); + + var report = await SuiteRunnerCore.RunAllAsync( + "subject", + identity, + [assertion], + (a, f) => RunAsync(a, f), + [waiver]); + + Assert.False(report.IsGreen); + Assert.Contains(report.Results, r => r.Status == ConformanceStatus.Failed && r.Detail.Contains("unknown waiver target", StringComparison.Ordinal)); + Assert.Equal("invariant", report.Suite); + Assert.Equal("preamble", report.PreambleText); + } + + [Fact] + public async Task The_generic_core_rejects_a_face_the_assertion_does_not_declare() + { + var assertion = Asserting((_, _) => Task.CompletedTask, faces: [TransportFace.Async]); + + await Assert.ThrowsAsync(() => RunAsync(assertion, TransportFace.None)); + } + + [Fact] + public async Task The_outer_token_cancelled_abandons_the_run_with_cancellation_and_still_disposes() + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(Ct); + await using var probe = new Probe(); + var assertion = Asserting(async (_, token) => + { + await cts.CancelAsync(); + await Task.Delay(Timeout.Infinite, token); + }); + + await Assert.ThrowsAnyAsync(() => + SuiteRunnerCore.RunAsync(assertion, TransportFace.None, () => probe, TimeSpan.FromSeconds(10), [], cts.Token)); + + Assert.Equal(1, probe.Disposed); + } + + [Fact] + public void TransportFace_has_three_members_with_stable_values() + { + // A pin: the enum is part of the kit's public surface, and None was added by phase 10 (P10-7). + Assert.Equal(0, (int)TransportFace.Async); + Assert.Equal(1, (int)TransportFace.Blocking); + Assert.Equal(2, (int)TransportFace.None); + Assert.Equal(3, Enum.GetValues().Length); + } + + private sealed class Probe : IAsyncDisposable + { + public bool Seen { get; set; } + + public int Disposed { get; private set; } + + public ValueTask DisposeAsync() + { + Disposed++; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Suite/SuiteRunnerTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Suite/SuiteRunnerTests.cs index 4c36d442..8f065bed 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Suite/SuiteRunnerTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Suite/SuiteRunnerTests.cs @@ -382,4 +382,15 @@ private sealed record LoopbackServerProbe(int Port) internal static Task UseAsync(SuiteContext ctx, string text) => Task.FromResult(new LoopbackServerProbe(ctx.StartServer(Dexpace.Sdk.Conformance.Wire.LoopbackResponse.Ok(text)).BaseUri.Port)); } + + [Fact] + public async Task The_transport_runner_rejects_the_None_face() + { + // Phase 10 (P10-7, plan 1.1): None is the face of an assertion that judges a value; every transport assertion drives a call surface. + var assertion = Asserting((_, _) => Task.CompletedTask, faces: [TransportFace.None]); + + var failure = await Assert.ThrowsAsync(() => RunAsync(new FakeSubject().Subject(), assertion, face: TransportFace.None)); + + Assert.Contains("None face", failure.Message, StringComparison.Ordinal); + } } From a546154dcface32d70aa8791cecf3f269bd2eddc Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 20:37:39 +0300 Subject: [PATCH 02/45] feat: SuiteOptions, per-suite report preambles and ConformanceReport.Combine (phase 10, #99) --- .../PublicAPI.Unshipped.txt | 13 +++ .../Subjects/SuiteOptions.cs | 39 ++++++++ .../ConformanceReportCombineTests.cs | 89 +++++++++++++++++++ .../Subjects/SuiteOptionsTests.cs | 38 ++++++++ 4 files changed, 179 insertions(+) create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/SuiteOptions.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceReportCombineTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Subjects/SuiteOptionsTests.cs diff --git a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt index 79b48d8c..dc41901f 100644 --- a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt @@ -76,6 +76,14 @@ Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.May = 2 -> Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.Must = 0 -> Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.Should = 1 -> Dexpace.Sdk.Conformance.RequirementLevel +Dexpace.Sdk.Conformance.SuiteOptions +Dexpace.Sdk.Conformance.SuiteOptions.$() -> Dexpace.Sdk.Conformance.SuiteOptions! +Dexpace.Sdk.Conformance.SuiteOptions.AssertionTimeout.get -> System.TimeSpan +Dexpace.Sdk.Conformance.SuiteOptions.AssertionTimeout.init -> void +Dexpace.Sdk.Conformance.SuiteOptions.Equals(Dexpace.Sdk.Conformance.SuiteOptions? other) -> bool +Dexpace.Sdk.Conformance.SuiteOptions.SuiteOptions() -> void +Dexpace.Sdk.Conformance.SuiteOptions.Waivers.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Conformance.SuiteOptions.Waivers.init -> void Dexpace.Sdk.Conformance.TransportFace Dexpace.Sdk.Conformance.TransportFace.Async = 0 -> Dexpace.Sdk.Conformance.TransportFace Dexpace.Sdk.Conformance.TransportFace.Blocking = 1 -> Dexpace.Sdk.Conformance.TransportFace @@ -156,6 +164,9 @@ override Dexpace.Sdk.Conformance.ConformanceResult.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceWaiver.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.ConformanceWaiver.GetHashCode() -> int override Dexpace.Sdk.Conformance.ConformanceWaiver.ToString() -> string! +override Dexpace.Sdk.Conformance.SuiteOptions.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.SuiteOptions.GetHashCode() -> int +override Dexpace.Sdk.Conformance.SuiteOptions.ToString() -> string! override Dexpace.Sdk.Conformance.TransportSettings.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.TransportSettings.GetHashCode() -> int override Dexpace.Sdk.Conformance.TransportSettings.ToString() -> string! @@ -166,6 +177,8 @@ static Dexpace.Sdk.Conformance.ConformanceResult.operator !=(Dexpace.Sdk.Conform static Dexpace.Sdk.Conformance.ConformanceResult.operator ==(Dexpace.Sdk.Conformance.ConformanceResult? left, Dexpace.Sdk.Conformance.ConformanceResult? right) -> bool static Dexpace.Sdk.Conformance.ConformanceWaiver.operator !=(Dexpace.Sdk.Conformance.ConformanceWaiver? left, Dexpace.Sdk.Conformance.ConformanceWaiver? right) -> bool static Dexpace.Sdk.Conformance.ConformanceWaiver.operator ==(Dexpace.Sdk.Conformance.ConformanceWaiver? left, Dexpace.Sdk.Conformance.ConformanceWaiver? right) -> bool +static Dexpace.Sdk.Conformance.SuiteOptions.operator !=(Dexpace.Sdk.Conformance.SuiteOptions? left, Dexpace.Sdk.Conformance.SuiteOptions? right) -> bool +static Dexpace.Sdk.Conformance.SuiteOptions.operator ==(Dexpace.Sdk.Conformance.SuiteOptions? left, Dexpace.Sdk.Conformance.SuiteOptions? right) -> bool static Dexpace.Sdk.Conformance.TransportSettings.operator !=(Dexpace.Sdk.Conformance.TransportSettings? left, Dexpace.Sdk.Conformance.TransportSettings? right) -> bool static Dexpace.Sdk.Conformance.TransportSettings.operator ==(Dexpace.Sdk.Conformance.TransportSettings? left, Dexpace.Sdk.Conformance.TransportSettings? right) -> bool static Dexpace.Sdk.Conformance.TransportSuite.Assertions.get -> System.Collections.Generic.IReadOnlyList! diff --git a/src/Dexpace.Sdk.Conformance/Subjects/SuiteOptions.cs b/src/Dexpace.Sdk.Conformance/Subjects/SuiteOptions.cs new file mode 100644 index 00000000..a5ae3419 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/SuiteOptions.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// How a run of the invariant, codec or packaging suite is bounded and which gaps it accepts (P10-7). The transport suite +/// keeps , which adds the release bound only a socket-driving suite needs. +/// +public sealed record SuiteOptions +{ + /// + /// The waivers in force: each turns a failing assertion into , and each must stay + /// needed, because a waived assertion that passes fails the run. Never ; empty by default. + /// + public IReadOnlyList Waivers + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = [.. value]; + } + } = []; + + /// + /// The longest one assertion may run; a hang is a result naming the bound, never a + /// hung run. Positive; 30 seconds by default. + /// + public TimeSpan AssertionTimeout + { + get; + init + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(value, TimeSpan.Zero); + field = value; + } + } = TimeSpan.FromSeconds(30); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceReportCombineTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceReportCombineTests.cs new file mode 100644 index 00000000..102fca2e --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Reporting/ConformanceReportCombineTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Support; +using Xunit; +using static Dexpace.Sdk.Conformance.Tests.Support.TestAssertions; + +namespace Dexpace.Sdk.Conformance.Tests.Reporting; + +/// Plan 1.2 (P10-6, P10-7): merging the reports of several suites, and each suite's own preamble. +[Trait("Category", "Unit")] +public sealed class ConformanceReportCombineTests +{ + private static ConformanceReport Report(string suite, params ConformanceResult[] results) => + ConformanceReport.Create("subject", results, [], new SuiteIdentity(suite, suite + " preamble")); + + [Fact] + public void Combine_concatenates_results_and_takes_the_worst_status_per_requirement() + { + var transport = Report("transport", Result(Make("transport-3.a", ["XCUT-1"]), ConformanceStatus.Passed)); + var invariant = Report("invariant", Result(Make("xcut-1.a", ["XCUT-1"], faces: [TransportFace.None]), ConformanceStatus.Failed, face: TransportFace.None)); + + var combined = ConformanceReport.Combine("both", [transport, invariant]); + + Assert.Equal("both", combined.SubjectName); + Assert.Equal("combined", combined.Suite); + Assert.Equal(2, combined.Results.Count); + Assert.Equal(ConformanceStatus.Failed, combined.ByRequirement["XCUT-1"]); + Assert.False(combined.IsGreen); + } + + [Fact] + public void Combine_reads_across_suites_per_appendix_B_item() + { + var invariant = Report("invariant", Result(Make("xcut-1.a", ["XCUT-1"]), ConformanceStatus.Passed)); + var packaging = Report("packaging", Result(Make("nfr-1.a", ["NFR-1"]), ConformanceStatus.NotExercised)); + + var combined = ConformanceReport.Combine("both", [invariant, packaging]); + + Assert.True(combined.IsGreen); + Assert.Equal(ConformanceStatus.Passed, combined.ByRequirement["XCUT-1"]); + Assert.Equal(ConformanceStatus.NotExercised, combined.ByRequirement["NFR-1"]); + } + + [Fact] + public void The_combined_waiver_list_is_the_union() + { + var one = new ConformanceWaiver("XCUT-1", "a"); + var two = new ConformanceWaiver("XCUT-2", "b"); + var left = ConformanceReport.Create("s", [], [one], new SuiteIdentity("invariant", "p")); + var right = ConformanceReport.Create("s", [], [one, two], new SuiteIdentity("codec", "q")); + + var text = ConformanceReport.Combine("both", [left, right]).ToString(); + + Assert.Contains("XCUT-1", text, StringComparison.Ordinal); + Assert.Contains("XCUT-2", text, StringComparison.Ordinal); + Assert.Equal(1, text.Split("XCUT-1 [", StringSplitOptions.None).Length - 1); + } + + [Fact] + public void Combine_of_zero_reports_and_null_guards() + { + Assert.Throws(() => ConformanceReport.Combine("x", [])); + Assert.Throws(() => ConformanceReport.Combine(null!, [Report("transport")])); + Assert.Throws(() => ConformanceReport.Combine("x", null!)); + Assert.Throws(() => ConformanceReport.Combine("x", [Report("transport"), null!])); + } + + [Fact] + public void Each_suite_report_renders_its_own_preamble() + { + var transport = ConformanceReport.Create("subject", []); + var invariant = Report("invariant"); + + Assert.StartsWith(ConformanceReport.Preamble, transport.ToString(), StringComparison.Ordinal); + Assert.Equal("transport", transport.Suite); + Assert.StartsWith("invariant preamble", invariant.ToString(), StringComparison.Ordinal); + Assert.Equal("invariant", invariant.Suite); + Assert.Equal("invariant preamble", invariant.PreambleText); + } + + [Fact] + public void A_combined_report_carries_each_distinct_input_preamble_once() + { + var combined = ConformanceReport.Combine("both", [Report("invariant"), Report("codec"), Report("invariant")]); + + Assert.Equal("invariant preamble" + Environment.NewLine + Environment.NewLine + "codec preamble", combined.PreambleText); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Subjects/SuiteOptionsTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Subjects/SuiteOptionsTests.cs new file mode 100644 index 00000000..eb317742 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Subjects/SuiteOptionsTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Subjects; + +[Trait("Category", "Unit")] +public sealed class SuiteOptionsTests +{ + [Fact] + public void The_defaults_are_thirty_seconds_and_no_waivers() + { + var options = new SuiteOptions(); + + Assert.Equal(TimeSpan.FromSeconds(30), options.AssertionTimeout); + Assert.Empty(options.Waivers); + } + + [Fact] + public void A_non_positive_assertion_timeout_is_rejected() + { + Assert.Throws(() => new SuiteOptions { AssertionTimeout = TimeSpan.Zero }); + Assert.Throws(() => new SuiteOptions { AssertionTimeout = TimeSpan.FromSeconds(-1) }); + } + + [Fact] + public void The_waivers_are_copied_and_null_is_rejected() + { + var waivers = new List { new("XCUT-1", "r") }; + var options = new SuiteOptions { Waivers = waivers }; + + waivers.Clear(); + + Assert.Single(options.Waivers); + Assert.Throws(() => new SuiteOptions { Waivers = null! }); + } +} From c9c8d3f264889a692a9fb7f1dee00c21dd847c51 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 20:44:35 +0300 Subject: [PATCH 03/45] feat: invariant suite skeleton and InvariantSubject (phase 10, #99) --- .../Assertions/InvariantAssertionBuilder.cs | 28 +++ .../Assertions/InvariantCatalogue.cs | 14 ++ .../Internals/ProbeTransport.cs | 129 +++++++++++ .../Internals/RecordingTimeProvider.cs | 81 +++++++ .../PublicAPI.Unshipped.txt | 117 ++++++++++ .../Subjects/AuthPolicyFactories.cs | 22 ++ .../Subjects/BoundedMapProbe.cs | 36 +++ .../Subjects/CredentialSample.cs | 13 ++ .../Subjects/InvariantSubject.cs | 211 ++++++++++++++++++ .../Subjects/ModelFactories.cs | 64 ++++++ .../Subjects/SeamResolution.cs | 13 ++ .../Suite/InvariantContext.cs | 105 +++++++++ .../Suite/InvariantIdentity.cs | 19 ++ .../Suite/InvariantSuite.cs | 86 +++++++ .../Assertions/AssertionCatalogueTests.cs | 11 +- .../Assertions/InvariantCatalogueTests.cs | 102 +++++++++ .../Assertions/KitCatalogues.cs | 18 ++ .../Drivers/InvariantControlRow.cs | 78 +++++++ .../Internals/RecordingTimeProviderTests.cs | 68 ++++++ .../Suite/InvariantSuiteTests.cs | 79 +++++++ 20 files changed, 1293 insertions(+), 1 deletion(-) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/InvariantAssertionBuilder.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/BoundedMapProbe.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/CredentialSample.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/SeamResolution.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/InvariantIdentity.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/InvariantSuite.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Internals/RecordingTimeProviderTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantAssertionBuilder.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantAssertionBuilder.cs new file mode 100644 index 00000000..d08decb8 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantAssertionBuilder.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The one place an invariant assertion is declared: its stable name, the requirements it is evidence for, the strength of its clause, its faces and its body. +internal static class InvariantAssertionBuilder +{ + /// Both call surfaces of the pipeline. + internal static TransportFace[] Both { get; } = [TransportFace.Async, TransportFace.Blocking]; + + /// No call surface: the assertion judges a value. + internal static TransportFace[] NoFace { get; } = [TransportFace.None]; + + /// Declares an assertion. + /// The stable public name, xcut-1.cancel-is-terminal. + /// The requirement IDs it is evidence for. + /// The strength of the clause it checks; never above the strongest of . + /// The faces it runs on. + /// The check; throws on a broken clause. + internal static ConformanceAssertion Define( + string name, + string[] ids, + RequirementLevel level, + TransportFace[] faces, + Func body) => + ConformanceAssertion.Create(name, ids, level, faces, body); +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs new file mode 100644 index 00000000..840867d1 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs @@ -0,0 +1,14 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// The invariant assertion catalogue, in the order of the design's table (phase 10, section C): each group contributes its +/// assertions in declaration order. +/// +internal static class InvariantCatalogue +{ + /// Every assertion the invariant suite runs. + internal static IReadOnlyList All { get; } = []; +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs b/src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs new file mode 100644 index 00000000..b835eb1c --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Conformance; + +/// +/// An in-memory transport on both faces for the invariant suite: a pipeline under test sends into it, and its +/// decides each send (numbered from 1) with the call's own token. It counts the sends, records the +/// requests and counts its own disposals, so an assertion can prove what reached the transport and that the SDK never closed +/// what it did not create (XCUT-22). It speaks no socket and reads no clock. +/// +internal sealed class ProbeTransport(ProbeTransport.Behavior behavior) : IAsyncHttpClient, IHttpClient +{ + private readonly object _lock = new(); + private readonly List _requests = []; + private int _sends; + private int _disposals; + + /// What the transport does with one send, on each face. + /// The asynchronous face. + /// The blocking face. + internal sealed record Behavior( + Func> Async, + Func Blocking) + { + /// Both faces answer by calling , which may throw. + /// Builds the response of one send from the request and the 1-based send number. + internal static Behavior Of(Func respond) => + new( + (request, send, _) => Task.FromResult(respond(request, send)), + (request, send, _) => respond(request, send)); + + /// Answers every send with a fresh body-less response of status . + /// The status code. + internal static Behavior Status(int code) => Of((request, _) => Empty(request, code)); + + /// + /// Blocks until the call's token fires and then throws carrying it: a transport + /// that honours cancellation and has nothing to answer with. is released once the send is in flight. + /// + /// Completed when a send has begun, so the caller can cancel knowing a send is parked. + internal static Behavior HangUntilCancelled(TaskCompletionSource? signalSent = null) => + new( + async (_, _, token) => + { + signalSent?.TrySetResult(); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = token.Register(static state => ((TaskCompletionSource)state!).TrySetCanceled(), cancelled); + return await cancelled.Task.ConfigureAwait(false); + }, + (_, _, token) => + { + signalSent?.TrySetResult(); + token.WaitHandle.WaitOne(); + token.ThrowIfCancellationRequested(); + throw new InvalidOperationException("The hang ended without cancellation."); + }); + } + + /// The number of sends received on either face. + internal int Sends => Volatile.Read(ref _sends); + + /// How many times the transport was disposed. + internal int Disposals => Volatile.Read(ref _disposals); + + /// The requests received, in arrival order. + internal IReadOnlyList Requests + { + get + { + lock (_lock) + { + return [.. _requests]; + } + } + } + + /// A fresh body-less response of status to . + /// The request answered. + /// The status code. + /// Response headers, or none. + internal static Response Empty(Request request, int code, Headers? headers = null) => + new(request, Status.FromCode(code), Protocol.Http11, headers, null, null); + + /// + public Task ExecuteAsync(Request request, RequestOptions options, CancellationToken cancellationToken) + { + var send = Record(request); + try + { + return behavior.Async(request, send, cancellationToken); + } +#pragma warning disable CA1031 // A synchronous throw from the behaviour is the transport's failure, delivered through the task like any other. + catch (Exception ex) +#pragma warning restore CA1031 + { + return Task.FromException(ex); + } + } + + /// + public Response Execute(Request request, RequestOptions options, CancellationToken cancellationToken) => + behavior.Blocking(request, Record(request), cancellationToken); + + /// + public void Dispose() => Interlocked.Increment(ref _disposals); + + /// + public ValueTask DisposeAsync() + { + Interlocked.Increment(ref _disposals); + return ValueTask.CompletedTask; + } + + private int Record(Request request) + { + lock (_lock) + { + _requests.Add(request); + } + + return Interlocked.Increment(ref _sends); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs b/src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs new file mode 100644 index 00000000..c637b4be --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs @@ -0,0 +1,81 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// A over that counts the timers it creates and the timers that are +/// disposed, so an assertion can prove that a cancelled wait cancelled the timer it armed (XCUT-3) without sleeping on +/// the wall clock: the kit never calls Task.Delay or reads a wall clock (the kit's 8a rule). moves +/// the clock it reports forward without waiting. +/// +internal sealed class RecordingTimeProvider : TimeProvider +{ + private int _created; + private int _disposed; + private long _offsetTicks; + + /// The number of timers created through this provider. + internal int TimersCreated => Volatile.Read(ref _created); + + /// The number of those timers that have been disposed (each counted once). + internal int TimersDisposed => Volatile.Read(ref _disposed); + + /// The timers created and not yet disposed. + internal int TimersLive => TimersCreated - TimersDisposed; + + /// + public override long TimestampFrequency => System.TimestampFrequency; + + /// Moves the time this provider reports forward by ; timers still run on real time. + /// A non-negative amount. + internal void Advance(TimeSpan by) + { + ArgumentOutOfRangeException.ThrowIfLessThan(by, TimeSpan.Zero); + Interlocked.Add(ref _offsetTicks, by.Ticks); + } + + /// + public override DateTimeOffset GetUtcNow() => System.GetUtcNow() + TimeSpan.FromTicks(Interlocked.Read(ref _offsetTicks)); + + /// + public override long GetTimestamp() => + System.GetTimestamp() + (long)(Interlocked.Read(ref _offsetTicks) * ((double)TimestampFrequency / TimeSpan.TicksPerSecond)); + + /// + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var inner = System.CreateTimer(callback, state, dueTime, period); + Interlocked.Increment(ref _created); + return new CountingTimer(inner, this); + } + + private void TimerDisposed() => Interlocked.Increment(ref _disposed); + + private sealed class CountingTimer(ITimer inner, RecordingTimeProvider owner) : ITimer + { + private int _done; + + public bool Change(TimeSpan dueTime, TimeSpan period) => inner.Change(dueTime, period); + + public void Dispose() + { + inner.Dispose(); + Count(); + } + + public async ValueTask DisposeAsync() + { + await inner.DisposeAsync().ConfigureAwait(false); + Count(); + } + + private void Count() + { + if (Interlocked.Exchange(ref _done, 1) == 0) + { + owner.TimerDisposed(); + } + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt index dc41901f..fc0e473c 100644 --- a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt @@ -2,11 +2,24 @@ Dexpace.Sdk.Conformance.AfterDisposeBehavior Dexpace.Sdk.Conformance.AfterDisposeBehavior.ThrowsObjectDisposedException = 1 -> Dexpace.Sdk.Conformance.AfterDisposeBehavior Dexpace.Sdk.Conformance.AfterDisposeBehavior.Unspecified = 0 -> Dexpace.Sdk.Conformance.AfterDisposeBehavior +Dexpace.Sdk.Conformance.AuthPolicyFactories +Dexpace.Sdk.Conformance.AuthPolicyFactories.$() -> Dexpace.Sdk.Conformance.AuthPolicyFactories! +Dexpace.Sdk.Conformance.AuthPolicyFactories.AuthPolicyFactories() -> void +Dexpace.Sdk.Conformance.AuthPolicyFactories.Basic.get -> System.Func! +Dexpace.Sdk.Conformance.AuthPolicyFactories.Basic.init -> void +Dexpace.Sdk.Conformance.AuthPolicyFactories.Bearer.get -> System.Func! +Dexpace.Sdk.Conformance.AuthPolicyFactories.Bearer.init -> void +Dexpace.Sdk.Conformance.AuthPolicyFactories.Equals(Dexpace.Sdk.Conformance.AuthPolicyFactories? other) -> bool Dexpace.Sdk.Conformance.BorrowedTransport Dexpace.Sdk.Conformance.BorrowedTransport.BorrowedTransport(Dexpace.Sdk.Core.Client.IAsyncHttpClient! transport, System.Func! sendThroughNativeClient, System.IAsyncDisposable! nativeClientOwner) -> void Dexpace.Sdk.Conformance.BorrowedTransport.DisposeAsync() -> System.Threading.Tasks.ValueTask Dexpace.Sdk.Conformance.BorrowedTransport.SendThroughNativeClient.get -> System.Func! Dexpace.Sdk.Conformance.BorrowedTransport.Transport.get -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! +Dexpace.Sdk.Conformance.BoundedMapProbe +Dexpace.Sdk.Conformance.BoundedMapProbe.BoundedMapProbe(int capacity, System.Action! insert, System.Func! count) -> void +Dexpace.Sdk.Conformance.BoundedMapProbe.Capacity.get -> int +Dexpace.Sdk.Conformance.BoundedMapProbe.Count.get -> System.Func! +Dexpace.Sdk.Conformance.BoundedMapProbe.Insert.get -> System.Action! Dexpace.Sdk.Conformance.ConformanceAssertion Dexpace.Sdk.Conformance.ConformanceAssertion.Faces.get -> System.Collections.Generic.IReadOnlyList! Dexpace.Sdk.Conformance.ConformanceAssertion.Level.get -> Dexpace.Sdk.Conformance.RequirementLevel @@ -68,14 +81,89 @@ Dexpace.Sdk.Conformance.ConformanceWaiver.Owner.get -> string? Dexpace.Sdk.Conformance.ConformanceWaiver.Owner.init -> void Dexpace.Sdk.Conformance.ConformanceWaiver.Reason.get -> string! Dexpace.Sdk.Conformance.ConformanceWaiver.RequirementId.get -> string! +Dexpace.Sdk.Conformance.CredentialSample +Dexpace.Sdk.Conformance.CredentialSample.$() -> Dexpace.Sdk.Conformance.CredentialSample! +Dexpace.Sdk.Conformance.CredentialSample.CredentialSample(string! Kind, string! Secret, System.Func! Render) -> void +Dexpace.Sdk.Conformance.CredentialSample.Deconstruct(out string! Kind, out string! Secret, out System.Func! Render) -> void +Dexpace.Sdk.Conformance.CredentialSample.Equals(Dexpace.Sdk.Conformance.CredentialSample? other) -> bool +Dexpace.Sdk.Conformance.CredentialSample.Kind.get -> string! +Dexpace.Sdk.Conformance.CredentialSample.Kind.init -> void +Dexpace.Sdk.Conformance.CredentialSample.Render.get -> System.Func! +Dexpace.Sdk.Conformance.CredentialSample.Render.init -> void +Dexpace.Sdk.Conformance.CredentialSample.Secret.get -> string! +Dexpace.Sdk.Conformance.CredentialSample.Secret.init -> void Dexpace.Sdk.Conformance.InternalCancellation Dexpace.Sdk.Conformance.InternalCancellation.CancelInFlight.get -> System.Action! Dexpace.Sdk.Conformance.InternalCancellation.InternalCancellation(Dexpace.Sdk.Core.Client.IAsyncHttpClient! transport, System.Action! cancelInFlight) -> void Dexpace.Sdk.Conformance.InternalCancellation.Transport.get -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! +Dexpace.Sdk.Conformance.InvariantSubject +Dexpace.Sdk.Conformance.InvariantSubject.$() -> Dexpace.Sdk.Conformance.InvariantSubject! +Dexpace.Sdk.Conformance.InvariantSubject.Auth.get -> Dexpace.Sdk.Conformance.AuthPolicyFactories! +Dexpace.Sdk.Conformance.InvariantSubject.Auth.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.CreateBoundedMap.get -> System.Func? +Dexpace.Sdk.Conformance.InvariantSubject.CreateBoundedMap.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.CreateCyclicException.get -> System.Func? +Dexpace.Sdk.Conformance.InvariantSubject.CreateCyclicException.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.CreatePipeline.get -> System.Func! +Dexpace.Sdk.Conformance.InvariantSubject.CreatePipeline.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.CreateResponseException.get -> System.Func! +Dexpace.Sdk.Conformance.InvariantSubject.CreateResponseException.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.CreateTokenCache.get -> System.Func>!>! +Dexpace.Sdk.Conformance.InvariantSubject.CreateTokenCache.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.Credentials.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Conformance.InvariantSubject.Credentials.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.DrawCnonce.get -> System.Func? +Dexpace.Sdk.Conformance.InvariantSubject.DrawCnonce.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.EnumerateCauses.get -> System.Func!>! +Dexpace.Sdk.Conformance.InvariantSubject.EnumerateCauses.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.Equals(Dexpace.Sdk.Conformance.InvariantSubject? other) -> bool +Dexpace.Sdk.Conformance.InvariantSubject.InvariantSubject() -> void +Dexpace.Sdk.Conformance.InvariantSubject.MapStrict.get -> System.Func? +Dexpace.Sdk.Conformance.InvariantSubject.MapStrict.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.Models.get -> Dexpace.Sdk.Conformance.ModelFactories! +Dexpace.Sdk.Conformance.InvariantSubject.Models.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.Name.get -> string! +Dexpace.Sdk.Conformance.InvariantSubject.Name.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.RedactHeaderValue.get -> System.Func! +Dexpace.Sdk.Conformance.InvariantSubject.RedactHeaderValue.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.RedactUrl.get -> System.Func! +Dexpace.Sdk.Conformance.InvariantSubject.RedactUrl.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.SeamResolution.get -> Dexpace.Sdk.Conformance.SeamResolution? +Dexpace.Sdk.Conformance.InvariantSubject.SeamResolution.init -> void +Dexpace.Sdk.Conformance.InvariantSuite +Dexpace.Sdk.Conformance.ModelFactories +Dexpace.Sdk.Conformance.ModelFactories.$() -> Dexpace.Sdk.Conformance.ModelFactories! +Dexpace.Sdk.Conformance.ModelFactories.BytesBody.get -> System.Func!>! +Dexpace.Sdk.Conformance.ModelFactories.BytesBody.init -> void +Dexpace.Sdk.Conformance.ModelFactories.ChallengeParameters.get -> System.Func!, System.Collections.Generic.IReadOnlyDictionary!>! +Dexpace.Sdk.Conformance.ModelFactories.ChallengeParameters.init -> void +Dexpace.Sdk.Conformance.ModelFactories.Equals(Dexpace.Sdk.Conformance.ModelFactories? other) -> bool +Dexpace.Sdk.Conformance.ModelFactories.InboundHeader.get -> System.Func! +Dexpace.Sdk.Conformance.ModelFactories.InboundHeader.init -> void +Dexpace.Sdk.Conformance.ModelFactories.MediaTypeParameters.get -> System.Func!, System.Collections.Generic.IReadOnlyDictionary!>! +Dexpace.Sdk.Conformance.ModelFactories.MediaTypeParameters.init -> void +Dexpace.Sdk.Conformance.ModelFactories.ModelFactories() -> void +Dexpace.Sdk.Conformance.ModelFactories.OutboundHeader.get -> System.Func! +Dexpace.Sdk.Conformance.ModelFactories.OutboundHeader.init -> void +Dexpace.Sdk.Conformance.ModelFactories.RequestWithHeader.get -> System.Func! +Dexpace.Sdk.Conformance.ModelFactories.RequestWithHeader.init -> void +Dexpace.Sdk.Conformance.ModelFactories.WithHeader.get -> System.Func! +Dexpace.Sdk.Conformance.ModelFactories.WithHeader.init -> void Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.May = 2 -> Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.Must = 0 -> Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.Should = 1 -> Dexpace.Sdk.Conformance.RequirementLevel +Dexpace.Sdk.Conformance.SeamResolution +Dexpace.Sdk.Conformance.SeamResolution.$() -> Dexpace.Sdk.Conformance.SeamResolution! +Dexpace.Sdk.Conformance.SeamResolution.Deconstruct(out System.Action! ResolveWithNone, out System.Action! ResolveWithTwo, out System.Func! ExplicitWins) -> void +Dexpace.Sdk.Conformance.SeamResolution.Equals(Dexpace.Sdk.Conformance.SeamResolution? other) -> bool +Dexpace.Sdk.Conformance.SeamResolution.ExplicitWins.get -> System.Func! +Dexpace.Sdk.Conformance.SeamResolution.ExplicitWins.init -> void +Dexpace.Sdk.Conformance.SeamResolution.ResolveWithNone.get -> System.Action! +Dexpace.Sdk.Conformance.SeamResolution.ResolveWithNone.init -> void +Dexpace.Sdk.Conformance.SeamResolution.ResolveWithTwo.get -> System.Action! +Dexpace.Sdk.Conformance.SeamResolution.ResolveWithTwo.init -> void +Dexpace.Sdk.Conformance.SeamResolution.SeamResolution(System.Action! ResolveWithNone, System.Action! ResolveWithTwo, System.Func! ExplicitWins) -> void Dexpace.Sdk.Conformance.SuiteOptions Dexpace.Sdk.Conformance.SuiteOptions.$() -> Dexpace.Sdk.Conformance.SuiteOptions! Dexpace.Sdk.Conformance.SuiteOptions.AssertionTimeout.get -> System.TimeSpan @@ -156,6 +244,9 @@ Dexpace.Sdk.Conformance.Wire.RecordedRequest.RawText.get -> string! Dexpace.Sdk.Conformance.Wire.RecordedRequest.RequestLine.get -> string! Dexpace.Sdk.Conformance.Wire.RecordedRequest.Target.get -> string! Dexpace.Sdk.Conformance.Wire.RecordedRequest.Version.get -> string! +override Dexpace.Sdk.Conformance.AuthPolicyFactories.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.AuthPolicyFactories.GetHashCode() -> int +override Dexpace.Sdk.Conformance.AuthPolicyFactories.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceAssertion.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceReport.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceResult.Equals(object? obj) -> bool @@ -164,12 +255,26 @@ override Dexpace.Sdk.Conformance.ConformanceResult.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceWaiver.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.ConformanceWaiver.GetHashCode() -> int override Dexpace.Sdk.Conformance.ConformanceWaiver.ToString() -> string! +override Dexpace.Sdk.Conformance.CredentialSample.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.CredentialSample.GetHashCode() -> int +override Dexpace.Sdk.Conformance.CredentialSample.ToString() -> string! +override Dexpace.Sdk.Conformance.InvariantSubject.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.InvariantSubject.GetHashCode() -> int +override Dexpace.Sdk.Conformance.InvariantSubject.ToString() -> string! +override Dexpace.Sdk.Conformance.ModelFactories.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.ModelFactories.GetHashCode() -> int +override Dexpace.Sdk.Conformance.ModelFactories.ToString() -> string! +override Dexpace.Sdk.Conformance.SeamResolution.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.SeamResolution.GetHashCode() -> int +override Dexpace.Sdk.Conformance.SeamResolution.ToString() -> string! override Dexpace.Sdk.Conformance.SuiteOptions.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.SuiteOptions.GetHashCode() -> int override Dexpace.Sdk.Conformance.SuiteOptions.ToString() -> string! override Dexpace.Sdk.Conformance.TransportSettings.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.TransportSettings.GetHashCode() -> int override Dexpace.Sdk.Conformance.TransportSettings.ToString() -> string! +static Dexpace.Sdk.Conformance.AuthPolicyFactories.operator !=(Dexpace.Sdk.Conformance.AuthPolicyFactories? left, Dexpace.Sdk.Conformance.AuthPolicyFactories? right) -> bool +static Dexpace.Sdk.Conformance.AuthPolicyFactories.operator ==(Dexpace.Sdk.Conformance.AuthPolicyFactories? left, Dexpace.Sdk.Conformance.AuthPolicyFactories? right) -> bool static Dexpace.Sdk.Conformance.ConformanceReport.Combine(string! subjectName, System.Collections.Generic.IEnumerable! reports) -> Dexpace.Sdk.Conformance.ConformanceReport! static Dexpace.Sdk.Conformance.ConformanceReport.Create(string! subjectName, System.Collections.Generic.IEnumerable! results) -> Dexpace.Sdk.Conformance.ConformanceReport! static Dexpace.Sdk.Conformance.ConformanceReport.Preamble.get -> string! @@ -177,6 +282,18 @@ static Dexpace.Sdk.Conformance.ConformanceResult.operator !=(Dexpace.Sdk.Conform static Dexpace.Sdk.Conformance.ConformanceResult.operator ==(Dexpace.Sdk.Conformance.ConformanceResult? left, Dexpace.Sdk.Conformance.ConformanceResult? right) -> bool static Dexpace.Sdk.Conformance.ConformanceWaiver.operator !=(Dexpace.Sdk.Conformance.ConformanceWaiver? left, Dexpace.Sdk.Conformance.ConformanceWaiver? right) -> bool static Dexpace.Sdk.Conformance.ConformanceWaiver.operator ==(Dexpace.Sdk.Conformance.ConformanceWaiver? left, Dexpace.Sdk.Conformance.ConformanceWaiver? right) -> bool +static Dexpace.Sdk.Conformance.CredentialSample.operator !=(Dexpace.Sdk.Conformance.CredentialSample? left, Dexpace.Sdk.Conformance.CredentialSample? right) -> bool +static Dexpace.Sdk.Conformance.CredentialSample.operator ==(Dexpace.Sdk.Conformance.CredentialSample? left, Dexpace.Sdk.Conformance.CredentialSample? right) -> bool +static Dexpace.Sdk.Conformance.InvariantSubject.operator !=(Dexpace.Sdk.Conformance.InvariantSubject? left, Dexpace.Sdk.Conformance.InvariantSubject? right) -> bool +static Dexpace.Sdk.Conformance.InvariantSubject.operator ==(Dexpace.Sdk.Conformance.InvariantSubject? left, Dexpace.Sdk.Conformance.InvariantSubject? right) -> bool +static Dexpace.Sdk.Conformance.InvariantSuite.Assertions.get -> System.Collections.Generic.IReadOnlyList! +static Dexpace.Sdk.Conformance.InvariantSuite.Preamble.get -> string! +static Dexpace.Sdk.Conformance.InvariantSuite.RunAllAsync(Dexpace.Sdk.Conformance.InvariantSubject! subject, Dexpace.Sdk.Conformance.SuiteOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Dexpace.Sdk.Conformance.InvariantSuite.RunAsync(Dexpace.Sdk.Conformance.InvariantSubject! subject, Dexpace.Sdk.Conformance.ConformanceAssertion! assertion, Dexpace.Sdk.Conformance.TransportFace face, Dexpace.Sdk.Conformance.SuiteOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Dexpace.Sdk.Conformance.ModelFactories.operator !=(Dexpace.Sdk.Conformance.ModelFactories? left, Dexpace.Sdk.Conformance.ModelFactories? right) -> bool +static Dexpace.Sdk.Conformance.ModelFactories.operator ==(Dexpace.Sdk.Conformance.ModelFactories? left, Dexpace.Sdk.Conformance.ModelFactories? right) -> bool +static Dexpace.Sdk.Conformance.SeamResolution.operator !=(Dexpace.Sdk.Conformance.SeamResolution? left, Dexpace.Sdk.Conformance.SeamResolution? right) -> bool +static Dexpace.Sdk.Conformance.SeamResolution.operator ==(Dexpace.Sdk.Conformance.SeamResolution? left, Dexpace.Sdk.Conformance.SeamResolution? right) -> bool static Dexpace.Sdk.Conformance.SuiteOptions.operator !=(Dexpace.Sdk.Conformance.SuiteOptions? left, Dexpace.Sdk.Conformance.SuiteOptions? right) -> bool static Dexpace.Sdk.Conformance.SuiteOptions.operator ==(Dexpace.Sdk.Conformance.SuiteOptions? left, Dexpace.Sdk.Conformance.SuiteOptions? right) -> bool static Dexpace.Sdk.Conformance.TransportSettings.operator !=(Dexpace.Sdk.Conformance.TransportSettings? left, Dexpace.Sdk.Conformance.TransportSettings? right) -> bool diff --git a/src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs b/src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs new file mode 100644 index 00000000..9de92ca1 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; + +namespace Dexpace.Sdk.Conformance; + +/// +/// The credential-stamping policies the invariant suite drives for XCUT-16 and XCUT-17: one that stamps a +/// credential it already holds and one that fetches a token first. A negative control replaces one with a policy that stamps (or +/// fetches) before it checks the scheme. +/// +public sealed record AuthPolicyFactories +{ + /// Builds a policy that stamps the Basic credential (username, password). Default: . + public Func Basic { get; init; } = static (user, password) => new BasicAuthPolicy(new BasicCredential(user, password)); + + /// Builds a policy that fetches a bearer token from and stamps it. Default: . + public Func Bearer { get; init; } = static credential => new BearerTokenAuthPolicy(credential, "scope"); +} diff --git a/src/Dexpace.Sdk.Conformance/Subjects/BoundedMapProbe.cs b/src/Dexpace.Sdk.Conformance/Subjects/BoundedMapProbe.cs new file mode 100644 index 00000000..916ae02e --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/BoundedMapProbe.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// The portable face of a bounded map for XCUT-14: what a driver hands the invariant suite instead of naming an internal +/// type. The suite inserts distinct keys through and reads the map's size through . +/// +public sealed class BoundedMapProbe +{ + /// Creates a probe. + /// The hard cap the map was built with; positive. + /// Inserts a key (a new entry for a key the map has not seen); must be safe to call from many threads. + /// The number of entries the map holds now; must be safe to call from many threads. + /// or is null. + /// is not positive. + public BoundedMapProbe(int capacity, Action insert, Func count) + { + ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); + ArgumentNullException.ThrowIfNull(insert); + ArgumentNullException.ThrowIfNull(count); + Capacity = capacity; + Insert = insert; + Count = count; + } + + /// The hard cap the map was built with. + public int Capacity { get; } + + /// Inserts a key. + public Action Insert { get; } + + /// The number of entries the map holds now. + public Func Count { get; } +} diff --git a/src/Dexpace.Sdk.Conformance/Subjects/CredentialSample.cs b/src/Dexpace.Sdk.Conformance/Subjects/CredentialSample.cs new file mode 100644 index 00000000..34ccc9f7 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/CredentialSample.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// A credential object and the secret inside it, for XCUT-19(d): the suite renders the credential the way a log line or +/// a serializer would and requires that the secret does not appear. +/// +/// What the credential is, named in a failure, for example BasicCredential. +/// The secret the credential holds; at least eight characters, so a hit is not an accident. +/// Renders the credential as a log line or a string conversion would. +public sealed record CredentialSample(string Kind, string Secret, Func Render); diff --git a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs new file mode 100644 index 00000000..c9c50ea8 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs @@ -0,0 +1,211 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Diagnostics; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Microsoft.Extensions.Logging; + +namespace Dexpace.Sdk.Conformance; + +/// +/// The composition the invariant suite judges (design section C, P10-4): a set of hooks, each defaulting to core's own public +/// component, so the suite is portable (a third party that composes its own policies, credentials or codec runs the same +/// assertions over its composition) and every assertion can have a negative control (a broken member in place of the default). +/// +/// +/// +/// Four hooks have no default, because the thing they reach is internal to core or lives in another package: +/// (XCUT-8), (XCUT-9), +/// (XCUT-14) and (XCUT-23). An assertion whose hook is is +/// with a reason naming the hook, never passed: a driver that does not supply a hook +/// must pin the assertion in its expected-NotExercised list (P10-28). +/// +/// +/// is optional and is not one of the four: means the suite draws through the public +/// DigestChallengeHandler.Authorize, so xcut-21 is always exercised. It judges the draw's width and that it does not +/// repeat; the operating-system CSPRNG source clause is cited by reference, because no output test can tell a CSPRNG from +/// . +/// +/// +public sealed record InvariantSubject +{ + private static readonly UrlRedactor s_redactor = new(); + + /// The name the report carries. core by default. + public string Name + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } = "core"; + + /// + /// Builds the pipeline under test over a transport, with the call-level options, the suite's recording time provider (every + /// wait, timer and attempt deadline must run on it) and the suite's recording logger. Default: the idempotency, client-identity + /// and set-date policies plus over the transport, capturing the options. + /// + public Func CreatePipeline + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static (transport, options, time, logger) => new PipelineBuilder() + .Add(new IdempotencyPolicy()) + .Add(new ClientIdentityPolicy()) + .Add(new SetDatePolicy(time)) + .AddStandardResilience(time, logger) + .Build(transport, options); + + /// Builds the exception for a received error response (XCUT-5). Default: the public constructor. + public Func CreateResponseException + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static response => new HttpResponseException(response); + + /// Walks an exception's cause chain (XCUT-9). Default: . + public Func> EnumerateCauses + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = ExceptionFacts.EnumerateCauses; + + /// + /// Builds a token cache over a credential and returns its read: given a request context, the token, fetching through the + /// credential at most once per expiry window (XCUT-12). Default: . + /// + public Func>> CreateTokenCache + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static (credential, time) => + { + var cache = new AccessTokenCache(credential, time); + return (context, token) => cache.GetAsync(context, token); + }; + + /// Redacts a URL for a log line or a span (XCUT-19(a)(b)). Default: . + public Func RedactUrl + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static url => s_redactor.Redact(url); + + /// Redacts a URL-valued header value such as Location (XCUT-19). Default: . + public Func RedactHeaderValue + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static value => s_redactor.RedactHeaderValue(value); + + /// The model factories XCUT-15 and XCUT-18 judge. Default: core's public factories. + public ModelFactories Models + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = new(); + + /// The credential-stamping policies XCUT-16 and XCUT-17 drive. Default: and . + public AuthPolicyFactories Auth + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = new(); + + /// The credential objects XCUT-19(d) renders. Default: core's Basic, API-key and Digest credentials and an access token. + public IReadOnlyList Credentials + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = [.. value]; + } + } = DefaultCredentials(); + + /// + /// No default. The strict status-to-exception factory (XCUT-8): must throw for a + /// non-error status. makes xcut-8.strict-factory-rejects . + /// + public Func? MapStrict { get; init; } + + /// + /// No default. Builds an exception whose cause chain is a cycle (XCUT-9). The suite only walks it and never throws + /// it (a thrown cyclic chain hangs the test host on Windows). makes xcut-9.cycle-safe-walk + /// . + /// + public Func? CreateCyclicException { get; init; } + + /// + /// No default. Builds a bounded map of the given capacity (XCUT-14). makes both + /// xcut-14 assertions . + /// + public Func? CreateBoundedMap { get; init; } + + /// + /// Optional. One security-relevant random draw, such as a Digest cnonce (XCUT-21). means + /// the suite draws through the public DigestChallengeHandler.Authorize. + /// + public Func? DrawCnonce { get; init; } + + /// + /// No default. How the single-implementation seam resolves (XCUT-23). makes + /// xcut-23.resolution-deterministic . + /// + public SeamResolution? SeamResolution { get; init; } + + private static CredentialSample[] DefaultCredentials() + { + var basic = new BasicCredential("service-user", "p4ssw0rd-secret"); + var apiKey = new ApiKeyCredential("api-key-secret-value"); + var digest = new DigestCredential("service-user", "d1gest-secret"); + var token = new AccessToken("access-token-secret"); + return + [ + new CredentialSample(nameof(BasicCredential), "p4ssw0rd-secret", basic.ToString), + new CredentialSample(nameof(ApiKeyCredential), "api-key-secret-value", apiKey.ToString), + new CredentialSample(nameof(DigestCredential), "d1gest-secret", digest.ToString), + new CredentialSample(nameof(AccessToken), "access-token-secret", token.ToString), + ]; + } +} diff --git a/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs b/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs new file mode 100644 index 00000000..0bd3445a --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Conformance; + +/// +/// The wire models the invariant suite builds, as delegates (P10-4): XCUT-15 (no model aliases a mutable input) and +/// XCUT-18 (names and values are validated before a transport sees them). Each member takes the mutable input and +/// returns the model, or the projection of the model the assertion inspects. The defaults call core's public factories; a +/// negative control replaces one with a model that breaks the rule. +/// +public sealed record ModelFactories +{ + /// + /// Builds a header collection holding one field through the outbound path (name, value) and returns it; throws + /// for a name or value the outbound grammar refuses. Default: Headers.Builder.Add. + /// + public Func OutboundHeader { get; init; } = static (name, value) => new Headers.Builder().Add(name, value).Build(); + + /// + /// Builds a header collection holding one field through the inbound (lenient) path; throws for a + /// control byte other than HTAB. Default: Headers.Builder.AddInbound. + /// + public Func InboundHeader { get; init; } = static (name, value) => new Headers.Builder().AddInbound(name, value).Build(); + + /// + /// Builds a media type from a mutable parameter map and returns the parameters of the model; the suite mutates the map + /// afterwards and requires the model unchanged. Default: MediaType.Of(...).Parameters. + /// + public Func, IReadOnlyDictionary> MediaTypeParameters { get; init; } = + static input => MediaType.Of("text", "plain", input).Parameters; + + /// + /// Builds an authentication challenge from a mutable parameter map and returns the parameters of the model; the suite mutates + /// the map afterwards and requires the model unchanged. Default: new AuthenticationChallenge(...).Parameters. + /// + public Func, IReadOnlyDictionary> ChallengeParameters { get; init; } = + static input => new AuthenticationChallenge("Bearer", input).Parameters; + + /// + /// Builds a request body from a mutable byte array and returns a reader of what the body would now send; the suite mutates + /// the array between the two calls and requires the body unchanged. Default: RequestBody.FromBytes. + /// + public Func> BytesBody { get; init; } = static input => + { + var body = RequestBody.FromBytes(input); + return () => + { + using var sink = new MemoryStream(); + body.WriteTo(sink); + return sink.ToArray(); + }; + }; + + /// Returns the given collection with one field set, as a collection that is not the given one. Default: Headers.With. + public Func WithHeader { get; init; } = static (headers, name, value) => headers.With(name, value); + + /// Returns the given request with one field set, as a request that is not the given one. Default: Request.WithHeader. + public Func RequestWithHeader { get; init; } = static (request, name, value) => request.WithHeader(name, value); +} diff --git a/src/Dexpace.Sdk.Conformance/Subjects/SeamResolution.cs b/src/Dexpace.Sdk.Conformance/Subjects/SeamResolution.cs new file mode 100644 index 00000000..a41a0d50 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/SeamResolution.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// How a single-implementation seam resolves, for XCUT-23: the three situations of the requirement as actions the +/// driver wires over its own composition root (the DI package, for the SDK). +/// +/// Resolves the seam with no candidate installed; must throw an actionable error. +/// Resolves the seam with two candidates and no explicit selection; must throw an error naming the choice. +/// Installs an explicit candidate beside a discoverable one and returns whether the explicit one was chosen. +public sealed record SeamResolution(Action ResolveWithNone, Action ResolveWithTwo, Func ExplicitWins); diff --git a/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs b/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs new file mode 100644 index 00000000..55255d48 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; + +namespace Dexpace.Sdk.Conformance; + +/// +/// What one run of one invariant assertion owns: the subject, the face, a recording clock and logger, and the transports it +/// builds. Created fresh for each (assertion, face) and disposed after it on every path, so a poisoned pipeline cannot fail a +/// later row (suite-contract clause 1). The faces are the two call surfaces of ; +/// runs an assertion that drives neither. +/// +internal sealed class InvariantContext(InvariantSubject subject, TransportFace face, SuiteOptions options) : IAsyncDisposable +{ + private readonly List _owned = []; + + /// The composition under test. + internal InvariantSubject Subject { get; } = subject; + + /// The face this run drives. + internal TransportFace Face { get; } = face; + + /// The run's options. + internal SuiteOptions Options { get; } = options; + + /// The clock every pipeline of this run waits on; it counts the timers it creates and disposes. + internal RecordingTimeProvider Time { get; } = new(); + + /// The logger every pipeline of this run logs to. + internal RecordingLogger Logger { get; } = new(); + + /// + /// How long an assertion waits for something that should already have happened or been cut: ten seconds, or half the + /// assertion's own bound when that is shorter, so a subject that hangs fails fast and named instead of running to the full bound. + /// + internal TimeSpan Bound => TimeSpan.FromSeconds(Math.Min(10, Options.AssertionTimeout.TotalSeconds / 2)); + + /// Returns , or ends the assertion as not exercised, naming the hook (P10-4). + /// The hook's type. + /// The hook as the subject supplied it. + /// The hook's member name on . + /// is . + internal static T RequireHook(T? hook, string name) + where T : class => + hook ?? throw new NotExercisedException($"the subject supplies no {name} hook, so nothing was exercised"); + + /// Takes ownership of ; it is disposed with the run. + /// The item's type. + /// What to dispose after the run. + internal T Own(T item) + where T : IAsyncDisposable + { + _owned.Add(item); + return item; + } + + /// Builds the subject's pipeline over on this run's clock and logger. + /// The terminal transport; the pipeline never owns it. + /// The call-level options, or the defaults. + internal HttpPipeline Build(IAsyncHttpClient transport, DexpaceClientOptions? clientOptions = null) => + Subject.CreatePipeline(transport, clientOptions ?? new DexpaceClientOptions(), Time, Logger); + + /// Sends one request through on this run's face; the blocking face runs on a dedicated thread. + /// The pipeline. + /// The request. + /// The caller's token. + /// Per-call options, or none. + internal Task SendAsync(HttpPipeline pipeline, Request request, CancellationToken token, RequestOptions? requestOptions = null) + { + var perCall = requestOptions ?? RequestOptions.Empty; + return Face == TransportFace.Blocking + ? Task.Factory.StartNew( + () => pipeline.Send(request, perCall, token), + CancellationToken.None, + TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default) + : pipeline.SendAsync(request, perCall, token).AsTask(); + } + + /// + /// Sends and returns how the call ended: when it succeeded (the response is disposed), otherwise the + /// exception it failed with. Bounded by , so a hung pipeline is a named failure. + /// + /// The pipeline. + /// The request. + /// What the call is, named if it hangs. + /// The caller's token. + /// Per-call options, or none. + internal async Task FailureOfAsync(HttpPipeline pipeline, Request request, string what, CancellationToken token, RequestOptions? requestOptions = null) => + await Outcome.OfAsync(SendAsync(pipeline, request, token, requestOptions), Bound, what, token).ConfigureAwait(false); + + /// + public async ValueTask DisposeAsync() + { + for (var i = _owned.Count - 1; i >= 0; i--) + { + await _owned[i].DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/InvariantIdentity.cs b/src/Dexpace.Sdk.Conformance/Suite/InvariantIdentity.cs new file mode 100644 index 00000000..d4039d90 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/InvariantIdentity.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The invariant suite's report identity: its name and what a green run does not prove. +internal static class InvariantIdentity +{ + /// What a green invariant run does not prove. + internal const string Preamble = + "Invariant conformance report. A green run does not prove: any clause whose hook the driver did not supply (such an " + + "assertion is NotExercised, never passed, and a driver pins the list it expects); anything about core's internals " + + "except through the hooks; behaviour over a real socket (the transport suite's); or any requirement no assertion cites. " + + "It proves that every assertion that ran met its clause over the composition it was given, or is waived below, and " + + "nothing more. Run against the defaults it judges core, and nothing about a third party's composition until they supply theirs."; + + /// The identity the report carries. + internal static SuiteIdentity Identity { get; } = new("invariant", Preamble); +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/InvariantSuite.cs b/src/Dexpace.Sdk.Conformance/Suite/InvariantSuite.cs new file mode 100644 index 00000000..d50e7bd3 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/InvariantSuite.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// The cross-cutting invariant suite (phase 10, design section C): one assertion per clause of XCUT-1 to XCUT-24 +/// that the SDK's own components can be asked, plus the scheduler-agnostic clause of NFR-11, run against an +/// . Framework-free like : it returns results and a +/// , and the caller's test framework decides what a failure means. +/// +/// +/// The transport-level clauses of XCUT-1, -2, -4 and -22 stay assertions of +/// (P10-6); this suite asserts the core-level clause of each, and merges the two by +/// requirement. An assertion whose hook the driver did not supply is , never passed. +/// +public static class InvariantSuite +{ + /// + /// What a green run of the invariant suite does not prove: the hooks the driver did not supply, core's internals reached + /// only through hooks, behaviour over a real socket, and any requirement no assertion cites. + /// + public static string Preamble { get; } = InvariantIdentity.Preamble; + + /// Every assertion, in a stable order. Names are stable public identifiers, frozen at the first shipped surface. + public static IReadOnlyList Assertions => InvariantCatalogue.All; + + /// Runs one assertion on one face of . + /// The composition under test. + /// One of . + /// A face declares. + /// Bounds and waivers; defaults when . + /// Abandons the run with . + /// The result. Assertion failures and subject crashes are results, not exceptions. + /// or is null. + /// is not one of , or is not one it declares. + public static Task RunAsync( + InvariantSubject subject, + ConformanceAssertion assertion, + TransportFace face, + SuiteOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(subject); + ArgumentNullException.ThrowIfNull(assertion); + if (!Assertions.Contains(assertion)) + { + throw new ArgumentException($"Assertion '{assertion.Name}' is not an invariant assertion.", nameof(assertion)); + } + + if (!assertion.Faces.Contains(face)) + { + throw new ArgumentException($"Assertion '{assertion.Name}' does not declare the {face} face.", nameof(face)); + } + + var effective = options ?? new SuiteOptions(); + return SuiteRunnerCore.RunAsync( + assertion, + face, + () => new InvariantContext(subject, face, effective), + effective.AssertionTimeout, + effective.Waivers, + cancellationToken); + } + + /// Runs every assertion on every face it declares, sequentially, and builds the report. + /// The composition under test. + /// Bounds and waivers; defaults when . + /// Abandons the run with . + /// The report; its is the verdict. + /// is null. + public static Task RunAllAsync( + InvariantSubject subject, + SuiteOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(subject); + var effective = options ?? new SuiteOptions(); + return SuiteRunnerCore.RunAllAsync( + subject.Name, + InvariantIdentity.Identity, + Assertions, + (assertion, face) => RunAsync(subject, assertion, face, effective, cancellationToken), + effective.Waivers); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/AssertionCatalogueTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/AssertionCatalogueTests.cs index 394c0025..e279c97b 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/AssertionCatalogueTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/AssertionCatalogueTests.cs @@ -159,8 +159,17 @@ public void Control_classes_are_discovered_by_shape_so_a_new_one_cannot_be_misse [Fact] public void Every_control_names_an_assertion_that_exists() { - var unknown = ControlClasses.Covered.Except(All.Select(a => a.Name), StringComparer.Ordinal).ToArray(); + // Phase 10 (validation F2): a control may name an assertion of any kit suite, so this compares against the union. + var unknown = ControlClasses.Covered.Except(KitCatalogues.All.Select(a => a.Name), StringComparer.Ordinal).ToArray(); Assert.Empty(unknown); } + + [Fact] + public void Assertion_names_are_unique_across_the_kit_catalogues() + { + var names = KitCatalogues.All.Select(a => a.Name).ToArray(); + + Assert.Equal(names.Length, names.Distinct(StringComparer.Ordinal).Count()); + } } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs new file mode 100644 index 00000000..9c0d5d11 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.RegularExpressions; +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Assertions; + +/// +/// The pin over the invariant catalogue (plan 1.3, "Invariant catalogue: the 28 assertions"): the names in order with their +/// requirement IDs, levels and faces, and a negative control for every one. The literal list is the design's table, so a later +/// edit to the catalogue must edit this list on purpose. +/// +[Trait("Category", "Unit")] +public sealed partial class InvariantCatalogueTests +{ + private const RequirementLevel M = RequirementLevel.Must; + private const RequirementLevel S = RequirementLevel.Should; + + private static readonly TransportFace[] s_both = [TransportFace.Async, TransportFace.Blocking]; + private static readonly TransportFace[] s_none = [TransportFace.None]; + + internal static (string Name, string[] Ids, RequirementLevel Level, TransportFace[] Faces)[] Expected { get; } = + [ + ("xcut-1.cancel-is-terminal", ["XCUT-1"], M, s_both), + ("xcut-2.timeout-is-retryable", ["XCUT-2"], M, s_both), + ("xcut-3.wait-promptly-cancellable", ["XCUT-3"], M, s_both), + ("xcut-4.two-branches", ["XCUT-4"], M, s_both), + ("xcut-5.one-classifier", ["XCUT-5"], M, s_none), + ("xcut-6.capability-queried", ["XCUT-6"], M, s_both), + ("xcut-7.configured-set-authoritative", ["XCUT-7"], M, s_both), + ("xcut-8.strict-factory-rejects", ["XCUT-8"], M, s_none), + ("xcut-8.convenience-returns-response", ["XCUT-8"], M, s_both), + ("xcut-9.cycle-safe-walk", ["XCUT-9"], M, s_none), + ("xcut-10.safety-gate-uniform", ["XCUT-10"], M, s_both), + ("xcut-11.shared-no-crosstalk", ["XCUT-11"], M, s_both), + ("xcut-12.single-flight-scoped", ["XCUT-12"], S, s_both), + ("xcut-13.dispose-latched", ["XCUT-13"], M, s_both), + ("xcut-13.dispose-non-blocking", ["XCUT-13"], M, s_both), + ("xcut-14.bounded-under-burst", ["XCUT-14"], M, s_none), + ("xcut-14.drains-after-insert", ["XCUT-14"], M, s_none), + ("xcut-15.no-external-alias", ["XCUT-15"], M, s_none), + ("xcut-16.no-credential-over-http", ["XCUT-16"], M, s_both), + ("xcut-17.redirect-hygiene", ["XCUT-17"], M, s_both), + ("xcut-18.model-validation", ["XCUT-18"], M, s_none), + ("xcut-19.redaction-default-deny", ["XCUT-19"], M, s_both), + ("xcut-20.observability-never-throws", ["XCUT-20"], M, s_both), + ("xcut-21.cnonce-unpredictable", ["XCUT-21"], M, s_none), + ("xcut-22.core-borrows", ["XCUT-22"], M, s_both), + ("xcut-23.resolution-deterministic", ["XCUT-23"], M, s_none), + ("xcut-24.preview-capped-non-consuming", ["XCUT-24"], S, s_both), + ("nfr-11.scheduler-agnostic", ["NFR-11"], S, s_both), + ]; + + [GeneratedRegex(@"^[a-z]+-\d+\.[a-z0-9-]+$", RegexOptions.CultureInvariant)] + private static partial Regex NamePattern(); + + private static IReadOnlyList All => InvariantSuite.Assertions; + + /// The expected rows the catalogue has registered so far: the full 28 once task 1.9 lands (the one deliberate staged pin). + private static (string Name, string[] Ids, RequirementLevel Level, TransportFace[] Faces)[] Registered => [.. Expected.Take(All.Count)]; + + [Fact] + public void The_registered_assertions_are_the_design_order_prefix_of_the_28_names() + { + Assert.Equal(28, Expected.Length); + Assert.Equal(Registered.Select(e => e.Name), All.Select(a => a.Name)); + } + + [Fact] + public void Each_registered_assertion_has_the_ids_level_and_faces_of_the_design_table() + { + var expected = Registered; + for (var i = 0; i < expected.Length; i++) + { + Assert.Equal(expected[i].Ids, All[i].RequirementIds); + Assert.Equal(expected[i].Level, All[i].Level); + Assert.Equal(expected[i].Faces, All[i].Faces); + } + } + + [Fact] + public void Names_are_stable_identifiers_and_no_assertion_is_stronger_than_its_strongest_requirement() + { + foreach (var assertion in All) + { + Assert.Matches(NamePattern(), assertion.Name); + Assert.All(assertion.RequirementIds, id => Assert.True(RequirementIndex.Contains(id), id)); + var strongest = assertion.RequirementIds.Select(id => { RequirementIndex.TryGetLevel(id, out var level); return level; }).Min(); + Assert.True(assertion.Level >= strongest, $"{assertion.Name} is {assertion.Level} but its strongest requirement is only {strongest}"); + } + } + + [Fact] + public void Every_invariant_assertion_has_a_negative_control() + { + var uncovered = All.Select(a => a.Name).Except(ControlClasses.Covered, StringComparer.Ordinal).ToArray(); + + Assert.Empty(uncovered); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs new file mode 100644 index 00000000..73afb30a --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance.Tests.Assertions; + +/// +/// The union of every catalogue the kit ships (phase 10, validation F2): the transport suite's, and the invariant, codec and +/// packaging suites' as they arrive. A negative control may name an assertion of any of them. +/// +internal static class KitCatalogues +{ + /// Every assertion of every suite. + internal static IReadOnlyList All { get; } = + [ + .. TransportSuite.Assertions, + .. InvariantSuite.Assertions, + ]; +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs new file mode 100644 index 00000000..3503f38c --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Drivers; + +/// +/// One negative control for the invariant suite: an assertion, a face, and a deliberately broken +/// that the assertion must catch (phase 10, plan convention 1). The tables of rows are static and declarative, so +/// InvariantCatalogueTests reads the assertion names they cover without any test having run first. +/// +internal sealed record InvariantControlRow( + string Assertion, + TransportFace Face, + string Name, + Func Subject, + ConformanceStatus Expected = ConformanceStatus.Failed) +{ + /// Short bounds so a hung control stays fast: the assertion's bound is six seconds, a wait inside it three. + internal static SuiteOptions Options { get; } = new() { AssertionTimeout = TimeSpan.FromSeconds(6) }; + + /// The distinct assertion names a table covers. + internal static IReadOnlyCollection CoveredBy(IEnumerable rows) => + [.. rows.Select(row => row.Assertion).Distinct(StringComparer.Ordinal)]; + + /// The theory data of a table: assertion, face and control name. + internal static TheoryData Cases(IEnumerable rows) + { + var data = new TheoryData(); + foreach (var row in rows) + { + data.Add(row.Assertion, row.Face, row.Name); + } + + return data; + } + + /// The theory data of the positive counterparts: every assertion a table covers, on each face it declares. + internal static TheoryData PositiveCases(IEnumerable rows, params string[] except) + { + var data = new TheoryData(); + foreach (var name in CoveredBy(rows).Except(except, StringComparer.Ordinal)) + { + foreach (var face in Find(name).Faces) + { + data.Add(name, face); + } + } + + return data; + } + + /// The invariant assertion named . + internal static ConformanceAssertion Find(string name) => + InvariantSuite.Assertions.SingleOrDefault(a => a.Name == name) + ?? throw new InvalidOperationException($"There is no invariant assertion named '{name}'."); + + /// Runs one control and fails unless the assertion produced the expected status. + internal static async Task RunAsync(IEnumerable rows, string assertion, TransportFace face, string name) + { + var row = rows.Single(r => r.Assertion == assertion && r.Face == face && r.Name == name); + var result = await InvariantSuite.RunAsync(row.Subject(), Find(assertion), face, Options, TestContext.Current.CancellationToken); + + Assert.True( + result.Status == row.Expected, + $"control '{name}' on {face} expected {row.Expected} but the assertion reported {result.Status}: {result.Detail}"); + Assert.False(string.IsNullOrWhiteSpace(result.Detail)); + } + + /// Runs the real assertion on one face against (the defaults when null) and fails unless it passed. + internal static async Task RunPositiveAsync(string assertion, TransportFace face, InvariantSubject? subject = null) + { + var result = await InvariantSuite.RunAsync(subject ?? new InvariantSubject(), Find(assertion), face, Options, TestContext.Current.CancellationToken); + + Assert.True(result.Status == ConformanceStatus.Passed, $"{assertion} on {face} against {subject?.Name ?? "the defaults"}: {result.Status}: {result.Detail}"); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Internals/RecordingTimeProviderTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Internals/RecordingTimeProviderTests.cs new file mode 100644 index 00000000..5ff49a6c --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Internals/RecordingTimeProviderTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Internals; + +[Trait("Category", "Unit")] +public sealed class RecordingTimeProviderTests +{ + [Fact] + public void A_timer_is_live_counted_and_disposal_is_counted_once() + { + var time = new RecordingTimeProvider(); + + var timer = time.CreateTimer(static _ => { }, null, TimeSpan.FromHours(1), Timeout.InfiniteTimeSpan); + + Assert.Equal(1, time.TimersCreated); + Assert.Equal(1, time.TimersLive); + timer.Dispose(); + timer.Dispose(); + Assert.Equal(1, time.TimersDisposed); + Assert.Equal(0, time.TimersLive); + } + + [Fact] + public async Task A_timer_fires_on_real_time_and_an_async_disposal_is_counted() + { + var time = new RecordingTimeProvider(); + var fired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var timer = time.CreateTimer(static state => ((TaskCompletionSource)state!).TrySetResult(), fired, TimeSpan.FromMilliseconds(1), Timeout.InfiniteTimeSpan); + await fired.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + await timer.DisposeAsync(); + + Assert.Equal(1, time.TimersDisposed); + } + + [Fact] + public void Time_delegates_to_the_system_provider_unless_advanced() + { + var time = new RecordingTimeProvider(); + var before = time.GetUtcNow(); + + time.Advance(TimeSpan.FromHours(2)); + var after = time.GetUtcNow(); + + Assert.True(after - before >= TimeSpan.FromHours(2)); + Assert.True(after - before < TimeSpan.FromHours(2) + TimeSpan.FromMinutes(5)); + Assert.Equal(TimeProvider.System.TimestampFrequency, time.TimestampFrequency); + Assert.True(time.GetTimestamp() >= TimeProvider.System.GetTimestamp()); + Assert.Throws(() => time.Advance(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public async Task A_cancelled_delay_over_the_provider_disposes_the_timer_it_armed() + { + var time = new RecordingTimeProvider(); + using var cts = new CancellationTokenSource(); + + var wait = time.DelayAsync(TimeSpan.FromHours(1), cts.Token); + await cts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => wait); + + Assert.Equal(time.TimersCreated, time.TimersDisposed); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs new file mode 100644 index 00000000..36074318 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Suite; + +/// Plan 1.3 (P10-4, R4): the invariant suite's shell. The assertions themselves are pinned by their control classes. +[Trait("Category", "Unit")] +public sealed class InvariantSuiteTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public void The_preamble_says_what_a_green_run_does_not_prove() + { + Assert.Contains("NotExercised", InvariantSuite.Preamble, StringComparison.Ordinal); + Assert.Contains("never passed", InvariantSuite.Preamble, StringComparison.Ordinal); + Assert.Contains("hook", InvariantSuite.Preamble, StringComparison.Ordinal); + } + + [Fact] + public async Task A_report_records_the_invariant_suite_and_its_preamble() + { + var report = await InvariantSuite.RunAllAsync(new InvariantSubject { Name = "probe" }, new SuiteOptions { AssertionTimeout = TimeSpan.FromSeconds(10) }, Ct); + + Assert.Equal("invariant", report.Suite); + Assert.Equal("probe", report.SubjectName); + Assert.Equal(InvariantSuite.Preamble, report.PreambleText); + Assert.StartsWith(InvariantSuite.Preamble, report.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void An_absent_hook_ends_the_assertion_as_not_exercised_naming_the_hook() + { + var failure = Assert.Throws(() => InvariantContext.RequireHook>(null, nameof(InvariantSubject.CreateBoundedMap))); + + Assert.Contains(nameof(InvariantSubject.CreateBoundedMap), failure.Message, StringComparison.Ordinal); + Assert.Equal(3, InvariantContext.RequireHook>(() => 3, "x")()); + } + + [Fact] + public async Task RunAsync_rejects_a_foreign_assertion_a_face_it_does_not_declare_and_null() + { + var foreign = TransportSuite.Assertions[0]; + + await Assert.ThrowsAsync(() => InvariantSuite.RunAsync(null!, foreign, TransportFace.Async, null, Ct)); + await Assert.ThrowsAsync(() => InvariantSuite.RunAsync(new InvariantSubject(), null!, TransportFace.Async, null, Ct)); + await Assert.ThrowsAsync(() => InvariantSuite.RunAllAsync(null!, null, Ct)); + await Assert.ThrowsAsync(() => InvariantSuite.RunAsync(new InvariantSubject(), foreign, TransportFace.Async, null, Ct)); + } + + [Fact] + public void The_subject_rejects_null_hooks_and_defaults_to_core() + { + var subject = new InvariantSubject(); + + Assert.Equal("core", subject.Name); + Assert.Null(subject.MapStrict); + Assert.Null(subject.CreateCyclicException); + Assert.Null(subject.CreateBoundedMap); + Assert.Null(subject.SeamResolution); + Assert.Null(subject.DrawCnonce); + Assert.Equal(4, subject.Credentials.Count); + Assert.Throws(() => new InvariantSubject { CreatePipeline = null! }); + Assert.Throws(() => new InvariantSubject { CreateResponseException = null! }); + Assert.Throws(() => new InvariantSubject { Name = " " }); + } + + [Fact] + public async Task The_bound_is_ten_seconds_or_half_the_assertion_bound_when_shorter() + { + await using var shortRun = new InvariantContext(new InvariantSubject(), TransportFace.None, new SuiteOptions { AssertionTimeout = TimeSpan.FromSeconds(6) }); + await using var longRun = new InvariantContext(new InvariantSubject(), TransportFace.None, new SuiteOptions()); + + Assert.Equal(TimeSpan.FromSeconds(3), shortRun.Bound); + Assert.Equal(TimeSpan.FromSeconds(10), longRun.Bound); + } +} From 37346459a4a577124b6597aecbb5e5ce486ba8ed Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 20:48:27 +0300 Subject: [PATCH 04/45] feat: invariant assertions xcut-1 to xcut-3 with negative controls (phase 10, #99) --- .../Assertions/CancellationInvariants.cs | 100 ++++++++++++++ .../Assertions/InvariantCatalogue.cs | 5 +- .../Assertions/InvariantOptions.cs | 22 ++++ src/Dexpace.Sdk.Conformance/Internals/Poll.cs | 54 ++++++++ .../Internals/ProbeTransport.cs | 2 +- .../Suite/InvariantContext.cs | 19 ++- .../Controls/BrokenPipelines.cs | 124 ++++++++++++++++++ .../InvariantCancellationControlTests.cs | 41 ++++++ .../Drivers/InvariantControlRow.cs | 17 ++- 9 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/CancellationInvariants.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/Poll.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantCancellationControlTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/CancellationInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/CancellationInvariants.cs new file mode 100644 index 00000000..b9c9aa6a --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/CancellationInvariants.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from nodejs-sdk@54aeed4 tests/conformance/xcut/cancellation-and-timeout.conformance.test.ts and ruby-sdk@5b17395 +// gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/ (cancellation and timeout), reshaped for .NET's token-based +// cancellation (design §10 entry 8: there is no interrupt flag to re-assert; the caller's token stays signalled). +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Conformance; + +/// +/// XCUT-1 to XCUT-3: cancellation is terminal and distinct from a timeout, a timeout is a retryable failure that +/// does not signal the caller's token, and an inter-attempt wait is promptly cancellable and disarms its timer. The core-level +/// clauses, through the subject's pipeline; the transport-level clauses are transport-3 and transport-4 (P10-6). +/// +internal static class CancellationInvariants +{ + internal static ConformanceAssertion[] Assertions() => + [ + InvariantAssertionBuilder.Define("xcut-1.cancel-is-terminal", ["XCUT-1"], RequirementLevel.Must, InvariantAssertionBuilder.Both, CancelIsTerminalAsync), + InvariantAssertionBuilder.Define("xcut-2.timeout-is-retryable", ["XCUT-2"], RequirementLevel.Must, InvariantAssertionBuilder.Both, TimeoutIsRetryableAsync), + InvariantAssertionBuilder.Define("xcut-3.wait-promptly-cancellable", ["XCUT-3"], RequirementLevel.Must, InvariantAssertionBuilder.Both, WaitIsPromptlyCancellableAsync), + ]; + + // A send parked in the transport, the caller cancels: one send, the caller's own token on the exception, no retry. + private static async Task CancelIsTerminalAsync(InvariantContext context, CancellationToken token) + { + var sent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var transport = context.Transport(ProbeTransport.Behavior.HangUntilCancelled(sent)); + var pipeline = context.Build(transport, InvariantOptions.Retrying(2)); + using var caller = CancellationTokenSource.CreateLinkedTokenSource(token); + + var call = context.FailureOfAsync(pipeline, Request.Get(InvariantOptions.Url), "the cancelled call", token, caller.Token); + await Bounded.WaitAsync(sent.Task, context.Bound, "the send to reach the transport", token).ConfigureAwait(false); + await caller.CancelAsync().ConfigureAwait(false); + var failure = await call.ConfigureAwait(false); + + Check.True( + failure is OperationCanceledException cancelled && cancelled.CancellationToken == caller.Token, + "a cancelled caller must surface OperationCanceledException carrying its own token, kept separate from a timeout", + "OperationCanceledException with the caller's token", + Outcome.Name(failure)); + Check.True(caller.Token.IsCancellationRequested, "the caller's token must still be signalled after the call ends", "signalled", "clear"); + Check.True(failure is not IRetryableError { IsRetryable: true }, "a cancellation must not report itself retryable", "non-retryable", "retryable"); + Check.Equal(transport.Sends, 1, "the number of sends of a cancelled call (a cancelled operation is never re-issued)"); + } + + // An attempt deadline over a transport that never answers: a retryable timeout, the caller's token clear, the call re-issued. + private static async Task TimeoutIsRetryableAsync(InvariantContext context, CancellationToken token) + { + var transport = context.Transport(ProbeTransport.Behavior.HangUntilCancelled()); + var options = new DexpaceClientOptions { AttemptTimeout = TimeSpan.FromMilliseconds(50), Retry = InvariantOptions.Fast(2) }; + var pipeline = context.Build(transport, options); + using var caller = CancellationTokenSource.CreateLinkedTokenSource(token); + + var failure = await context.FailureOfAsync(pipeline, Request.Get(InvariantOptions.Url), "the call whose every attempt times out", token, caller.Token).ConfigureAwait(false); + + Check.True(failure is not null, "a call whose every attempt times out must fail", "a failure", "the call succeeded"); + Check.True(failure is not OperationCanceledException, "a deadline must not be reported as a cancellation", "a timeout", Outcome.Name(failure)); + Check.True(Outcome.FindTimeout(failure) is not null, "the failure must be classified as a timeout (the timeout branch is checked first)", "a timeout exception", Outcome.Name(failure)); + Check.True(failure is IRetryableError { IsRetryable: true }, "a timeout must be a retryable failure", "retryable", Outcome.Name(failure)); + Check.True(!caller.Token.IsCancellationRequested, "a timeout must not signal the caller's token", "clear", "signalled"); + Check.True(transport.Sends >= 2, "a timed-out attempt must be re-issued by the retry layer", "at least 2 sends", transport.Sends.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + // A retry waiting out a long backoff, the caller cancels: the call ends promptly with the caller's cancellation and every + // timer the wait armed is disposed. The wait is on the run's recording clock, so the timers are countable. + private static async Task WaitIsPromptlyCancellableAsync(InvariantContext context, CancellationToken token) + { + var transport = context.Transport(ProbeTransport.Behavior.Status(503)); + var options = new DexpaceClientOptions + { + Retry = new RetryOptions { MaxRetryAttempts = 3, FixedDelay = TimeSpan.FromSeconds(30), Jitter = 0 }, + }; + var pipeline = context.Build(transport, options); + using var caller = CancellationTokenSource.CreateLinkedTokenSource(token); + + var call = context.FailureOfAsync(pipeline, Request.Get(InvariantOptions.Url), "the call cancelled during a 30 s retry wait", token, caller.Token); + await Poll.UntilAsync(() => transport.Sends >= 1, context.Bound, "the first attempt to reach the transport", token).ConfigureAwait(false); + + // The wait arms a timer on the run's clock; a pipeline that waits some other way arms none and is judged on the rest. + await Poll.HoldsWithinAsync(() => context.Time.TimersCreated >= 1, TimeSpan.FromSeconds(1), token).ConfigureAwait(false); + await caller.CancelAsync().ConfigureAwait(false); + var failure = await call.ConfigureAwait(false); + + Check.True( + failure is OperationCanceledException cancelled && cancelled.CancellationToken == caller.Token, + "a cancelled retry wait must surface the cancellation, not a spurious timeout or the last error", + "OperationCanceledException with the caller's token", + Outcome.Name(failure)); + Check.Equal(transport.Sends, 1, "the number of sends of a call cancelled during its first wait"); + await Poll.UntilAsync( + () => context.Time.TimersLive == 0, + context.Bound, + $"every timer the wait armed to be disposed ({context.Time.TimersCreated} created, {context.Time.TimersDisposed} disposed)", + token).ConfigureAwait(false); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs index 840867d1..ef52f681 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs @@ -10,5 +10,8 @@ namespace Dexpace.Sdk.Conformance; internal static class InvariantCatalogue { /// Every assertion the invariant suite runs. - internal static IReadOnlyList All { get; } = []; + internal static IReadOnlyList All { get; } = + [ + .. CancellationInvariants.Assertions(), + ]; } diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs new file mode 100644 index 00000000..6bfcc30d --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; + +namespace Dexpace.Sdk.Conformance; + +/// The client options the invariant assertions share, so no assertion waits on a real backoff it did not ask for. +internal static class InvariantOptions +{ + /// The URL every assertion requests: nothing is sent to it, the transport is in memory. + internal const string Url = "https://example.test/resource"; + + /// Retries up to times, a millisecond apart and without jitter. + /// The retries after the first send. + internal static RetryOptions Fast(int attempts) => + new() { MaxRetryAttempts = attempts, FixedDelay = TimeSpan.FromMilliseconds(1), Jitter = 0 }; + + /// Client options retrying times, fast. + /// The retries after the first send. + internal static DexpaceClientOptions Retrying(int attempts) => new() { Retry = Fast(attempts) }; +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/Poll.cs b/src/Dexpace.Sdk.Conformance/Internals/Poll.cs new file mode 100644 index 00000000..9169a2f4 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/Poll.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; + +namespace Dexpace.Sdk.Conformance; + +/// +/// Waiting for a condition another thread or task is about to make true, bounded in real time (suite-contract clause 6). The +/// kit never sleeps on the wall clock and never calls Task.Delay: the pause between looks is +/// over the system clock, and the bound is a cancellation source, so no assertion reads +/// a clock to decide a verdict. +/// +internal static class Poll +{ + private static readonly TimeSpan s_interval = TimeSpan.FromMilliseconds(2); + + /// Returns once holds; fails naming if it has not within . + /// The condition; cheap and side-effect free. + /// The ceiling. + /// What was being waited for, named in the failure. + /// The caller's token. + /// The bound passed first. + internal static async Task UntilAsync(Func condition, TimeSpan bound, string what, CancellationToken cancellationToken) + { + if (!await HoldsWithinAsync(condition, bound, cancellationToken).ConfigureAwait(false)) + { + throw new ConformanceException( + $"{what} did not happen within its {Bounded.Format(bound)} bound", + $"{what} within {Bounded.Format(bound)}", + "it had not happened"); + } + } + + /// Whether holds within ; returns as soon as it does. + /// The condition; cheap and side-effect free. + /// The ceiling. + /// The caller's token. + internal static async Task HoldsWithinAsync(Func condition, TimeSpan bound, CancellationToken cancellationToken) + { + using var expiry = new CancellationTokenSource(bound); + while (!condition()) + { + if (expiry.IsCancellationRequested) + { + return false; + } + + await TimeProvider.System.DelayAsync(s_interval, cancellationToken).ConfigureAwait(false); + } + + return true; + } +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs b/src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs index b835eb1c..aa185f03 100644 --- a/src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs +++ b/src/Dexpace.Sdk.Conformance/Internals/ProbeTransport.cs @@ -50,7 +50,7 @@ internal static Behavior HangUntilCancelled(TaskCompletionSource? signalSent = n { signalSent?.TrySetResult(); var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = token.Register(static state => ((TaskCompletionSource)state!).TrySetCanceled(), cancelled); + using var registration = token.Register(() => cancelled.TrySetCanceled(token)); return await cancelled.Task.ConfigureAwait(false); }, (_, _, token) => diff --git a/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs b/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs index 55255d48..092d883a 100644 --- a/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs +++ b/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs @@ -59,6 +59,12 @@ internal T Own(T item) return item; } +#pragma warning disable CA2000 // Ownership passes to the context's list, which DisposeAsync drains on every path. + /// Builds an in-memory transport with ; disposed with the run. + /// What the transport does with each send. + internal ProbeTransport Transport(ProbeTransport.Behavior behavior) => Own(new ProbeTransport(behavior)); +#pragma warning restore CA2000 + /// Builds the subject's pipeline over on this run's clock and logger. /// The terminal transport; the pipeline never owns it. /// The call-level options, or the defaults. @@ -89,10 +95,17 @@ internal Task SendAsync(HttpPipeline pipeline, Request request, Cancel /// The pipeline. /// The request. /// What the call is, named if it hangs. - /// The caller's token. + /// The assertion's token, which bounds the wait for the call to settle. + /// The token handed to the pipeline as the caller's, or for . A test that cancels the call cancels this one. /// Per-call options, or none. - internal async Task FailureOfAsync(HttpPipeline pipeline, Request request, string what, CancellationToken token, RequestOptions? requestOptions = null) => - await Outcome.OfAsync(SendAsync(pipeline, request, token, requestOptions), Bound, what, token).ConfigureAwait(false); + internal async Task FailureOfAsync( + HttpPipeline pipeline, + Request request, + string what, + CancellationToken token, + CancellationToken? callToken = null, + RequestOptions? requestOptions = null) => + await Outcome.OfAsync(SendAsync(pipeline, request, callToken ?? token, requestOptions), Bound, what, token).ConfigureAwait(false); /// public async ValueTask DisposeAsync() diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs new file mode 100644 index 00000000..4682dc47 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// +/// The deliberately broken pipeline policies and subjects the invariant suite's negative controls use (plan 1.4 and after): each +/// breaks exactly the clause its assertion judges, and a pipeline built from one of them replaces the subject's +/// CreatePipeline. They are test code over core's public policy surface, never part of the kit. +/// +internal static class BrokenPipelines +{ + /// A subject whose pipeline is exactly the given policies over the transport (no standard resilience). + /// What is broken. + /// Builds the policies from the run's recording clock. + internal static InvariantSubject Subject(string name, Func policies) => + new() + { + Name = "broken: " + name, + CreatePipeline = (transport, options, time, _) => new PipelineBuilder().AddRange(policies(time)).Build(transport, options), + }; + + /// A retry loop that treats a cancelled send as a failure to retry (XCUT-1). + internal sealed class RetriesCancellation : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + for (var attempt = 0; ; attempt++) + { + try + { + return await continuation.RunAsync(request, context); + } + catch (OperationCanceledException) when (attempt < 3) + { + } + } + } + } + + /// A layer that reports a cancellation as a retryable transport failure (XCUT-1). + internal sealed class WrapsCancellation : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + try + { + return await continuation.RunAsync(request, context); + } + catch (OperationCanceledException cancelled) + { + throw new ServiceRequestException("interrupted", cancelled); + } + } + } + + /// An attempt deadline that surfaces as a cancellation and ends the call (XCUT-2). + internal sealed class DeadlineAsCancellation : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + using var deadline = new CancellationTokenSource(context.Options.AttemptTimeout ?? TimeSpan.FromSeconds(30)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, deadline.Token); + return await continuation.RunAsync(request, context.WithCancellationToken(linked.Token)); + } + } + + /// A retry wait that does not honour the caller's token (XCUT-3). + internal sealed class IgnoresTokenWait(TimeProvider time) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + for (var attempt = 0; ; attempt++) + { + var response = await continuation.RunAsync(request, context); + if (response.Status.Code != 503 || attempt >= 3) + { + return response; + } + + response.Dispose(); + await time.DelayAsync(context.Options.Retry.FixedDelay ?? TimeSpan.FromSeconds(30), CancellationToken.None); + } + } + } + + /// A retry wait that is cancellable but never disposes the timer it armed (XCUT-3). + internal sealed class LeaksItsTimer(TimeProvider time) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + for (var attempt = 0; ; attempt++) + { + var response = await continuation.RunAsync(request, context); + if (response.Status.Code != 503 || attempt >= 3) + { + return response; + } + + response.Dispose(); + var woke = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = time.CreateTimer(_ => woke.TrySetResult(), null, context.Options.Retry.FixedDelay ?? TimeSpan.FromSeconds(30), Timeout.InfiniteTimeSpan); + using var registration = context.CancellationToken.Register(() => woke.TrySetCanceled(context.CancellationToken)); + await woke.Task; + } + } + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantCancellationControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantCancellationControlTests.cs new file mode 100644 index 00000000..fb843b86 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantCancellationControlTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for xcut-1, xcut-2 and xcut-3 (plan 1.4): each broken pipeline must fail the assertion that judges its clause, and the defaults must pass. +[Trait("Category", "Unit")] +public sealed class InvariantCancellationControlTests +{ + internal static IReadOnlyList Rows { get; } = + [ + .. Both("xcut-1.cancel-is-terminal", "retries a cancelled send", "never re-issued", () => BrokenPipelines.Subject("retries a cancelled send", _ => [new BrokenPipelines.RetriesCancellation()])), + .. Both("xcut-1.cancel-is-terminal", "reports a cancellation as a retryable transport failure", "carrying its own token", () => BrokenPipelines.Subject("wraps a cancellation", _ => [new BrokenPipelines.WrapsCancellation()])), + .. Both("xcut-2.timeout-is-retryable", "reports a deadline as a cancellation", "not be reported as a cancellation", () => BrokenPipelines.Subject("deadline as cancellation", _ => [new BrokenPipelines.DeadlineAsCancellation()])), + .. Both("xcut-3.wait-promptly-cancellable", "waits without honouring the caller's token", "did not finish within", () => BrokenPipelines.Subject("ignores the token", time => [new BrokenPipelines.IgnoresTokenWait(time)])), + .. Both("xcut-3.wait-promptly-cancellable", "leaves the timer it armed running", "every timer the wait armed", () => BrokenPipelines.Subject("leaks its timer", time => [new BrokenPipelines.LeaksItsTimer(time)])), + ]; + + public static IReadOnlyCollection Covered => InvariantControlRow.CoveredBy(Rows); + + public static TheoryData Cases => InvariantControlRow.Cases(Rows); + + public static TheoryData PositiveCases => InvariantControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_broken_subject_is_caught(string assertion, TransportFace face, string control) => InvariantControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_default_subject_passes(string assertion, TransportFace face) => InvariantControlRow.RunPositiveAsync(assertion, face); + + private static InvariantControlRow[] Both(string assertion, string name, string because, Func subject) => + [ + new(assertion, TransportFace.Async, name, subject, Because: because), + new(assertion, TransportFace.Blocking, name, subject, Because: because), + ]; +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs index 3503f38c..fa96649d 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/InvariantControlRow.cs @@ -15,7 +15,8 @@ internal sealed record InvariantControlRow( TransportFace Face, string Name, Func Subject, - ConformanceStatus Expected = ConformanceStatus.Failed) + ConformanceStatus Expected = ConformanceStatus.Failed, + string? Because = null) { /// Short bounds so a hung control stays fast: the assertion's bound is six seconds, a wait inside it three. internal static SuiteOptions Options { get; } = new() { AssertionTimeout = TimeSpan.FromSeconds(6) }; @@ -64,8 +65,15 @@ internal static async Task RunAsync(IEnumerable rows, strin Assert.True( result.Status == row.Expected, - $"control '{name}' on {face} expected {row.Expected} but the assertion reported {result.Status}: {result.Detail}"); + $"control '{name}' on {face} expected {row.Expected} but the assertion reported {result.Status}: {result.Detail}{Trace(result)}"); Assert.False(string.IsNullOrWhiteSpace(result.Detail)); + if (row.Because is not null) + { + // A control must fail for its own reason: a broken subject that trips some other clause proves nothing about this one. + Assert.True( + result.Detail.Contains(row.Because, StringComparison.OrdinalIgnoreCase), + $"control '{name}' on {face} failed, but not for the expected reason '{row.Because}': {result.Detail}"); + } } /// Runs the real assertion on one face against (the defaults when null) and fails unless it passed. @@ -73,6 +81,9 @@ internal static async Task RunPositiveAsync(string assertion, TransportFace face { var result = await InvariantSuite.RunAsync(subject ?? new InvariantSubject(), Find(assertion), face, Options, TestContext.Current.CancellationToken); - Assert.True(result.Status == ConformanceStatus.Passed, $"{assertion} on {face} against {subject?.Name ?? "the defaults"}: {result.Status}: {result.Detail}"); + Assert.True(result.Status == ConformanceStatus.Passed, $"{assertion} on {face} against {subject?.Name ?? "the defaults"}: {result.Status}: {result.Detail}{Trace(result)}"); } + + private static string Trace(ConformanceResult result) => + result.Status is ConformanceStatus.Errored && result.Exception is { } exception ? Environment.NewLine + exception : string.Empty; } From baf4ab2f897fabb05023be8ed87d5dd0c0168104 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 20:50:49 +0300 Subject: [PATCH 05/45] feat: invariant assertions xcut-4 to xcut-8 with negative controls (phase 10, #99) --- .../Assertions/InvariantCatalogue.cs | 1 + .../Assertions/TaxonomyInvariants.cs | 195 ++++++++++++++++++ .../Internals/ProbeRetryableError.cs | 17 ++ .../Suite/InvariantContext.cs | 10 + .../Controls/BrokenPipelines.cs | 105 ++++++++++ .../Controls/InvariantTaxonomyControlTests.cs | 70 +++++++ 6 files changed, 398 insertions(+) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/TaxonomyInvariants.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/ProbeRetryableError.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs index ef52f681..17a627bf 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs @@ -13,5 +13,6 @@ internal static class InvariantCatalogue internal static IReadOnlyList All { get; } = [ .. CancellationInvariants.Assertions(), + .. TaxonomyInvariants.Assertions(), ]; } diff --git a/src/Dexpace.Sdk.Conformance/Assertions/TaxonomyInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/TaxonomyInvariants.cs new file mode 100644 index 00000000..978d0b81 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/TaxonomyInvariants.cs @@ -0,0 +1,195 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from nodejs-sdk@54aeed4 tests/conformance/xcut/error-taxonomy.conformance.test.ts and ruby-sdk@5b17395 +// gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/ (error taxonomy and retry classification). The status +// matrix is the one of appendix C, XCUT-5: 408, 429 and every 5xx except 501 and 505. +using System.Globalization; +using System.Net.Sockets; +using System.Text; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Conformance; + +/// +/// XCUT-4 to XCUT-8: the two-branch error taxonomy, the one status classifier behind the baked flag, retry +/// classification by capability, the configured status set that is authoritative, and the strict status-to-exception factory +/// that refuses a non-error status. The transport-level clause of XCUT-4 is transport-20 (P10-6). +/// +internal static class TaxonomyInvariants +{ + // XCUT-5: the one classifier, as a table. 507 is outside the lists the reference tests, and bakes retryable true. + private static readonly int[] s_classified = [400, 404, 408, 429, 500, 501, 502, 503, 504, 505, 507, 599]; + + internal static ConformanceAssertion[] Assertions() => + [ + InvariantAssertionBuilder.Define("xcut-4.two-branches", ["XCUT-4"], RequirementLevel.Must, InvariantAssertionBuilder.Both, TwoBranchesAsync), + InvariantAssertionBuilder.Define("xcut-5.one-classifier", ["XCUT-5"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, OneClassifierAsync), + InvariantAssertionBuilder.Define("xcut-6.capability-queried", ["XCUT-6"], RequirementLevel.Must, InvariantAssertionBuilder.Both, CapabilityQueriedAsync), + InvariantAssertionBuilder.Define("xcut-7.configured-set-authoritative", ["XCUT-7"], RequirementLevel.Must, InvariantAssertionBuilder.Both, ConfiguredSetAuthoritativeAsync), + InvariantAssertionBuilder.Define("xcut-8.strict-factory-rejects", ["XCUT-8"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, StrictFactoryRejectsAsync), + InvariantAssertionBuilder.Define("xcut-8.convenience-returns-response", ["XCUT-8"], RequirementLevel.Must, InvariantAssertionBuilder.Both, ConvenienceReturnsResponseAsync), + ]; + + // Branch (a) carries a fully received response with status, headers and body; branch (b) carries none and is retryable, with + // an I/O-family cause. "Catchable as a generic I/O error" is the judged part of §10 entry 7 and is not asserted (P10-25). + private static async Task TwoBranchesAsync(InvariantContext context, CancellationToken token) + { + var payload = Encoding.UTF8.GetBytes("no such thing"); + var answering = context.Transport(ProbeTransport.Behavior.Of((request, _) => new Response( + request, + Status.FromCode(404), + Protocol.Http11, + new Headers.Builder().Add("X-Probe", "yes").Build(), + ResponseBody.FromBytes(payload), + null))); + var protocol = InvariantContext.WithErrorMapping(context.Build(answering, InvariantOptions.Retrying(0))); + + var received = await context.FailureOfAsync(protocol, Request.Get(InvariantOptions.Url), "the call answered with a 404", token).ConfigureAwait(false); + + var http = received as HttpResponseException + ?? throw new ConformanceException("an error status must surface as the response-carrying error", "HttpResponseException", Outcome.Name(received)); + Check.Equal(http.Status.Code, 404, "the status the response-carrying error exposes"); + Check.Equal(http.Response.Headers.Get("X-Probe"), "yes", "a header of the received response, exposed by the error"); + var body = await Check.GuardAsync(() => http.Response.Body.ReadAsBytesAsync(token), "reading the error's body", token).ConfigureAwait(false); + Check.SequenceEqual(body, payload, "the body the response-carrying error exposes"); + + var refusing = context.Transport(ProbeTransport.Behavior.Of((_, _) => + throw new ServiceRequestException("connection refused", new SocketException((int)SocketError.ConnectionRefused)))); + var transport = context.Build(refusing, InvariantOptions.Retrying(0)); + + var refused = await context.FailureOfAsync(transport, Request.Get(InvariantOptions.Url), "the call whose connection is refused", token).ConfigureAwait(false); + + Check.True(refused is SdkException and not HttpResponseException, "a failure with no response must be the transport branch, not a response-carrying error", "an SDK exception carrying no response", Outcome.Name(refused)); + Check.True(refused is IRetryableError { IsRetryable: true }, "a transport error must report itself always retryable", "retryable", Outcome.Name(refused)); + Check.True( + Outcome.Find(refused) is not null || Outcome.Find(refused) is not null, + "a transport error must keep the I/O-family cause it was raised for", + "a SocketException or IOException in the cause chain", + Outcome.Name(refused)); + } + + // The baked flag comes from one classifier: 408, 429 and every 5xx except 501 and 505 are retryable, nothing else is. + private static Task OneClassifierAsync(InvariantContext context, CancellationToken token) + { + var mismatches = new List(); + foreach (var code in s_classified) + { + using var response = new Response(Request.Get(InvariantOptions.Url), Status.FromCode(code), Protocol.Http11); + var baked = context.Subject.CreateResponseException(response).IsRetryable; + var expected = code is 408 or 429 or (>= 500 and <= 599 and not 501 and not 505); + if (baked != expected) + { + mismatches.Add(string.Create(CultureInfo.InvariantCulture, $"{code} baked {baked} (the classifier says {expected})")); + } + } + + Check.True( + mismatches.Count == 0, + "the baked retryable flag must follow the one classifier: 408, 429 and every 5xx except 501 and 505", + "no mismatch over " + string.Join(", ", s_classified), + string.Join("; ", mismatches)); + return Task.CompletedTask; + } + + // A type the classifier has never seen, declaring itself retryable, is retried; one declaring itself not retryable is not. + private static async Task CapabilityQueriedAsync(InvariantContext context, CancellationToken token) + { + foreach (var retryable in new[] { true, false }) + { + var transport = context.Transport(ProbeTransport.Behavior.Of((_, _) => throw new ProbeRetryableError(retryable))); + var pipeline = context.Build(transport, InvariantOptions.Retrying(2)); + + var failure = await context.FailureOfAsync(pipeline, Request.Get(InvariantOptions.Url), "a call failing with an unknown error type", token).ConfigureAwait(false); + + Check.True(Outcome.Find(failure) is not null, "the unknown error must surface to the caller once the retries end", "the unknown error type", Outcome.Name(failure)); + Check.Equal( + transport.Sends, + retryable ? 3 : 1, + retryable + ? "the sends of an unknown error type declaring itself retryable (retry decisions query the capability, not a concrete type)" + : "the sends of an unknown error type declaring itself not retryable"); + } + } + + // The configured set decides, not the baked flag: widen it with 501, narrow it by 500, and the same set re-classifies a + // re-sent error status. + private static async Task ConfiguredSetAuthoritativeAsync(InvariantContext context, CancellationToken token) + { + await ExpectSendsAsync(context, [501], [501], 3, "a 501 in the configured set (its baked flag is false)", token).ConfigureAwait(false); + await ExpectSendsAsync(context, [408, 429, 502, 503, 504], [500], 1, "a 500 removed from the configured set (its baked flag is true)", token).ConfigureAwait(false); + await ExpectSendsAsync(context, [503], [503, 501, 200], 2, "a 503 then a 501 with only 503 configured (the re-sent 501 is not re-classified as a failure)", token).ConfigureAwait(false); + await ExpectSendsAsync(context, [503, 501], [503, 501, 200], 3, "a 503 then a 501 with both configured (the re-sent 501 is re-classified as a failure)", token).ConfigureAwait(false); + } + + private static async Task ExpectSendsAsync(InvariantContext context, int[] configured, int[] script, int expectedSends, string what, CancellationToken token) + { + var transport = context.Transport(ProbeTransport.Behavior.Of((request, send) => ProbeTransport.Empty(request, script[Math.Min(send, script.Length) - 1]))); + var options = new DexpaceClientOptions { Retry = InvariantOptions.Fast(2) with { RetryableStatusCodes = new HashSet(configured) } }; + var pipeline = context.Build(transport, options); + + await context.FailureOfAsync(pipeline, Request.Get(InvariantOptions.Url), what, token).ConfigureAwait(false); + + Check.Equal(transport.Sends, expectedSends, $"the sends for {what}"); + } + + // The strict factory refuses 1xx, 2xx and 3xx with an argument error and maps an error status to the exception that carries it. + private static Task StrictFactoryRejectsAsync(InvariantContext context, CancellationToken token) + { + var strict = InvariantContext.RequireHook(context.Subject.MapStrict, nameof(InvariantSubject.MapStrict)); + foreach (var code in new[] { 100, 200, 204, 302, 304 }) + { + using var response = new Response(Request.Get(InvariantOptions.Url), Status.FromCode(code), Protocol.Http11); + var (made, thrown) = Attempt(strict, response); + Check.True( + thrown is ArgumentException, + $"the strict factory must raise an argument error for status {code} rather than fabricate a \"successful exception\"", + "ArgumentException", + made is not null ? "returned " + made.GetType().Name : Outcome.Name(thrown)); + } + + foreach (var code in new[] { 404, 503 }) + { + using var response = new Response(Request.Get(InvariantOptions.Url), Status.FromCode(code), Protocol.Http11); + var (made, thrown) = Attempt(strict, response); + Check.True(made is not null, $"the strict factory must map the error status {code}", "an exception for the status", Outcome.Name(thrown)); + } + + return Task.CompletedTask; + } + + // The convenience form returns a non-error response as it is, through the pipeline with error mapping on. + private static async Task ConvenienceReturnsResponseAsync(InvariantContext context, CancellationToken token) + { + foreach (var code in new[] { 200, 201, 204, 304 }) + { + var transport = context.Transport(ProbeTransport.Behavior.Status(code)); + var pipeline = InvariantContext.WithErrorMapping(context.Build(transport, InvariantOptions.Retrying(0))); + + using var response = await Check.GuardAsync( + () => context.SendAsync(pipeline, Request.Get(InvariantOptions.Url), token), + $"a call answered with the non-error status {code}", + token).ConfigureAwait(false); + + Check.Equal(response.Status.Code, code, $"the status of the response returned for a {code}"); + } + } + + private static (Exception? Made, Exception? Thrown) Attempt(Func factory, Response response) + { + try + { + return (factory(response), null); + } +#pragma warning disable CA1031 // The point of the method: whatever the factory raises is the answer. + catch (Exception ex) +#pragma warning restore CA1031 + { + return (null, ex); + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/ProbeRetryableError.cs b/src/Dexpace.Sdk.Conformance/Internals/ProbeRetryableError.cs new file mode 100644 index 00000000..605ed8a4 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/ProbeRetryableError.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Errors; + +namespace Dexpace.Sdk.Conformance; + +/// +/// An error type the SDK has never heard of, declaring through whether it may be retried. It +/// derives from alone (no I/O family, no SDK base), so the only thing that can make a retry layer retry it +/// is the capability, not a concrete-type match (XCUT-6). +/// +internal sealed class ProbeRetryableError(bool retryable) : Exception("a failure from a transport the SDK has never heard of"), IRetryableError +{ + /// + public bool IsRetryable { get; } = retryable; +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs b/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs index 092d883a..404db6dc 100644 --- a/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs +++ b/src/Dexpace.Sdk.Conformance/Suite/InvariantContext.cs @@ -6,6 +6,7 @@ using Dexpace.Sdk.Core.Http.Request; using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; namespace Dexpace.Sdk.Conformance; @@ -65,6 +66,15 @@ internal T Own(T item) internal ProbeTransport Transport(ProbeTransport.Behavior behavior) => Own(new ProbeTransport(behavior)); #pragma warning restore CA2000 + /// + /// Returns with error mapping on: the pipeline itself when it already holds an + /// , otherwise a flattened copy with one added (PIPE-35), so an error status reaches the caller + /// as the exception the SDK maps it to rather than as a response. + /// + /// The subject's pipeline. + internal static HttpPipeline WithErrorMapping(HttpPipeline pipeline) => + pipeline.Policies.OfType().Any() ? pipeline : PipelineBuilder.Flatten(pipeline).Add(new ErrorMappingPolicy()).Build(); + /// Builds the subject's pipeline over on this run's clock and logger. /// The terminal transport; the pipeline never owns it. /// The call-level options, or the defaults. diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs index 4682dc47..2ac6aef5 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs @@ -26,6 +26,17 @@ internal static InvariantSubject Subject(string name, Func new PipelineBuilder().AddRange(policies(time)).Build(transport, options), }; + /// The default pipeline of a subject with one more policy added to it (flattened, PIPE-35), so a control breaks one layer and keeps the rest. + /// What is broken. + /// Builds the policy that breaks the clause. + internal static InvariantSubject OverDefault(string name, Func extra) => + new() + { + Name = "broken: " + name, + CreatePipeline = (transport, options, time, logger) => + PipelineBuilder.Flatten(new InvariantSubject().CreatePipeline(transport, options, time, logger)).Add(extra(time)).Build(), + }; + /// A retry loop that treats a cancelled send as a failure to retry (XCUT-1). internal sealed class RetriesCancellation : HttpPipelinePolicy { @@ -121,4 +132,98 @@ public override async ValueTask ProcessAsync(Request request, Pipeline } } } + + /// A mapping that turns a refused connection into an error that is not retryable (XCUT-4). + internal sealed class RefusalNotRetryable : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + try + { + return await continuation.RunAsync(request, context); + } + catch (ServiceRequestException refused) + { + throw new SdkException("refused: " + refused.Message); + } + } + } + + /// A mapping that surfaces an error status as a bare SDK error carrying no response (XCUT-4). + internal sealed class StatusAsBareError : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + try + { + return await continuation.RunAsync(request, context); + } + catch (HttpResponseException failed) + { + throw new SdkException("the server answered " + failed.Status.Code); + } + } + } + + /// A retry loop that retries only the concrete transport types it knows (XCUT-6). + internal sealed class RetriesConcreteTypes : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + for (var attempt = 0; ; attempt++) + { + try + { + return await continuation.RunAsync(request, context); + } + catch (Exception ex) when (ex is ServiceRequestException or IOException && attempt < context.Options.Retry.MaxRetryAttempts) + { + } + } + } + } + + /// A retry loop that decides from the error's baked retryable flag instead of the configured status set (XCUT-7). + internal sealed class RetriesOnBakedFlag : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + for (var attempt = 0; ; attempt++) + { + var response = await continuation.RunAsync(request, context); + if (!response.Status.IsError || attempt >= context.Options.Retry.MaxRetryAttempts || !new HttpResponseException(response).IsRetryable) + { + return response; + } + + response.Dispose(); + } + } + } + + /// A mapping step that raises for every status from 300 up, so a non-error response is not returned as it is (XCUT-8). + internal sealed class ThrowsForRedirects : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var response = await continuation.RunAsync(request, context); + return response.Status.Code >= 300 ? throw new HttpResponseException(response) : response; + } + } + + /// An exception whose retryable flag is whatever the factory baked in (XCUT-5). + internal sealed class BakedException(bool retryable) : SdkException("baked") + { + public override bool IsRetryable => retryable; + } } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs new file mode 100644 index 00000000..c767726e --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Response; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for xcut-4 to xcut-8 (plan 1.5). +[Trait("Category", "Unit")] +public sealed class InvariantTaxonomyControlTests +{ + private static bool Classified(int code) => code is 408 or 429 or (>= 500 and <= 599 and not 501 and not 505); + + internal static IReadOnlyList Rows { get; } = + [ + .. Both("xcut-4.two-branches", "turns a refused connection into a non-retryable error", "always retryable", () => BrokenPipelines.OverDefault("refusal not retryable", _ => new BrokenPipelines.RefusalNotRetryable())), + .. Both("xcut-4.two-branches", "surfaces an error status as a bare error", "response-carrying error", () => BrokenPipelines.OverDefault("status as a bare error", _ => new BrokenPipelines.StatusAsBareError())), + new("xcut-5.one-classifier", TransportFace.None, "bakes false for 507", () => new InvariantSubject { Name = "broken: 507", CreateResponseException = r => new BrokenPipelines.BakedException(r.Status.Code != 507 && Classified(r.Status.Code)) }, Because: "507"), + new("xcut-5.one-classifier", TransportFace.None, "bakes true for 501", () => new InvariantSubject { Name = "broken: 501", CreateResponseException = r => new BrokenPipelines.BakedException(r.Status.Code == 501 || Classified(r.Status.Code)) }, Because: "501"), + new("xcut-5.one-classifier", TransportFace.None, "hardcodes true for every status", () => new InvariantSubject { Name = "broken: hardcoded", CreateResponseException = _ => new BrokenPipelines.BakedException(true) }, Because: "400"), + .. Both("xcut-6.capability-queried", "retries only the concrete types it knows", "declaring itself retryable", () => BrokenPipelines.Subject("concrete types", _ => [new BrokenPipelines.RetriesConcreteTypes()])), + .. Both("xcut-7.configured-set-authoritative", "reads the baked flag instead of the configured set", "501 in the configured set", () => BrokenPipelines.Subject("baked flag", _ => [new BrokenPipelines.RetriesOnBakedFlag()])), + new("xcut-8.strict-factory-rejects", TransportFace.None, "maps a 200", () => new InvariantSubject { Name = "broken: maps a 200", MapStrict = r => new HttpResponseException(r) }, Because: "argument error"), + new("xcut-8.strict-factory-rejects", TransportFace.None, "returns null-like for an error status", () => new InvariantSubject { Name = "broken: refuses everything", MapStrict = _ => throw new ArgumentException("no") }, Because: "must map the error status"), + .. Both("xcut-8.convenience-returns-response", "throws for a 3xx", "non-error status", () => BrokenPipelines.OverDefault("throws for a 3xx", _ => new BrokenPipelines.ThrowsForRedirects())), + ]; + + public static IReadOnlyCollection Covered => InvariantControlRow.CoveredBy(Rows); + + public static TheoryData Cases => InvariantControlRow.Cases(Rows); + + public static TheoryData PositiveCases => InvariantControlRow.PositiveCases(Rows, "xcut-8.strict-factory-rejects"); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_broken_subject_is_caught(string assertion, TransportFace face, string control) => InvariantControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_default_subject_passes(string assertion, TransportFace face) => InvariantControlRow.RunPositiveAsync(assertion, face); + + [Fact] + public async Task The_strict_factory_assertion_is_NotExercised_naming_its_hook_when_none_is_supplied() + { + var result = await InvariantSuite.RunAsync(new InvariantSubject(), InvariantControlRow.Find("xcut-8.strict-factory-rejects"), TransportFace.None, InvariantControlRow.Options, TestContext.Current.CancellationToken); + + Assert.Equal(ConformanceStatus.NotExercised, result.Status); + Assert.Contains(nameof(InvariantSubject.MapStrict), result.Detail, StringComparison.Ordinal); + } + + [Fact] + public Task A_conforming_strict_factory_passes() => + InvariantControlRow.RunPositiveAsync( + "xcut-8.strict-factory-rejects", + TransportFace.None, + new InvariantSubject + { + Name = "conforming strict factory", + MapStrict = response => response.Status.IsError ? new HttpResponseException(response) : throw new ArgumentException("not an error status"), + }); + + private static InvariantControlRow[] Both(string assertion, string name, string because, Func subject) => + [ + new(assertion, TransportFace.Async, name, subject, Because: because), + new(assertion, TransportFace.Blocking, name, subject, Because: because), + ]; +} From 7f06a504c104f3e8ee9c9aca4fe7df1f751ce48e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 20:53:21 +0300 Subject: [PATCH 06/45] feat: invariant assertions xcut-9 to xcut-12 with negative controls (phase 10, #99) --- .../Assertions/InvariantCatalogue.cs | 1 + .../Assertions/InvariantOptions.cs | 6 + .../Assertions/ResilienceInvariants.cs | 173 ++++++++++++++++++ .../Internals/GatedCredential.cs | 50 +++++ .../Internals/StructurallyEqualProbe.cs | 18 ++ .../Controls/BrokenPipelines.cs | 82 +++++++++ .../InvariantResilienceControlTests.cs | 84 +++++++++ 7 files changed, 414 insertions(+) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/ResilienceInvariants.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/GatedCredential.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/StructurallyEqualProbe.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs index 17a627bf..f7096a3c 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs @@ -14,5 +14,6 @@ internal static class InvariantCatalogue [ .. CancellationInvariants.Assertions(), .. TaxonomyInvariants.Assertions(), + .. ResilienceInvariants.Assertions(), ]; } diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs index 6bfcc30d..af291fbe 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantOptions.cs @@ -11,6 +11,12 @@ internal static class InvariantOptions /// The URL every assertion requests: nothing is sent to it, the transport is in memory. internal const string Url = "https://example.test/resource"; + /// How many calls the concurrency assertions run at once (XCUT-11); a negative control may rendezvous this many. + internal const int Concurrency = 64; + + /// How many threads the bounded-map burst runs (XCUT-14), and how many readers race a token (XCUT-12). + internal const int Racers = 8; + /// Retries up to times, a millisecond apart and without jitter. /// The retries after the first send. internal static RetryOptions Fast(int attempts) => diff --git a/src/Dexpace.Sdk.Conformance/Assertions/ResilienceInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/ResilienceInvariants.cs new file mode 100644 index 00000000..cea943c8 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/ResilienceInvariants.cs @@ -0,0 +1,173 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from nodejs-sdk@54aeed4 tests/conformance/xcut/retry-safety.conformance.test.ts and +// concurrency-and-lifecycle.conformance.test.ts, and ruby-sdk@5b17395 gems/dexpace-conformance/lib/dexpace/conformance/ +// invariant_suite/ (retry safety, concurrency). The five-way matrix of XCUT-10 is chapter 19's own. +using System.Globalization; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Pipeline; + +namespace Dexpace.Sdk.Conformance; + +/// +/// XCUT-9 to XCUT-12: a cycle-safe cause walk, one retry-safety gate for protocol and transport failures alike, +/// shared components that hold no per-call state, and a single-flight token refresh whose lock is scoped to its cache. +/// +internal static class ResilienceInvariants +{ + private const int WalkLimit = 256; + + internal static ConformanceAssertion[] Assertions() => + [ + InvariantAssertionBuilder.Define("xcut-9.cycle-safe-walk", ["XCUT-9"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, CycleSafeWalkAsync), + InvariantAssertionBuilder.Define("xcut-10.safety-gate-uniform", ["XCUT-10"], RequirementLevel.Must, InvariantAssertionBuilder.Both, SafetyGateUniformAsync), + InvariantAssertionBuilder.Define("xcut-11.shared-no-crosstalk", ["XCUT-11"], RequirementLevel.Must, InvariantAssertionBuilder.Both, SharedNoCrosstalkAsync), + InvariantAssertionBuilder.Define("xcut-12.single-flight-scoped", ["XCUT-12"], RequirementLevel.Should, InvariantAssertionBuilder.Both, SingleFlightScopedAsync), + ]; + + // The suite only ever walks the cyclic exception and never throws it: a thrown cyclic chain hangs the test host on Windows. + // A walk is bounded by taking at most WalkLimit steps, so a walk with no visited set ends as a failure instead of a hang. + private static Task CycleSafeWalkAsync(InvariantContext context, CancellationToken token) + { + var create = InvariantContext.RequireHook(context.Subject.CreateCyclicException, nameof(InvariantSubject.CreateCyclicException)); + var walk = context.Subject.EnumerateCauses; + + var cyclic = create(); + var visited = walk(cyclic).Take(WalkLimit).ToList(); + Check.True(visited.Count < WalkLimit, "a walk over a cyclic cause chain must terminate", $"fewer than {WalkLimit} steps", $"{WalkLimit} steps without ending"); + Check.True(visited.Count > 0, "a walk over a cyclic cause chain must still visit the exception itself", "at least one cause", "none"); + Check.Equal( + visited.Distinct(ReferenceEqualityComparer.Instance).Count(), + visited.Count, + "the distinct causes the walk visited (each cause is visited once, tracked by reference identity)"); + + // Three distinct exceptions that are Equals to one another: a walk tracking visited causes by equality sees one. + var third = new StructurallyEqualProbe("same", null); + var second = new StructurallyEqualProbe("same", third); + var first = new StructurallyEqualProbe("same", second); + var chain = walk(first).Take(WalkLimit).ToList(); + Check.Equal(chain.Count, 3, "the causes visited along a chain of three distinct, structurally equal exceptions (visited causes are tracked by reference identity, not by Equals)"); + return Task.CompletedTask; + } + + // Retry safety is decided at the retry step, independently of retryability, and the same way for protocol and transport + // failures: no case may special-case a transport error. + private static async Task SafetyGateUniformAsync(InvariantContext context, CancellationToken token) + { + var body = new byte[] { 1, 2, 3 }; + var transportFailure = ProbeTransport.Behavior.Of((_, _) => throw new ServiceRequestException("connection reset")); + var protocolFailure = ProbeTransport.Behavior.Status(503); + + await ExpectSendsAsync(context, "a body-less GET failing with a transport error", Request.Get(InvariantOptions.Url), transportFailure, 3, token).ConfigureAwait(false); + await ExpectSendsAsync(context, "a body-less POST failing with a protocol error", Request.Create(Method.Post, InvariantOptions.Url), protocolFailure, 1, token).ConfigureAwait(false); + await ExpectSendsAsync(context, "a body-less POST failing with a transport error that never reached the server", Request.Create(Method.Post, InvariantOptions.Url), transportFailure, 1, token).ConfigureAwait(false); + await ExpectSendsAsync(context, "a POST with a replayable body failing with a protocol error", Request.Post(InvariantOptions.Url, RequestBody.FromBytes(body)), protocolFailure, 3, token).ConfigureAwait(false); + await ExpectSendsAsync(context, "a POST with a replayable body failing with a transport error", Request.Post(InvariantOptions.Url, RequestBody.FromBytes(body)), transportFailure, 3, token).ConfigureAwait(false); + + using var singleUse = new CountingStream(new MemoryStream(body)); + await ExpectSendsAsync(context, "a POST with a single-use streaming body failing with a transport error", Request.Post(InvariantOptions.Url, RequestBody.FromStream(singleUse)), transportFailure, 1, token).ConfigureAwait(false); + using var singleUseProtocol = new CountingStream(new MemoryStream(body)); + await ExpectSendsAsync(context, "a POST with a single-use streaming body failing with a protocol error", Request.Post(InvariantOptions.Url, RequestBody.FromStream(singleUseProtocol)), protocolFailure, 1, token).ConfigureAwait(false); + } + + private static async Task ExpectSendsAsync(InvariantContext context, string what, Request request, ProbeTransport.Behavior behavior, int expected, CancellationToken token) + { + var transport = context.Transport(behavior); + var pipeline = context.Build(transport, InvariantOptions.Retrying(2)); + + await context.FailureOfAsync(pipeline, request, what, token).ConfigureAwait(false); + + Check.Equal(transport.Sends, expected, $"the sends of {what}"); + } + + // One shared pipeline, many concurrent distinct requests, each answered by an echo of its own id: per-call state must live on + // the call, so no response may carry another call's id. + private static async Task SharedNoCrosstalkAsync(InvariantContext context, CancellationToken token) + { + var transport = context.Transport(ProbeTransport.Behavior.Of((request, _) => + ProbeTransport.Empty(request, 200, new Headers.Builder().Add("X-Echo", request.Url.Query).Build()))); + var pipeline = context.Build(transport, InvariantOptions.Retrying(0)); + + var calls = Enumerable.Range(0, InvariantOptions.Concurrency) + .Select(id => EchoAsync(context, pipeline, id, token)) + .ToArray(); + var echoes = await Bounded.WaitForAllAsync(calls, context.Bound, "the concurrent calls", token).ConfigureAwait(false); + + var crossed = echoes.Where(echo => echo.Echoed != "?id=" + echo.Id.ToString(CultureInfo.InvariantCulture)).Take(3).ToArray(); + Check.True( + crossed.Length == 0, + "a shared pipeline must hold no per-call state: every response must answer its own request", + "each response echoing its own id", + string.Join(", ", crossed.Select(echo => $"call {echo.Id} was answered with {echo.Echoed}"))); + } + + private static async Task<(int Id, string? Echoed)> EchoAsync(InvariantContext context, HttpPipeline pipeline, int id, CancellationToken token) + { + var request = Request.Get(string.Create(CultureInfo.InvariantCulture, $"{InvariantOptions.Url}?id={id}")); + using var response = await Check.GuardAsync(() => context.SendAsync(pipeline, request, token), $"concurrent call {id}", token).ConfigureAwait(false); + return (id, response.Headers.Get("X-Echo")); + } + + // N readers race one expiring token: one fetch. A fetch blocked on one credential's cache must not hold another's. + private static async Task SingleFlightScopedAsync(InvariantContext context, CancellationToken token) + { + var createCache = context.Subject.CreateTokenCache; + var scope = new TokenRequestContext(["scope"]); + + var racing = new GatedCredential(context.Time, gated: true); + var read = createCache(racing, context.Time); + try + { + var readers = Enumerable.Range(0, InvariantOptions.Racers * 2).Select(_ => StartRead(context, read, scope, token)).ToArray(); + await Bounded.WaitAsync(racing.Started, context.Bound, "the first fetch to begin", token).ConfigureAwait(false); + racing.Release(); + await Bounded.WaitForAllAsync(readers, context.Bound, "the racing readers", token).ConfigureAwait(false); + } + finally + { + racing.Release(); + } + + Check.Equal(racing.Fetches, 1, $"the credential fetches of {InvariantOptions.Racers * 2} readers racing one expiring token (the refresh is single-flight)"); + + var held = new GatedCredential(context.Time, gated: true); + var free = new GatedCredential(context.Time, gated: false); + var readHeld = createCache(held, context.Time); + var readFree = createCache(free, context.Time); + try + { + var blocked = StartRead(context, readHeld, scope, token); + await Bounded.WaitAsync(held.Started, context.Bound, "the held fetch to begin", token).ConfigureAwait(false); + var other = StartRead(context, readFree, scope, token); + var finished = await Poll.HoldsWithinAsync(() => other.IsCompleted, context.Bound, token).ConfigureAwait(false); + Check.True( + finished, + "a token read through a different credential's cache must not wait on another cache's in-flight fetch (the lock is scoped to its cache)", + "the unrelated read completing while the first fetch is parked", + "it was still waiting"); + held.Release(); + await Bounded.WaitAsync(blocked, context.Bound, "the held read after its fetch is released", token).ConfigureAwait(false); + } + finally + { + held.Release(); + } + } + + private static Task StartRead( + InvariantContext context, + Func> read, + TokenRequestContext scope, + CancellationToken token) => + context.Face == TransportFace.Blocking + ? Task.Factory.StartNew( + () => read(scope, token).AsTask(), + CancellationToken.None, + TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default).Unwrap() + : read(scope, token).AsTask(); +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/GatedCredential.cs b/src/Dexpace.Sdk.Conformance/Internals/GatedCredential.cs new file mode 100644 index 00000000..1315b2a5 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/GatedCredential.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; + +namespace Dexpace.Sdk.Conformance; + +/// +/// A whose fetch the assertion controls: it counts the fetches, says when the first one began, and +/// (when gated) parks every fetch until the assertion releases the gate. No sleeping: the gate is a task the kit completes +/// (XCUT-12). +/// +internal sealed class GatedCredential : TokenCredential +{ + private readonly TimeProvider _time; + private readonly TaskCompletionSource? _gate; + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _fetches; + + /// Creates a credential. + /// The clock the token's expiry is read against: an hour ahead of it. + /// Whether each fetch parks until . + internal GatedCredential(TimeProvider time, bool gated) + { + _time = time; + _gate = gated ? new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) : null; + } + + /// The number of fetches so far. + internal int Fetches => Volatile.Read(ref _fetches); + + /// Completed when the first fetch has begun. + internal Task Started => _started.Task; + + /// Lets every parked fetch (and every later one) finish. + internal void Release() => _gate?.TrySetResult(); + + /// + public override async ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + { + Interlocked.Increment(ref _fetches); + _started.TrySetResult(); + if (_gate is not null) + { + await _gate.Task.WaitAsync(ct).ConfigureAwait(false); + } + + return new AccessToken("gated-token-value", _time.GetUtcNow().AddHours(1)); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/StructurallyEqualProbe.cs b/src/Dexpace.Sdk.Conformance/Internals/StructurallyEqualProbe.cs new file mode 100644 index 00000000..b8db41dc --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/StructurallyEqualProbe.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// An exception that is to every other of its kind with the same message. A cause walk that +/// tracks visited causes by equality instead of reference identity stops at the second of a chain of these, which is how +/// XCUT-9 tells the two apart without ever throwing a cyclic chain. +/// +internal sealed class StructurallyEqualProbe(string message, Exception? inner) : Exception(message, inner) +{ + /// + public override bool Equals(object? obj) => obj is StructurallyEqualProbe other && other.Message == Message; + + /// + public override int GetHashCode() => Message.GetHashCode(StringComparison.Ordinal); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs index 2ac6aef5..347c826b 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs @@ -3,6 +3,7 @@ using Dexpace.Sdk.Core.Configuration; using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; using Dexpace.Sdk.Core.Http.Request; using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; @@ -226,4 +227,85 @@ internal sealed class BakedException(bool retryable) : SdkException("baked") { public override bool IsRetryable => retryable; } + + /// + /// A retry loop with the safety gate applied to one kind of failure only, or to neither (XCUT-10). A request is safe to re-send + /// when it has no body and an idempotent method, or a replayable body. + /// + internal sealed class RetryLoop(bool gateTransportFailures, bool gateProtocolFailures) : HttpPipelinePolicy + { + private static readonly HashSet s_retryableStatuses = [408, 429, 500, 502, 503, 504]; + + public override PipelineStage Stage => PipelineStage.Retry; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var retries = context.Options.Retry.MaxRetryAttempts; + for (var attempt = 0; ; attempt++) + { + try + { + var response = await continuation.RunAsync(request, context); + if (attempt < retries && s_retryableStatuses.Contains(response.Status.Code) && (!gateProtocolFailures || Safe(request))) + { + response.Dispose(); + continue; + } + + return response; + } + catch (ServiceRequestException) when (attempt < retries && (!gateTransportFailures || Safe(request))) + { + } + } + } + + private static bool Safe(Request request) => + request.Body is null + ? request.Method == Method.Get || request.Method == Method.Head || request.Method == Method.Put || request.Method == Method.Delete || request.Method == Method.Options + : request.Body.IsReplayable; + } + + /// A step that remembers the request in a field shared by every call and forwards what the field holds (XCUT-11). Every call waits for all the others first, so the cross-talk is certain. + internal sealed class HoldsRequestInAField : HttpPipelinePolicy + { + private readonly TaskCompletionSource _everyoneArrived = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Request? _last; + private int _arrived; + + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + _last = request; + if (Interlocked.Increment(ref _arrived) == InvariantOptions.Concurrency) + { + _everyoneArrived.TrySetResult(); + } + + await _everyoneArrived.Task; + return await continuation.RunAsync(_last, context); + } + } + + /// A cause walk with no visited set, lazily and without end over a cycle (XCUT-9). + /// The exception to walk. + internal static IEnumerable WalkWithoutVisitedSet(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + yield return current; + } + } + + /// A cause walk that tracks visited causes with the default equality comparer (XCUT-9). + /// The exception to walk. + internal static IEnumerable WalkByEquals(Exception exception) + { + var seen = new HashSet(); + for (var current = exception; current is not null && seen.Add(current); current = current.InnerException) + { + yield return current; + } + } } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs new file mode 100644 index 00000000..3baea2cf --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.TestSupport.Recovery; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for xcut-9 to xcut-12 (plan 1.6). +[Trait("Category", "Unit")] +public sealed class InvariantResilienceControlTests +{ + // One lock for every cache of the control, so a fetch parked in one cache holds every other: the scope the requirement forbids. + private static readonly SemaphoreSlim s_globalLock = new(1, 1); + + internal static IReadOnlyList Rows { get; } = + [ + new("xcut-9.cycle-safe-walk", TransportFace.None, "tracks visited causes by Equals", () => Walking(BrokenPipelines.WalkByEquals), Because: "reference identity"), + new("xcut-9.cycle-safe-walk", TransportFace.None, "has no visited set", () => Walking(BrokenPipelines.WalkWithoutVisitedSet), Because: "must terminate"), + .. Both("xcut-10.safety-gate-uniform", "special-cases a transport failure", "body-less POST failing with a transport error", () => BrokenPipelines.Subject("gate skips transport failures", _ => [new BrokenPipelines.RetryLoop(gateTransportFailures: false, gateProtocolFailures: true)])), + .. Both("xcut-10.safety-gate-uniform", "has no safety gate at all", "body-less POST failing with a protocol error", () => BrokenPipelines.Subject("no gate", _ => [new BrokenPipelines.RetryLoop(gateTransportFailures: false, gateProtocolFailures: false)])), + .. Both("xcut-11.shared-no-crosstalk", "holds the request in a field of the shared step", "answer its own request", () => BrokenPipelines.Subject("field", _ => [new BrokenPipelines.HoldsRequestInAField()])), + .. Both("xcut-12.single-flight-scoped", "fetches once per caller", "single-flight", () => new InvariantSubject { Name = "broken: per caller", CreateTokenCache = (credential, _) => (context, token) => credential.GetTokenAsync(context, token) }), + .. Both("xcut-12.single-flight-scoped", "serialises every cache behind one lock", "different credential's cache", () => new InvariantSubject { Name = "broken: global lock", CreateTokenCache = GlobalLockCache }), + ]; + + public static IReadOnlyCollection Covered => InvariantControlRow.CoveredBy(Rows); + + public static TheoryData Cases => InvariantControlRow.Cases(Rows); + + public static TheoryData PositiveCases => InvariantControlRow.PositiveCases(Rows, "xcut-9.cycle-safe-walk"); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_broken_subject_is_caught(string assertion, TransportFace face, string control) => InvariantControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_default_subject_passes(string assertion, TransportFace face) => InvariantControlRow.RunPositiveAsync(assertion, face); + + [Fact] + public async Task The_cycle_assertion_is_NotExercised_naming_its_hook_when_none_is_supplied() + { + var result = await InvariantSuite.RunAsync(new InvariantSubject(), InvariantControlRow.Find("xcut-9.cycle-safe-walk"), TransportFace.None, InvariantControlRow.Options, TestContext.Current.CancellationToken); + + Assert.Equal(ConformanceStatus.NotExercised, result.Status); + Assert.Contains(nameof(InvariantSubject.CreateCyclicException), result.Detail, StringComparison.Ordinal); + } + + [Fact] + public Task A_cycle_safe_walk_passes_over_a_self_cycle_and_a_two_node_cycle() => + Task.WhenAll( + InvariantControlRow.RunPositiveAsync("xcut-9.cycle-safe-walk", TransportFace.None, new InvariantSubject { Name = "self cycle", CreateCyclicException = CyclicExceptions.SelfCycle }), + InvariantControlRow.RunPositiveAsync("xcut-9.cycle-safe-walk", TransportFace.None, new InvariantSubject { Name = "two-node cycle", CreateCyclicException = CyclicExceptions.TwoNodeCycle })); + + private static InvariantSubject Walking(Func> walk) => + new() { Name = "broken: walk", CreateCyclicException = CyclicExceptions.SelfCycle, EnumerateCauses = walk }; + + private static Func> GlobalLockCache(TokenCredential credential, TimeProvider time) + { + AccessToken? cached = null; + return async (context, token) => + { + await s_globalLock.WaitAsync(token); + try + { + cached ??= await credential.GetTokenAsync(context, token); + return cached.Value; + } + finally + { + s_globalLock.Release(); + } + }; + } + + private static InvariantControlRow[] Both(string assertion, string name, string because, Func subject) => + [ + new(assertion, TransportFace.Async, name, subject, Because: because), + new(assertion, TransportFace.Blocking, name, subject, Because: because), + ]; +} From 35be45ebc073fe901316e9692ae4b530e057b1c9 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 20:57:29 +0300 Subject: [PATCH 07/45] feat: invariant assertions xcut-13 to xcut-15 with negative controls (phase 10, #99) --- .../Assertions/InvariantCatalogue.cs | 1 + .../Assertions/LifecycleInvariants.cs | 238 ++++++++++++++++++ .../Internals/ParkedReadStream.cs | 8 + .../Internals/ReleaseCountingBody.cs | 53 ++++ .../Internals/UnusedHandler.cs | 13 + .../PublicAPI.Unshipped.txt | 20 ++ .../Subjects/DisposableUnderTest.cs | 14 ++ .../Subjects/InvariantSubject.cs | 35 +++ .../Subjects/ModelFactories.cs | 12 + .../Controls/BoundedProbes.cs | 106 ++++++++ .../InvariantLifecycleControlTests.cs | 86 +++++++ 11 files changed, 586 insertions(+) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/ReleaseCountingBody.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/UnusedHandler.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/DisposableUnderTest.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/BoundedProbes.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs index f7096a3c..ba084187 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs @@ -15,5 +15,6 @@ internal static class InvariantCatalogue .. CancellationInvariants.Assertions(), .. TaxonomyInvariants.Assertions(), .. ResilienceInvariants.Assertions(), + .. LifecycleInvariants.Assertions(), ]; } diff --git a/src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs new file mode 100644 index 00000000..18ba911e --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs @@ -0,0 +1,238 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from nodejs-sdk@54aeed4 tests/conformance/xcut/concurrency-and-lifecycle.conformance.test.ts and ruby-sdk@5b17395 +// gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/ (lifecycle, bounded maps, immutable models). The interrupt +// flag clause of XCUT-13 is vacuous on .NET (design §10 entry 8, §3.7): there is no ambient interrupt flag to preserve. +using System.Globalization; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Conformance; + +/// +/// XCUT-13 to XCUT-15: components close once, whatever the call pattern, and never wait for a parked read; maps keyed +/// by callers or servers drain back to their cap after a burst; and a model keeps no alias to a mutable input. +/// +internal static class LifecycleInvariants +{ + private const int BoundedCapacity = 32; + + internal static ConformanceAssertion[] Assertions() => + [ + InvariantAssertionBuilder.Define("xcut-13.dispose-latched", ["XCUT-13"], RequirementLevel.Must, InvariantAssertionBuilder.Both, DisposeLatchedAsync), + InvariantAssertionBuilder.Define("xcut-13.dispose-non-blocking", ["XCUT-13"], RequirementLevel.Must, InvariantAssertionBuilder.Both, DisposeNonBlockingAsync), + InvariantAssertionBuilder.Define("xcut-14.bounded-under-burst", ["XCUT-14"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, BoundedUnderBurstAsync), + InvariantAssertionBuilder.Define("xcut-14.drains-after-insert", ["XCUT-14"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, DrainsAfterInsertAsync), + InvariantAssertionBuilder.Define("xcut-15.no-external-alias", ["XCUT-15"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, NoExternalAliasAsync), + ]; + + // Closing twice in a row, then from many threads at once, releases the body exactly once. Counted at the resource. + private static async Task DisposeLatchedAsync(InvariantContext context, CancellationToken token) + { + for (var index = 0; ; index++) + { + using var body = new ReleaseCountingBody(parked: false); + var disposables = context.Subject.CreateDisposables(body); + if (index >= disposables.Count) + { + Check.True(index > 0, "the subject supplies no disposable component to close", "at least one", "none"); + return; + } + + var component = disposables[index]; + await CloseAsync(context, component, token).ConfigureAwait(false); + await CloseAsync(context, component, token).ConfigureAwait(false); + await Bounded.WaitForAllAsync( + [.. Enumerable.Range(0, InvariantOptions.Racers).Select(_ => CloseAsync(context, component, token).ContinueWith(static _ => 0, TaskScheduler.Default))], + context.Bound, + $"closing a {component.Kind} from {InvariantOptions.Racers} threads at once", + token).ConfigureAwait(false); + + Check.Equal(body.Releases, 1, $"the releases of the body behind a {component.Kind} closed twice and then from {InvariantOptions.Racers} threads at once (a close is latched)"); + } + } + + // A read parked on the body when the component closes: the close returns without waiting for it. + private static async Task DisposeNonBlockingAsync(InvariantContext context, CancellationToken token) + { + for (var index = 0; ; index++) + { + using var body = new ReleaseCountingBody(parked: true); + var disposables = context.Subject.CreateDisposables(body); + if (index >= disposables.Count) + { + return; + } + + var component = disposables[index]; + var parked = body.Parked!; + var stream = await body.OpenReadAsync(token).ConfigureAwait(false); + await using var streamScope = stream.ConfigureAwait(false); + var read = stream.ReadAsync(new byte[16], token).AsTask(); + try + { + await Bounded.WaitAsync(parked.ReadStarted, context.Bound, "a read to park on the body", token).ConfigureAwait(false); + + await Bounded.WaitAsync( + CloseAsync(context, component, token), + context.Bound, + $"closing a {component.Kind} while a read is parked on its body (a close must not wait for it)", + token).ConfigureAwait(false); + } + finally + { + parked.Release(); + await ObserveAsync(read).ConfigureAwait(false); + } + + Check.Equal(body.Releases, 1, $"the releases of the body behind a {component.Kind} closed with a read parked on it"); + } + } + + // Insert far more than the cap, one thread and then many: never above the cap after an insert, and not stuck above it after a burst. + private static async Task BoundedUnderBurstAsync(InvariantContext context, CancellationToken token) + { + var create = InvariantContext.RequireHook(context.Subject.CreateBoundedMap, nameof(InvariantSubject.CreateBoundedMap)); + + var single = create(BoundedCapacity); + for (var i = 0; i < single.Capacity * 8; i++) + { + single.Insert("single-" + i.ToString(CultureInfo.InvariantCulture)); + var count = single.Count(); + Check.True( + count <= single.Capacity, + "a map keyed by callers or servers must never hold more than its cap after an insert", + $"at most {single.Capacity} entries", + $"{count} after insert {i + 1}"); + } + + var burst = create(BoundedCapacity); + await InsertBurstAsync(context, burst, burst.Capacity * 4, token).ConfigureAwait(false); + Check.True( + burst.Count() <= burst.Capacity, + "a map must not be left above its cap by a concurrent insert burst", + $"at most {burst.Capacity} entries", + burst.Count().ToString(CultureInfo.InvariantCulture)); + } + + // The map is one insert short of its cap when many threads insert at once: it must drain back under the cap after each insert, + // with a loop, so the burst converges to the bound (a single pre-insert check-then-evict leaves it stuck above). + private static async Task DrainsAfterInsertAsync(InvariantContext context, CancellationToken token) + { + var create = InvariantContext.RequireHook(context.Subject.CreateBoundedMap, nameof(InvariantSubject.CreateBoundedMap)); + var probe = create(BoundedCapacity); + for (var i = 0; i < probe.Capacity - 1; i++) + { + probe.Insert("fill-" + i.ToString(CultureInfo.InvariantCulture)); + } + + await InsertBurstAsync(context, probe, probe.Capacity, token).ConfigureAwait(false); + + var count = probe.Count(); + Check.True( + count <= probe.Capacity, + "after a concurrent insert burst the map must drain back to its cap (a loop after each insert, not a single pre-insert check-then-evict)", + $"at most {probe.Capacity} entries", + count.ToString(CultureInfo.InvariantCulture)); + } + + private static async Task InsertBurstAsync(InvariantContext context, BoundedMapProbe probe, int perThread, CancellationToken token) + { + using var start = new ManualResetEventSlim(initialState: false); + var threads = Enumerable.Range(0, InvariantOptions.Racers) + .Select(id => Task.Factory.StartNew( + () => + { + start.Wait(token); + for (var i = 0; i < perThread; i++) + { + probe.Insert(string.Create(CultureInfo.InvariantCulture, $"burst-{id}-{i}")); + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default)) + .ToArray(); + + start.Set(); + await Bounded.WaitAsync(Task.WhenAll(threads), context.Bound, "the concurrent insert burst", token).ConfigureAwait(false); + } + + // Mutating what a model was built from must not change the model; a "setter" returns a new instance. + private static Task NoExternalAliasAsync(InvariantContext context, CancellationToken token) + { + var models = context.Subject.Models; + + var typeInput = new Dictionary { ["charset"] = "utf-8" }; + var typeView = models.MediaTypeParameters(typeInput); + typeInput["charset"] = "tampered"; + typeInput["added"] = "x"; + UnchangedParameters(typeView, "a media type's parameters"); + + var challengeInput = new Dictionary { ["charset"] = "utf-8" }; + var challengeView = models.ChallengeParameters(challengeInput); + challengeInput["charset"] = "tampered"; + challengeInput["added"] = "x"; + UnchangedParameters(challengeView, "an authentication challenge's parameters"); + + var bytes = new byte[] { 1, 2, 3 }; + var read = models.BytesBody(bytes); + var before = (byte[])read().Clone(); + bytes[0] = 9; + Check.SequenceEqual(read(), before, "the bytes a request body sends after the array it was built from was mutated"); + + var built = models.HeadersAfterBuilderMutation(); + Check.True(built.Contains("A") && !built.Contains("B"), "headers built before a builder was mutated again must not see the mutation", "A present, B absent", built.ToString()); + + var headers = new Headers.Builder().Add("A", "1").Build(); + var withB = models.WithHeader(headers, "B", "2"); + Check.True(!ReferenceEquals(withB, headers), "a header \"setter\" must return a new instance", "a new instance", "the same instance"); + Check.True(!headers.Contains("B") && withB.Get("B") == "2", "adding a header must leave the original unchanged and the copy changed", "original without B, copy with B", headers.ToString()); + + var request = Request.Get(InvariantOptions.Url); + var withHeader = models.RequestWithHeader(request, "X-New", "1"); + Check.True(!ReferenceEquals(withHeader, request), "a request \"setter\" must return a new instance", "a new instance", "the same instance"); + Check.True(!request.Headers.Contains("X-New") && withHeader.Headers.Get("X-New") == "1", "adding a request header must leave the original unchanged and the copy changed", "original without X-New, copy with it", request.Headers.ToString()); + return Task.CompletedTask; + } + + private static void UnchangedParameters(IReadOnlyDictionary view, string what) => + Check.True( + view.Count == 1 && view.TryGetValue("charset", out var charset) && charset == "utf-8", + $"{what} must not change when the map they were built from is mutated afterwards", + "charset=utf-8 and nothing else", + string.Join(", ", view.Select(pair => pair.Key + "=" + pair.Value))); + + private static async Task CloseAsync(InvariantContext context, DisposableUnderTest component, CancellationToken token) + { + try + { + if (context.Face == TransportFace.Blocking) + { + await Task.Factory.StartNew(component.Dispose, CancellationToken.None, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).ConfigureAwait(false); + } + else + { + await component.DisposeAsync().ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not ConformanceException && !token.IsCancellationRequested) + { + throw new ConformanceException($"closing a {component.Kind} threw {ex.GetType().Name}: a repeated or concurrent close must be a no-op", ex); + } + } + + private static async Task ObserveAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } +#pragma warning disable CA1031 // The parked read ends in whatever way the close ended it; only that it ends matters here. + catch (Exception) +#pragma warning restore CA1031 + { + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/ParkedReadStream.cs b/src/Dexpace.Sdk.Conformance/Internals/ParkedReadStream.cs index ba6ff52b..c7a5b83b 100644 --- a/src/Dexpace.Sdk.Conformance/Internals/ParkedReadStream.cs +++ b/src/Dexpace.Sdk.Conformance/Internals/ParkedReadStream.cs @@ -14,6 +14,7 @@ internal sealed class ParkedReadStream : Stream { private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _readFinished = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _observedCancellation; private int _disposed; @@ -21,6 +22,9 @@ internal sealed class ParkedReadStream : Stream internal Task ReadStarted => _readStarted.Task; /// Whether a parked read ended because its cancellation token fired. + /// Completed when the parked read has ended, by release, cancellation or disposal. + internal Task ReadFinished => _readFinished.Task; + internal bool ObservedCancellation => Volatile.Read(ref _observedCancellation) == 1; /// Whether the stream was disposed. @@ -61,6 +65,10 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation Interlocked.Exchange(ref _observedCancellation, 1); throw; } + finally + { + _readFinished.TrySetResult(); + } return 0; } diff --git a/src/Dexpace.Sdk.Conformance/Internals/ReleaseCountingBody.cs b/src/Dexpace.Sdk.Conformance/Internals/ReleaseCountingBody.cs new file mode 100644 index 00000000..eacb3ce0 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/ReleaseCountingBody.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Conformance; + +/// +/// A response body that counts its releases at the resource it holds (XCUT-13): the count moves every time the resource is +/// released, whichever path released it, so a component that closes twice shows as two. When parked, the body's stream holds a read +/// open until the assertion lets it go, which is how an assertion tells a close that waits for it from one that does not. +/// +internal sealed class ReleaseCountingBody : ResponseBody +{ + private readonly ParkedReadStream? _parked; + private int _releases; + + /// Creates a body. + /// Whether its stream parks every read until released. + internal ReleaseCountingBody(bool parked) => _parked = parked ? new ParkedReadStream() : null; + + /// How many times the resource was released. + internal int Releases => Volatile.Read(ref _releases); + + /// The parked stream, when the body was created parked. + internal ParkedReadStream? Parked => _parked; + + /// + public override MediaType? ContentType => null; + + /// Releases the resource once more: what a close does, and what a component without a latch does on every call. + internal void ReleaseResource() + { + Interlocked.Increment(ref _releases); + _parked?.Dispose(); + } + + /// + public override Task OpenReadAsync(CancellationToken cancellationToken = default) => + Task.FromResult(_parked ?? (Stream)new MemoryStream([], writable: false)); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + ReleaseResource(); + } + + base.Dispose(disposing); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/UnusedHandler.cs b/src/Dexpace.Sdk.Conformance/Internals/UnusedHandler.cs new file mode 100644 index 00000000..62b229e8 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/UnusedHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Conformance; + +/// A response handler a typed response is built with and never asked to run: the assertions that use it only close the response. +internal sealed class UnusedHandler : IResponseHandler +{ + /// + public ValueTask HandleAsync(Response response, CancellationToken cancellationToken) => ValueTask.FromResult(0); +} diff --git a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt index fc0e473c..62fd9499 100644 --- a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt @@ -92,6 +92,17 @@ Dexpace.Sdk.Conformance.CredentialSample.Render.get -> System.Func! Dexpace.Sdk.Conformance.CredentialSample.Render.init -> void Dexpace.Sdk.Conformance.CredentialSample.Secret.get -> string! Dexpace.Sdk.Conformance.CredentialSample.Secret.init -> void +Dexpace.Sdk.Conformance.DisposableUnderTest +Dexpace.Sdk.Conformance.DisposableUnderTest.$() -> Dexpace.Sdk.Conformance.DisposableUnderTest! +Dexpace.Sdk.Conformance.DisposableUnderTest.Deconstruct(out string! Kind, out System.Action! Dispose, out System.Func! DisposeAsync) -> void +Dexpace.Sdk.Conformance.DisposableUnderTest.DisposableUnderTest(string! Kind, System.Action! Dispose, System.Func! DisposeAsync) -> void +Dexpace.Sdk.Conformance.DisposableUnderTest.Dispose.get -> System.Action! +Dexpace.Sdk.Conformance.DisposableUnderTest.Dispose.init -> void +Dexpace.Sdk.Conformance.DisposableUnderTest.DisposeAsync.get -> System.Func! +Dexpace.Sdk.Conformance.DisposableUnderTest.DisposeAsync.init -> void +Dexpace.Sdk.Conformance.DisposableUnderTest.Equals(Dexpace.Sdk.Conformance.DisposableUnderTest? other) -> bool +Dexpace.Sdk.Conformance.DisposableUnderTest.Kind.get -> string! +Dexpace.Sdk.Conformance.DisposableUnderTest.Kind.init -> void Dexpace.Sdk.Conformance.InternalCancellation Dexpace.Sdk.Conformance.InternalCancellation.CancelInFlight.get -> System.Action! Dexpace.Sdk.Conformance.InternalCancellation.InternalCancellation(Dexpace.Sdk.Core.Client.IAsyncHttpClient! transport, System.Action! cancelInFlight) -> void @@ -104,6 +115,8 @@ Dexpace.Sdk.Conformance.InvariantSubject.CreateBoundedMap.get -> System.Func void Dexpace.Sdk.Conformance.InvariantSubject.CreateCyclicException.get -> System.Func? Dexpace.Sdk.Conformance.InvariantSubject.CreateCyclicException.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.CreateDisposables.get -> System.Func!>! +Dexpace.Sdk.Conformance.InvariantSubject.CreateDisposables.init -> void Dexpace.Sdk.Conformance.InvariantSubject.CreatePipeline.get -> System.Func! Dexpace.Sdk.Conformance.InvariantSubject.CreatePipeline.init -> void Dexpace.Sdk.Conformance.InvariantSubject.CreateResponseException.get -> System.Func! @@ -138,6 +151,8 @@ Dexpace.Sdk.Conformance.ModelFactories.BytesBody.init -> void Dexpace.Sdk.Conformance.ModelFactories.ChallengeParameters.get -> System.Func!, System.Collections.Generic.IReadOnlyDictionary!>! Dexpace.Sdk.Conformance.ModelFactories.ChallengeParameters.init -> void Dexpace.Sdk.Conformance.ModelFactories.Equals(Dexpace.Sdk.Conformance.ModelFactories? other) -> bool +Dexpace.Sdk.Conformance.ModelFactories.HeadersAfterBuilderMutation.get -> System.Func! +Dexpace.Sdk.Conformance.ModelFactories.HeadersAfterBuilderMutation.init -> void Dexpace.Sdk.Conformance.ModelFactories.InboundHeader.get -> System.Func! Dexpace.Sdk.Conformance.ModelFactories.InboundHeader.init -> void Dexpace.Sdk.Conformance.ModelFactories.MediaTypeParameters.get -> System.Func!, System.Collections.Generic.IReadOnlyDictionary!>! @@ -258,6 +273,9 @@ override Dexpace.Sdk.Conformance.ConformanceWaiver.ToString() -> string! override Dexpace.Sdk.Conformance.CredentialSample.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.CredentialSample.GetHashCode() -> int override Dexpace.Sdk.Conformance.CredentialSample.ToString() -> string! +override Dexpace.Sdk.Conformance.DisposableUnderTest.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.DisposableUnderTest.GetHashCode() -> int +override Dexpace.Sdk.Conformance.DisposableUnderTest.ToString() -> string! override Dexpace.Sdk.Conformance.InvariantSubject.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.InvariantSubject.GetHashCode() -> int override Dexpace.Sdk.Conformance.InvariantSubject.ToString() -> string! @@ -284,6 +302,8 @@ static Dexpace.Sdk.Conformance.ConformanceWaiver.operator !=(Dexpace.Sdk.Conform static Dexpace.Sdk.Conformance.ConformanceWaiver.operator ==(Dexpace.Sdk.Conformance.ConformanceWaiver? left, Dexpace.Sdk.Conformance.ConformanceWaiver? right) -> bool static Dexpace.Sdk.Conformance.CredentialSample.operator !=(Dexpace.Sdk.Conformance.CredentialSample? left, Dexpace.Sdk.Conformance.CredentialSample? right) -> bool static Dexpace.Sdk.Conformance.CredentialSample.operator ==(Dexpace.Sdk.Conformance.CredentialSample? left, Dexpace.Sdk.Conformance.CredentialSample? right) -> bool +static Dexpace.Sdk.Conformance.DisposableUnderTest.operator !=(Dexpace.Sdk.Conformance.DisposableUnderTest? left, Dexpace.Sdk.Conformance.DisposableUnderTest? right) -> bool +static Dexpace.Sdk.Conformance.DisposableUnderTest.operator ==(Dexpace.Sdk.Conformance.DisposableUnderTest? left, Dexpace.Sdk.Conformance.DisposableUnderTest? right) -> bool static Dexpace.Sdk.Conformance.InvariantSubject.operator !=(Dexpace.Sdk.Conformance.InvariantSubject? left, Dexpace.Sdk.Conformance.InvariantSubject? right) -> bool static Dexpace.Sdk.Conformance.InvariantSubject.operator ==(Dexpace.Sdk.Conformance.InvariantSubject? left, Dexpace.Sdk.Conformance.InvariantSubject? right) -> bool static Dexpace.Sdk.Conformance.InvariantSuite.Assertions.get -> System.Collections.Generic.IReadOnlyList! diff --git a/src/Dexpace.Sdk.Conformance/Subjects/DisposableUnderTest.cs b/src/Dexpace.Sdk.Conformance/Subjects/DisposableUnderTest.cs new file mode 100644 index 00000000..870d48b5 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/DisposableUnderTest.cs @@ -0,0 +1,14 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// One disposable component for XCUT-13: how to close it on each face. The asynchronous assertion calls +/// , the blocking one ; both must be latched (a repeat is a no-op) and must not +/// wait for a read parked on the body. +/// +/// What the component is, named in a failure, for example Response. +/// The synchronous close. +/// The asynchronous close. +public sealed record DisposableUnderTest(string Kind, Action Dispose, Func DisposeAsync); diff --git a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs index c9c50ea8..fd8165e4 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs @@ -6,6 +6,9 @@ using Dexpace.Sdk.Core.Configuration; using Dexpace.Sdk.Core.Diagnostics; using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.ServerSentEvents; using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; using Dexpace.Sdk.Core.Pipeline.Policies; @@ -163,6 +166,21 @@ public IReadOnlyList Credentials } } = DefaultCredentials(); + /// + /// Builds the disposable components XCUT-13 closes, each over a fresh body: the caller closes one twice, and concurrently, + /// and requires the body released exactly once, without waiting for a read parked on it. Default: a core , + /// a and a . + /// + public Func> CreateDisposables + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = DefaultDisposables; + /// /// No default. The strict status-to-exception factory (XCUT-8): must throw for a /// non-error status. makes xcut-8.strict-factory-rejects . @@ -194,6 +212,23 @@ public IReadOnlyList Credentials /// public SeamResolution? SeamResolution { get; init; } +#pragma warning disable CA2000 // Ownership passes to the returned disposables, which the assertion closes. + private static DisposableUnderTest[] DefaultDisposables(ResponseBody body) + { + var request = Request.Get("https://example.test/"); + var headers = new Headers.Builder().Add("Content-Type", "text/event-stream").Build(); + var response = new Response(request, Status.Ok, Protocol.Http11, headers, body); + var typed = new TypedResponse(new Response(request, Status.Ok, Protocol.Http11, headers, body), new UnusedHandler()); + var events = ServerSentEventStream.FromResponse(new Response(request, Status.Ok, Protocol.Http11, headers, body)); + return + [ + new DisposableUnderTest(nameof(Response), response.Dispose, response.DisposeAsync), + new DisposableUnderTest(nameof(TypedResponse), typed.Dispose, typed.DisposeAsync), + new DisposableUnderTest(nameof(ServerSentEventStream), events.Dispose, events.DisposeAsync), + ]; + } +#pragma warning restore CA2000 + private static CredentialSample[] DefaultCredentials() { var basic = new BasicCredential("service-user", "p4ssw0rd-secret"); diff --git a/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs b/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs index 0bd3445a..67e733b2 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs @@ -56,6 +56,18 @@ public sealed record ModelFactories }; }; + /// + /// Builds a header collection holding the field A: 1 from a builder, then keeps adding B: 2 to that builder, and + /// returns what was built before the second field was added; the suite requires B absent. Default: Headers.Builder.Build. + /// + public Func HeadersAfterBuilderMutation { get; init; } = static () => + { + var builder = new Headers.Builder().Add("A", "1"); + var built = builder.Build(); + builder.Add("B", "2"); + return built; + }; + /// Returns the given collection with one field set, as a collection that is not the given one. Default: Headers.With. public Func WithHeader { get; init; } = static (headers, name, value) => headers.With(name, value); diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BoundedProbes.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BoundedProbes.cs new file mode 100644 index 00000000..82a4d896 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BoundedProbes.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Concurrent; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// The bounded-map probes of the XCUT-14 controls: the conforming one and the broken ones (plan 1.7). +internal static class BoundedProbes +{ + /// A conforming map: a lock and a loop that drains to the cap after every insert. + internal static BoundedMapProbe Conforming(int capacity) + { + var map = new Dictionary(); + var gate = new object(); + return new BoundedMapProbe( + capacity, + key => + { + lock (gate) + { + map[key] = 1; + while (map.Count > capacity) + { + map.Remove(map.Keys.First()); + } + } + }, + () => + { + lock (gate) + { + return map.Count; + } + }); + } + + /// A map that never evicts. + internal static BoundedMapProbe Unbounded(int capacity) + { + var map = new ConcurrentDictionary(); + return new BoundedMapProbe(capacity, key => map[key] = 1, () => map.Count); + } + + /// A map whose single pre-insert check uses "above" for "at", so it settles one entry over the cap. + internal static BoundedMapProbe OffByOne(int capacity) + { + var map = new ConcurrentDictionary(); + return new BoundedMapProbe( + capacity, + key => + { + if (map.Count > capacity) + { + EvictOne(map); + } + + map[key] = 1; + }, + () => map.Count); + } + + /// + /// A map that checks the size once before it inserts and evicts one entry if it was full: right sequentially, wrong the moment + /// several threads straddle the cap. The first insert of each burst thread rendezvous at the check, so the straddle is certain + /// (as many threads as the suite runs: ), and the map then settles above its cap for good. + /// + internal static BoundedMapProbe PreInsertCheckThenEvict(int capacity) + { + var map = new ConcurrentDictionary(); +#pragma warning disable CA2000 // Lives as long as the probe; the test process ends it. + var rendezvous = new Barrier(InvariantOptions.Racers); + var arrived = new ThreadLocal(); +#pragma warning restore CA2000 + return new BoundedMapProbe( + capacity, + key => + { + var seen = map.Count; + if (seen == capacity - 1 && !arrived.Value) + { + arrived.Value = true; + rendezvous.SignalAndWait(TimeSpan.FromSeconds(20)); + } + + if (seen >= capacity) + { + EvictOne(map); + } + + map[key] = 1; + }, + () => map.Count); + } + + private static void EvictOne(ConcurrentDictionary map) + { + foreach (var key in map.Keys) + { + if (map.TryRemove(key, out _)) + { + return; + } + } + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs new file mode 100644 index 00000000..96f0e11f --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for xcut-13 to xcut-15 (plan 1.7). +[Trait("Category", "Unit")] +public sealed class InvariantLifecycleControlTests +{ + internal static IReadOnlyList Rows { get; } = + [ + .. Both("xcut-13.dispose-latched", "releases the resource on every close", "closed twice", () => WithDisposable(body => new DisposableUnderTest( + "naive closer", + () => body.ReleaseResource(), + () => + { + body.ReleaseResource(); + return ValueTask.CompletedTask; + }))), + .. Both("xcut-13.dispose-non-blocking", "waits for the read parked on the body", "must not wait", () => WithDisposable(body => new DisposableUnderTest( + "closer that waits", + () => body.Parked!.ReadFinished.Wait(), + async () => await body.Parked!.ReadFinished))), + new("xcut-14.bounded-under-burst", TransportFace.None, "never evicts", () => Mapping(BoundedProbes.Unbounded), Because: "never hold more than its cap"), + new("xcut-14.bounded-under-burst", TransportFace.None, "checks above the cap instead of at it", () => Mapping(BoundedProbes.OffByOne), Because: "never hold more than its cap"), + new("xcut-14.drains-after-insert", TransportFace.None, "never evicts", () => Mapping(BoundedProbes.Unbounded), Because: "drain back"), + new("xcut-14.drains-after-insert", TransportFace.None, "checks once before it inserts", () => Mapping(BoundedProbes.PreInsertCheckThenEvict), Because: "drain back"), + new("xcut-15.no-external-alias", TransportFace.None, "keeps the caller's media-type parameter map", () => Modelling(new ModelFactories { MediaTypeParameters = input => input }), Because: "media type's parameters"), + new("xcut-15.no-external-alias", TransportFace.None, "keeps the caller's challenge parameter map", () => Modelling(new ModelFactories { ChallengeParameters = input => input }), Because: "challenge's parameters"), + new("xcut-15.no-external-alias", TransportFace.None, "keeps the caller's byte array", () => Modelling(new ModelFactories { BytesBody = input => () => input }), Because: "request body sends"), + new("xcut-15.no-external-alias", TransportFace.None, "shows a builder's later fields", () => Modelling(new ModelFactories { HeadersAfterBuilderMutation = () => new Headers.Builder().Add("A", "1").Add("B", "2").Build() }), Because: "before a builder"), + new("xcut-15.no-external-alias", TransportFace.None, "returns the same headers from a setter", () => Modelling(new ModelFactories { WithHeader = (headers, _, _) => headers }), Because: "header \"setter\""), + new("xcut-15.no-external-alias", TransportFace.None, "returns the same request from a setter", () => Modelling(new ModelFactories { RequestWithHeader = (request, _, _) => request }), Because: "request \"setter\""), + ]; + + public static IReadOnlyCollection Covered => InvariantControlRow.CoveredBy(Rows); + + public static TheoryData Cases => InvariantControlRow.Cases(Rows); + + public static TheoryData PositiveCases => InvariantControlRow.PositiveCases(Rows, "xcut-14.bounded-under-burst", "xcut-14.drains-after-insert"); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_broken_subject_is_caught(string assertion, TransportFace face, string control) => InvariantControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_default_subject_passes(string assertion, TransportFace face) => InvariantControlRow.RunPositiveAsync(assertion, face); + + [Theory] + [InlineData("xcut-14.bounded-under-burst")] + [InlineData("xcut-14.drains-after-insert")] + public async Task The_bounded_map_assertions_are_NotExercised_naming_their_hook_when_none_is_supplied(string assertion) + { + var result = await InvariantSuite.RunAsync(new InvariantSubject(), InvariantControlRow.Find(assertion), TransportFace.None, InvariantControlRow.Options, TestContext.Current.CancellationToken); + + Assert.Equal(ConformanceStatus.NotExercised, result.Status); + Assert.Contains(nameof(InvariantSubject.CreateBoundedMap), result.Detail, StringComparison.Ordinal); + } + + [Theory] + [InlineData("xcut-14.bounded-under-burst")] + [InlineData("xcut-14.drains-after-insert")] + public Task A_conforming_bounded_map_passes(string assertion) => + InvariantControlRow.RunPositiveAsync(assertion, TransportFace.None, new InvariantSubject { Name = "conforming map", CreateBoundedMap = BoundedProbes.Conforming }); + + private static InvariantSubject WithDisposable(Func make) => + new() { Name = "broken: disposable", CreateDisposables = body => [make((ReleaseCountingBody)body)] }; + + private static InvariantSubject Mapping(Func create) => + new() { Name = "broken: bounded map", CreateBoundedMap = create }; + + private static InvariantSubject Modelling(ModelFactories models) => + new() { Name = "broken: models", Models = models }; + + private static InvariantControlRow[] Both(string assertion, string name, string because, Func subject) => + [ + new(assertion, TransportFace.Async, name, subject, Because: because), + new(assertion, TransportFace.Blocking, name, subject, Because: because), + ]; +} From 8bc7f0b30d3a55bd9bd269beeabb12a05f269df9 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:11:44 +0300 Subject: [PATCH 08/45] feat: invariant assertions xcut-16 to xcut-19 with negative controls (phase 10, #99) --- .../Assertions/InvariantCatalogue.cs | 1 + .../Assertions/SecurityInvariants.cs | 244 ++++++++++++++++++ .../Assertions/SecurityVectors.cs | 72 ++++++ .../Controls/BrokenPipelines.cs | 111 ++++++++ .../Controls/InvariantSecurityControlTests.cs | 63 +++++ 5 files changed, 491 insertions(+) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/SecurityVectors.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs index ba084187..8cc44bd8 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs @@ -16,5 +16,6 @@ .. CancellationInvariants.Assertions(), .. TaxonomyInvariants.Assertions(), .. ResilienceInvariants.Assertions(), .. LifecycleInvariants.Assertions(), + .. SecurityInvariants.Assertions(), ]; } diff --git a/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs new file mode 100644 index 00000000..0563597b --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs @@ -0,0 +1,244 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from nodejs-sdk@54aeed4 tests/conformance/xcut/security-by-default.conformance.test.ts and ruby-sdk@5b17395 +// gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/ (security by default). The dispatch-time half of XCUT-18 +// (a transport re-checks what reaches the wire) stays a transport assertion (P10-6). +using System.Text; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; + +namespace Dexpace.Sdk.Conformance; + +/// +/// XCUT-16 to XCUT-19: no credential over a non-https scheme, credential hygiene on every redirect hop, header names +/// and values validated before a transport sees them, and secrets redacted by default in every rendering. +/// +internal static class SecurityInvariants +{ + private static readonly string[] s_credentialHeaders = ["Authorization", "Cookie", "Proxy-Authorization"]; + + internal static ConformanceAssertion[] Assertions() => + [ + InvariantAssertionBuilder.Define("xcut-16.no-credential-over-http", ["XCUT-16"], RequirementLevel.Must, InvariantAssertionBuilder.Both, NoCredentialOverHttpAsync), + InvariantAssertionBuilder.Define("xcut-17.redirect-hygiene", ["XCUT-17"], RequirementLevel.Must, InvariantAssertionBuilder.Both, RedirectHygieneAsync), + InvariantAssertionBuilder.Define("xcut-18.model-validation", ["XCUT-18"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, ModelValidationAsync), + InvariantAssertionBuilder.Define("xcut-19.redaction-default-deny", ["XCUT-19"], RequirementLevel.Must, InvariantAssertionBuilder.Both, RedactionDefaultDenyAsync), + ]; + + // A credential-stamping step refuses http before any fetch or header write; a credential-free re-issue still proceeds. + private static async Task NoCredentialOverHttpAsync(InvariantContext context, CancellationToken token) + { + var plain = new DexpaceClientOptions { Retry = InvariantOptions.Fast(0) }; + + var basicOverHttp = context.Transport(ProbeTransport.Behavior.Status(200)); + var failure = await context.FailureOfAsync(WithAuth(context, basicOverHttp, plain, context.Subject.Auth.Basic("svc-user", "svc-secret")), Request.Get("http://example.test/x"), "a Basic-authenticated call over http", token).ConfigureAwait(false); + Check.True(failure is not null, "a credential must not be stamped over http: the call must fail before it is sent", "a failure", "the call succeeded"); + Check.Equal(basicOverHttp.Sends, 0, "the requests that reached the transport from a Basic-authenticated call over http (the guard comes before the header write)"); + + var fetching = new GatedCredential(context.Time, gated: false); + var bearerOverHttp = context.Transport(ProbeTransport.Behavior.Status(200)); + failure = await context.FailureOfAsync(WithAuth(context, bearerOverHttp, plain, context.Subject.Auth.Bearer(fetching)), Request.Get("http://example.test/x"), "a bearer-authenticated call over http", token).ConfigureAwait(false); + Check.True(failure is not null, "a credential must not be stamped over http: the call must fail before it is sent", "a failure", "the call succeeded"); + Check.Equal(fetching.Fetches, 0, "the token fetches of a bearer-authenticated call over http (the guard comes before the fetch)"); + Check.Equal(bearerOverHttp.Sends, 0, "the requests that reached the transport from a bearer-authenticated call over http"); + + var secure = context.Transport(ProbeTransport.Behavior.Status(200)); + failure = await context.FailureOfAsync(WithAuth(context, secure, plain, context.Subject.Auth.Basic("svc-user", "svc-secret")), Request.Get("https://example.test/x"), "a Basic-authenticated call over https", token).ConfigureAwait(false); + Check.True(failure is null, "the guard must only refuse a non-secure scheme: a Basic call over https must succeed", "success", Outcome.Name(failure)); + Check.True(secure.Requests.Count == 1 && secure.Requests[0].Headers.Contains("Authorization"), "a Basic call over https must carry its credential", "one request with Authorization", secure.Requests.Count.ToString(System.Globalization.CultureInfo.InvariantCulture) + " requests"); + + // A permitted https to http hop: the re-issue is credential-free, so the guard does not refuse it. + var hops = context.Transport(Script(("https://example.test/start", 302, "http://example.test/next"))); + var downgrade = new DexpaceClientOptions { Retry = InvariantOptions.Fast(0), Redirect = new RedirectOptions { AllowHttpsToHttpDowngrade = true } }; + failure = await context.FailureOfAsync(WithAuth(context, hops, downgrade, context.Subject.Auth.Basic("svc-user", "svc-secret")), Request.Get("https://example.test/start"), "a call redirected from https to http", token).ConfigureAwait(false); + Check.True(failure is null, "a credential-free re-issue over http must proceed", "success", Outcome.Name(failure)); + Check.True(hops.Requests.Count == 2 && !hops.Requests[1].Headers.Contains("Authorization"), "the re-issue over http must carry no credential", "the second request without Authorization", Describe(hops.Requests)); + } + + // Authorization is stripped on every hop; Cookie and Proxy-Authorization on a cross-origin hop, judged against the seed. + private static async Task RedirectHygieneAsync(InvariantContext context, CancellationToken token) + { + var seed = Request.Get("https://a.example.test/start") + .WithHeader("Authorization", "Bearer " + SecurityVectors.AuthorizationSecret) + .WithHeader("Cookie", "sid=" + SecurityVectors.CookieSecret) + .WithHeader("Proxy-Authorization", "Basic " + SecurityVectors.AuthorizationSecret); + var options = new DexpaceClientOptions { Retry = InvariantOptions.Fast(0) }; + + var sameOrigin = context.Transport(Script(("https://a.example.test/start", 302, "https://a.example.test/next"))); + await Followed(context, sameOrigin, options, seed, "a same-origin redirect", token).ConfigureAwait(false); + var hop = sameOrigin.Requests[1]; + Check.True(!hop.Headers.Contains("Authorization"), "Authorization must be stripped before every redirect re-issue, even a same-origin one", "no Authorization on the re-issue", "Authorization present"); + Check.True(hop.Headers.Contains("Cookie") && hop.Headers.Contains("Proxy-Authorization"), "Cookie and Proxy-Authorization are kept on a same-origin hop", "both kept", Describe(sameOrigin.Requests)); + + var crossOrigin = context.Transport(Script(("https://a.example.test/start", 302, "https://b.example.test/x"))); + await Followed(context, crossOrigin, options, seed, "a cross-origin redirect", token).ConfigureAwait(false); + AssertNoCredentials(crossOrigin.Requests[1], "a cross-origin redirect"); + + var twice = context.Transport(Script( + ("https://a.example.test/start", 302, "https://b.example.test/x"), + ("https://b.example.test/x", 302, "https://b.example.test/y"))); + await Followed(context, twice, options, seed, "two hops to a foreign origin", token).ConfigureAwait(false); + AssertNoCredentials(twice.Requests[2], "the second hop to the foreign origin (origin is judged against the seed, not the previous hop)"); + + var withUserInfo = context.Transport(Script(("https://a.example.test/start", 302, "https://" + SecurityVectors.UserInfo + "@a.example.test/next"))); + await Followed(context, withUserInfo, options, seed, "a redirect whose Location carries userinfo", token).ConfigureAwait(false); + Check.True(withUserInfo.Requests[1].Url.UserInfo.Length == 0, "userinfo in a Location must be dropped before the re-issue", "no userinfo", "userinfo present"); + + var downgrade = context.Transport(Script(("https://a.example.test/start", 302, "http://a.example.test/next"))); + var refused = await context.FailureOfAsync(context.Build(downgrade, options), seed, "a redirect from https to http", token).ConfigureAwait(false); + Check.True(refused is not null, "an https to http redirect must be refused unless the downgrade is enabled", "a failure", "the call succeeded"); + Check.Equal(downgrade.Sends, 1, "the requests sent for a refused downgrade (the http hop is never sent)"); + } + + private static async Task Followed(InvariantContext context, ProbeTransport transport, DexpaceClientOptions options, Request seed, string what, CancellationToken token) + { + var failure = await context.FailureOfAsync(context.Build(transport, options), seed, what, token).ConfigureAwait(false); + Check.True(failure is null, $"{what} must be followed", "success", Outcome.Name(failure)); + Check.True(transport.Requests.Count >= 2, $"{what} must be re-issued", "at least 2 requests", transport.Requests.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private static void AssertNoCredentials(Request hop, string where) => + Check.True( + !hop.Headers.Contains("Authorization") && !hop.Headers.Contains("Cookie") && !hop.Headers.Contains("Proxy-Authorization"), + $"on {where}, Authorization, Cookie and Proxy-Authorization must all be stripped", + "none of the three", + string.Join(", ", s_credentialHeaders.Where(hop.Headers.Contains))); + + // Names reject every C0 control, DEL and non-ASCII; outbound values the same except HTAB; inbound values permit obs-text. + private static Task ModelValidationAsync(InvariantContext context, CancellationToken token) + { + var models = context.Subject.Models; + + foreach (var c in SecurityVectors.NameRejects) + { + RejectsArgument(() => models.OutboundHeader("X-" + c + "-Y", "v"), $"an outbound header name containing {SecurityVectors.Describe(c)} inside it"); + RejectsArgument(() => models.InboundHeader("X-" + c + "-Y", "v"), $"an inbound header name containing {SecurityVectors.Describe(c)} inside it"); + + // Surrounding whitespace (SP and HTAB) is trimmed before validation and the trimmed name stored (HTTP-17), so an HTAB at + // either end is not part of the name; every other character of the set is refused at the ends too. + foreach (var edged in new[] { c + "X", "X" + c }) + { + if (c == '\t') + { + Check.Equal(string.Join(",", models.OutboundHeader(edged, "v").Names), "X", "the stored name of an outbound header name with HTAB at one end (surrounding whitespace is trimmed, HTTP-17)"); + Check.Equal(string.Join(",", models.InboundHeader(edged, "v").Names), "X", "the stored name of an inbound header name with HTAB at one end (surrounding whitespace is trimmed, HTTP-17)"); + } + else + { + RejectsArgument(() => models.OutboundHeader(edged, "v"), $"an outbound header name with {SecurityVectors.Describe(c)} at one end"); + RejectsArgument(() => models.InboundHeader(edged, "v"), $"an inbound header name with {SecurityVectors.Describe(c)} at one end"); + } + } + } + + foreach (var c in SecurityVectors.OutboundValueRejects) + { + RejectsArgument(() => models.OutboundHeader("X-Probe", "a" + c + "b"), $"an outbound header value containing {SecurityVectors.Describe(c)}"); + } + + foreach (var c in SecurityVectors.InboundValueRejects) + { + RejectsArgument(() => models.InboundHeader("X-Probe", "a" + c + "b"), $"an inbound header value containing {SecurityVectors.Describe(c)}"); + } + + Check.Equal(models.OutboundHeader("X-Probe", "a\tb").Get("X-Probe"), "a\tb", "an outbound header value with HTAB inside it (accepted)"); + Check.Equal(models.InboundHeader("X-Probe", "a\tb").Get("X-Probe"), "a\tb", "an inbound header value with HTAB inside it (accepted)"); + Check.Equal(models.InboundHeader("X-Probe", "café").Get("X-Probe"), "café", "an inbound header value with obs-text (accepted)"); + return Task.CompletedTask; + } + + private static void RejectsArgument(Func build, string what) + { + Exception? thrown = null; + try + { + _ = build(); + } +#pragma warning disable CA1031 // The point of the method: what the model does with a hostile character is the answer. + catch (Exception ex) +#pragma warning restore CA1031 + { + thrown = ex; + } + + Check.True(thrown is ArgumentException, $"{what} must be rejected with an argument error", "ArgumentException", Outcome.Name(thrown)); + } + + // Secrets are redacted by default in URLs, headers, credential renderings and logs, and no body is logged unless asked. + private static async Task RedactionDefaultDenyAsync(InvariantContext context, CancellationToken token) + { + var subject = context.Subject; + var redacted = subject.RedactUrl(SecurityVectors.SecretUrl); + AssertNoSecrets(redacted, "a redacted URL (userinfo, query values and fragment tokens)"); + AssertNoSecrets(subject.RedactHeaderValue(SecurityVectors.SecretLocation), "a redacted URL-valued header value"); + + foreach (var credential in subject.Credentials) + { + Check.True( + !credential.Render().Contains(credential.Secret, StringComparison.Ordinal), + $"a {credential.Kind} must not reveal its secret in its string form", + "the secret absent", + "the secret present"); + } + + // The pipeline logs headers by default-deny: only the allow-list is verbatim; nothing is logged with a body. + var transport = context.Transport(ProbeTransport.Behavior.Of((request, _) => new Response( + request, + Status.Ok, + Protocol.Http11, + new Headers.Builder().Add("Content-Type", "text/plain").Add("Set-Cookie", "s=" + SecurityVectors.SetCookieSecret).Add("X-Custom", SecurityVectors.CustomHeaderSecret).Build(), + ResponseBody.FromBytes(Encoding.UTF8.GetBytes("body=" + SecurityVectors.BodySecret)), + null))); + var seed = Request.Get(InvariantOptions.Url + "?token=" + SecurityVectors.QuerySecret + "&api-version=1") + .WithHeader("Authorization", "Bearer " + SecurityVectors.AuthorizationSecret) + .WithHeader("Cookie", "sid=" + SecurityVectors.CookieSecret) + .WithHeader("Accept", "application/json"); + foreach (var level in new[] { HttpLogLevel.None, HttpLogLevel.Headers }) + { + var options = new DexpaceClientOptions { Retry = InvariantOptions.Fast(0), Logging = new HttpLoggingOptions { Level = level } }; + var failure = await context.FailureOfAsync(context.Build(transport, options), seed, $"a call logged at the {level} level", token).ConfigureAwait(false); + Check.True(failure is null, "the logged call must succeed", "success", Outcome.Name(failure)); + } + + var rendered = context.Logger.Entries; + foreach (var secret in new[] { SecurityVectors.QuerySecret, SecurityVectors.AuthorizationSecret, SecurityVectors.CookieSecret, SecurityVectors.SetCookieSecret, SecurityVectors.CustomHeaderSecret, SecurityVectors.BodySecret }) + { + Check.True( + !context.Logger.Leaks(secret), + "logging must redact secrets by default: header values outside the allow-list, query values, and every body", + "no log line carrying " + secret, + rendered.Count + " log lines, one carrying it"); + } + + if (rendered.Count == 0) + { + throw new ConformanceVacuousException("the pipeline logged nothing at the Headers level, so there was nothing to redact"); + } + } + + private static void AssertNoSecrets(string text, string what) + { + var leaked = SecurityVectors.UrlSecrets.Where(secret => text.Contains(secret, StringComparison.Ordinal)).ToArray(); + Check.True(leaked.Length == 0, $"{what} must not reveal a secret", "none of the secrets", string.Join(", ", leaked)); + } + + private static HttpPipeline WithAuth(InvariantContext context, ProbeTransport transport, DexpaceClientOptions options, HttpPipelinePolicy auth) => + PipelineBuilder.Flatten(context.Build(transport, options)).Add(auth).Build(); + + private static ProbeTransport.Behavior Script(params (string From, int Status, string? Location)[] hops) => + ProbeTransport.Behavior.Of((request, _) => + { + var key = request.Url.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.Unescaped); + var hop = hops.FirstOrDefault(entry => entry.From == key); + return hop.From is null + ? ProbeTransport.Empty(request, 200) + : ProbeTransport.Empty(request, hop.Status, new Headers.Builder().Add("Location", hop.Location!).Build()); + }); + + private static string Describe(IReadOnlyList requests) => + string.Join("; ", requests.Select(request => $"{request.Url.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.Unescaped)} [{string.Join(", ", request.Headers.Names)}]")); +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/SecurityVectors.cs b/src/Dexpace.Sdk.Conformance/Assertions/SecurityVectors.cs new file mode 100644 index 00000000..edf4b24c --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/SecurityVectors.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// The byte table of chapter 19 (XCUT-18) and the redaction vectors of XCUT-19, ported from nodejs-sdk@54aeed4 +// tests/conformance/xcut/security-by-default.conformance.test.ts. The secrets are long, distinctive tokens, so a hit in a rendered +// line is never an accident. +using System.Globalization; + +namespace Dexpace.Sdk.Conformance; + +/// The vectors xcut-18 and xcut-19 judge: control and non-ASCII characters by role, and URLs and headers carrying secrets. +internal static class SecurityVectors +{ + /// The userinfo of the redaction URL: a user name and a password. + internal const string UserInfo = "svc-user-9f3a:p4ss-9f3a"; + + /// A secret query-parameter value. + internal const string QuerySecret = "QUERYSECRET-71c2"; + + /// A secret in a key=value fragment token. + internal const string FragmentSecret = "FRAGSECRET-5d0e"; + + /// A secret in an Authorization header. + internal const string AuthorizationSecret = "AUTHSECRET-b83a"; + + /// A secret in a Cookie header. + internal const string CookieSecret = "COOKIESECRET-2e47"; + + /// A secret in a Set-Cookie header. + internal const string SetCookieSecret = "SETCOOKIESECRET-c19b"; + + /// A value of a header outside the allow-list. + internal const string CustomHeaderSecret = "CUSTOMSECRET-0aa7"; + + /// A secret in a response body. + internal const string BodySecret = "BODYSECRET-3c5f"; + + /// The URL xcut-19 redacts: userinfo, a secret query value, an allow-listed one and a fragment token. + internal static string SecretUrl { get; } = $"https://{UserInfo}@example.test/path?token={QuerySecret}&api-version=2026-01&sig={QuerySecret}#access_token={FragmentSecret}&state=ok"; + + /// A Location-style header value carrying userinfo and a secret query value. + internal static string SecretLocation { get; } = $"https://{UserInfo}@example.test/callback?code={QuerySecret}"; + + /// The substrings that must not survive redaction of or . + internal static IReadOnlyList UrlSecrets { get; } = ["svc-user-9f3a", "p4ss-9f3a", QuerySecret, FragmentSecret]; + + /// The characters a header name must reject: every C0 control (HTAB included), DEL, and non-ASCII. + internal static IReadOnlyList NameRejects { get; } = [.. ControlsAndDel(includeTab: true), '\u0080', 'é', '中']; + + /// The characters an outbound header value must reject: the same set except HTAB. + internal static IReadOnlyList OutboundValueRejects { get; } = [.. ControlsAndDel(includeTab: false), '\u0080', 'é', '中']; + + /// The characters an inbound header value must reject: control bytes other than HTAB (obs-text is permitted). + internal static IReadOnlyList InboundValueRejects { get; } = [.. ControlsAndDel(includeTab: false)]; + + /// Names the character for a failure message: U+0009. + /// The character. + internal static string Describe(char c) => string.Create(CultureInfo.InvariantCulture, $"U+{(int)c:X4}"); + + private static IEnumerable ControlsAndDel(bool includeTab) + { + for (var c = 0; c <= 0x1F; c++) + { + if (includeTab || c != 0x09) + { + yield return (char)c; + } + } + + yield return '\u007f'; + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs index 347c826b..b680211c 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs @@ -308,4 +308,115 @@ internal static IEnumerable WalkByEquals(Exception exception) yield return current; } } + + /// An auth step that stamps its credential without looking at the scheme (XCUT-16). + internal sealed class StampsWithoutGuard : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Auth; + + public override ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) => + continuation.RunAsync(request.WithHeader("Authorization", "Basic c3ZjOnNlY3JldA=="), context); + } + + /// An auth step that fetches its token first and only then checks the scheme (XCUT-16). + internal sealed class FetchesBeforeGuard(Dexpace.Sdk.Core.Auth.TokenCredential credential) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Auth; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var token = await credential.GetTokenAsync(new Dexpace.Sdk.Core.Auth.TokenRequestContext(["scope"]), context.CancellationToken); + return request.Url.Scheme != Uri.UriSchemeHttps + ? throw new SdkException("https is required") + : await continuation.RunAsync(request.WithHeader("Authorization", "Bearer " + token.Token), context); + } + } + + /// What a broken redirect step gets wrong (XCUT-17). + internal enum RedirectBug + { + /// It re-issues with the caller's Authorization. + KeepsAuthorization, + + /// It strips Authorization but keeps Cookie and Proxy-Authorization across origins. + KeepsCookieAcrossOrigins, + + /// It rebuilds each hop from the seed and judges the origin against the previous hop instead of the seed. + JudgesAgainstPreviousHop, + } + + /// A redirect loop with one hygiene bug (XCUT-17). It follows 3xx with a Location, up to five times. + internal sealed class BrokenRedirect(RedirectBug bug) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.Redirect; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var current = request; + var previous = Origin(request.Url); + var seed = previous; + for (var hop = 0; hop < 5; hop++) + { + var response = await continuation.RunAsync(current, context); + if (response.Status.Code is not (301 or 302 or 307 or 308) || response.Headers.Get("Location") is not { } location) + { + return response; + } + + response.Dispose(); + var next = new UriBuilder(new Uri(current.Url, location)) { UserName = string.Empty, Password = string.Empty }.Uri; + var origin = Origin(next); + var headers = bug == RedirectBug.JudgesAgainstPreviousHop ? request.Headers : current.Headers; + if (bug != RedirectBug.KeepsAuthorization) + { + headers = headers.Without("Authorization"); + } + + var crossOrigin = bug == RedirectBug.JudgesAgainstPreviousHop ? origin != previous : origin != seed; + if (crossOrigin && bug != RedirectBug.KeepsCookieAcrossOrigins) + { + headers = headers.Without("Cookie").Without("Proxy-Authorization"); + } + + current = new Request(current.Method, next, headers); + previous = origin; + } + + throw new SdkException("too many redirects"); + } + + private static string Origin(Uri url) => $"{url.Scheme}://{url.Host}:{url.Port}"; + } + + /// A diagnostics step that logs every request header verbatim (XCUT-19). + internal sealed class LogsHeadersVerbatim(Microsoft.Extensions.Logging.ILogger logger) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + foreach (var name in request.Headers.Names) + { + var line = name + ": " + request.Headers.Get(name); + logger.Log(Microsoft.Extensions.Logging.LogLevel.Information, 0, line, null, static (state, _) => state); + } + + return continuation.RunAsync(request, context); + } + } + + /// A diagnostics step that logs the response body (XCUT-19, XCUT-24). + internal sealed class LogsBody(Microsoft.Extensions.Logging.ILogger logger) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var response = await continuation.RunAsync(request, context); + var bytes = await response.Body.ReadAsBytesAsync(context.CancellationToken); + var line = System.Text.Encoding.UTF8.GetString(bytes); + logger.Log(Microsoft.Extensions.Logging.LogLevel.Information, 0, line, null, static (state, _) => state); + return response.WithBody(ResponseBody.FromBytes(bytes)); + } + } } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs new file mode 100644 index 00000000..ef82fc5d --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for xcut-16 to xcut-19 (plan 1.8). +[Trait("Category", "Unit")] +public sealed class InvariantSecurityControlTests +{ + internal static IReadOnlyList Rows { get; } = + [ + .. Both("xcut-16.no-credential-over-http", "stamps its credential before it looks at the scheme", "must not be stamped over http", () => new InvariantSubject { Name = "broken: stamps over http", Auth = new AuthPolicyFactories { Basic = (_, _) => new BrokenPipelines.StampsWithoutGuard() } }), + .. Both("xcut-16.no-credential-over-http", "fetches the token before the https guard", "token fetches", () => new InvariantSubject { Name = "broken: fetches first", Auth = new AuthPolicyFactories { Bearer = credential => new BrokenPipelines.FetchesBeforeGuard(credential) } }), + .. Both("xcut-17.redirect-hygiene", "re-issues with the caller's Authorization", "even a same-origin one", () => Redirecting(BrokenPipelines.RedirectBug.KeepsAuthorization)), + .. Both("xcut-17.redirect-hygiene", "keeps Cookie and Proxy-Authorization across origins", "cross-origin redirect", () => Redirecting(BrokenPipelines.RedirectBug.KeepsCookieAcrossOrigins)), + .. Both("xcut-17.redirect-hygiene", "judges the origin against the previous hop", "judged against the seed", () => Redirecting(BrokenPipelines.RedirectBug.JudgesAgainstPreviousHop)), + new("xcut-18.model-validation", TransportFace.None, "accepts HTAB in a header name", () => Modelling(new ModelFactories { OutboundHeader = (name, value) => new Headers.Builder().Add(name.Contains('\t', StringComparison.Ordinal) ? "X-Tab" : name, value).Build() }), Because: "outbound header name"), + new("xcut-18.model-validation", TransportFace.None, "drops a CR from an outbound value and accepts it", () => Modelling(new ModelFactories { OutboundHeader = (name, value) => new Headers.Builder().Add(name, value.Replace("\r", string.Empty, StringComparison.Ordinal)).Build() }), Because: "outbound header value"), + new("xcut-18.model-validation", TransportFace.None, "accepts a CR or LF in an inbound value", () => Modelling(new ModelFactories { InboundHeader = (name, value) => new Headers.Builder().AddInbound(name, value.Replace("\r", string.Empty, StringComparison.Ordinal).Replace("\n", string.Empty, StringComparison.Ordinal)).Build() }), Because: "inbound header value"), + .. Both("xcut-19.redaction-default-deny", "redacts nothing", "redacted URL", () => new InvariantSubject { Name = "broken: allow-all redactor", RedactUrl = url => url }), + .. Both("xcut-19.redaction-default-deny", "renders a credential with its secret", "must not reveal its secret", () => new InvariantSubject { Name = "broken: leaky credential", Credentials = [new CredentialSample("Leaky", "leaky-secret-123", () => "Leaky { Secret = leaky-secret-123 }")] }), + .. Both("xcut-19.redaction-default-deny", "logs request headers verbatim", "logging must redact", () => Logging(logger => new BrokenPipelines.LogsHeadersVerbatim(logger))), + .. Both("xcut-19.redaction-default-deny", "logs the response body", "logging must redact", () => Logging(logger => new BrokenPipelines.LogsBody(logger))), + ]; + + public static IReadOnlyCollection Covered => InvariantControlRow.CoveredBy(Rows); + + public static TheoryData Cases => InvariantControlRow.Cases(Rows); + + public static TheoryData PositiveCases => InvariantControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_broken_subject_is_caught(string assertion, TransportFace face, string control) => InvariantControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_default_subject_passes(string assertion, TransportFace face) => InvariantControlRow.RunPositiveAsync(assertion, face); + + private static InvariantSubject Redirecting(BrokenPipelines.RedirectBug bug) => + BrokenPipelines.Subject("redirect: " + bug, _ => [new BrokenPipelines.BrokenRedirect(bug)]); + + private static InvariantSubject Modelling(ModelFactories models) => new() { Name = "broken: models", Models = models }; + + private static InvariantSubject Logging(Func extra) => + new() + { + Name = "broken: logging", + CreatePipeline = (transport, options, time, logger) => + PipelineBuilder.Flatten(new InvariantSubject().CreatePipeline(transport, options, time, logger)).Add(extra(logger)).Build(), + }; + + private static InvariantControlRow[] Both(string assertion, string name, string because, Func subject) => + [ + new(assertion, TransportFace.Async, name, subject, Because: because), + new(assertion, TransportFace.Blocking, name, subject, Because: because), + ]; +} From e996381fdbba50133e2a5c4b51345e09ff593cd7 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:17:52 +0300 Subject: [PATCH 09/45] feat: invariant assertions xcut-20 to xcut-24 and nfr-11 with negative controls (phase 10, #99) --- .../Assertions/HostInvariants.cs | 322 ++++++++++++++++++ .../Assertions/InvariantCatalogue.cs | 1 + .../Internals/PatternStream.cs | 75 ++++ .../Internals/SingleThreadContext.cs | 94 +++++ .../Internals/ThrowingLogger.cs | 37 ++ .../PublicAPI.Unshipped.txt | 4 + .../Subjects/InvariantSubject.cs | 22 ++ .../Assertions/InvariantCatalogueTests.cs | 29 +- .../Controls/BrokenPipelines.cs | 72 ++++ .../Controls/InvariantHostControlTests.cs | 104 ++++++ .../Suite/InvariantSuiteTests.cs | 19 ++ 11 files changed, 768 insertions(+), 11 deletions(-) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/SingleThreadContext.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/ThrowingLogger.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs new file mode 100644 index 00000000..d2f07676 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs @@ -0,0 +1,322 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from nodejs-sdk@54aeed4 tests/conformance/xcut/diagnostic-previews.conformance.test.ts and security-by-default.conformance.test.ts, +// and ruby-sdk@5b17395 gems/dexpace-conformance/lib/dexpace/conformance/invariant_suite/ (observability, entropy, ownership, resolution, +// previews). XCUT-24's non-consuming clause is declined for an error-body snapshot above 1 MiB (P10-23) and is not asserted for it. +using System.Globalization; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; + +namespace Dexpace.Sdk.Conformance; + +/// +/// XCUT-20 to XCUT-24 and the scheduler-agnostic clause of NFR-11: observability that never throws into the +/// request path, unpredictable and non-repeating nonces, components that close only what they created, a seam that resolves +/// deterministically, capped non-consuming previews, and a call that completes on whatever scheduler the caller runs it on. +/// +internal static class HostInvariants +{ + private const int Draws = 10_000; + private const long LargeBody = 10L * 1024 * 1024; + private const int PreviewCap = 1024; + private const int ReadAhead = 64 * 1024; + private const string CnonceMarker = "cnonce=\""; + + private static readonly string[] s_malformedUrls = + [ + string.Empty, + "not a url at all", + "http://[::1", + "http://svc-user-9f3a:p4ss-9f3a@[bad", + "ht!tp://x/y", + "http://exa mple.com/%zz", + ]; + + internal static ConformanceAssertion[] Assertions() => + [ + InvariantAssertionBuilder.Define("xcut-20.observability-never-throws", ["XCUT-20"], RequirementLevel.Must, InvariantAssertionBuilder.Both, ObservabilityNeverThrowsAsync), + InvariantAssertionBuilder.Define("xcut-21.cnonce-unpredictable", ["XCUT-21"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, CnonceUnpredictableAsync), + InvariantAssertionBuilder.Define("xcut-22.core-borrows", ["XCUT-22"], RequirementLevel.Must, InvariantAssertionBuilder.Both, CoreBorrowsAsync), + InvariantAssertionBuilder.Define("xcut-23.resolution-deterministic", ["XCUT-23"], RequirementLevel.Must, InvariantAssertionBuilder.NoFace, ResolutionDeterministicAsync), + InvariantAssertionBuilder.Define("xcut-24.preview-capped-non-consuming", ["XCUT-24"], RequirementLevel.Should, InvariantAssertionBuilder.Both, PreviewCappedNonConsumingAsync), + InvariantAssertionBuilder.Define("nfr-11.scheduler-agnostic", ["NFR-11"], RequirementLevel.Should, InvariantAssertionBuilder.Both, SchedulerAgnosticAsync), + ]; + + // A malformed URL renders a placeholder, and a sink that fails on every touch does not fail the request. + private static async Task ObservabilityNeverThrowsAsync(InvariantContext context, CancellationToken token) + { + foreach (var url in s_malformedUrls) + { + string? rendered = null; + Exception? thrown = null; + try + { + rendered = context.Subject.RedactUrl(url); + } +#pragma warning disable CA1031 // The point of the check: whatever the renderer raises for hostile input is the answer. + catch (Exception ex) +#pragma warning restore CA1031 + { + thrown = ex; + } + + Check.True(thrown is null, "rendering a malformed URL must not throw into the caller's path", "a placeholder", Outcome.Name(thrown)); + Check.True(rendered is not null && !rendered.Contains("p4ss-9f3a", StringComparison.Ordinal), "a malformed URL must render as a safe placeholder that does not echo its secrets", "a placeholder", rendered ?? "null"); + } + + var failing = new ThrowingLogger(); + var transport = context.Transport(ProbeTransport.Behavior.Of((request, _) => new Response( + request, + Status.Ok, + Protocol.Http11, + new Headers.Builder().Add("Content-Type", "text/plain").Build(), + ResponseBody.FromBytes("ok"u8.ToArray()), + null))); + var options = new DexpaceClientOptions { Retry = InvariantOptions.Fast(0), Logging = new HttpLoggingOptions { Level = HttpLogLevel.Body } }; + var pipeline = context.Subject.CreatePipeline(transport, options, context.Time, failing); + + var failure = await context.FailureOfAsync(pipeline, Request.Post(InvariantOptions.Url, RequestBody.FromBytes("payload"u8.ToArray())), "a call logged to a sink that fails every time", token).ConfigureAwait(false); + + Check.True(failure is null, "a failing log sink must not fail the request: instrumentation degrades and the request proceeds", "success", Outcome.Name(failure)); + if (failing.Touches == 0) + { + throw new ConformanceVacuousException("the pipeline never touched the failing log sink at the Body level, so there was no sink failure to survive"); + } + } + + // 128 bits of lower-case hex, never repeating over ten thousand draws. The source clause (the operating system's CSPRNG) is + // not an output property: it is cited by reference to DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator. + private static Task CnonceUnpredictableAsync(InvariantContext context, CancellationToken token) + { + var draw = context.Subject.DrawCnonce ?? DigestDraw(); + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < Draws; i++) + { + var value = draw() ?? string.Empty; + Check.True( + value.Length == 32 && value.All(c => c is (>= '0' and <= '9') or (>= 'a' and <= 'f')), + "a nonce must carry at least 128 bits, drawn as 32 lower-case hex characters", + "32 hex characters (128 bits)", + $"{value.Length} characters"); + if (!seen.Add(value)) + { + Check.Fail("a nonce must not repeat: a strong source does not repeat within ten thousand draws", "no repeat", $"a repeat at draw {i + 1}"); + } + } + + return Task.CompletedTask; + } + + private static Func DigestDraw() + { + var handler = new DigestChallengeHandler(new DigestCredential("svc-user", "svc-secret")); + var challenges = AuthenticationChallenge.Parse("Digest realm=\"probe\", nonce=\"abc123\", qop=\"auth\", algorithm=SHA-256"); + var request = Request.Get(InvariantOptions.Url); + return () => + { + var authorized = handler.Authorize(challenges, request, proxy: false) + ?? throw new ConformanceException("the Digest handler produced no Authorization for a satisfiable challenge"); + var header = authorized.Headers.Get("Authorization") ?? string.Empty; + var start = header.IndexOf(CnonceMarker, StringComparison.Ordinal); + var end = start < 0 ? -1 : header.IndexOf('"', start + CnonceMarker.Length); + return start < 0 || end < 0 + ? throw new ConformanceException("the Digest Authorization carried no cnonce") + : header[(start + CnonceMarker.Length)..end]; + }; + } + + // The SDK closes only what it created: a pipeline and the two bridges over a caller's transport dispose nothing of it. + private static async Task CoreBorrowsAsync(InvariantContext context, CancellationToken token) + { + var transport = context.Transport(ProbeTransport.Behavior.Status(200)); + var pipeline = context.Build(transport, InvariantOptions.Retrying(0)); + var failure = await context.FailureOfAsync(pipeline, Request.Get(InvariantOptions.Url), "a call through a pipeline over a borrowed transport", token).ConfigureAwait(false); + Check.True(failure is null, "a call over a borrowed transport must succeed", "success", Outcome.Name(failure)); + Check.Equal(transport.Disposals, 0, "the transports disposed by a call through a pipeline over them"); + + if (context.Face == TransportFace.Blocking) + { + pipeline.Dispose(); + } + else + { + await pipeline.DisposeAsync().ConfigureAwait(false); + } + + Check.Equal(transport.Disposals, 0, "the transports disposed when the pipeline over them was disposed"); + + var bridged = context.Subject.AsAsync(transport); + using (var response = await Check.GuardAsync(() => bridged.ExecuteAsync(Request.Get(InvariantOptions.Url), token), "a call through the asynchronous bridge", token).ConfigureAwait(false)) + { + Check.Equal(response.Status.Code, 200, "the status through the asynchronous bridge"); + } + + await bridged.DisposeAsync().ConfigureAwait(false); + Check.Equal(transport.Disposals, 0, "the transports disposed when the asynchronous bridge over them was disposed"); + + var blocking = context.Subject.AsBlocking(transport); + using (var response = await Check.GuardAsync( + () => Task.Factory.StartNew(() => blocking.Execute(Request.Get(InvariantOptions.Url), token), CancellationToken.None, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, TaskScheduler.Default), + "a call through the blocking bridge", + token).ConfigureAwait(false)) + { + Check.Equal(response.Status.Code, 200, "the status through the blocking bridge"); + } + + blocking.Dispose(); + Check.Equal(transport.Disposals, 0, "the transports disposed when the blocking bridge over them was disposed"); + + using var after = await Check.GuardAsync(() => transport.ExecuteAsync(Request.Get(InvariantOptions.Url), token), "a call straight to the transport after everything above was disposed", token).ConfigureAwait(false); + Check.Equal(after.Status.Code, 200, "the status of the borrowed transport after the components over it were disposed"); + } + + // No candidate or two with no choice is an error naming the problem; an explicit install wins. + private static Task ResolutionDeterministicAsync(InvariantContext context, CancellationToken token) + { + var seam = InvariantContext.RequireHook(context.Subject.SeamResolution, nameof(InvariantSubject.SeamResolution)); + + var none = Attempt(seam.ResolveWithNone); + Check.True(none is not null && none.Message.Length > 0, "resolving a seam with no candidate must fail loudly with an actionable error, not pick nothing silently", "an error with a message", none is null ? "no error" : "an error with no message"); + + var two = Attempt(seam.ResolveWithTwo); + Check.True(two is not null && two.Message.Length > 0, "resolving a seam with two candidates and no explicit selection must fail loudly, not pick one silently", "an error with a message", two is null ? "no error" : "an error with no message"); + + Check.True(seam.ExplicitWins(), "an explicit install must win against a discoverable candidate", "the explicit candidate", "another candidate"); + return Task.CompletedTask; + } + + // A preview is byte-capped and does not pull the body ahead of its reader; the reader still sees every byte. An error snapshot is byte-capped. + private static async Task PreviewCappedNonConsumingAsync(InvariantContext context, CancellationToken token) + { + PatternStream? source = null; + var transport = context.Transport(ProbeTransport.Behavior.Of((request, _) => + { + source = new PatternStream(LargeBody); + return new Response( + request, + Status.Ok, + Protocol.Http11, + new Headers.Builder().Add("Content-Type", "text/plain").Build(), + ResponseBody.FromStream(source, MediaType.Parse("text/plain"), LargeBody), + null); + })); + var options = new DexpaceClientOptions { Retry = InvariantOptions.Fast(0), Logging = new HttpLoggingOptions { Level = HttpLogLevel.Body, BodyPreviewSize = PreviewCap } }; + var pipeline = context.Build(transport, options); + + using var response = await Check.GuardAsync(() => context.SendAsync(pipeline, Request.Get(InvariantOptions.Url), token), "a call with a 10 MiB response logged at the Body level", token).ConfigureAwait(false); + + Check.True( + source!.BytesPulled <= PreviewCap + ReadAhead, + "a body preview must not materialize the payload before its reader asks for it", + $"at most {PreviewCap + ReadAhead} bytes pulled before the first read", + source.BytesPulled.ToString(CultureInfo.InvariantCulture)); + var stream = await response.Body.OpenReadAsync(token).ConfigureAwait(false); + await using var streamScope = stream.ConfigureAwait(false); + var seen = await ReadAndVerifyAsync(stream, token).ConfigureAwait(false); + Check.Equal(seen, LargeBody, "the bytes the consumer read after a preview was taken (a preview does not disturb the primary read path)"); + + var largest = context.Logger.Entries.Select(entry => entry.Message.Length).DefaultIfEmpty(0).Max(); + Check.True(largest <= (PreviewCap * 8) + 4096, "a logged body preview must be byte-capped", $"at most {(PreviewCap * 8) + 4096} characters in any log line", largest.ToString(CultureInfo.InvariantCulture)); + + PatternStream? failing = null; + var erroring = context.Transport(ProbeTransport.Behavior.Of((request, _) => + { + failing = new PatternStream(LargeBody); + return new Response(request, Status.FromCode(500), Protocol.Http11, null, ResponseBody.FromStream(failing, MediaType.Parse("text/plain"), LargeBody), null); + })); + var mapped = InvariantContext.WithErrorMapping(context.Build(erroring, InvariantOptions.Retrying(0))); + var snapshot = await context.FailureOfAsync(mapped, Request.Get(InvariantOptions.Url), "a call answered with a 500 carrying a 10 MiB body", token).ConfigureAwait(false); + var http = snapshot as HttpResponseException + ?? throw new ConformanceException("an error status must surface as the response-carrying error", "HttpResponseException", Outcome.Name(snapshot)); + Check.True( + failing!.BytesPulled <= Response.MaxBufferedErrorBytes + ReadAhead, + "an error-body snapshot must be byte-capped", + $"at most {Response.MaxBufferedErrorBytes + ReadAhead} bytes pulled", + failing.BytesPulled.ToString(CultureInfo.InvariantCulture)); + var kept = await Check.GuardAsync(() => http.Response.Body.ReadAsBytesAsync(token), "reading the error's snapshot", token).ConfigureAwait(false); + Check.True(kept.Length <= Response.MaxBufferedErrorBytes, "the error's snapshot must be byte-capped", $"at most {Response.MaxBufferedErrorBytes} bytes", kept.Length.ToString(CultureInfo.InvariantCulture)); + } + + private static async Task ReadAndVerifyAsync(Stream stream, CancellationToken token) + { + var buffer = new byte[ReadAhead]; + long total = 0; + int read; + while ((read = await stream.ReadAsync(buffer, token).ConfigureAwait(false)) > 0) + { + for (var i = 0; i < read; i++) + { + if (buffer[i] != PatternStream.At(total + i)) + { + Check.Fail("the consumer must read the body unchanged after a preview was taken", $"byte {total + i} = {PatternStream.At(total + i)}", buffer[i].ToString(CultureInfo.InvariantCulture)); + } + } + + total += read; + } + + return total; + } + + // The same call from the thread pool, a dedicated thread, an exclusive scheduler and a single-threaded context completes. + private static async Task SchedulerAgnosticAsync(InvariantContext context, CancellationToken token) + { + var transport = context.Transport(ProbeTransport.Behavior.Status(200)); + var pipeline = context.Build(transport, InvariantOptions.Retrying(0)); + var request = Request.Get(InvariantOptions.Url); + var exclusive = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler; + using var singleThread = new SingleThreadContext(); + + var environments = new (string Name, Func> Run)[] + { + ("the thread pool", () => Task.Run(() => CallAsync(context, pipeline, request, token), CancellationToken.None)), + ("a dedicated thread", () => Task.Factory.StartNew(() => CallAsync(context, pipeline, request, token), CancellationToken.None, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap()), + ("an exclusive scheduler", () => Task.Factory.StartNew(() => CallAsync(context, pipeline, request, token), CancellationToken.None, TaskCreationOptions.DenyChildAttach, exclusive).Unwrap()), + ("a single-threaded synchronization context", () => singleThread.RunAsync(() => CallAsync(context, pipeline, request, token))), + }; + + foreach (var (name, run) in environments) + { + var statuses = await Check.GuardAsync( + () => Bounded.WaitForAllAsync([run()], context.Bound, $"a call made from {name}", token), + $"a call made from {name}", + token).ConfigureAwait(false); + Check.Equal(statuses[0], 200, $"the status of a call made from {name}"); + } + } + + // The call runs where the environment put it: the blocking face blocks that thread, the asynchronous face awaits there. + private static async Task CallAsync(InvariantContext context, HttpPipeline pipeline, Request request, CancellationToken token) + { + if (context.Face == TransportFace.Blocking) + { + using var blocked = pipeline.Send(request, RequestOptions.Empty, token); + return blocked.Status.Code; + } + + using var response = await pipeline.SendAsync(request, RequestOptions.Empty, token).ConfigureAwait(false); + return response.Status.Code; + } + + private static Exception? Attempt(Action action) + { + try + { + action(); + return null; + } +#pragma warning disable CA1031 // The point of the method: whatever the resolution raises is the answer. + catch (Exception ex) +#pragma warning restore CA1031 + { + return ex; + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs index 8cc44bd8..babb5008 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/InvariantCatalogue.cs @@ -17,5 +17,6 @@ .. TaxonomyInvariants.Assertions(), .. ResilienceInvariants.Assertions(), .. LifecycleInvariants.Assertions(), .. SecurityInvariants.Assertions(), + .. HostInvariants.Assertions(), ]; } diff --git a/src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs b/src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs new file mode 100644 index 00000000..a664f329 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// A forward-only stream of a fixed length whose byte at position i is i mod 251, generated on demand: a large body +/// that costs no memory to hold, whose every byte a consumer can verify, and that counts how much of it was pulled (XCUT-24). +/// +internal sealed class PatternStream(long length) : Stream +{ + private long _position; + private long _pulled; + + /// How many bytes have been read from the stream so far. + internal long BytesPulled => Interlocked.Read(ref _pulled); + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => false; + + /// + public override long Length => length; + + /// + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + /// The byte at . + /// A zero-based position. + internal static byte At(long position) => (byte)(position % 251); + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span buffer) + { + var take = (int)Math.Min(buffer.Length, length - _position); + for (var i = 0; i < take; i++) + { + buffer[i] = At(_position + i); + } + + _position += take; + Interlocked.Add(ref _pulled, take); + return take; + } + + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + ValueTask.FromResult(Read(buffer.Span)); + + /// + public override void Flush() + { + } + + /// + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/SingleThreadContext.cs b/src/Dexpace.Sdk.Conformance/Internals/SingleThreadContext.cs new file mode 100644 index 00000000..4b27817c --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/SingleThreadContext.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Concurrent; + +namespace Dexpace.Sdk.Conformance; + +/// +/// A with exactly one thread, the way a UI thread or a legacy request context has one: work +/// posted to it runs there, one item at a time. A library call made on it that blocks the thread on work that needs the thread +/// again never finishes, which is how nfr-11.scheduler-agnostic finds a policy that is not scheduler-agnostic. +/// +internal sealed class SingleThreadContext : SynchronizationContext, IDisposable +{ + private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = []; + private readonly Thread _thread; + + /// Starts the context's thread. + internal SingleThreadContext() + { + _thread = new Thread(Pump) { IsBackground = true, Name = "single-thread-context" }; + _thread.Start(); + } + + /// + public override void Post(SendOrPostCallback d, object? state) + { + try + { + _queue.Add((d, state)); + } + catch (InvalidOperationException) + { + // The context was disposed: the work is dropped, as a completed UI loop drops it. + } + } + + /// + public override void Send(SendOrPostCallback d, object? state) + { + if (Thread.CurrentThread == _thread) + { + d(state); + return; + } + + using var done = new ManualResetEventSlim(); + Post(_ => + { + d(state); + done.Set(); + }, null); + done.Wait(); + } + + /// Runs on the context's thread, with the context current, and completes when it does. + /// The result. + /// The work; its continuations capture this context. + internal Task RunAsync(Func> work) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(async _ => + { + try + { +#pragma warning disable CA2007 // The point: the work's continuations must capture this context, as a UI caller's do. + var result = await work(); +#pragma warning restore CA2007 +#pragma warning disable RS0030 // SEAM-30: this completion carries the assertion's own result (a status code), never a Response that could be orphaned. + completion.TrySetResult(result); +#pragma warning restore RS0030 + } +#pragma warning disable CA1031 // The failure belongs to the caller's task, not to the pump. + catch (Exception ex) +#pragma warning restore CA1031 + { + completion.TrySetException(ex); + } + }, null); + return completion.Task; + } + + /// + public void Dispose() => _queue.CompleteAdding(); + + private void Pump() + { + SetSynchronizationContext(this); + foreach (var (callback, state) in _queue.GetConsumingEnumerable()) + { + callback(state); + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/ThrowingLogger.cs b/src/Dexpace.Sdk.Conformance/Internals/ThrowingLogger.cs new file mode 100644 index 00000000..483069f6 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/ThrowingLogger.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Microsoft.Extensions.Logging; + +namespace Dexpace.Sdk.Conformance; + +/// +/// A log sink that fails every time it is touched, so an assertion can prove that observability never throws into the caller's +/// request path (XCUT-20). It counts the touches, so an assertion can tell a pipeline that survived the failures from one +/// that never logged at all. +/// +internal sealed class ThrowingLogger : ILogger +{ + private int _touches; + + /// How many times the sink was touched (a scope opened or an event written). + internal int Touches => Volatile.Read(ref _touches); + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull + { + Interlocked.Increment(ref _touches); + throw new InvalidOperationException("the log sink failed to open a scope"); + } + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Interlocked.Increment(ref _touches); + throw new InvalidOperationException("the log sink failed to write an event"); + } +} diff --git a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt index 62fd9499..7541e0bd 100644 --- a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt @@ -109,6 +109,10 @@ Dexpace.Sdk.Conformance.InternalCancellation.InternalCancellation(Dexpace.Sdk.Co Dexpace.Sdk.Conformance.InternalCancellation.Transport.get -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! Dexpace.Sdk.Conformance.InvariantSubject Dexpace.Sdk.Conformance.InvariantSubject.$() -> Dexpace.Sdk.Conformance.InvariantSubject! +Dexpace.Sdk.Conformance.InvariantSubject.AsAsync.get -> System.Func! +Dexpace.Sdk.Conformance.InvariantSubject.AsAsync.init -> void +Dexpace.Sdk.Conformance.InvariantSubject.AsBlocking.get -> System.Func! +Dexpace.Sdk.Conformance.InvariantSubject.AsBlocking.init -> void Dexpace.Sdk.Conformance.InvariantSubject.Auth.get -> Dexpace.Sdk.Conformance.AuthPolicyFactories! Dexpace.Sdk.Conformance.InvariantSubject.Auth.init -> void Dexpace.Sdk.Conformance.InvariantSubject.CreateBoundedMap.get -> System.Func? diff --git a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs index fd8165e4..b0a34817 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs @@ -181,6 +181,28 @@ public Func> CreateDisposables } } = DefaultDisposables; + /// Wraps a blocking client as an asynchronous one (XCUT-22: the bridge owns nothing). Default: HttpClientExtensions.AsAsync on the default scheduler. + public Func AsAsync + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static client => client.AsAsync(TaskScheduler.Default); + + /// Wraps an asynchronous client as a blocking one (XCUT-22: the bridge owns nothing). Default: HttpClientExtensions.AsBlocking. + public Func AsBlocking + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static client => client.AsBlocking(); + /// /// No default. The strict status-to-exception factory (XCUT-8): must throw for a /// non-error status. makes xcut-8.strict-factory-rejects . diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs index 9c0d5d11..6532303b 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/InvariantCatalogueTests.cs @@ -58,28 +58,35 @@ public sealed partial class InvariantCatalogueTests private static IReadOnlyList All => InvariantSuite.Assertions; - /// The expected rows the catalogue has registered so far: the full 28 once task 1.9 lands (the one deliberate staged pin). - private static (string Name, string[] Ids, RequirementLevel Level, TransportFace[] Faces)[] Registered => [.. Expected.Take(All.Count)]; - [Fact] - public void The_registered_assertions_are_the_design_order_prefix_of_the_28_names() + public void The_catalogue_is_the_28_named_assertions_in_the_design_order() { Assert.Equal(28, Expected.Length); - Assert.Equal(Registered.Select(e => e.Name), All.Select(a => a.Name)); + Assert.Equal(28, All.Count); + Assert.Equal(Expected.Select(e => e.Name), All.Select(a => a.Name)); + Assert.Equal(All.Count, All.Select(a => a.Name).Distinct(StringComparer.Ordinal).Count()); } [Fact] - public void Each_registered_assertion_has_the_ids_level_and_faces_of_the_design_table() + public void Each_assertion_has_the_ids_level_and_faces_of_the_design_table() { - var expected = Registered; - for (var i = 0; i < expected.Length; i++) + for (var i = 0; i < Expected.Length; i++) { - Assert.Equal(expected[i].Ids, All[i].RequirementIds); - Assert.Equal(expected[i].Level, All[i].Level); - Assert.Equal(expected[i].Faces, All[i].Faces); + Assert.Equal(Expected[i].Ids, All[i].RequirementIds); + Assert.Equal(Expected[i].Level, All[i].Level); + Assert.Equal(Expected[i].Faces, All[i].Faces); } } + [Fact] + public void Every_xcut_requirement_and_the_nfr_11_clause_is_cited_by_the_invariant_suite() + { + var cited = All.SelectMany(a => a.RequirementIds).ToHashSet(StringComparer.Ordinal); + + Assert.All(Enumerable.Range(1, 24), n => Assert.Contains($"XCUT-{n}", cited)); + Assert.Contains("NFR-11", cited); + } + [Fact] public void Names_are_stable_identifiers_and_no_assertion_is_stronger_than_its_strongest_requirement() { diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs index b680211c..127d3a5d 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs @@ -419,4 +419,76 @@ public override async ValueTask ProcessAsync(Request request, Pipeline return response.WithBody(ResponseBody.FromBytes(bytes)); } } + + /// A bridge that closes the blocking client it wraps when it is disposed (XCUT-22). + internal sealed class DisposingAsyncBridge(Dexpace.Sdk.Core.Client.IHttpClient inner) : Dexpace.Sdk.Core.Client.IAsyncHttpClient + { + public Task ExecuteAsync(Request request, RequestOptions options, CancellationToken cancellationToken) => + Task.Run(() => inner.Execute(request, options, cancellationToken), CancellationToken.None); + + public ValueTask DisposeAsync() + { + inner.Dispose(); + return ValueTask.CompletedTask; + } + } + + /// A bridge that closes the asynchronous client it wraps when it is disposed (XCUT-22). + internal sealed class DisposingBlockingBridge(Dexpace.Sdk.Core.Client.IAsyncHttpClient inner) : Dexpace.Sdk.Core.Client.IHttpClient + { + public Response Execute(Request request, RequestOptions options, CancellationToken cancellationToken) => + Task.Run(() => inner.ExecuteAsync(request, options, cancellationToken), CancellationToken.None).GetAwaiter().GetResult(); + + public void Dispose() => inner.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + /// A pipeline layer that closes the caller's transport after every call (XCUT-22). + internal sealed class DisposesTransportAfterCall(Dexpace.Sdk.Core.Client.IAsyncHttpClient transport) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerCall; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var response = await continuation.RunAsync(request, context); + await transport.DisposeAsync(); + return response; + } + } + + /// A diagnostics step that reads the whole response body so it can preview it (XCUT-24). + /// Whether it buffers only error responses. + internal sealed class ReadsWholeBody(bool onlyErrors) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var response = await continuation.RunAsync(request, context); + if (onlyErrors && !response.Status.IsError) + { + return response; + } + + var bytes = await response.Body.ReadAsBytesAsync(context.CancellationToken); + return response.WithBody(ResponseBody.FromBytes(bytes, response.Body.ContentType)); + } + } + + /// A step that waits for work posted to the caller's synchronization context, which a single-threaded caller cannot run while it waits (NFR-11). + internal sealed class BlocksOnTheCallersContext : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + if (SynchronizationContext.Current is { } caller) + { + using var done = new ManualResetEventSlim(); + caller.Post(_ => done.Set(), null); + done.Wait(TimeSpan.FromSeconds(30)); + } + + return await continuation.RunAsync(request, context); + } + } } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs new file mode 100644 index 00000000..8b5e49f9 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Security.Cryptography; +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Core.Pipeline; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for xcut-20 to xcut-24 and nfr-11 (plan 1.9). +[Trait("Category", "Unit")] +public sealed class InvariantHostControlTests +{ + private static readonly string[] s_shortSequence = Enumerable.Range(0, 8).Select(n => n.ToString("x32", System.Globalization.CultureInfo.InvariantCulture)).ToArray(); + + internal static IReadOnlyList Rows { get; } = + [ + .. Both("xcut-20.observability-never-throws", "throws on a malformed URL", "must not throw", () => new InvariantSubject { Name = "broken: renderer", RedactUrl = url => new Uri(url).Host }), + .. Both("xcut-20.observability-never-throws", "writes to the sink without a guard", "must not fail the request", () => Logging(logger => new BrokenPipelines.LogsHeadersVerbatim(logger))), + new("xcut-21.cnonce-unpredictable", TransportFace.None, "draws 64 bits", () => Drawing(() => Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(8))), Because: "128 bits"), + new("xcut-21.cnonce-unpredictable", TransportFace.None, "cycles through eight values", () => Drawing(new Cycle().Next), Because: "must not repeat"), + .. Both("xcut-22.core-borrows", "closes the asynchronous bridge's client", "asynchronous bridge", () => new InvariantSubject { Name = "broken: async bridge", AsAsync = client => new BrokenPipelines.DisposingAsyncBridge(client) }), + .. Both("xcut-22.core-borrows", "closes the blocking bridge's client", "blocking bridge", () => new InvariantSubject { Name = "broken: blocking bridge", AsBlocking = client => new BrokenPipelines.DisposingBlockingBridge(client) }), + .. Both("xcut-22.core-borrows", "closes the caller's transport after a call", "call through a pipeline", () => new InvariantSubject + { + Name = "broken: pipeline", + CreatePipeline = (transport, options, time, logger) => + PipelineBuilder.Flatten(new InvariantSubject().CreatePipeline(transport, options, time, logger)).Add(new BrokenPipelines.DisposesTransportAfterCall(transport)).Build(), + }), + new("xcut-23.resolution-deterministic", TransportFace.None, "picks the last of two", () => Seam(new SeamResolution(Throws, () => { }, () => true)), Because: "two candidates"), + new("xcut-23.resolution-deterministic", TransportFace.None, "picks nothing silently", () => Seam(new SeamResolution(() => { }, Throws, () => true)), Because: "no candidate"), + new("xcut-23.resolution-deterministic", TransportFace.None, "lets a discoverable candidate beat the explicit one", () => Seam(new SeamResolution(Throws, Throws, () => false)), Because: "explicit install must win"), + .. Both("xcut-24.preview-capped-non-consuming", "buffers the whole body to preview it", "materialize the payload", () => Previewing(new BrokenPipelines.ReadsWholeBody(onlyErrors: false))), + .. Both("xcut-24.preview-capped-non-consuming", "buffers the whole error body for its snapshot", "error-body snapshot must be byte-capped", () => Previewing(new BrokenPipelines.ReadsWholeBody(onlyErrors: true))), + .. Both("nfr-11.scheduler-agnostic", "blocks on the caller's synchronization context", "single-threaded synchronization context", () => BrokenPipelines.Subject("blocks on the context", _ => [new BrokenPipelines.BlocksOnTheCallersContext()])), + ]; + + public static IReadOnlyCollection Covered => InvariantControlRow.CoveredBy(Rows); + + public static TheoryData Cases => InvariantControlRow.Cases(Rows); + + public static TheoryData PositiveCases => InvariantControlRow.PositiveCases(Rows, "xcut-23.resolution-deterministic"); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_broken_subject_is_caught(string assertion, TransportFace face, string control) => InvariantControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_default_subject_passes(string assertion, TransportFace face) => InvariantControlRow.RunPositiveAsync(assertion, face); + + [Fact] + public async Task The_resolution_assertion_is_NotExercised_naming_its_hook_when_none_is_supplied() + { + var result = await InvariantSuite.RunAsync(new InvariantSubject(), InvariantControlRow.Find("xcut-23.resolution-deterministic"), TransportFace.None, InvariantControlRow.Options, TestContext.Current.CancellationToken); + + Assert.Equal(ConformanceStatus.NotExercised, result.Status); + Assert.Contains(nameof(InvariantSubject.SeamResolution), result.Detail, StringComparison.Ordinal); + } + + [Fact] + public Task A_conforming_resolution_passes() => + InvariantControlRow.RunPositiveAsync("xcut-23.resolution-deterministic", TransportFace.None, Seam(new SeamResolution(Throws, Throws, () => true))); + + [Fact] + public Task A_supplied_cryptographic_draw_passes() => + InvariantControlRow.RunPositiveAsync("xcut-21.cnonce-unpredictable", TransportFace.None, Drawing(() => RandomNumberGenerator.GetHexString(32, lowercase: true))); + + private static void Throws() => throw new InvalidOperationException("no unambiguous candidate: install one explicitly"); + + private static InvariantSubject Drawing(Func draw) => new() { Name = "broken: draw", DrawCnonce = draw }; + + private static InvariantSubject Seam(SeamResolution resolution) => new() { Name = "broken: seam", SeamResolution = resolution }; + + private static InvariantSubject Logging(Func extra) => + new() + { + Name = "broken: logging", + CreatePipeline = (transport, options, time, logger) => + PipelineBuilder.Flatten(new InvariantSubject().CreatePipeline(transport, options, time, logger)).Add(extra(logger)).Build(), + }; + + private static InvariantSubject Previewing(HttpPipelinePolicy extra) => + new() + { + Name = "broken: preview", + CreatePipeline = (transport, options, time, logger) => + PipelineBuilder.Flatten(new InvariantSubject().CreatePipeline(transport, options, time, logger)).Add(extra).Build(), + }; + + private static InvariantControlRow[] Both(string assertion, string name, string because, Func subject) => + [ + new(assertion, TransportFace.Async, name, subject, Because: because), + new(assertion, TransportFace.Blocking, name, subject, Because: because), + ]; + + private sealed class Cycle + { + private int _next; + + public string Next() => s_shortSequence[_next++ % s_shortSequence.Length]; + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs index 36074318..58a4ee47 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Suite/InvariantSuiteTests.cs @@ -76,4 +76,23 @@ public async Task The_bound_is_ten_seconds_or_half_the_assertion_bound_when_shor Assert.Equal(TimeSpan.FromSeconds(3), shortRun.Bound); Assert.Equal(TimeSpan.FromSeconds(10), longRun.Bound); } + + [Fact] + public async Task The_whole_suite_over_the_defaults_is_green_and_exactly_the_five_hookless_assertions_are_not_exercised() + { + // The pinned list of every driver that supplies no internal hook, the AOT smoke among them (P10-28, plan reading R13). + var report = await InvariantSuite.RunAllAsync(new InvariantSubject(), new SuiteOptions { AssertionTimeout = TimeSpan.FromSeconds(20) }, Ct); + + Assert.True(report.IsGreen, report.ToString()); + Assert.Equal( + [ + "xcut-14.bounded-under-burst", + "xcut-14.drains-after-insert", + "xcut-23.resolution-deterministic", + "xcut-8.strict-factory-rejects", + "xcut-9.cycle-safe-walk", + ], + report.Results.Where(r => r.Status == ConformanceStatus.NotExercised).Select(r => r.Assertion.Name).Order(StringComparer.Ordinal)); + Assert.DoesNotContain(report.Results, r => r.Status is ConformanceStatus.Vacuous or ConformanceStatus.Waived or ConformanceStatus.Failed or ConformanceStatus.Errored); + } } From e1f0dd74c29c39d8d8f72df25456596a784d6c01 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:20:22 +0300 Subject: [PATCH 10/45] test: core driver for the invariant suite; SEAM-2 allow-list admits the core-only kit (phase 10, #99) --- .../Architecture/Seam2ArchitectureTests.cs | 20 ++++- .../Conformance/InvariantConformanceTests.cs | 89 +++++++++++++++++++ .../Conformance/InvariantCoreSubject.cs | 38 ++++++++ .../Dexpace.Sdk.Core.Tests.csproj | 3 + .../Dexpace.Sdk.Core.Tests/packages.lock.json | 6 ++ 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantCoreSubject.cs diff --git a/tests/Dexpace.Sdk.Core.Tests/Architecture/Seam2ArchitectureTests.cs b/tests/Dexpace.Sdk.Core.Tests/Architecture/Seam2ArchitectureTests.cs index 0faa8fea..847fbfc3 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Architecture/Seam2ArchitectureTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Architecture/Seam2ArchitectureTests.cs @@ -17,9 +17,13 @@ namespace Dexpace.Sdk.Core.Tests.Architecture; [Trait("Category", "Unit")] public sealed class Seam2ArchitectureTests { - // The only Dexpace assemblies core's suite may see: core, the in-memory fakes, and the suite itself. + // The only Dexpace assemblies core's suite may see: core, the in-memory fakes, the conformance kit and the suite itself. The kit + // joined in phase 10 (P10-5): SEAM-2's conformance clause is that core's suite runs "without any adapter", and the kit references + // core alone (KitDependencyArchitectureTests), so it brings no transport and no codec; The_kit_brings_no_transport_or_codec and the + // build-graph test below keep failing on any Dexpace.Sdk.Http.* or Dexpace.Sdk.Serialization.* arrival. private static readonly HashSet s_allowed = new(StringComparer.Ordinal) { + "Dexpace.Sdk.Conformance", "Dexpace.Sdk.Core", "Dexpace.Sdk.TestSupport", "Dexpace.Sdk.Core.Tests", @@ -59,6 +63,20 @@ public void Core_suite_build_graph_holds_no_adapter() Assert.Empty(adapters); } + [Fact] + public void The_kit_brings_no_transport_or_codec() + { + // The argument for admitting the kit: nothing it reaches is an adapter. Named explicitly, so the allow-list edit above can + // never quietly widen into an adapter. + var reached = DexpaceClosure(typeof(Seam2ArchitectureTests).Assembly); + + Assert.Contains("Dexpace.Sdk.Conformance", reached); + Assert.DoesNotContain(reached, static name => name.StartsWith("Dexpace.Sdk.Http.", StringComparison.Ordinal)); + Assert.DoesNotContain(reached, static name => name.StartsWith("Dexpace.Sdk.Serialization.", StringComparison.Ordinal)); + Assert.DoesNotContain(reached, static name => name.StartsWith("Dexpace.Sdk.Extensions.", StringComparison.Ordinal)); + Assert.DoesNotContain(DepsJsonLibraries(), static name => name.StartsWith("Dexpace.Sdk.Http.", StringComparison.Ordinal) || name.StartsWith("Dexpace.Sdk.Serialization.", StringComparison.Ordinal)); + } + [Fact] public void Core_suite_drives_the_pipeline_through_a_fake_transport() { diff --git a/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs b/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs new file mode 100644 index 00000000..f80f0f84 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Conformance; + +/// +/// The invariant suite driven against core (phase 10, P10-5): one case per (assertion, face), plus the whole suite as a report. This +/// driver supplies the three hooks that reach core's internals (MapStrict, CreateCyclicException, CreateBoundedMap), +/// so all but xcut-23 are exercised; the list of what is not is pinned (P10-28), so a missing hook never reads as conformance. No +/// assertion is waived. +/// +[Trait("Category", "Conformance")] +public sealed class InvariantConformanceTests +{ + public static TheoryData Rows() + { + var rows = new TheoryData(); + foreach (var assertion in InvariantSuite.Assertions) + { + foreach (var face in assertion.Faces) + { + rows.Add(assertion.Name, face); + } + } + + return rows; + } + + [Theory] + [MemberData(nameof(Rows))] + public async Task Assertion_holds(string name, TransportFace face) + { + var assertion = InvariantSuite.Assertions.Single(a => a.Name == name); + var result = await InvariantSuite.RunAsync(InvariantCoreSubject.Create(), assertion, face, InvariantCoreSubject.Options, TestContext.Current.CancellationToken); + + if (result.Status == ConformanceStatus.Passed) + { + return; + } + + if (result.Status is ConformanceStatus.Failed or ConformanceStatus.Errored) + { + Assert.Fail(result.Detail); + } + else + { + Assert.Skip($"{result.Status}: {result.Detail}"); + } + } + + [Fact] + public async Task The_whole_suite_is_green_and_its_report_is_written() + { + var report = await InvariantSuite.RunAllAsync(InvariantCoreSubject.Create(), InvariantCoreSubject.Options, TestContext.Current.CancellationToken); + + TestContext.Current.TestOutputHelper?.WriteLine(report.ToString()); + Assert.True(report.IsGreen, report.ToString()); + Assert.Equal("invariant", report.Suite); + } + + [Fact] + public async Task Only_xcut_23_is_not_exercised_and_nothing_else_is_vacuous_or_waived() + { + var report = await InvariantSuite.RunAllAsync(InvariantCoreSubject.Create(), InvariantCoreSubject.Options, TestContext.Current.CancellationToken); + + var notExercised = report.Results.Where(static r => r.Status == ConformanceStatus.NotExercised).Select(static r => r.Assertion.Name).Distinct().Order(StringComparer.Ordinal); + Assert.Equal(InvariantCoreSubject.ExpectedNotExercised, notExercised); + Assert.DoesNotContain(report.Results, static r => r.Status is ConformanceStatus.Vacuous or ConformanceStatus.Waived); + } + + [Fact] + public void No_assertion_is_waived_by_this_driver() => Assert.Empty(InvariantCoreSubject.Options.Waivers); + + [Fact] + public async Task Every_xcut_requirement_is_covered_by_a_result_that_is_not_a_gap() + { + var report = await InvariantSuite.RunAllAsync(InvariantCoreSubject.Create(), InvariantCoreSubject.Options, TestContext.Current.CancellationToken); + + foreach (var n in Enumerable.Range(1, 24).Where(n => n != 23)) + { + Assert.Equal(ConformanceStatus.Passed, report.ByRequirement[$"XCUT-{n}"]); + } + + Assert.Equal(ConformanceStatus.NotExercised, report.ByRequirement["XCUT-23"]); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantCoreSubject.cs b/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantCoreSubject.cs new file mode 100644 index 00000000..825673f3 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantCoreSubject.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Internal; +using Dexpace.Sdk.TestSupport.Recovery; + +namespace Dexpace.Sdk.Core.Tests.Conformance; + +/// +/// The composition the core driver runs the invariant suite over: core's own components, plus the three hooks only this project can +/// supply because they reach internals (phase 10, P10-4, P10-5). The fourth no-default hook, SeamResolution, lives in the +/// dependency-injection package and is supplied by that driver, so xcut-23 stays not exercised here. +/// +internal static class InvariantCoreSubject +{ + /// The assertions this driver leaves not exercised, and why: core has no seam to resolve (P10-28, plan reading R13). + internal static IReadOnlyList ExpectedNotExercised { get; } = ["xcut-23.resolution-deterministic"]; + + /// Bounds the whole suite sensibly on a slow runner. + internal static SuiteOptions Options { get; } = new() { AssertionTimeout = TimeSpan.FromSeconds(30) }; + + /// Core, with the strict mapping factory, a cyclic exception (built to be walked, never thrown) and a -backed probe. + internal static InvariantSubject Create() => + new() + { + Name = "Dexpace.Sdk.Core (internal hooks)", + MapStrict = ErrorMapping.ToException, + CreateCyclicException = CyclicExceptions.SelfCycle, + CreateBoundedMap = capacity => + { + var map = new BoundedMap(capacity); + var value = new object(); + return new BoundedMapProbe(capacity, key => map.Set(key, value), () => map.EntryCount); + }, + }; +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj b/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj index 9d259ec5..1fed0a60 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj +++ b/tests/Dexpace.Sdk.Core.Tests/Dexpace.Sdk.Core.Tests.csproj @@ -28,6 +28,9 @@ + + diff --git a/tests/Dexpace.Sdk.Core.Tests/packages.lock.json b/tests/Dexpace.Sdk.Core.Tests/packages.lock.json index 14cac983..f8921f5c 100644 --- a/tests/Dexpace.Sdk.Core.Tests/packages.lock.json +++ b/tests/Dexpace.Sdk.Core.Tests/packages.lock.json @@ -256,6 +256,12 @@ "xunit.v3.runner.common": "[4.0.1]" } }, + "dexpace.sdk.conformance": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )" + } + }, "dexpace.sdk.core": { "type": "Project", "dependencies": { From 0bc9ad90b58f37dc6211204d8e5ea9f88022a765 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:21:55 +0300 Subject: [PATCH 11/45] test: DI driver for the invariant suite (SeamResolution) (phase 10, #99) --- .../InvariantResolutionConformanceTests.cs | 101 +++++++++++++++++ .../Conformance/InvariantSubjectFactory.cs | 106 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantResolutionConformanceTests.cs create mode 100644 tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantSubjectFactory.cs diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantResolutionConformanceTests.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantResolutionConformanceTests.cs new file mode 100644 index 00000000..95fa23bc --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantResolutionConformanceTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance; +using Xunit; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests.Conformance; + +/// +/// The invariant suite driven against the pipeline AddDexpaceClient builds (phase 10, P10-5; the DI half of XCUT-10, XCUT-22 and +/// XCUT-23): one case per (assertion, face), plus the whole suite as a report. This driver supplies SeamResolution, so xcut-23 is the +/// one only it exercises; the four assertions that need core's internals are not exercised here and are pinned (P10-28). No assertion is waived. +/// +[Trait("Category", "Conformance")] +public sealed class InvariantResolutionConformanceTests +{ + public static TheoryData Rows() + { + var rows = new TheoryData(); + foreach (var assertion in InvariantSuite.Assertions) + { + foreach (var face in assertion.Faces) + { + rows.Add(assertion.Name, face); + } + } + + return rows; + } + + [Theory] + [MemberData(nameof(Rows))] + public async Task Assertion_holds(string name, TransportFace face) + { + await using var factory = new InvariantSubjectFactory(); + var assertion = InvariantSuite.Assertions.Single(a => a.Name == name); + + var result = await InvariantSuite.RunAsync(factory.Create(), assertion, face, InvariantSubjectFactory.Options, TestContext.Current.CancellationToken); + + if (result.Status == ConformanceStatus.Passed) + { + return; + } + + if (result.Status is ConformanceStatus.Failed or ConformanceStatus.Errored) + { + Assert.Fail(result.Detail); + } + else + { + Assert.Skip($"{result.Status}: {result.Detail}"); + } + } + + [Fact] + public async Task The_suite_runs_over_the_DI_built_pipeline_and_the_report_is_written() + { + await using var factory = new InvariantSubjectFactory(); + + var report = await InvariantSuite.RunAllAsync(factory.Create(), InvariantSubjectFactory.Options, TestContext.Current.CancellationToken); + + TestContext.Current.TestOutputHelper?.WriteLine(report.ToString()); + Assert.True(report.IsGreen, report.ToString()); + Assert.Equal(ConformanceStatus.Passed, report.ByRequirement["XCUT-23"]); + Assert.Equal(ConformanceStatus.Passed, report.ByRequirement["XCUT-10"]); + Assert.Equal(ConformanceStatus.Passed, report.ByRequirement["XCUT-22"]); + } + + [Fact] + public async Task Only_the_four_hook_less_assertions_are_not_exercised() + { + await using var factory = new InvariantSubjectFactory(); + + var report = await InvariantSuite.RunAllAsync(factory.Create(), InvariantSubjectFactory.Options, TestContext.Current.CancellationToken); + + var notExercised = report.Results.Where(static r => r.Status == ConformanceStatus.NotExercised).Select(static r => r.Assertion.Name).Distinct().Order(StringComparer.Ordinal); + Assert.Equal(InvariantSubjectFactory.ExpectedNotExercised, notExercised); + Assert.DoesNotContain(report.Results, static r => r.Status is ConformanceStatus.Vacuous or ConformanceStatus.Waived); + Assert.Empty(InvariantSubjectFactory.Options.Waivers); + } + + [Fact] + public void No_codec_fails_the_start_with_the_install_hint() + { + var failure = Assert.ThrowsAny(InvariantSubjectFactory.Resolution.ResolveWithNone); + + Assert.Contains("UseSerde", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_different_second_install_fails_the_call_naming_both() + { + var failure = Assert.Throws(InvariantSubjectFactory.Resolution.ResolveWithTwo); + + Assert.Contains("ScriptedTransport", failure.Message, StringComparison.Ordinal); + Assert.Contains("AsyncOnlyTransport", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void An_explicit_install_wins() => Assert.True(InvariantSubjectFactory.Resolution.ExplicitWins()); +} diff --git a/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantSubjectFactory.cs b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantSubjectFactory.cs new file mode 100644 index 00000000..cb0db4ec --- /dev/null +++ b/tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/InvariantSubjectFactory.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Concurrent; +using Dexpace.Sdk.Conformance; +using Dexpace.Sdk.Core.Client; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.TestSupport.Transports; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Dexpace.Sdk.Extensions.DependencyInjection.Tests.Conformance; + +/// +/// The invariant suite's subject for the dependency-injection package (phase 10, P10-5): the pipeline is the keyed of +/// AddDexpaceClient over the transport each assertion builds (the options pass through Configure, the run's recording clock and the kit's +/// logger through the container), and SeamResolution is the package's own single-registration check (XCUT-23, P9-16): no codec fails the +/// start with the install hint, a different second install fails the call naming both, an explicit install wins. The containers are disposed with the +/// factory, asynchronously (a synchronous dispose throws for an async-only transport). +/// +internal sealed class InvariantSubjectFactory : IAsyncDisposable +{ + private const string Name = "kit"; + private readonly ConcurrentBag _providers = []; + + /// The assertions this driver leaves not exercised, and why: they reach core internals only the core driver can supply (P10-28, plan reading R13). + internal static IReadOnlyList ExpectedNotExercised { get; } = + [ + "xcut-14.bounded-under-burst", + "xcut-14.drains-after-insert", + "xcut-8.strict-factory-rejects", + "xcut-9.cycle-safe-walk", + ]; + + /// Bounds the suite sensibly on a slow runner. + internal static SuiteOptions Options { get; } = new() { AssertionTimeout = TimeSpan.FromSeconds(30) }; + + /// The package's seam, as the three situations of XCUT-23; each builds and disposes its own container. + internal static SeamResolution Resolution { get; } = new( + ResolveWithNone: static () => + { + var services = new ServiceCollection(); + _ = services.AddDexpaceClient(Name, Config.Section()); + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Validate(); + }, + ResolveWithTwo: static () => + { + var builder = TestClients.Add(new ServiceCollection(), Name).UseTransport(new ScriptedTransport()); + _ = builder.UseTransport(new AsyncOnlyTransport()); + }, + ExplicitWins: static () => + { + var explicitTransport = new ScriptedTransport(); + var services = new ServiceCollection(); + _ = TestClients.Add(services, Name).UseTransport(explicitTransport); + using var provider = services.BuildServiceProvider(); + return ReferenceEquals(explicitTransport, provider.GetRequiredKeyedService(Name)); + }); + + /// The subject: the DI-built pipeline, and the package's seam. + internal InvariantSubject Create() => + new() + { + Name = "Dexpace.Sdk.Extensions.DependencyInjection (named client)", + CreatePipeline = Build, + SeamResolution = Resolution, + }; + + /// + public async ValueTask DisposeAsync() + { + foreach (var provider in _providers) + { + await provider.DisposeAsync(); + } + } + + private HttpPipeline Build(IAsyncHttpClient transport, DexpaceClientOptions options, TimeProvider time, ILogger logger) + { + var services = new ServiceCollection(); + _ = TestClients.Add(services, Name).UseTransport(transport).Configure(_ => options); + services.Replace(ServiceDescriptor.Singleton(time)); + services.Replace(ServiceDescriptor.Singleton(new SingleLoggerFactory(logger))); + var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + _providers.Add(provider); + return provider.GetRequiredKeyedService(Name); + } + + /// Hands every category the one logger the kit supplies, so the logging assertions see what the package's pipeline emits. + private sealed class SingleLoggerFactory(ILogger logger) : ILoggerFactory + { + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) => logger; + + public void Dispose() + { + } + } +} From 3e7072c88b87aad2db0b7b51bc9c68e261158051 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:23:06 +0300 Subject: [PATCH 12/45] chore: RS0030 bans for Exception.GetBaseException and ConcurrentDictionary outside BoundedMap (phase 10, #99) --- BannedSymbols.txt | 14 +++++ src/Dexpace.Sdk.Core/Internal/BoundedMap.cs | 2 + .../Architecture/BannedSymbolsSiteTests.cs | 56 ++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/BannedSymbols.txt b/BannedSymbols.txt index db5f85db..44222615 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -38,10 +38,24 @@ M:System.Uri.ToString;Uri.ToString() unescapes; use OriginalString or GetCompone # Context-store and lifetime discipline in core (design §5.4). T:System.Threading.AsyncLocal`1;The SDK holds no ambient per-call state; correlate through CallKey and the context chain (design §5.4 P13) M:System.Collections.Concurrent.ConcurrentDictionary`2.TryRemove(System.Collections.Generic.KeyValuePair{`0,`1});The value-comparing TryRemove belongs only in BoundedMap's slot (design §5.4, P4a-12) +# Every map keyed by callers or servers is a BoundedMap (XCUT-14, P10-18): all seven public ConcurrentDictionary constructors are banned, and the one +# sanctioned construction, BoundedMap's own, is a scoped pragma with a why-comment. The type is not banned: that would flag every use of BoundedMap's field +# and its cref, and the value-comparing TryRemove pair above would re-enable the rule inside any file-scoped exemption (design P10-18, validation F1). +M:System.Collections.Concurrent.ConcurrentDictionary`2.#ctor;A process- or instance-lived concurrent map is a BoundedMap, or it does not land (XCUT-14, design §5.4, P10-18) +M:System.Collections.Concurrent.ConcurrentDictionary`2.#ctor(System.Int32,System.Int32);A process- or instance-lived concurrent map is a BoundedMap, or it does not land (XCUT-14, design §5.4, P10-18) +M:System.Collections.Concurrent.ConcurrentDictionary`2.#ctor(System.Collections.Generic.IEnumerable{System.Collections.Generic.KeyValuePair{`0,`1}});A process- or instance-lived concurrent map is a BoundedMap, or it does not land (XCUT-14, design §5.4, P10-18) +M:System.Collections.Concurrent.ConcurrentDictionary`2.#ctor(System.Collections.Generic.IEqualityComparer{`0});A process- or instance-lived concurrent map is a BoundedMap, or it does not land (XCUT-14, design §5.4, P10-18) +M:System.Collections.Concurrent.ConcurrentDictionary`2.#ctor(System.Collections.Generic.IEnumerable{System.Collections.Generic.KeyValuePair{`0,`1}},System.Collections.Generic.IEqualityComparer{`0});A process- or instance-lived concurrent map is a BoundedMap, or it does not land (XCUT-14, design §5.4, P10-18) +M:System.Collections.Concurrent.ConcurrentDictionary`2.#ctor(System.Int32,System.Collections.Generic.IEnumerable{System.Collections.Generic.KeyValuePair{`0,`1}},System.Collections.Generic.IEqualityComparer{`0});A process- or instance-lived concurrent map is a BoundedMap, or it does not land (XCUT-14, design §5.4, P10-18) +M:System.Collections.Concurrent.ConcurrentDictionary`2.#ctor(System.Int32,System.Int32,System.Collections.Generic.IEqualityComparer{`0});A process- or instance-lived concurrent map is a BoundedMap, or it does not land (XCUT-14, design §5.4, P10-18) T:System.Runtime.CompilerServices.ConditionalWeakTable`2;Weak tables are not used in core (design §5.4) T:System.WeakReference`1;Weak references are not used in core (design §5.4) T:System.WeakReference;Weak references are not used in core (design §5.4) +# Cause walks are cycle-safe (XCUT-9, P10-18): Exception.GetBaseException walks the inner-exception chain to its end with no cycle check, which is the walk +# behind the Windows test-host hang CyclicExceptions documents. Every cause walk goes through ExceptionFacts.EnumerateCauses. +M:System.Exception.GetBaseException;Walks the inner-exception chain to its end with no cycle check; use ExceptionFacts.EnumerateCauses (XCUT-9, P10-18) + # Unsafe deserialization (design §3.4). The Newtonsoft entries are inert while no project references it; they # keep the ban in force if one ever does. T:System.Runtime.Serialization.Formatters.Binary.BinaryFormatter;BinaryFormatter is prohibited (design §3.4) diff --git a/src/Dexpace.Sdk.Core/Internal/BoundedMap.cs b/src/Dexpace.Sdk.Core/Internal/BoundedMap.cs index 49ff8dcb..cc301b90 100644 --- a/src/Dexpace.Sdk.Core/Internal/BoundedMap.cs +++ b/src/Dexpace.Sdk.Core/Internal/BoundedMap.cs @@ -46,7 +46,9 @@ internal BoundedMap(int capacity, IEqualityComparer? comparer = null) { ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); Capacity = capacity; +#pragma warning disable RS0030 // The one sanctioned construction of ConcurrentDictionary (XCUT-14, P10-18): a map outside this class must be a BoundedMap. _map = new ConcurrentDictionary(comparer); +#pragma warning restore RS0030 } /// The bound. diff --git a/tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs b/tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs index df3d1e1c..32997c1f 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs @@ -7,7 +7,9 @@ namespace Dexpace.Sdk.Core.Tests.Architecture; /// /// Design §5.4 / §8.1, P6c-25: ExecutionContext.SuppressFlow appears in exactly one source file, the background-work -/// helper that BannedSymbols.txt reserves it for. A source scan, with a self-test that proves it can fail. +/// helper that BannedSymbols.txt reserves it for. Phase 10 (P10-18) adds the two XCUT gates the same way: +/// ConcurrentDictionary is constructed only in BoundedMap (XCUT-14) and Exception.GetBaseException +/// is used nowhere (XCUT-9). The build is the gate (RS0030); these are source scans with a self-test that proves they can fail. /// [Trait("Category", "Unit")] public sealed class BannedSymbolsSiteTests @@ -20,6 +22,56 @@ public void SuppressFlow_appears_only_in_BackgroundWork() Assert.Equal(["Internal/BackgroundWork.cs"], files); } + [Fact] + public void ConcurrentDictionary_is_constructed_only_in_BoundedMap() + { + // XCUT-14, V13: a process- or instance-lived concurrent map is a BoundedMap or it does not land. The constructors are banned (RS0030), so + // the build already refuses a new one; this scan keeps the type itself out of every other file of every library, target-typed new included. + var files = FilesContaining("ConcurrentDictionary", SourceTreeRoot()); + + Assert.Equal(["Dexpace.Sdk.Core/Internal/BoundedMap.cs"], files); + } + + [Fact] + public void The_one_construction_of_ConcurrentDictionary_sits_in_a_scoped_pragma_with_a_why_comment() + { + // The constructor ban needs exactly one exemption (the construction at BoundedMap's constructor), beside the existing one for the value-comparing TryRemove. + var lines = File.ReadAllLines(Path.Combine(SourceRoot(), "Internal", "BoundedMap.cs")); + var construction = Array.FindIndex(lines, static line => line.Contains("new ConcurrentDictionary<", StringComparison.Ordinal)); + + Assert.True(construction > 0); + Assert.StartsWith("#pragma warning disable RS0030 //", lines[construction - 1], StringComparison.Ordinal); + Assert.Contains("XCUT-14", lines[construction - 1], StringComparison.Ordinal); + Assert.Equal("#pragma warning restore RS0030", lines[construction + 1]); + Assert.Equal(2, lines.Count(static line => line.Contains("#pragma warning disable RS0030", StringComparison.Ordinal))); + } + + [Fact] + public void GetBaseException_appears_nowhere_in_src() + { + // XCUT-9, V14: the cause walk behind the Windows test-host hang. Every walk goes through ExceptionFacts.EnumerateCauses. + Assert.Empty(FilesContaining("GetBaseException", SourceTreeRoot())); + } + + [Fact] + public void The_scanner_can_fail_for_the_two_xcut_tokens() + { + var directory = Directory.CreateTempSubdirectory("scan"); + try + { + File.WriteAllText(Path.Combine(directory.FullName, "a.cs"), "var map = new ConcurrentDictionary();"); + File.WriteAllText(Path.Combine(directory.FullName, "b.cs"), "var root = failure.GetBaseException();"); + File.WriteAllText(Path.Combine(directory.FullName, "c.cs"), "nothing here"); + + Assert.Equal(["a.cs"], FilesContaining("ConcurrentDictionary", directory.FullName)); + Assert.Equal(["b.cs"], FilesContaining("GetBaseException", directory.FullName)); + } + finally + { + directory.Delete(recursive: true); + } + } + [Fact] public void The_scanner_can_fail() { @@ -46,6 +98,8 @@ [.. Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) .Select(f => Path.GetRelativePath(root, f).Replace('\\', '/')) .Order(StringComparer.Ordinal)]; + private static string SourceTreeRoot() => Path.GetDirectoryName(SourceRoot())!; + private static string SourceRoot() { for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) From 5be45b18cc6bb340c88c2eeaae3fcb640589721c Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:47:05 +0300 Subject: [PATCH 13/45] chore: PR 1 gate fixes, import order and the banned-call grep (phase 10, #99) --- src/Dexpace.Sdk.Conformance/Internals/Poll.cs | 2 +- src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs | 2 +- src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Dexpace.Sdk.Conformance/Internals/Poll.cs b/src/Dexpace.Sdk.Conformance/Internals/Poll.cs index 9169a2f4..3ec015d0 100644 --- a/src/Dexpace.Sdk.Conformance/Internals/Poll.cs +++ b/src/Dexpace.Sdk.Conformance/Internals/Poll.cs @@ -7,7 +7,7 @@ namespace Dexpace.Sdk.Conformance; /// /// Waiting for a condition another thread or task is about to make true, bounded in real time (suite-contract clause 6). The -/// kit never sleeps on the wall clock and never calls Task.Delay: the pause between looks is +/// kit never sleeps on the wall clock and never uses the banned delay overloads: the pause between looks is /// over the system clock, and the bound is a cancellation source, so no assertion reads /// a clock to decide a verdict. /// diff --git a/src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs b/src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs index c637b4be..e49b514a 100644 --- a/src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs +++ b/src/Dexpace.Sdk.Conformance/Internals/RecordingTimeProvider.cs @@ -6,7 +6,7 @@ namespace Dexpace.Sdk.Conformance; /// /// A over that counts the timers it creates and the timers that are /// disposed, so an assertion can prove that a cancelled wait cancelled the timer it armed (XCUT-3) without sleeping on -/// the wall clock: the kit never calls Task.Delay or reads a wall clock (the kit's 8a rule). moves +/// the wall clock: the kit never delays through the banned overloads and never reads a wall clock (the kit's 8a rule). moves /// the clock it reports forward without waiting. /// internal sealed class RecordingTimeProvider : TimeProvider diff --git a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs index b0a34817..34d24086 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs @@ -8,10 +8,10 @@ using Dexpace.Sdk.Core.Errors; using Dexpace.Sdk.Core.Http.Common; using Dexpace.Sdk.Core.Http.Request; -using Dexpace.Sdk.Core.ServerSentEvents; using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.Core.ServerSentEvents; using Microsoft.Extensions.Logging; namespace Dexpace.Sdk.Conformance; From 91422b8b00725c597a5084811365223416a9922f Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:55:39 +0300 Subject: [PATCH 14/45] feat: codec suite, CodecSubject and probe models with 11 assertions and negative controls (phase 10, #99) --- .../Assertions/Check.cs | 27 ++ .../Assertions/CodecAssertionBuilder.cs | 28 ++ .../Assertions/CodecCatalogue.cs | 16 + .../Assertions/CodecFailureAssertions.cs | 110 ++++++ .../Assertions/CodecRoundTripAssertions.cs | 107 ++++++ .../Assertions/CodecTristateAssertions.cs | 102 ++++++ .../Codec/ProbeAddress.cs | 9 + .../Codec/ProbeContact.cs | 14 + .../Codec/ProbePatch.cs | 16 + .../Codec/ProbeSequence.cs | 13 + .../Codec/ProbeUnencodable.cs | 13 + .../Internals/ProbeBufferWriter.cs | 35 ++ .../Internals/TrackingStream.cs | 139 ++++++++ .../PublicAPI.Unshipped.txt | 95 +++++ .../Subjects/CodecSubject.cs | 50 +++ .../Suite/CodecContext.cs | 87 +++++ .../Suite/CodecIdentity.cs | 20 ++ .../Suite/CodecSuite.cs | 83 +++++ .../Assertions/CodecCatalogueTests.cs | 92 +++++ .../Assertions/KitCatalogues.cs | 1 + .../Controls/CodecFailureControlTests.cs | 34 ++ .../Controls/CodecRoundTripControlTests.cs | 35 ++ .../Controls/CodecTristateControlTests.cs | 35 ++ .../Controls/ReferenceSerde.cs | 331 ++++++++++++++++++ .../Drivers/CodecControlRow.cs | 86 +++++ .../Suite/CodecSuiteTests.cs | 84 +++++ 26 files changed, 1662 insertions(+) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/CodecAssertionBuilder.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/CodecCatalogue.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/CodecFailureAssertions.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/CodecRoundTripAssertions.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/CodecTristateAssertions.cs create mode 100644 src/Dexpace.Sdk.Conformance/Codec/ProbeAddress.cs create mode 100644 src/Dexpace.Sdk.Conformance/Codec/ProbeContact.cs create mode 100644 src/Dexpace.Sdk.Conformance/Codec/ProbePatch.cs create mode 100644 src/Dexpace.Sdk.Conformance/Codec/ProbeSequence.cs create mode 100644 src/Dexpace.Sdk.Conformance/Codec/ProbeUnencodable.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/ProbeBufferWriter.cs create mode 100644 src/Dexpace.Sdk.Conformance/Internals/TrackingStream.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/CodecSubject.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/CodecContext.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/CodecIdentity.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/CodecSuite.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Assertions/CodecCatalogueTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecFailureControlTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecRoundTripControlTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecTristateControlTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/ReferenceSerde.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Drivers/CodecControlRow.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Suite/CodecSuiteTests.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/Check.cs b/src/Dexpace.Sdk.Conformance/Assertions/Check.cs index b9c2ad90..c4fad0ea 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/Check.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/Check.cs @@ -48,6 +48,33 @@ await GuardAsync(async () => return true; }, what, cancellationToken).ConfigureAwait(false); + /// The synchronous form of : a subject failure where success was required is a . + /// The operation. + /// What was being done, for the message. + internal static T Guard(Func operation, string what) + { + try + { + return operation(); + } + catch (Exception ex) when (ex is not ConformanceException) + { + throw new ConformanceException($"{what}: the subject failed with {ex.GetType().Name} where success was required", ex); + } + } + + /// The non-generic form of . + /// The operation. + /// What was being done, for the message. + internal static void Guard(Action operation, string what) => + Guard( + () => + { + operation(); + return true; + }, + what); + /// Fails the assertion. /// The clause that was broken, one line. /// What the clause requires. diff --git a/src/Dexpace.Sdk.Conformance/Assertions/CodecAssertionBuilder.cs b/src/Dexpace.Sdk.Conformance/Assertions/CodecAssertionBuilder.cs new file mode 100644 index 00000000..578802c4 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/CodecAssertionBuilder.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The one place a codec assertion is declared: its stable name, the requirements it is evidence for, the strength of its clause, its faces and its body. +internal static class CodecAssertionBuilder +{ + /// The asynchronous stream pair and the synchronous buffer and span pair of the codec. + internal static TransportFace[] Both { get; } = [TransportFace.Async, TransportFace.Blocking]; + + /// No call surface: the assertion judges a value. + internal static TransportFace[] NoFace { get; } = [TransportFace.None]; + + /// Declares an assertion. + /// The stable public name, serde-1.round-trip. + /// The requirement IDs it is evidence for. + /// The strength of the clause it checks; never above the strongest of . + /// The faces it runs on. + /// The check; throws on a broken clause. + internal static ConformanceAssertion Define( + string name, + string[] ids, + RequirementLevel level, + TransportFace[] faces, + Func body) => + ConformanceAssertion.Create(name, ids, level, faces, body); +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/CodecCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/CodecCatalogue.cs new file mode 100644 index 00000000..62c9256e --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/CodecCatalogue.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The codec assertion catalogue, in the order of the design (phase 10, section D): each group contributes its assertions in declaration order. +internal static class CodecCatalogue +{ + /// Every assertion the codec suite runs. + internal static IReadOnlyList All { get; } = + [ + .. CodecRoundTripAssertions.Assertions(), + .. CodecFailureAssertions.Assertions(), + .. CodecTristateAssertions.Assertions(), + ]; +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/CodecFailureAssertions.cs b/src/Dexpace.Sdk.Conformance/Assertions/CodecFailureAssertions.cs new file mode 100644 index 00000000..d89acf33 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/CodecFailureAssertions.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from ruby-sdk@5b17395 gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/ (failure surfaces). SERDE-13 (a wire null into a +// non-null target, naming the type) is the typed readers' check in core's SerdeValues, after the codec has returned: it stays by reference +// to ReadValueTests and SerdeValuesTests (validation F6). +using System.Buffers; +using Dexpace.Sdk.Conformance.Codec; +using Dexpace.Sdk.Core.Errors; + +namespace Dexpace.Sdk.Conformance; + +/// +/// SERDE-9, SERDE-10 and SERDE-12: a codec's own failures surface as the SDK's serde exceptions with the cause chained, in +/// the direction they happened, and a genuine stream error is never re-wrapped as one. +/// +internal static class CodecFailureAssertions +{ + private static readonly ProbeContact s_contact = new("Ada", 36, new ProbeAddress("1 Main Street", "Springfield"), ["alpha"]); + + internal static ConformanceAssertion[] Assertions() => + [ + CodecAssertionBuilder.Define("serde-9.no-library-type-escapes", ["SERDE-9"], RequirementLevel.Must, CodecAssertionBuilder.Both, NoLibraryTypeEscapesAsync), + CodecAssertionBuilder.Define("serde-10.directional-subtypes", ["SERDE-10"], RequirementLevel.Must, CodecAssertionBuilder.Both, DirectionalSubtypesAsync), + CodecAssertionBuilder.Define("serde-12.stream-error-unwrapped", ["SERDE-12"], RequirementLevel.Must, CodecAssertionBuilder.Both, StreamErrorUnwrappedAsync), + ]; + + // A malformed document and an unencodable value both fail as the SDK's serde exception, with the codec's own error as the cause. + private static async Task NoLibraryTypeEscapesAsync(CodecContext context, CancellationToken token) + { + var read = await FailureOfAsync(() => context.DecodeAsync(context.Subject.MalformedPayload.ToArray(), token)).ConfigureAwait(false); + AssertSdkFailure(read, "reading a malformed document"); + + var write = await FailureOfAsync(() => context.EncodeAsync(new ProbeUnencodable(static () => { }), token)).ConfigureAwait(false); + AssertSdkFailure(write, "writing a value no codec can encode"); + } + + // A read failure is a DeserializationException and a write failure a SerializationException, both of the one serde root. + private static async Task DirectionalSubtypesAsync(CodecContext context, CancellationToken token) + { + var read = await FailureOfAsync(() => context.DecodeAsync(context.Subject.MalformedPayload.ToArray(), token)).ConfigureAwait(false); + Check.True(read is DeserializationException, "a failure on the read path must be a deserialization-specific subtype", "DeserializationException", Outcome.Name(read)); + + var write = await FailureOfAsync(() => context.EncodeAsync(new ProbeUnencodable(static () => { }), token)).ConfigureAwait(false); + Check.True(write is SerializationException, "a failure on the write path must be a serialization-specific subtype", "SerializationException", Outcome.Name(write)); + } + + // A genuine error from the caller's stream (or buffer) travels out as it is, an I/O error, never re-wrapped as a codec failure. + private static async Task StreamErrorUnwrappedAsync(CodecContext context, CancellationToken token) + { + var bytes = await Check.GuardAsync(() => context.EncodeAsync(s_contact, token), "encoding a contact", token).ConfigureAwait(false); + var reset = new IOException("connection reset by peer"); + var diskFull = new IOException("no space left on device"); + + Exception? readFailure; + Exception? writeFailure; + if (context.Face == TransportFace.Async) + { + readFailure = await FailureOfAsync(async () => + { + using var failing = new TrackingStream(bytes, failure: reset); + return await context.Serde.DeserializeAsync(failing, token).ConfigureAwait(false); + }).ConfigureAwait(false); + writeFailure = await FailureOfAsync(async () => + { + using var failing = new TrackingStream(failure: diskFull); + await context.Serde.SerializeAsync(failing, s_contact, token).ConfigureAwait(false); + return true; + }).ConfigureAwait(false); + } + else + { + readFailure = await FailureOfAsync(() => + { + using var failing = new TrackingStream(bytes, failure: reset); + return Task.FromResult(context.Serde.Deserialize(failing)); + }).ConfigureAwait(false); + writeFailure = await FailureOfAsync(() => + { + context.Serde.Serialize(new ProbeBufferWriter(diskFull), s_contact); + return Task.FromResult(true); + }).ConfigureAwait(false); + } + + Check.True(readFailure is IOException && readFailure is not SerdeException, "a stream error while reading must propagate unwrapped as an I/O error, not as a codec failure", "the IOException itself", Outcome.Name(readFailure)); + Check.True(writeFailure is IOException && writeFailure is not SerdeException, "a stream error while writing must propagate unwrapped as an I/O error, not as a codec failure", "the IOException itself", Outcome.Name(writeFailure)); + } + + private static void AssertSdkFailure(Exception? failure, string what) + { + Check.True(failure is not null, $"{what} must fail", "a failure", "it succeeded"); + Check.True(failure is SerdeException, $"{what} must surface as the SDK's serde exception, never the backing library's own type", "a SerdeException", Outcome.Name(failure)); + Check.True(failure!.InnerException is not null, $"{what} must chain the codec's own failure as the cause", "an inner exception", "none"); + } + + private static async Task FailureOfAsync(Func> operation) + { + try + { + _ = await operation().ConfigureAwait(false); + return null; + } +#pragma warning disable CA1031 // The point of the method: whatever the codec raises is the answer. + catch (Exception ex) +#pragma warning restore CA1031 + { + return ex; + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/CodecRoundTripAssertions.cs b/src/Dexpace.Sdk.Conformance/Assertions/CodecRoundTripAssertions.cs new file mode 100644 index 00000000..7c03a20f --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/CodecRoundTripAssertions.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from ruby-sdk@5b17395 gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/ (the round trip, the declared media type and the +// caller-owned target). SERDE-4 (the fixed-buffer encode) is core's SerdeExtensions.Serialize(ISerde, Span, T), not a codec property: it +// stays by reference to SerdeProfileTests (validation F6); the suite uses that extension only to hand the codec a region to write into. +using Dexpace.Sdk.Conformance.Codec; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Conformance; + +/// +/// SERDE-1 to SERDE-3 and SEAM-20: one codec that encodes and decodes what it was given, declares a wire format of its own, +/// and reads and writes a caller-owned stream or buffer without closing it. +/// +internal static class CodecRoundTripAssertions +{ + private static readonly ProbeContact s_contact = new("Ada", 36, new ProbeAddress("1 Main Street", "Springfield"), ["alpha", "beta"]); + + internal static ConformanceAssertion[] Assertions() => + [ + CodecAssertionBuilder.Define("serde-1.round-trip", ["SERDE-1"], RequirementLevel.Must, CodecAssertionBuilder.Both, RoundTripAsync), + CodecAssertionBuilder.Define("serde-2.declared-media-type", ["SERDE-2"], RequirementLevel.Must, CodecAssertionBuilder.NoFace, DeclaredMediaTypeAsync), + CodecAssertionBuilder.Define("serde-3.target-not-closed", ["SERDE-3", "SEAM-20"], RequirementLevel.Must, CodecAssertionBuilder.Both, TargetNotClosedAsync), + ]; + + // One bundle: the same codec instance encodes a value and decodes it back, field for field. + private static async Task RoundTripAsync(CodecContext context, CancellationToken token) + { + var back = await context.RoundTripAsync(s_contact, "a contact (a string, a number, a nested record and a list)", token).ConfigureAwait(false); + + Check.True(back is not null && Same(s_contact, back), "a value must survive a round trip through one codec unchanged", Describe(s_contact), back is null ? "null" : Describe(back)); + } + + // The codec declares a format-specific media type, and a request body made from a value plus the codec is typed with it. + private static Task DeclaredMediaTypeAsync(CodecContext context, CancellationToken token) + { + var serde = context.Serde; + var declared = serde.DefaultMediaType; + Check.True(declared is not null, "a codec must declare the wire media type it produces", "a media type", "null"); + Check.True( + declared!.FullType is not ("*/*" or "application/octet-stream"), + "a codec's media type must not default to a format-agnostic constant", + "a format-specific media type", + declared.FullType); + if (context.Subject.ExpectedMediaType is { } expected) + { + Check.Equal(declared.FullType, expected.FullType, "the media type the codec declares"); + } + + var body = RequestBody.FromValue(s_contact, serde); + Check.True( + Equals(body.ContentType, serde.DefaultMediaType), + "the declared media type must be the default Content-Type of a request body made from a value plus the codec (and must be stable)", + serde.DefaultMediaType.ToString(), + body.ContentType?.ToString() ?? "null"); + return Task.CompletedTask; + } + + // Encoding into, and decoding from, a caller-supplied target reads and writes it fully and never closes it. + private static async Task TargetNotClosedAsync(CodecContext context, CancellationToken token) + { + var bytes = await Check.GuardAsync(() => context.EncodeAsync(s_contact, token), "encoding a contact", token).ConfigureAwait(false); + + if (context.Face == TransportFace.Async) + { + using var target = new TrackingStream(); + await Check.GuardAsync(async () => await context.Serde.SerializeAsync(target, s_contact, token).ConfigureAwait(false), "encoding into a caller-owned stream", token).ConfigureAwait(false); + Check.Equal(target.Closes, 0, "the times the codec closed the caller's target stream"); + Check.True(target.Total > 0, "the codec must write the payload fully into the caller's stream", "bytes written", "none"); + target.WriteByte(0); + } + else + { + using var writer = new ProbeBufferWriter(); + Check.Guard(() => context.Serde.Serialize(writer, s_contact), "encoding into a caller-owned buffer writer"); + Check.Equal(writer.Disposals, 0, "the times the codec disposed the caller's buffer writer"); + Check.True(writer.Written.Length > 0, "the codec must write the payload fully into the caller's buffer", "bytes written", "none"); + + // The encode-into-buffer profile touches only the target region (SEAM-20, SERDE-3). + var region = new byte[bytes.Length + 8]; + Array.Fill(region, (byte)0xAA); + var written = Check.Guard(() => context.Serde.Serialize(region.AsSpan(4, bytes.Length), s_contact), "encoding into a caller-owned region"); + var untouched = region.AsSpan(0, 4).ToArray().Concat(region.AsSpan(4 + written).ToArray()).All(b => b == 0xAA); + Check.True(untouched, "an encode into a region must touch only that region", "the bytes outside it unchanged", "a byte outside it was overwritten"); + } + + using var source = new TrackingStream(bytes); + var decoded = context.Face == TransportFace.Async + ? await Check.GuardAsync(async () => await context.Serde.DeserializeAsync(source, token).ConfigureAwait(false), "decoding from a caller-owned stream", token).ConfigureAwait(false) + : Check.Guard(() => context.Serde.Deserialize(source), "decoding from a caller-owned stream"); + Check.Equal(source.Closes, 0, "the times the codec closed the caller's source stream"); + Check.True(decoded is not null && Same(s_contact, decoded), "decoding from a caller-owned stream must give the value back", Describe(s_contact), decoded is null ? "null" : Describe(decoded)); + Check.True(source.Cursor == source.Total, "the codec must read a caller's stream to its end", $"position {source.Total}", $"position {source.Cursor}"); + } + + private static bool Same(ProbeContact left, ProbeContact right) => + left.Name == right.Name + && left.Age == right.Age + && left.Address == right.Address + && right.Tags is not null + && left.Tags.SequenceEqual(right.Tags); + + private static string Describe(ProbeContact contact) => + $"{contact.Name}, {contact.Age}, {contact.Address}, [{string.Join(", ", contact.Tags ?? [])}]"; +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/CodecTristateAssertions.cs b/src/Dexpace.Sdk.Conformance/Assertions/CodecTristateAssertions.cs new file mode 100644 index 00000000..dda41e87 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/CodecTristateAssertions.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from nodejs-sdk@54aeed4 tests/conformance/ (the tri-state codec cases) and ruby-sdk@5b17395 +// gems/dexpace-conformance/lib/dexpace/conformance/codec_suite/ (tri-state). The suite judges what a codec preserves, through the codec's own +// round trip, not how it spells it: Absent must not encode like Null (and must be shorter, because it omits the key), and each state must come back. +// SERDE-14, SERDE-18 and SERDE-30 are Tristate's own API in core, with no bypass to break: they stay by reference to TristateTests and +// TristateSentinelTests (validation F6). +using Dexpace.Sdk.Conformance.Codec; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Conformance; + +/// +/// SERDE-15 to SERDE-17, SERDE-19 and SERDE-20: a PATCH tri-state keeps Absent, Null and Present apart on the wire +/// and back, a missing key means Absent, the default configuration wires all of it, and a value with no key to omit degrades to a wire null. +/// +internal static class CodecTristateAssertions +{ + private static readonly ProbeAddress s_address = new("1 Main Street", "Springfield"); + + private static ProbePatch AllAbsent => new(default, default, default); + + private static ProbePatch AllNull => new(Tristate.Null, Tristate.Null, Tristate.Null); + + private static ProbePatch Mixed => new(Tristate.Present("Ada"), Tristate.Null, Tristate.Present(s_address)); + + internal static ConformanceAssertion[] Assertions() => + [ + CodecAssertionBuilder.Define("serde-15.tristate-emission", ["SERDE-15"], RequirementLevel.Must, CodecAssertionBuilder.Both, EmissionAsync), + CodecAssertionBuilder.Define("serde-16.tristate-decode", ["SERDE-16"], RequirementLevel.Must, CodecAssertionBuilder.Both, DecodeAsync), + CodecAssertionBuilder.Define("serde-17.missing-key-absent", ["SERDE-17"], RequirementLevel.Must, CodecAssertionBuilder.Both, MissingKeyAbsentAsync), + CodecAssertionBuilder.Define("serde-19.default-wiring", ["SERDE-19"], RequirementLevel.Must, CodecAssertionBuilder.Both, DefaultWiringAsync), + CodecAssertionBuilder.Define("serde-20.top-level-degrades", ["SERDE-20"], RequirementLevel.Should, CodecAssertionBuilder.Both, TopLevelDegradesAsync), + ]; + + // Absent omits the key, Null writes it with a wire null, Present writes it with the value: three different encodings, the first the shortest. + private static async Task EmissionAsync(CodecContext context, CancellationToken token) + { + var absent = await Check.GuardAsync(() => context.EncodeAsync(AllAbsent, token), "encoding a PATCH with every member absent", token).ConfigureAwait(false); + var nulls = await Check.GuardAsync(() => context.EncodeAsync(AllNull, token), "encoding a PATCH with every member null", token).ConfigureAwait(false); + var present = await Check.GuardAsync(() => context.EncodeAsync(new ProbePatch(Tristate.Present("Ada"), Tristate.Present(36), Tristate.Present(s_address)), token), "encoding a PATCH with every member present", token).ConfigureAwait(false); + + Check.True(!absent.AsSpan().SequenceEqual(nulls), "Absent and Null must not encode alike: a PATCH would be silently corrupted", "different encodings", "identical bytes"); + Check.True(absent.Length < nulls.Length, "Absent must omit the key entirely, so its encoding is shorter than one that writes the key with a null", $"fewer than {nulls.Length} bytes", $"{absent.Length} bytes"); + Check.True(!nulls.AsSpan().SequenceEqual(present) && present.Length > nulls.Length, "Present must write the key with the encoded inner value", $"more than {nulls.Length} bytes", $"{present.Length} bytes"); + } + + // Each state decodes back to itself, the inner value keeping its declared element type. + private static async Task DecodeAsync(CodecContext context, CancellationToken token) + { + var back = await context.RoundTripAsync(Mixed, "a PATCH (present string, null number, present record)", token).ConfigureAwait(false); + Check.True(back is not null, "a PATCH must decode", "a value", "null"); + Check.True(back!.Name.IsPresent && back.Name.Value == "Ada", "a present string member must decode to Present with the inner value", "Present(Ada)", back.Name.ToString()); + Check.True(back.Age.IsNull, "an explicit null member must decode to Null", "Null", back.Age.ToString()); + Check.True(back.Address.IsPresent && back.Address.Value == s_address, "a present record member must decode to Present with the inner record", "Present(address)", back.Address.ToString()); + + var second = await context.RoundTripAsync(new ProbePatch(Tristate.Null, Tristate.Present(0), default), "a PATCH (null string, present zero, absent record)", token).ConfigureAwait(false); + Check.True(second is not null && second.Name.IsNull, "an explicit null member must decode to Null", "Null", second?.Name.ToString() ?? "null"); + Check.True(second!.Age.IsPresent && second.Age.Value == 0, "a present value-type member must decode to Present with its declared element type preserved", "Present(0)", second.Age.ToString()); + Check.True(second.Address.IsAbsent, "an absent member must decode to Absent", "Absent", second.Address.ToString()); + } + + // A document with no key for a member means Absent: the codec's own encoding of an all-absent PATCH has no keys. + private static async Task MissingKeyAbsentAsync(CodecContext context, CancellationToken token) + { + var back = await context.RoundTripAsync(AllAbsent, "a PATCH with every member absent (a document with no keys)", token).ConfigureAwait(false); + + Check.True(back is not null, "a document with no keys must decode to a value", "a value", "null"); + Check.True( + back!.Name.IsAbsent && back.Age.IsAbsent && back.Address.IsAbsent, + "a member with no key on the wire must resolve to Absent (its declared default), not to Null", + "Absent, Absent, Absent", + $"{back.Name}, {back.Age}, {back.Address}"); + } + + // The codec as the factory built it, with no extra configuration, keeps a mixed PATCH intact. + private static async Task DefaultWiringAsync(CodecContext context, CancellationToken token) + { + var back = await context.RoundTripAsync(Mixed, "a mixed PATCH through the codec's default configuration", token).ConfigureAwait(false); + + Check.True( + back is not null && back.Name == Mixed.Name && back.Age == Mixed.Age && back.Address == Mixed.Address, + "the default codec configuration must wire the tri-state semantics: Absent, Null and Present must all survive, or a PATCH is silently corrupted", + "the PATCH unchanged", + back is null ? "null" : $"{back.Name}, {back.Age}, {back.Address}"); + } + + // With no enclosing object to omit a key, a top-level value or an array element degrades to the wire null and never throws. + private static async Task TopLevelDegradesAsync(CodecContext context, CancellationToken token) + { + var absent = await Check.GuardAsync(() => context.EncodeAsync(Tristate.Absent, token), "encoding a top-level Absent", token).ConfigureAwait(false); + var nulls = await Check.GuardAsync(() => context.EncodeAsync(Tristate.Null, token), "encoding a top-level Null", token).ConfigureAwait(false); + Check.True(absent.AsSpan().SequenceEqual(nulls), "a top-level Absent has no key to omit, so it must encode as the wire null, like Null", "identical bytes", "different encodings"); + + var sequence = new ProbeSequence([Tristate.Present("x"), Tristate.Absent, Tristate.Null, Tristate.Present("y")]); + var back = await context.RoundTripAsync(sequence, "an array of tri-state elements, one absent", token).ConfigureAwait(false); + Check.True(back is { Items.Length: 4 }, "an absent array element must degrade to null without shifting the indices", "4 elements", back is null ? "null" : back.Items.Length + " elements"); + Check.True(back!.Items[0].IsPresent && back.Items[0].Value == "x" && back.Items[3].IsPresent && back.Items[3].Value == "y", "the present elements must keep their indices", "x at 0 and y at 3", $"{back.Items[0]} at 0 and {back.Items[3]} at 3"); + Check.True(!back.Items[1].IsPresent && !back.Items[2].IsPresent, "the absent and null elements must not decode as present", "no value", $"{back.Items[1]} and {back.Items[2]}"); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Codec/ProbeAddress.cs b/src/Dexpace.Sdk.Conformance/Codec/ProbeAddress.cs new file mode 100644 index 00000000..41de6ea8 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Codec/ProbeAddress.cs @@ -0,0 +1,9 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance.Codec; + +/// A nested record of the codec suite's probe models (phase 10, P10-8): the value inside and inside a present . +/// A street line. +/// A city. +public sealed record ProbeAddress(string Street, string City); diff --git a/src/Dexpace.Sdk.Conformance/Codec/ProbeContact.cs b/src/Dexpace.Sdk.Conformance/Codec/ProbeContact.cs new file mode 100644 index 00000000..3519e5c6 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Codec/ProbeContact.cs @@ -0,0 +1,14 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance.Codec; + +/// +/// The round-trip probe model: a string, a number, a nested record and a list. A codec that needs compile-time metadata (System.Text.Json +/// with a source-generated context) registers it with one line, beside the other probe models (phase 10, P10-8). +/// +/// A name. +/// A number. +/// A nested record. +/// A list of strings. +public sealed record ProbeContact(string Name, int Age, ProbeAddress Address, IReadOnlyList Tags); diff --git a/src/Dexpace.Sdk.Conformance/Codec/ProbePatch.cs b/src/Dexpace.Sdk.Conformance/Codec/ProbePatch.cs new file mode 100644 index 00000000..59116412 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Codec/ProbePatch.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Conformance.Codec; + +/// +/// The PATCH probe model: three members of three element types (a string, the value type and a +/// record). Each member's declared default is , as SERDE-17 requires, so a record built with +/// no argument for it, and a document with no key for it, both mean absent. +/// +/// A string member. +/// A value-type member. +/// A record member. +public sealed record ProbePatch(Tristate Name, Tristate Age, Tristate Address); diff --git a/src/Dexpace.Sdk.Conformance/Codec/ProbeSequence.cs b/src/Dexpace.Sdk.Conformance/Codec/ProbeSequence.cs new file mode 100644 index 00000000..a9471460 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Codec/ProbeSequence.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Conformance.Codec; + +/// +/// An array of elements, for SERDE-20: an element inside an array has no key to omit, so an absent +/// element degrades to the wire null and the indices never shift. +/// +/// The elements. +public sealed record ProbeSequence(Tristate[] Items); diff --git a/src/Dexpace.Sdk.Conformance/Codec/ProbeUnencodable.cs b/src/Dexpace.Sdk.Conformance/Codec/ProbeUnencodable.cs new file mode 100644 index 00000000..f0e223ab --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Codec/ProbeUnencodable.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance.Codec; + +/// +/// A value no codec can encode: it carries a delegate. Serializing it is the codec suite's write-path failure (SERDE-9, +/// SERDE-10), which must surface as the SDK's serialization failure with the codec's own error chained, never as the codec's own type. +/// A driver registers it with its codec's metadata, one line beside the other probe models, so the failure is the codec's own and not a +/// missing-metadata one. +/// +/// A delegate, which a data format has no representation for. +public sealed record ProbeUnencodable(Action Callback); diff --git a/src/Dexpace.Sdk.Conformance/Internals/ProbeBufferWriter.cs b/src/Dexpace.Sdk.Conformance/Internals/ProbeBufferWriter.cs new file mode 100644 index 00000000..2c4d340b --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/ProbeBufferWriter.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Buffers; + +namespace Dexpace.Sdk.Conformance; + +/// +/// A caller-owned buffer writer for the codec suite: it counts how often it was disposed (a codec must not take ownership of a target it +/// was handed, SERDE-3) and, when given a failure, throws it as soon as a codec asks for room (a genuine downstream write error that +/// must propagate unwrapped, SERDE-12). +/// +internal sealed class ProbeBufferWriter(IOException? failure = null) : IBufferWriter, IDisposable +{ + private readonly ArrayBufferWriter _inner = new(); + private int _disposals; + + /// How many times the writer was disposed. + internal int Disposals => Volatile.Read(ref _disposals); + + /// The bytes written. + internal ReadOnlySpan Written => _inner.WrittenSpan; + + /// + public void Advance(int count) => _inner.Advance(count); + + /// + public Memory GetMemory(int sizeHint = 0) => failure is null ? _inner.GetMemory(sizeHint) : throw failure; + + /// + public Span GetSpan(int sizeHint = 0) => failure is null ? _inner.GetSpan(sizeHint) : throw failure; + + /// + public void Dispose() => Interlocked.Increment(ref _disposals); +} diff --git a/src/Dexpace.Sdk.Conformance/Internals/TrackingStream.cs b/src/Dexpace.Sdk.Conformance/Internals/TrackingStream.cs new file mode 100644 index 00000000..f47b028d --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Internals/TrackingStream.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// A caller-owned stream for the codec suite: it reads and writes an in-memory buffer, counts how often it was closed (the codec must not +/// close it, SERDE-3) and, when given a failure, throws it from every read and write (a genuine stream I/O error that must propagate +/// unwrapped, SERDE-12). Closing it does not release the buffer, so the assertion can still read what was written. +/// +internal sealed class TrackingStream : Stream +{ + private readonly MemoryStream _buffer; + private readonly IOException? _failure; + private int _closes; + + /// Creates a stream over (empty when none). + /// The bytes to read. + /// The error to throw from every read and write, or for none. + internal TrackingStream(byte[]? initial = null, IOException? failure = null) + { + _buffer = initial is null ? new MemoryStream() : new MemoryStream(initial, writable: true) { Position = 0 }; + _failure = failure; + } + + /// How many times the stream was closed or disposed. + internal int Closes => Volatile.Read(ref _closes); + + /// The bytes written so far. + internal byte[] Written => _buffer.ToArray(); + + /// The stream's length, readable after it was closed. + internal long Total => _buffer.Length; + + /// The read position, readable after it was closed. + internal long Cursor => _buffer.Position; + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => true; + + /// + public override bool CanWrite => true; + + /// + public override long Length => _buffer.Length; + + /// + public override long Position + { + get => _buffer.Position; + set => _buffer.Position = value; + } + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + /// + public override int Read(Span buffer) + { + ThrowIfClosedOrFaulting(); + return _buffer.Read(buffer); + } + + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + try + { + return ValueTask.FromResult(Read(buffer.Span)); + } +#pragma warning disable CA1031 // The failure belongs in the returned task, as any asynchronous stream delivers it. + catch (Exception ex) +#pragma warning restore CA1031 + { + return ValueTask.FromException(ex); + } + } + + /// + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + /// + public override void Write(ReadOnlySpan buffer) + { + ThrowIfClosedOrFaulting(); + _buffer.Write(buffer); + } + + /// + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + try + { + Write(buffer.Span); + return ValueTask.CompletedTask; + } +#pragma warning disable CA1031 // The failure belongs in the returned task, as any asynchronous stream delivers it. + catch (Exception ex) +#pragma warning restore CA1031 + { + return ValueTask.FromException(ex); + } + } + + /// + public override void Flush() + { + } + + /// + public override long Seek(long offset, SeekOrigin origin) => _buffer.Seek(offset, origin); + + /// + public override void SetLength(long value) => _buffer.SetLength(value); + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + Interlocked.Increment(ref _closes); + } + + base.Dispose(disposing); + } + + private void ThrowIfClosedOrFaulting() + { + if (_failure is not null) + { + throw _failure; + } + + ObjectDisposedException.ThrowIf(Closes > 0, this); + } +} diff --git a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt index 7541e0bd..d22b2354 100644 --- a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt @@ -20,6 +20,66 @@ Dexpace.Sdk.Conformance.BoundedMapProbe.BoundedMapProbe(int capacity, System.Act Dexpace.Sdk.Conformance.BoundedMapProbe.Capacity.get -> int Dexpace.Sdk.Conformance.BoundedMapProbe.Count.get -> System.Func! Dexpace.Sdk.Conformance.BoundedMapProbe.Insert.get -> System.Action! +Dexpace.Sdk.Conformance.Codec.ProbeAddress +Dexpace.Sdk.Conformance.Codec.ProbeAddress.$() -> Dexpace.Sdk.Conformance.Codec.ProbeAddress! +Dexpace.Sdk.Conformance.Codec.ProbeAddress.City.get -> string! +Dexpace.Sdk.Conformance.Codec.ProbeAddress.City.init -> void +Dexpace.Sdk.Conformance.Codec.ProbeAddress.Deconstruct(out string! Street, out string! City) -> void +Dexpace.Sdk.Conformance.Codec.ProbeAddress.Equals(Dexpace.Sdk.Conformance.Codec.ProbeAddress? other) -> bool +Dexpace.Sdk.Conformance.Codec.ProbeAddress.ProbeAddress(string! Street, string! City) -> void +Dexpace.Sdk.Conformance.Codec.ProbeAddress.Street.get -> string! +Dexpace.Sdk.Conformance.Codec.ProbeAddress.Street.init -> void +Dexpace.Sdk.Conformance.Codec.ProbeContact +Dexpace.Sdk.Conformance.Codec.ProbeContact.$() -> Dexpace.Sdk.Conformance.Codec.ProbeContact! +Dexpace.Sdk.Conformance.Codec.ProbeContact.Address.get -> Dexpace.Sdk.Conformance.Codec.ProbeAddress! +Dexpace.Sdk.Conformance.Codec.ProbeContact.Address.init -> void +Dexpace.Sdk.Conformance.Codec.ProbeContact.Age.get -> int +Dexpace.Sdk.Conformance.Codec.ProbeContact.Age.init -> void +Dexpace.Sdk.Conformance.Codec.ProbeContact.Deconstruct(out string! Name, out int Age, out Dexpace.Sdk.Conformance.Codec.ProbeAddress! Address, out System.Collections.Generic.IReadOnlyList! Tags) -> void +Dexpace.Sdk.Conformance.Codec.ProbeContact.Equals(Dexpace.Sdk.Conformance.Codec.ProbeContact? other) -> bool +Dexpace.Sdk.Conformance.Codec.ProbeContact.Name.get -> string! +Dexpace.Sdk.Conformance.Codec.ProbeContact.Name.init -> void +Dexpace.Sdk.Conformance.Codec.ProbeContact.ProbeContact(string! Name, int Age, Dexpace.Sdk.Conformance.Codec.ProbeAddress! Address, System.Collections.Generic.IReadOnlyList! Tags) -> void +Dexpace.Sdk.Conformance.Codec.ProbeContact.Tags.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Conformance.Codec.ProbeContact.Tags.init -> void +Dexpace.Sdk.Conformance.Codec.ProbePatch +Dexpace.Sdk.Conformance.Codec.ProbePatch.$() -> Dexpace.Sdk.Conformance.Codec.ProbePatch! +Dexpace.Sdk.Conformance.Codec.ProbePatch.Address.get -> Dexpace.Sdk.Core.Serialization.Tristate +Dexpace.Sdk.Conformance.Codec.ProbePatch.Address.init -> void +Dexpace.Sdk.Conformance.Codec.ProbePatch.Age.get -> Dexpace.Sdk.Core.Serialization.Tristate +Dexpace.Sdk.Conformance.Codec.ProbePatch.Age.init -> void +Dexpace.Sdk.Conformance.Codec.ProbePatch.Deconstruct(out Dexpace.Sdk.Core.Serialization.Tristate Name, out Dexpace.Sdk.Core.Serialization.Tristate Age, out Dexpace.Sdk.Core.Serialization.Tristate Address) -> void +Dexpace.Sdk.Conformance.Codec.ProbePatch.Equals(Dexpace.Sdk.Conformance.Codec.ProbePatch? other) -> bool +Dexpace.Sdk.Conformance.Codec.ProbePatch.Name.get -> Dexpace.Sdk.Core.Serialization.Tristate +Dexpace.Sdk.Conformance.Codec.ProbePatch.Name.init -> void +Dexpace.Sdk.Conformance.Codec.ProbePatch.ProbePatch(Dexpace.Sdk.Core.Serialization.Tristate Name, Dexpace.Sdk.Core.Serialization.Tristate Age, Dexpace.Sdk.Core.Serialization.Tristate Address) -> void +Dexpace.Sdk.Conformance.Codec.ProbeSequence +Dexpace.Sdk.Conformance.Codec.ProbeSequence.$() -> Dexpace.Sdk.Conformance.Codec.ProbeSequence! +Dexpace.Sdk.Conformance.Codec.ProbeSequence.Deconstruct(out Dexpace.Sdk.Core.Serialization.Tristate[]! Items) -> void +Dexpace.Sdk.Conformance.Codec.ProbeSequence.Equals(Dexpace.Sdk.Conformance.Codec.ProbeSequence? other) -> bool +Dexpace.Sdk.Conformance.Codec.ProbeSequence.Items.get -> Dexpace.Sdk.Core.Serialization.Tristate[]! +Dexpace.Sdk.Conformance.Codec.ProbeSequence.Items.init -> void +Dexpace.Sdk.Conformance.Codec.ProbeSequence.ProbeSequence(Dexpace.Sdk.Core.Serialization.Tristate[]! Items) -> void +Dexpace.Sdk.Conformance.Codec.ProbeUnencodable +Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.$() -> Dexpace.Sdk.Conformance.Codec.ProbeUnencodable! +Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.Callback.get -> System.Action! +Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.Callback.init -> void +Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.Deconstruct(out System.Action! Callback) -> void +Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.Equals(Dexpace.Sdk.Conformance.Codec.ProbeUnencodable? other) -> bool +Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.ProbeUnencodable(System.Action! Callback) -> void +Dexpace.Sdk.Conformance.CodecSubject +Dexpace.Sdk.Conformance.CodecSubject.$() -> Dexpace.Sdk.Conformance.CodecSubject! +Dexpace.Sdk.Conformance.CodecSubject.CodecSubject() -> void +Dexpace.Sdk.Conformance.CodecSubject.CreateSerde.get -> System.Func! +Dexpace.Sdk.Conformance.CodecSubject.CreateSerde.init -> void +Dexpace.Sdk.Conformance.CodecSubject.Equals(Dexpace.Sdk.Conformance.CodecSubject? other) -> bool +Dexpace.Sdk.Conformance.CodecSubject.ExpectedMediaType.get -> Dexpace.Sdk.Core.Http.Common.MediaType? +Dexpace.Sdk.Conformance.CodecSubject.ExpectedMediaType.init -> void +Dexpace.Sdk.Conformance.CodecSubject.MalformedPayload.get -> System.ReadOnlyMemory +Dexpace.Sdk.Conformance.CodecSubject.MalformedPayload.init -> void +Dexpace.Sdk.Conformance.CodecSubject.Name.get -> string! +Dexpace.Sdk.Conformance.CodecSubject.Name.init -> void +Dexpace.Sdk.Conformance.CodecSuite Dexpace.Sdk.Conformance.ConformanceAssertion Dexpace.Sdk.Conformance.ConformanceAssertion.Faces.get -> System.Collections.Generic.IReadOnlyList! Dexpace.Sdk.Conformance.ConformanceAssertion.Level.get -> Dexpace.Sdk.Conformance.RequirementLevel @@ -266,6 +326,24 @@ Dexpace.Sdk.Conformance.Wire.RecordedRequest.Version.get -> string! override Dexpace.Sdk.Conformance.AuthPolicyFactories.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.AuthPolicyFactories.GetHashCode() -> int override Dexpace.Sdk.Conformance.AuthPolicyFactories.ToString() -> string! +override Dexpace.Sdk.Conformance.Codec.ProbeAddress.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.Codec.ProbeAddress.GetHashCode() -> int +override Dexpace.Sdk.Conformance.Codec.ProbeAddress.ToString() -> string! +override Dexpace.Sdk.Conformance.Codec.ProbeContact.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.Codec.ProbeContact.GetHashCode() -> int +override Dexpace.Sdk.Conformance.Codec.ProbeContact.ToString() -> string! +override Dexpace.Sdk.Conformance.Codec.ProbePatch.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.Codec.ProbePatch.GetHashCode() -> int +override Dexpace.Sdk.Conformance.Codec.ProbePatch.ToString() -> string! +override Dexpace.Sdk.Conformance.Codec.ProbeSequence.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.Codec.ProbeSequence.GetHashCode() -> int +override Dexpace.Sdk.Conformance.Codec.ProbeSequence.ToString() -> string! +override Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.GetHashCode() -> int +override Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.ToString() -> string! +override Dexpace.Sdk.Conformance.CodecSubject.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.CodecSubject.GetHashCode() -> int +override Dexpace.Sdk.Conformance.CodecSubject.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceAssertion.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceReport.ToString() -> string! override Dexpace.Sdk.Conformance.ConformanceResult.Equals(object? obj) -> bool @@ -297,6 +375,23 @@ override Dexpace.Sdk.Conformance.TransportSettings.GetHashCode() -> int override Dexpace.Sdk.Conformance.TransportSettings.ToString() -> string! static Dexpace.Sdk.Conformance.AuthPolicyFactories.operator !=(Dexpace.Sdk.Conformance.AuthPolicyFactories? left, Dexpace.Sdk.Conformance.AuthPolicyFactories? right) -> bool static Dexpace.Sdk.Conformance.AuthPolicyFactories.operator ==(Dexpace.Sdk.Conformance.AuthPolicyFactories? left, Dexpace.Sdk.Conformance.AuthPolicyFactories? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeAddress.operator !=(Dexpace.Sdk.Conformance.Codec.ProbeAddress? left, Dexpace.Sdk.Conformance.Codec.ProbeAddress? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeAddress.operator ==(Dexpace.Sdk.Conformance.Codec.ProbeAddress? left, Dexpace.Sdk.Conformance.Codec.ProbeAddress? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeContact.operator !=(Dexpace.Sdk.Conformance.Codec.ProbeContact? left, Dexpace.Sdk.Conformance.Codec.ProbeContact? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeContact.operator ==(Dexpace.Sdk.Conformance.Codec.ProbeContact? left, Dexpace.Sdk.Conformance.Codec.ProbeContact? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbePatch.operator !=(Dexpace.Sdk.Conformance.Codec.ProbePatch? left, Dexpace.Sdk.Conformance.Codec.ProbePatch? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbePatch.operator ==(Dexpace.Sdk.Conformance.Codec.ProbePatch? left, Dexpace.Sdk.Conformance.Codec.ProbePatch? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeSequence.operator !=(Dexpace.Sdk.Conformance.Codec.ProbeSequence? left, Dexpace.Sdk.Conformance.Codec.ProbeSequence? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeSequence.operator ==(Dexpace.Sdk.Conformance.Codec.ProbeSequence? left, Dexpace.Sdk.Conformance.Codec.ProbeSequence? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.operator !=(Dexpace.Sdk.Conformance.Codec.ProbeUnencodable? left, Dexpace.Sdk.Conformance.Codec.ProbeUnencodable? right) -> bool +static Dexpace.Sdk.Conformance.Codec.ProbeUnencodable.operator ==(Dexpace.Sdk.Conformance.Codec.ProbeUnencodable? left, Dexpace.Sdk.Conformance.Codec.ProbeUnencodable? right) -> bool +static Dexpace.Sdk.Conformance.CodecSubject.DefaultMalformedPayload.get -> System.ReadOnlyMemory +static Dexpace.Sdk.Conformance.CodecSubject.operator !=(Dexpace.Sdk.Conformance.CodecSubject? left, Dexpace.Sdk.Conformance.CodecSubject? right) -> bool +static Dexpace.Sdk.Conformance.CodecSubject.operator ==(Dexpace.Sdk.Conformance.CodecSubject? left, Dexpace.Sdk.Conformance.CodecSubject? right) -> bool +static Dexpace.Sdk.Conformance.CodecSuite.Assertions.get -> System.Collections.Generic.IReadOnlyList! +static Dexpace.Sdk.Conformance.CodecSuite.Preamble.get -> string! +static Dexpace.Sdk.Conformance.CodecSuite.RunAllAsync(Dexpace.Sdk.Conformance.CodecSubject! subject, Dexpace.Sdk.Conformance.SuiteOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Dexpace.Sdk.Conformance.CodecSuite.RunAsync(Dexpace.Sdk.Conformance.CodecSubject! subject, Dexpace.Sdk.Conformance.ConformanceAssertion! assertion, Dexpace.Sdk.Conformance.TransportFace face, Dexpace.Sdk.Conformance.SuiteOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static Dexpace.Sdk.Conformance.ConformanceReport.Combine(string! subjectName, System.Collections.Generic.IEnumerable! reports) -> Dexpace.Sdk.Conformance.ConformanceReport! static Dexpace.Sdk.Conformance.ConformanceReport.Create(string! subjectName, System.Collections.Generic.IEnumerable! results) -> Dexpace.Sdk.Conformance.ConformanceReport! static Dexpace.Sdk.Conformance.ConformanceReport.Preamble.get -> string! diff --git a/src/Dexpace.Sdk.Conformance/Subjects/CodecSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/CodecSubject.cs new file mode 100644 index 00000000..2c90fe3c --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/CodecSubject.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Conformance; + +/// +/// The codec the codec suite judges (phase 10, P10-8): a factory for the to test, built fresh for each run and +/// exactly as a consumer would build it (no extra configuration: SERDE-19 asks whether the defaults wire the PATCH semantics), the +/// media type it is expected to declare, and a payload it must refuse. A codec that needs compile-time metadata registers the +/// Dexpace.Sdk.Conformance.Codec probe models in its own context. +/// +public sealed record CodecSubject +{ + /// + /// The default bytes the codec must refuse to read: a truncated JSON object. They are not a valid document of JSON, XML or the common + /// binary formats, so most codecs refuse them; a codec for a format that accepts them supplies its own . + /// + public static ReadOnlyMemory DefaultMalformedPayload => "{\"name\": }"u8.ToArray(); + + /// The name the report carries. + public string Name + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } = "codec"; + + /// Builds the codec, fresh for each run, exactly as a consumer would. Required. + public Func CreateSerde + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = value; + } + } = static () => throw new InvalidOperationException("A CodecSubject needs a CreateSerde factory."); + + /// The media type the codec must declare (SERDE-2), or to check only that it declares a format-specific one. + public MediaType? ExpectedMediaType { get; init; } + + /// Bytes the codec must refuse to read, as a failure of the SDK's deserialization type (SERDE-9, SERDE-10). Defaults to . + public ReadOnlyMemory MalformedPayload { get; init; } = DefaultMalformedPayload; +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/CodecContext.cs b/src/Dexpace.Sdk.Conformance/Suite/CodecContext.cs new file mode 100644 index 00000000..d7463366 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/CodecContext.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Buffers; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Conformance; + +/// +/// What one run of one codec assertion owns: the subject, the face, and the codec, built fresh the way a consumer builds it (lazily, so a +/// factory that throws is an errored result of the assertion and not an exception out of the run). The two faces are the two call +/// surfaces of : the asynchronous stream pair, and the synchronous buffer and span pair. +/// +internal sealed class CodecContext(CodecSubject subject, TransportFace face, SuiteOptions options) : IAsyncDisposable +{ + private ISerde? _serde; + + /// The codec under test. + internal CodecSubject Subject { get; } = subject; + + /// The face this run drives. + internal TransportFace Face { get; } = face; + + /// The run's options. + internal SuiteOptions Options { get; } = options; + + /// The codec, built on first use by the subject's factory. + internal ISerde Serde => _serde ??= Subject.CreateSerde() ?? throw new ConformanceException("CreateSerde returned null: the codec suite needs a codec to judge."); + + /// Encodes on this run's face and returns the bytes written. + /// The value's type. + /// The value. + /// The assertion's token. + internal async Task EncodeAsync(T value, CancellationToken token) + { + if (Face == TransportFace.Async) + { + using var sink = new MemoryStream(); + await Serde.SerializeAsync(sink, value, token).ConfigureAwait(false); + return sink.ToArray(); + } + + var buffer = new ArrayBufferWriter(); + Serde.Serialize(buffer, value); + return buffer.WrittenSpan.ToArray(); + } + + /// Decodes on this run's face. + /// The type to decode to. + /// The encoded value. + /// The assertion's token. + internal async Task DecodeAsync(byte[] bytes, CancellationToken token) + { + if (Face == TransportFace.Async) + { + using var source = new MemoryStream(bytes, writable: false); + return await Serde.DeserializeAsync(source, token).ConfigureAwait(false); + } + + return Serde.Deserialize(bytes); + } + + /// Encodes and decodes on this run's face, turning any codec failure into a named conformance failure. + /// The value's type. + /// The value. + /// What is being round-tripped, named in a failure. + /// The assertion's token. + internal async Task RoundTripAsync(T value, string what, CancellationToken token) + { + var bytes = await Check.GuardAsync(() => EncodeAsync(value, token), $"encoding {what}", token).ConfigureAwait(false); + return await Check.GuardAsync(() => DecodeAsync(bytes, token), $"decoding {what}", token).ConfigureAwait(false); + } + + /// + public async ValueTask DisposeAsync() + { + switch (_serde) + { + case IAsyncDisposable asyncDisposable: + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + break; + case IDisposable disposable: + disposable.Dispose(); + break; + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/CodecIdentity.cs b/src/Dexpace.Sdk.Conformance/Suite/CodecIdentity.cs new file mode 100644 index 00000000..f9474170 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/CodecIdentity.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The codec suite's report identity: its name and what a green run does not prove. +internal static class CodecIdentity +{ + /// What a green codec run does not prove. + internal const string Preamble = + "Codec conformance report. A green run does not prove: the codec's own metadata for models it was not given (the probe models are " + + "the only types the suite serializes); configuration it was not handed (the codec is built exactly as the driver's factory builds " + + "it); the wire format byte for byte (the suite judges what a codec preserves and refuses, not how it spells it); core's typed " + + "readers, SerdeExtensions and Tristate's own API (SERDE-4, SERDE-13, SERDE-14, SERDE-18, SERDE-30, which judge core code " + + "and are cited by reference); or any requirement no assertion cites. It proves that every assertion that ran met its clause " + + "over the codec it was given, or is waived below, and nothing more."; + + /// The identity the report carries. + internal static SuiteIdentity Identity { get; } = new("codec", Preamble); +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/CodecSuite.cs b/src/Dexpace.Sdk.Conformance/Suite/CodecSuite.cs new file mode 100644 index 00000000..0834023a --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/CodecSuite.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// The codec conformance suite (phase 10, design section D): eleven assertions over the SERDE requirements a codec seam can be asked +/// (SERDE-1 to -3, -9, -10, -12, -15 to -17, -19, -20, and SEAM-20), run against a +/// on both faces of : the asynchronous stream pair and the synchronous +/// buffer and span pair. Framework-free like . +/// +/// +/// The suite serializes only the probe models of Dexpace.Sdk.Conformance.Codec; a codec that needs compile-time metadata registers them. +/// SERDE-4, -13, -14, -18 and -30 judge core code (the span profile, the typed readers, Tristate<T>'s own +/// API), not the codec seam, so they are not assertions here (validation F6). +/// +public static class CodecSuite +{ + /// What a green run of the codec suite does not prove. + public static string Preamble { get; } = CodecIdentity.Preamble; + + /// Every assertion, in a stable order. Names are stable public identifiers, frozen at the first shipped surface. + public static IReadOnlyList Assertions => CodecCatalogue.All; + + /// Runs one assertion on one face of . + /// The codec under test. + /// One of . + /// A face declares. + /// Bounds and waivers; defaults when . + /// Abandons the run with . + /// The result. Assertion failures and codec crashes are results, not exceptions. + /// or is null. + /// is not one of , or is not one it declares. + public static Task RunAsync( + CodecSubject subject, + ConformanceAssertion assertion, + TransportFace face, + SuiteOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(subject); + ArgumentNullException.ThrowIfNull(assertion); + if (!Assertions.Contains(assertion)) + { + throw new ArgumentException($"Assertion '{assertion.Name}' is not a codec assertion.", nameof(assertion)); + } + + if (!assertion.Faces.Contains(face)) + { + throw new ArgumentException($"Assertion '{assertion.Name}' does not declare the {face} face.", nameof(face)); + } + + var effective = options ?? new SuiteOptions(); + return SuiteRunnerCore.RunAsync( + assertion, + face, + () => new CodecContext(subject, face, effective), + effective.AssertionTimeout, + effective.Waivers, + cancellationToken); + } + + /// Runs every assertion on every face it declares, sequentially, and builds the report. + /// The codec under test. + /// Bounds and waivers; defaults when . + /// Abandons the run with . + /// The report; its is the verdict. + /// is null. + public static Task RunAllAsync( + CodecSubject subject, + SuiteOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(subject); + var effective = options ?? new SuiteOptions(); + return SuiteRunnerCore.RunAllAsync( + subject.Name, + CodecIdentity.Identity, + Assertions, + (assertion, face) => RunAsync(subject, assertion, face, effective, cancellationToken), + effective.Waivers); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/CodecCatalogueTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/CodecCatalogueTests.cs new file mode 100644 index 00000000..69774800 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/CodecCatalogueTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.RegularExpressions; +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Assertions; + +/// +/// The pin over the codec catalogue (plan 2.1 to 2.4): the 11 names in order with their requirement IDs, levels and faces, and a negative control +/// for every one. SERDE-4, -13, -14, -18 and -30 are not here by design (validation F6: they judge core code). +/// +[Trait("Category", "Unit")] +public sealed partial class CodecCatalogueTests +{ + private const RequirementLevel M = RequirementLevel.Must; + private const RequirementLevel S = RequirementLevel.Should; + + private static readonly TransportFace[] s_both = [TransportFace.Async, TransportFace.Blocking]; + private static readonly TransportFace[] s_none = [TransportFace.None]; + + private static readonly (string Name, string[] Ids, RequirementLevel Level, TransportFace[] Faces)[] s_expected = + [ + ("serde-1.round-trip", ["SERDE-1"], M, s_both), + ("serde-2.declared-media-type", ["SERDE-2"], M, s_none), + ("serde-3.target-not-closed", ["SERDE-3", "SEAM-20"], M, s_both), + ("serde-9.no-library-type-escapes", ["SERDE-9"], M, s_both), + ("serde-10.directional-subtypes", ["SERDE-10"], M, s_both), + ("serde-12.stream-error-unwrapped", ["SERDE-12"], M, s_both), + ("serde-15.tristate-emission", ["SERDE-15"], M, s_both), + ("serde-16.tristate-decode", ["SERDE-16"], M, s_both), + ("serde-17.missing-key-absent", ["SERDE-17"], M, s_both), + ("serde-19.default-wiring", ["SERDE-19"], M, s_both), + ("serde-20.top-level-degrades", ["SERDE-20"], S, s_both), + ]; + + private static readonly string[] s_byReference = ["SERDE-4", "SERDE-13", "SERDE-14", "SERDE-18", "SERDE-30"]; + private static readonly string[] s_lifted = ["SERDE-1", "SERDE-2", "SERDE-3", "SEAM-20", "SERDE-9", "SERDE-10", "SERDE-12", "SERDE-15", "SERDE-16", "SERDE-17", "SERDE-19", "SERDE-20"]; + + [GeneratedRegex(@"^[a-z]+-\d+\.[a-z0-9-]+$", RegexOptions.CultureInvariant)] + private static partial Regex NamePattern(); + + private static IReadOnlyList All => CodecSuite.Assertions; + + [Fact] + public void The_catalogue_is_the_11_named_assertions_in_the_design_order() + { + Assert.Equal(11, All.Count); + Assert.Equal(s_expected.Select(e => e.Name), All.Select(a => a.Name)); + } + + [Fact] + public void Each_assertion_has_the_ids_level_and_faces_of_the_design_table() + { + for (var i = 0; i < s_expected.Length; i++) + { + Assert.Equal(s_expected[i].Ids, All[i].RequirementIds); + Assert.Equal(s_expected[i].Level, All[i].Level); + Assert.Equal(s_expected[i].Faces, All[i].Faces); + } + } + + [Fact] + public void Names_are_stable_identifiers_and_no_assertion_is_stronger_than_its_strongest_requirement() + { + foreach (var assertion in All) + { + Assert.Matches(NamePattern(), assertion.Name); + Assert.All(assertion.RequirementIds, id => Assert.True(RequirementIndex.Contains(id), id)); + var strongest = assertion.RequirementIds.Select(id => { RequirementIndex.TryGetLevel(id, out var level); return level; }).Min(); + Assert.True(assertion.Level >= strongest, $"{assertion.Name} is {assertion.Level} but its strongest requirement is only {strongest}"); + } + } + + [Fact] + public void The_five_requirements_that_judge_core_code_are_deliberately_not_lifted() + { + var cited = All.SelectMany(a => a.RequirementIds).ToHashSet(StringComparer.Ordinal); + + Assert.All(s_byReference, id => Assert.DoesNotContain(id, cited)); + Assert.All(s_lifted, id => Assert.Contains(id, cited)); + } + + [Fact] + public void Every_codec_assertion_has_a_negative_control() + { + var uncovered = All.Select(a => a.Name).Except(ControlClasses.Covered, StringComparer.Ordinal).ToArray(); + + Assert.Empty(uncovered); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs index 73afb30a..f3af23ce 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs @@ -14,5 +14,6 @@ internal static class KitCatalogues [ .. TransportSuite.Assertions, .. InvariantSuite.Assertions, + .. CodecSuite.Assertions, ]; } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecFailureControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecFailureControlTests.cs new file mode 100644 index 00000000..e2eaa6dc --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecFailureControlTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for serde-9, serde-10 and serde-12 (plan 2.3). +[Trait("Category", "Unit")] +public sealed class CodecFailureControlTests +{ + internal static IReadOnlyList Rows { get; } = + [ + .. CodecControlRow.OnEveryFace("serde-9.no-library-type-escapes", "lets the library's exception type escape", SerdeFlaw.LeaksLibraryException, "never the backing library"), + .. CodecControlRow.OnEveryFace("serde-9.no-library-type-escapes", "drops the original failure", SerdeFlaw.DropsCause, "chain"), + .. CodecControlRow.OnEveryFace("serde-10.directional-subtypes", "raises a read failure as the write subtype", SerdeFlaw.WrongDirection, "deserialization-specific"), + .. CodecControlRow.OnEveryFace("serde-12.stream-error-unwrapped", "re-wraps a genuine stream error", SerdeFlaw.WrapsIoErrors, "unwrapped"), + ]; + + public static IReadOnlyCollection Covered => CodecControlRow.CoveredBy(Rows); + + public static TheoryData Cases => CodecControlRow.Cases(Rows); + + public static TheoryData PositiveCases => CodecControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_flawed_codec_is_caught(string assertion, TransportFace face, string control) => CodecControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_conforming_codec_passes(string assertion, TransportFace face) => CodecControlRow.RunPositiveAsync(assertion, face); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecRoundTripControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecRoundTripControlTests.cs new file mode 100644 index 00000000..8b6e73c9 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecRoundTripControlTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for serde-1, serde-2 and serde-3 (plan 2.2): each flawed reference codec must fail the assertion that judges its clause, and the conforming one must pass. +[Trait("Category", "Unit")] +public sealed class CodecRoundTripControlTests +{ + internal static IReadOnlyList Rows { get; } = + [ + .. CodecControlRow.OnEveryFace("serde-1.round-trip", "loses the list", SerdeFlaw.LosesField, "survive a round trip"), + .. CodecControlRow.OnEveryFace("serde-2.declared-media-type", "declares application/octet-stream", SerdeFlaw.OctetStream, "format-agnostic constant"), + .. CodecControlRow.OnEveryFace("serde-2.declared-media-type", "declares a different media type on every read", SerdeFlaw.UnstableMediaType, "default Content-Type"), + .. CodecControlRow.OnEveryFace("serde-3.target-not-closed", "closes the caller's target", SerdeFlaw.ClosesTarget, "the caller's"), + .. CodecControlRow.OnEveryFace("serde-3.target-not-closed", "closes the caller's source", SerdeFlaw.ClosesSource, "the caller's source stream"), + ]; + + public static IReadOnlyCollection Covered => CodecControlRow.CoveredBy(Rows); + + public static TheoryData Cases => CodecControlRow.Cases(Rows); + + public static TheoryData PositiveCases => CodecControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_flawed_codec_is_caught(string assertion, TransportFace face, string control) => CodecControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_conforming_codec_passes(string assertion, TransportFace face) => CodecControlRow.RunPositiveAsync(assertion, face); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecTristateControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecTristateControlTests.cs new file mode 100644 index 00000000..517d3699 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/CodecTristateControlTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for serde-15, -16, -17, -19 and -20 (plan 2.4). +[Trait("Category", "Unit")] +public sealed class CodecTristateControlTests +{ + internal static IReadOnlyList Rows { get; } = + [ + .. CodecControlRow.OnEveryFace("serde-15.tristate-emission", "writes a wire null for an absent member", SerdeFlaw.NullForAbsent, "must not encode alike"), + .. CodecControlRow.OnEveryFace("serde-16.tristate-decode", "decodes an explicit null as absent", SerdeFlaw.NullDecodesAsAbsent, "explicit null member must decode to Null"), + .. CodecControlRow.OnEveryFace("serde-17.missing-key-absent", "decodes a missing key as null", SerdeFlaw.MissingKeyIsNull, "no key on the wire"), + .. CodecControlRow.OnEveryFace("serde-19.default-wiring", "has no tri-state wiring", SerdeFlaw.NoTristateWiring, "default codec configuration"), + .. CodecControlRow.OnEveryFace("serde-20.top-level-degrades", "throws for a top-level absent value", SerdeFlaw.ThrowsForTopLevelAbsent, "top-level Absent"), + ]; + + public static IReadOnlyCollection Covered => CodecControlRow.CoveredBy(Rows); + + public static TheoryData Cases => CodecControlRow.Cases(Rows); + + public static TheoryData PositiveCases => CodecControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public Task The_flawed_codec_is_caught(string assertion, TransportFace face, string control) => CodecControlRow.RunAsync(Rows, assertion, face, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public Task The_conforming_codec_passes(string assertion, TransportFace face) => CodecControlRow.RunPositiveAsync(assertion, face); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/ReferenceSerde.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/ReferenceSerde.cs new file mode 100644 index 00000000..8df96b66 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/ReferenceSerde.cs @@ -0,0 +1,331 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Buffers; +using System.Text.Json; +using Dexpace.Sdk.Conformance.Codec; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// What a gets wrong; each flaw is the negative control of one codec assertion (plan 2.2 to 2.4). +[Flags] +public enum SerdeFlaw +{ + /// A conforming codec. + None = 0, + + /// It drops the list when it encodes a contact (SERDE-1). + LosesField = 1, + + /// It declares a format-agnostic media type (SERDE-2). + OctetStream = 1 << 1, + + /// Its declared media type changes from one read to the next (SERDE-2). + UnstableMediaType = 1 << 2, + + /// It closes the caller's target on encode (SERDE-3). + ClosesTarget = 1 << 3, + + /// It closes the caller's source on decode (SERDE-3). + ClosesSource = 1 << 4, + + /// It lets the backing library's exception type escape (SERDE-9). + LeaksLibraryException = 1 << 5, + + /// It raises the SDK's exception without the original failure as the cause (SERDE-9). + DropsCause = 1 << 6, + + /// It raises a read failure as the write-path subtype (SERDE-10). + WrongDirection = 1 << 7, + + /// It re-wraps a genuine stream I/O error as a codec failure (SERDE-12). + WrapsIoErrors = 1 << 8, + + /// It writes a wire null for an absent member (SERDE-15). + NullForAbsent = 1 << 9, + + /// It decodes an explicit null member as absent (SERDE-16). + NullDecodesAsAbsent = 1 << 10, + + /// It decodes a missing key as null (SERDE-17). + MissingKeyIsNull = 1 << 11, + + /// It has no tri-state wiring: absent and null are one thing on the wire (SERDE-19). + NoTristateWiring = 1 << 12, + + /// It throws for a top-level absent value (SERDE-20). + ThrowsForTopLevelAbsent = 1 << 13, +} + +/// +/// A small JSON codec for exactly the codec suite's probe models, over and , with switchable +/// s: the in-kit reference the codec controls run against (plan 2.2). It uses no serializer metadata, so the kit's own tests +/// reference no codec adapter. +/// +public sealed class ReferenceSerde(SerdeFlaw flaw = SerdeFlaw.None) : ISerde +{ + private int _reads; + + /// + public MediaType DefaultMediaType => + flaw.HasFlag(SerdeFlaw.OctetStream) ? MediaType.Parse("application/octet-stream") + : flaw.HasFlag(SerdeFlaw.UnstableMediaType) && Interlocked.Increment(ref _reads) % 2 == 0 ? MediaType.Parse("application/json") + : CommonMediaTypes.ApplicationJsonUtf8; + + /// + public async ValueTask SerializeAsync(Stream destination, T value, CancellationToken cancellationToken = default) + { + var bytes = Encode(value); + await destination.WriteAsync(bytes, cancellationToken); + if (flaw.HasFlag(SerdeFlaw.ClosesTarget)) + { + await destination.DisposeAsync(); + } + } + + /// + public async ValueTask DeserializeAsync(Stream source, CancellationToken cancellationToken = default) + { + using var buffer = new MemoryStream(); + try + { + await source.CopyToAsync(buffer, cancellationToken); + } + catch (IOException io) when (flaw.HasFlag(SerdeFlaw.WrapsIoErrors)) + { + throw new DeserializationException("the stream failed", io); + } + + if (flaw.HasFlag(SerdeFlaw.ClosesSource)) + { + await source.DisposeAsync(); + } + + return Decode(buffer.ToArray()); + } + + /// + public T? Deserialize(Stream source) + { + using var buffer = new MemoryStream(); + try + { + source.CopyTo(buffer); + } + catch (IOException io) when (flaw.HasFlag(SerdeFlaw.WrapsIoErrors)) + { + throw new DeserializationException("the stream failed", io); + } + + if (flaw.HasFlag(SerdeFlaw.ClosesSource)) + { + source.Dispose(); + } + + return Decode(buffer.ToArray()); + } + + /// + public void Serialize(IBufferWriter destination, T value) + { + var bytes = Encode(value); + try + { + destination.GetSpan(bytes.Length); + } + catch (IOException io) when (flaw.HasFlag(SerdeFlaw.WrapsIoErrors)) + { + throw new SerializationException("the target failed", io); + } + + destination.Write(bytes); + if (flaw.HasFlag(SerdeFlaw.ClosesTarget)) + { + (destination as IDisposable)?.Dispose(); + } + } + + /// + public T? Deserialize(ReadOnlySpan utf8) => Decode(utf8.ToArray()); + + private byte[] Encode(T value) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + try + { + Write(writer, value); + } + catch (NotSupportedException ex) when (!flaw.HasFlag(SerdeFlaw.LeaksLibraryException)) + { + throw flaw.HasFlag(SerdeFlaw.DropsCause) ? new SerializationException("cannot encode") : new SerializationException("cannot encode " + typeof(T).Name, ex); + } + } + + return buffer.WrittenSpan.ToArray(); + } + + private void Write(Utf8JsonWriter writer, T value) + { + switch (value) + { + case ProbeContact contact: + writer.WriteStartObject(); + writer.WriteString("name", contact.Name); + writer.WriteNumber("age", contact.Age); + writer.WritePropertyName("address"); + WriteAddress(writer, contact.Address); + if (!flaw.HasFlag(SerdeFlaw.LosesField)) + { + writer.WriteStartArray("tags"); + foreach (var tag in contact.Tags) + { + writer.WriteStringValue(tag); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + break; + case ProbeAddress address: + WriteAddress(writer, address); + break; + case ProbePatch patch: + writer.WriteStartObject(); + Member(writer, "name", patch.Name, static (w, v) => w.WriteStringValue(v)); + Member(writer, "age", patch.Age, static (w, v) => w.WriteNumberValue(v)); + Member(writer, "address", patch.Address, WriteAddress); + writer.WriteEndObject(); + break; + case ProbeSequence sequence: + writer.WriteStartObject(); + writer.WriteStartArray("items"); + foreach (var item in sequence.Items) + { + Element(writer, item); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + break; + case Tristate top: + if (top.IsAbsent && flaw.HasFlag(SerdeFlaw.ThrowsForTopLevelAbsent)) + { + throw new SerializationException("a top-level absent value cannot be encoded"); + } + + Element(writer, top); + break; + default: + throw new NotSupportedException($"The type {typeof(T).Name} is not supported (delegates and unknown types have no representation)."); + } + } + + private static void WriteAddress(Utf8JsonWriter writer, ProbeAddress address) + { + writer.WriteStartObject(); + writer.WriteString("street", address.Street); + writer.WriteString("city", address.City); + writer.WriteEndObject(); + } + + private void Member(Utf8JsonWriter writer, string name, Tristate member, Action write) + where TValue : notnull + { + if (member.IsPresent) + { + writer.WritePropertyName(name); + write(writer, member.Value); + } + else if (member.IsNull || flaw.HasFlag(SerdeFlaw.NullForAbsent) || flaw.HasFlag(SerdeFlaw.NoTristateWiring)) + { + writer.WriteNull(name); + } + } + + private static void Element(Utf8JsonWriter writer, Tristate item) + { + if (item.IsPresent) + { + writer.WriteStringValue(item.Value); + } + else + { + writer.WriteNullValue(); + } + } + + private T? Decode(byte[] bytes) + { + try + { + using var document = JsonDocument.Parse(bytes); + return (T?)Read(document.RootElement, typeof(T)); + } + catch (JsonException ex) when (!flaw.HasFlag(SerdeFlaw.LeaksLibraryException)) + { + if (flaw.HasFlag(SerdeFlaw.WrongDirection)) + { + throw new SerializationException("cannot decode", ex); + } + + throw flaw.HasFlag(SerdeFlaw.DropsCause) ? new DeserializationException("cannot decode") : new DeserializationException("cannot decode " + typeof(T).Name, ex); + } + } + + private object? Read(JsonElement element, Type type) + { + if (type == typeof(ProbeContact)) + { + return new ProbeContact( + element.GetProperty("name").GetString()!, + element.GetProperty("age").GetInt32(), + ReadAddress(element.GetProperty("address")), + element.TryGetProperty("tags", out var tags) ? [.. tags.EnumerateArray().Select(static t => t.GetString()!)] : []); + } + + if (type == typeof(ProbePatch)) + { + return new ProbePatch( + Member(element, "name", static e => e.GetString()!), + Member(element, "age", static e => e.GetInt32()), + Member(element, "address", ReadAddress)); + } + + if (type == typeof(ProbeSequence)) + { + return new ProbeSequence([.. element.GetProperty("items").EnumerateArray().Select(static e => e.ValueKind == JsonValueKind.Null ? Tristate.Null : Tristate.Present(e.GetString()!))]); + } + + if (type == typeof(Tristate)) + { + return element.ValueKind == JsonValueKind.Null ? Tristate.Null : Tristate.Present(element.GetString()!); + } + + throw new JsonException("unsupported type " + type.Name); + } + + private static ProbeAddress ReadAddress(JsonElement element) => + new(element.GetProperty("street").GetString()!, element.GetProperty("city").GetString()!); + + private Tristate Member(JsonElement owner, string name, Func read) + where TValue : notnull + { + if (!owner.TryGetProperty(name, out var member)) + { + return flaw.HasFlag(SerdeFlaw.MissingKeyIsNull) ? Tristate.Null : Tristate.Absent; + } + + if (member.ValueKind == JsonValueKind.Null) + { + return flaw.HasFlag(SerdeFlaw.NullDecodesAsAbsent) || flaw.HasFlag(SerdeFlaw.NoTristateWiring) ? Tristate.Absent : Tristate.Null; + } + + return Tristate.Present(read(member)); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Drivers/CodecControlRow.cs b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/CodecControlRow.cs new file mode 100644 index 00000000..9510d879 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/CodecControlRow.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Controls; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Drivers; + +/// One negative control for the codec suite: an assertion, a face, and a deliberately broken codec (a with one ) that the assertion must catch. +internal sealed record CodecControlRow(string Assertion, TransportFace Face, string Name, SerdeFlaw Flaw, string Because) +{ + /// Short bounds so a control stays fast. + internal static SuiteOptions Options { get; } = new() { AssertionTimeout = TimeSpan.FromSeconds(10) }; + + /// The codec subject over a reference codec with . + /// What the codec gets wrong. + internal static CodecSubject Subject(SerdeFlaw flaw = SerdeFlaw.None) => + new() { Name = "reference codec " + flaw, CreateSerde = () => new ReferenceSerde(flaw), ExpectedMediaType = Dexpace.Sdk.Core.Http.Common.CommonMediaTypes.ApplicationJson }; + + /// The same row on every face the assertion declares. + /// The assertion's name. + /// What the codec gets wrong, in words. + /// The flaw. + /// The text the failure must contain. + internal static CodecControlRow[] OnEveryFace(string assertion, string name, SerdeFlaw flaw, string because) => + [.. Find(assertion).Faces.Select(face => new CodecControlRow(assertion, face, name, flaw, because))]; + + /// The distinct assertion names a table covers. + internal static IReadOnlyCollection CoveredBy(IEnumerable rows) => + [.. rows.Select(row => row.Assertion).Distinct(StringComparer.Ordinal)]; + + /// The theory data of a table: assertion, face and control name. + internal static TheoryData Cases(IEnumerable rows) + { + var data = new TheoryData(); + foreach (var row in rows) + { + data.Add(row.Assertion, row.Face, row.Name); + } + + return data; + } + + /// The theory data of the positive counterparts: every assertion a table covers, on each face it declares. + internal static TheoryData PositiveCases(IEnumerable rows) + { + var data = new TheoryData(); + foreach (var name in CoveredBy(rows)) + { + foreach (var face in Find(name).Faces) + { + data.Add(name, face); + } + } + + return data; + } + + /// The codec assertion named . + internal static ConformanceAssertion Find(string name) => + CodecSuite.Assertions.SingleOrDefault(a => a.Name == name) + ?? throw new InvalidOperationException($"There is no codec assertion named '{name}'."); + + /// Runs one control and fails unless the assertion failed for the row's reason. + internal static async Task RunAsync(IEnumerable rows, string assertion, TransportFace face, string name) + { + var row = rows.Single(r => r.Assertion == assertion && r.Face == face && r.Name == name); + var result = await CodecSuite.RunAsync(Subject(row.Flaw), Find(assertion), face, Options, TestContext.Current.CancellationToken); + + Assert.True(result.Status == ConformanceStatus.Failed, $"control '{name}' on {face} expected Failed but the assertion reported {result.Status}: {result.Detail}{Trace(result)}"); + Assert.True( + result.Detail.Contains(row.Because, StringComparison.OrdinalIgnoreCase), + $"control '{name}' on {face} failed, but not for the expected reason '{row.Because}': {result.Detail}"); + } + + /// Runs the real assertion on one face against a conforming reference codec and fails unless it passed. + internal static async Task RunPositiveAsync(string assertion, TransportFace face) + { + var result = await CodecSuite.RunAsync(Subject(), Find(assertion), face, Options, TestContext.Current.CancellationToken); + + Assert.True(result.Status == ConformanceStatus.Passed, $"{assertion} on {face} against the conforming reference codec: {result.Status}: {result.Detail}{Trace(result)}"); + } + + private static string Trace(ConformanceResult result) => + result.Status is ConformanceStatus.Errored && result.Exception is { } exception ? Environment.NewLine + exception : string.Empty; +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Suite/CodecSuiteTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Suite/CodecSuiteTests.cs new file mode 100644 index 00000000..76f95788 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Suite/CodecSuiteTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Codec; +using Dexpace.Sdk.Conformance.Tests.Controls; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Suite; + +/// Plan 2.1 (P10-8): the codec suite's shell and its probe models. +[Trait("Category", "Unit")] +public sealed class CodecSuiteTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public void The_preamble_says_what_a_green_run_does_not_prove() + { + Assert.Contains("metadata for models it was not given", CodecSuite.Preamble, StringComparison.Ordinal); + Assert.Contains("configuration it was not handed", CodecSuite.Preamble, StringComparison.Ordinal); + } + + [Fact] + public async Task A_report_records_the_codec_suite_and_its_preamble_and_a_conforming_codec_is_green() + { + var report = await CodecSuite.RunAllAsync(new CodecSubject { Name = "reference", CreateSerde = () => new ReferenceSerde() }, new SuiteOptions { AssertionTimeout = TimeSpan.FromSeconds(10) }, Ct); + + Assert.True(report.IsGreen, report.ToString()); + Assert.Equal("codec", report.Suite); + Assert.Equal(CodecSuite.Preamble, report.PreambleText); + Assert.Equal(report.Results.Count, report.Results.Count(r => r.Status == ConformanceStatus.Passed)); + Assert.Equal(CodecSuite.Assertions.Sum(a => a.Faces.Count), report.Results.Count); + } + + [Fact] + public async Task A_codec_factory_that_throws_is_a_red_result_not_an_exception() + { + // The factory runs inside the assertion, so a crash is a result: Failed where the assertion guards the codec's calls, Errored where it reads the codec itself. + var report = await CodecSuite.RunAllAsync(new CodecSubject { CreateSerde = () => throw new InvalidOperationException("no codec") }, null, Ct); + + Assert.False(report.IsGreen); + Assert.All(report.Results, r => Assert.True(r.Status is ConformanceStatus.Failed or ConformanceStatus.Errored, r.Assertion.Name + ": " + r.Status)); + Assert.Contains(report.Results, r => r.Detail.Contains("no codec", StringComparison.Ordinal) || r.Detail.Contains("InvalidOperationException", StringComparison.Ordinal)); + } + + [Fact] + public void The_subject_defaults_to_a_malformed_json_payload_and_rejects_null_hooks() + { + var subject = new CodecSubject(); + + Assert.Equal("codec", subject.Name); + Assert.Equal("{\"name\": }"u8.ToArray(), subject.MalformedPayload.ToArray()); + Assert.Null(subject.ExpectedMediaType); + Assert.Throws(() => new CodecSubject { CreateSerde = null! }); + Assert.Throws(() => new CodecSubject { Name = " " }); + Assert.Throws(subject.CreateSerde); + } + + [Fact] + public async Task RunAsync_rejects_a_foreign_assertion_a_face_it_does_not_declare_and_null() + { + var subject = new CodecSubject { CreateSerde = () => new ReferenceSerde() }; + var foreign = TransportSuite.Assertions[0]; + var codec = CodecSuite.Assertions.Single(a => a.Name == "serde-1.round-trip"); + + await Assert.ThrowsAsync(() => CodecSuite.RunAsync(null!, codec, TransportFace.Async, null, Ct)); + await Assert.ThrowsAsync(() => CodecSuite.RunAsync(subject, null!, TransportFace.Async, null, Ct)); + await Assert.ThrowsAsync(() => CodecSuite.RunAllAsync(null!, null, Ct)); + await Assert.ThrowsAsync(() => CodecSuite.RunAsync(subject, foreign, TransportFace.Async, null, Ct)); + await Assert.ThrowsAsync(() => CodecSuite.RunAsync(subject, codec, TransportFace.None, null, Ct)); + } + + [Fact] + public void The_probe_models_are_plain_records_with_absent_as_the_default_of_every_tri_state() + { + var patch = new ProbePatch(default, default, default); + + Assert.True(patch.Name.IsAbsent && patch.Age.IsAbsent && patch.Address.IsAbsent); + Assert.Equal(new ProbeAddress("a", "b"), new ProbeAddress("a", "b")); + Assert.Equal(["x"], new ProbeContact("n", 1, new ProbeAddress("a", "b"), ["x"]).Tags); + Assert.Single(new ProbeSequence([default]).Items); + Assert.NotNull(new ProbeUnencodable(static () => { }).Callback); + } +} From faea5819c702bea22b68f9f7b2958053a2e89ca4 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 21:55:39 +0300 Subject: [PATCH 15/45] test: STJ driver for the codec suite (phase 10, #99) --- .../Conformance/CodecConformanceTests.cs | 83 +++++++++++++++++++ .../Conformance/ProbeContext.cs | 21 +++++ ....Serialization.SystemTextJson.Tests.csproj | 2 + .../packages.lock.json | 6 ++ 4 files changed, 112 insertions(+) create mode 100644 tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/CodecConformanceTests.cs create mode 100644 tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/ProbeContext.cs diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/CodecConformanceTests.cs b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/CodecConformanceTests.cs new file mode 100644 index 00000000..bdd0a57b --- /dev/null +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/CodecConformanceTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Linq; +using Dexpace.Sdk.Conformance; +using Dexpace.Sdk.Core.Http.Common; +using Xunit; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests.Conformance; + +/// +/// The codec suite driven against (phase 10, P10-8): one case per (assertion, face), plus the whole suite as a +/// report. The codec is built exactly as a consumer builds it, from the default options over the probe context, with no waiver and no assertion +/// left not exercised: both lists are pinned (P10-28). +/// +[Trait("Category", "Conformance")] +public sealed class CodecConformanceTests +{ + private static readonly SuiteOptions s_options = new() { AssertionTimeout = TimeSpan.FromSeconds(30) }; + + public static TheoryData Rows() + { + var rows = new TheoryData(); + foreach (var assertion in CodecSuite.Assertions) + { + foreach (var face in assertion.Faces) + { + rows.Add(assertion.Name, face); + } + } + + return rows; + } + + private static CodecSubject Subject() => new() + { + Name = "Dexpace.Sdk.Serialization.SystemTextJson", + CreateSerde = static () => new SystemTextJsonSerde(SystemTextJsonSerde.CreateDefaultOptions(ProbeContext.Default)), + ExpectedMediaType = CommonMediaTypes.ApplicationJson, + }; + + [Theory] + [MemberData(nameof(Rows))] + public async Task Assertion_holds(string name, TransportFace face) + { + var assertion = CodecSuite.Assertions.Single(a => a.Name == name); + + var result = await CodecSuite.RunAsync(Subject(), assertion, face, s_options, TestContext.Current.CancellationToken); + + Assert.True(result.Status == ConformanceStatus.Passed, $"{name} on {face}: {result.Status}: {result.Detail}"); + } + + [Fact] + public async Task The_whole_suite_is_green_and_its_report_is_written() + { + var report = await CodecSuite.RunAllAsync(Subject(), s_options, TestContext.Current.CancellationToken); + + TestContext.Current.TestOutputHelper?.WriteLine(report.ToString()); + Assert.True(report.IsGreen, report.ToString()); + Assert.Equal("codec", report.Suite); + } + + [Fact] + public async Task Nothing_is_not_exercised_vacuous_or_waived() + { + var report = await CodecSuite.RunAllAsync(Subject(), s_options, TestContext.Current.CancellationToken); + + Assert.Empty(s_options.Waivers); + Assert.All(report.Results, static r => Assert.Equal(ConformanceStatus.Passed, r.Status)); + Assert.Equal(CodecSuite.Assertions.Sum(static a => a.Faces.Count), report.Results.Count); + } + + [Fact] + public async Task Every_lifted_serde_requirement_is_covered_by_a_passing_result() + { + var report = await CodecSuite.RunAllAsync(Subject(), s_options, TestContext.Current.CancellationToken); + + foreach (var id in new[] { "SERDE-1", "SERDE-2", "SERDE-3", "SERDE-9", "SERDE-10", "SERDE-12", "SERDE-15", "SERDE-16", "SERDE-17", "SERDE-19", "SERDE-20", "SEAM-20" }) + { + Assert.Equal(ConformanceStatus.Passed, report.ByRequirement[id]); + } + } +} diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/ProbeContext.cs b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/ProbeContext.cs new file mode 100644 index 00000000..56daa5cc --- /dev/null +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/ProbeContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.Json.Serialization; +using Dexpace.Sdk.Conformance.Codec; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.Serialization.SystemTextJson.Tests.Conformance; + +/// +/// The metadata the codec suite's probe models need (phase 10, P10-8): one line per model, in the default generation mode (not the fast-path-only +/// mode, which cannot carry the tri-state wiring, CLAUDE.md). Tristate<string> is registered for the suite's top-level case +/// (SERDE-20); is registered too, so its failure is the codec's own and not a missing-metadata one. +/// +[JsonSerializable(typeof(ProbeAddress))] +[JsonSerializable(typeof(ProbeContact))] +[JsonSerializable(typeof(ProbePatch))] +[JsonSerializable(typeof(ProbeSequence))] +[JsonSerializable(typeof(Tristate))] +[JsonSerializable(typeof(ProbeUnencodable))] +internal sealed partial class ProbeContext : JsonSerializerContext; diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj index f6273899..b7a33660 100644 --- a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests.csproj @@ -20,6 +20,8 @@ + + diff --git a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/packages.lock.json b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/packages.lock.json index ace1112a..f0805e33 100644 --- a/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/packages.lock.json +++ b/tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/packages.lock.json @@ -256,6 +256,12 @@ "xunit.v3.runner.common": "[4.0.1]" } }, + "dexpace.sdk.conformance": { + "type": "Project", + "dependencies": { + "Dexpace.Sdk.Core": "[0.0.1-alpha.1, )" + } + }, "dexpace.sdk.core": { "type": "Project", "dependencies": { From cd3c5fc9b2ad1188f7773f8238f0e7a1495d2afb Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:03:56 +0300 Subject: [PATCH 16/45] feat: packaging suite with 8 assertions, BCL package readers and negative controls (phase 10, #99) --- .../Assertions/AssemblyPackagingAssertions.cs | 83 +++++++++++ .../DependencyPackagingAssertions.cs | 65 +++++++++ .../Assertions/MetadataPackagingAssertions.cs | 70 ++++++++++ .../Assertions/PackagingAssertionBuilder.cs | 25 ++++ .../Assertions/PackagingCatalogue.cs | 16 +++ .../Packaging/AssemblyMetadataReader.cs | 116 ++++++++++++++++ .../Packaging/NupkgReader.cs | 64 +++++++++ .../Packaging/NuspecReader.cs | 51 +++++++ .../Packaging/PackageFacts.cs | 44 ++++++ .../PublicAPI.Unshipped.txt | 38 ++++++ .../Subjects/PackagePolicy.cs | 13 ++ .../Subjects/PackagingSubject.cs | 67 +++++++++ .../Suite/PackagingContext.cs | 38 ++++++ .../Suite/PackagingIdentity.cs | 18 +++ .../Suite/PackagingSuite.cs | 64 +++++++++ .../Suite/SuiteRunnerCore.cs | 92 +++++++++++-- .../Assertions/KitCatalogues.cs | 1 + .../Assertions/PackagingCatalogueTests.cs | 64 +++++++++ .../Controls/PackagingAssemblyControlTests.cs | 41 ++++++ .../PackagingDependencyControlTests.cs | 42 ++++++ .../Controls/PackagingMetadataControlTests.cs | 44 ++++++ .../Drivers/PackagingControlRow.cs | 89 ++++++++++++ .../Packaging/NupkgReaderTests.cs | 111 +++++++++++++++ .../Suite/PackagingSuiteTests.cs | 114 ++++++++++++++++ .../Support/NupkgBuilder.cs | 129 ++++++++++++++++++ .../Support/PackageSpec.cs | 46 +++++++ 26 files changed, 1532 insertions(+), 13 deletions(-) create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/AssemblyPackagingAssertions.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/DependencyPackagingAssertions.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/MetadataPackagingAssertions.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/PackagingAssertionBuilder.cs create mode 100644 src/Dexpace.Sdk.Conformance/Assertions/PackagingCatalogue.cs create mode 100644 src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs create mode 100644 src/Dexpace.Sdk.Conformance/Packaging/NupkgReader.cs create mode 100644 src/Dexpace.Sdk.Conformance/Packaging/NuspecReader.cs create mode 100644 src/Dexpace.Sdk.Conformance/Packaging/PackageFacts.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/PackagePolicy.cs create mode 100644 src/Dexpace.Sdk.Conformance/Subjects/PackagingSubject.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/PackagingContext.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/PackagingIdentity.cs create mode 100644 src/Dexpace.Sdk.Conformance/Suite/PackagingSuite.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Assertions/PackagingCatalogueTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Drivers/PackagingControlRow.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Suite/PackagingSuiteTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Support/NupkgBuilder.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Support/PackageSpec.cs diff --git a/src/Dexpace.Sdk.Conformance/Assertions/AssemblyPackagingAssertions.cs b/src/Dexpace.Sdk.Conformance/Assertions/AssemblyPackagingAssertions.cs new file mode 100644 index 00000000..68b76ff9 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/AssemblyPackagingAssertions.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from ruby-sdk@5b17395 gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/ (declarations the artefact carries), over .NET +// assemblies: the trim and AOT declarations are the packed assembly's own AssemblyMetadata (design §9.2, V6). +using System.Text.RegularExpressions; + +namespace Dexpace.Sdk.Conformance; + +/// +/// NFR-3, NFR-8 (with NFR-9's rule-file clause) and NFR-10 over the packed assemblies: nothing grants its internals to +/// another assembly than its own test project, every library declares itself trimmable and AOT-compatible, and every package targets the one floor. +/// +internal static partial class AssemblyPackagingAssertions +{ + internal static ConformanceAssertion[] Assertions() => + [ + PackagingAssertionBuilder.Define("nfr-3.no-internals-granted", ["NFR-3"], RequirementLevel.Should, NoInternalsGranted), + PackagingAssertionBuilder.Define("nfr-8.trim-and-aot-declared", ["NFR-8", "NFR-9"], RequirementLevel.Must, TrimAndAotDeclared), + PackagingAssertionBuilder.Define("nfr-10.single-floor", ["NFR-10"], RequirementLevel.Must, SingleFloor), + ]; + + [GeneratedRegex(@"^net(\d+)\.(\d+)$", RegexOptions.CultureInvariant)] + private static partial Regex Moniker(); + + // Every InternalsVisibleTo names the package's own test project. + private static void NoInternalsGranted(PackagingContext context) + { + foreach (var package in context.Packages) + { + var assembly = Library(package); + foreach (var granted in assembly.InternalsVisibleTo) + { + var name = granted.Split(',')[0].Trim(); + Check.Equal(name, package.Id + ".Tests", $"the assembly '{package.Id}' grants its internals to (only its own test project may see them)"); + } + } + } + + // The keep configuration is the declaration in the assembly: IsTrimmable and IsAotCompatible, both True (design §9.2: the rule files are + // metadata carried in the assembly, and the trim and AOT analyzers ran at build). + private static void TrimAndAotDeclared(PackagingContext context) + { + foreach (var package in context.Packages) + { + var metadata = Library(package).AssemblyMetadata; + foreach (var key in new[] { "IsTrimmable", "IsAotCompatible" }) + { + metadata.TryGetValue(key, out var value); + Check.Equal(value ?? "(absent)", "True", $"the AssemblyMetadata '{key}' of '{package.Id}'"); + } + } + } + + // One lib folder, one dependency group and one TargetFramework attribute, all the declared floor. + private static void SingleFloor(PackagingContext context) + { + var floor = context.Subject.DeclaredFloor; + var expectedAttribute = FrameworkName(floor); + foreach (var package in context.Packages) + { + Check.SequenceEqual(package.LibFolders, [floor], $"the lib folders of '{package.Id}' (one, the declared floor)"); + Check.True(package.DependencyGroups.Count <= 1, $"'{package.Id}' declares {package.DependencyGroups.Count} dependency groups", "at most one group", package.DependencyGroups.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)); + foreach (var group in package.DependencyGroups) + { + Check.Equal(group.TargetFramework, floor, $"the target framework of the dependency group of '{package.Id}'"); + } + + Check.Equal(Library(package).TargetFramework ?? "(absent)", expectedAttribute, $"the TargetFramework attribute of the assembly in '{package.Id}'"); + } + } + + private static AssemblyFacts Library(PackageFacts package) => + package.Assembly ?? throw new ConformanceException($"'{package.Id}' packs no assembly named '{package.Id}.dll'", $"lib//{package.Id}.dll", "none"); + + private static string FrameworkName(string moniker) + { + var match = Moniker().Match(moniker); + return match.Success + ? $".NETCoreApp,Version=v{match.Groups[1].Value}.{match.Groups[2].Value}" + : throw new ConformanceException($"the declared floor '{moniker}' is not a netX.Y moniker the suite can map to a TargetFramework attribute"); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/DependencyPackagingAssertions.cs b/src/Dexpace.Sdk.Conformance/Assertions/DependencyPackagingAssertions.cs new file mode 100644 index 00000000..7671a96f --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/DependencyPackagingAssertions.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from ruby-sdk@5b17395 gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/ (the dependency budget), over nuspecs instead of +// gemspecs. The same rule runs over the build output in scripts/ci/dependency-audit.cs (two checks, two subjects). +namespace Dexpace.Sdk.Conformance; + +/// +/// NFR-1 and NFR-2 over the published nuspecs: core depends on the logging facade and nothing else (design §10 entry 1), and every +/// other package depends on core, the first-party packages its policy names and at most one third-party root (design §10 entry 2), all first-party +/// ones at the lockstep version. +/// +internal static class DependencyPackagingAssertions +{ + /// The one dependency core may carry: the logging facade, compile-time only in spirit (design §10 entry 1). + internal const string Facade = "Microsoft.Extensions.Logging.Abstractions"; + + private const string FirstParty = "Dexpace."; + + internal static ConformanceAssertion[] Assertions() => + [ + PackagingAssertionBuilder.Define("nfr-1.core-dependencies", ["NFR-1"], RequirementLevel.Must, CoreDependencies), + PackagingAssertionBuilder.Define("nfr-2.adapter-budget", ["NFR-2"], RequirementLevel.Should, AdapterBudget), + ]; + + // Exactly one dependency group, for the declared floor, listing exactly the facade. + private static void CoreDependencies(PackagingContext context) + { + var core = context.Core; + Check.Equal(core.DependencyGroups.Count, 1, $"the dependency groups of '{core.Id}' (one, for the declared floor)"); + var group = core.DependencyGroups[0]; + Check.Equal(group.TargetFramework, context.Subject.DeclaredFloor, $"the target framework of the dependency group of '{core.Id}'"); + Check.SequenceEqual(group.Dependencies.Select(d => d.Id), [Facade], $"the dependencies of '{core.Id}' (exactly the logging facade, design §10 entry 1)"); + } + + // Core, the first-party packages the policy names, the facade, and at most one third-party root; every first-party dependency at the lockstep version. + private static void AdapterBudget(PackagingContext context) + { + foreach (var package in context.Adapters) + { + context.Subject.Policies.TryGetValue(package.Id, out var policy); + var allowed = policy?.FirstPartyIds ?? []; + foreach (var dependency in package.DependencyGroups.SelectMany(g => g.Dependencies)) + { + if (dependency.Id.StartsWith(FirstParty, StringComparison.Ordinal)) + { + Check.True( + dependency.Id == context.Subject.CoreId || allowed.Contains(dependency.Id, StringComparer.Ordinal), + $"'{package.Id}' depends on the first-party package '{dependency.Id}', which its policy does not name", + $"core or one of: {string.Join(", ", allowed)}", + dependency.Id); + Check.Equal(dependency.Version, package.Version, $"the version of '{package.Id}''s dependency on '{dependency.Id}' (first-party packages are lock-stepped)"); + } + else + { + Check.True( + dependency.Id == Facade || string.Equals(dependency.Id, policy?.ThirdPartyRoot, StringComparison.Ordinal), + $"'{package.Id}' depends on '{dependency.Id}', a third-party root beyond the one its policy allows (at most one, design §10 entry 2)", + policy?.ThirdPartyRoot is null ? "the logging facade only" : $"the facade and '{policy.ThirdPartyRoot}'", + dependency.Id); + } + } + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/MetadataPackagingAssertions.cs b/src/Dexpace.Sdk.Conformance/Assertions/MetadataPackagingAssertions.cs new file mode 100644 index 00000000..00a6bebc --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/MetadataPackagingAssertions.cs @@ -0,0 +1,70 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// Ported from ruby-sdk@5b17395 gems/dexpace-conformance/lib/dexpace/conformance/packaging_suite/ (gemspec metadata and the version constant). +using System.Globalization; + +namespace Dexpace.Sdk.Conformance; + +/// +/// NFR-13, NFR-14 and NFR-15 over the published metadata: the license and copyright every package declares (the package-level face +/// of the header rule), one version for every package and every dependency between them, and a version the library can read back at run time. +/// +internal static class MetadataPackagingAssertions +{ + internal static ConformanceAssertion[] Assertions() => + [ + PackagingAssertionBuilder.Define("nfr-13.license-declared", ["NFR-13"], RequirementLevel.Should, LicenseDeclared), + PackagingAssertionBuilder.Define("nfr-14.lockstep-version", ["NFR-14"], RequirementLevel.Should, LockstepVersion), + PackagingAssertionBuilder.Define("nfr-15.version-resolvable", ["NFR-15"], RequirementLevel.Should, VersionResolvable), + ]; + + // A license expression and a copyright, the same in every package. + private static void LicenseDeclared(PackagingContext context) + { + foreach (var package in context.Packages) + { + Check.True(!string.IsNullOrWhiteSpace(package.License), $"'{package.Id}' declares no license expression", "a license expression", "none"); + Check.True(!string.IsNullOrWhiteSpace(package.Copyright), $"'{package.Id}' declares no copyright", "a copyright", "none"); + } + + var first = context.Packages[0]; + foreach (var package in context.Packages) + { + Check.Equal(package.License, first.License, $"the license expression of '{package.Id}' (equal across packages)"); + Check.Equal(package.Copyright, first.Copyright, $"the copyright of '{package.Id}' (equal across packages)"); + } + } + + // One version for every package, and every dependency between two of them requires that version. + private static void LockstepVersion(PackagingContext context) + { + var version = context.Packages[0].Version; + var ids = context.Packages.Select(package => package.Id).ToHashSet(StringComparer.Ordinal); + foreach (var package in context.Packages) + { + Check.Equal(package.Version, version, $"the version of '{package.Id}' (every package ships at one version)"); + foreach (var dependency in package.DependencyGroups.SelectMany(g => g.Dependencies).Where(d => ids.Contains(d.Id))) + { + Check.Equal(dependency.Version, version, $"the version '{package.Id}' requires of '{dependency.Id}'"); + } + } + } + + // The informational version, without its "+" build metadata, is the package's version, and is never a placeholder. + private static void VersionResolvable(PackagingContext context) + { + foreach (var package in context.Packages) + { + var informational = package.Assembly?.InformationalVersion + ?? throw new ConformanceException($"the assembly in '{package.Id}' carries no AssemblyInformationalVersion", "an informational version", "none"); + var version = informational.Split('+')[0]; + Check.True( + !string.Equals(version, "unknown", StringComparison.OrdinalIgnoreCase) && !version.StartsWith("0.0.0", StringComparison.Ordinal), + $"the informational version of '{package.Id}' is a placeholder", + "a real version", + informational); + Check.Equal(version, package.Version, string.Create(CultureInfo.InvariantCulture, $"the informational version of '{package.Id}' (without its + metadata) against the nuspec version")); + } + } +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/PackagingAssertionBuilder.cs b/src/Dexpace.Sdk.Conformance/Assertions/PackagingAssertionBuilder.cs new file mode 100644 index 00000000..8ad242d6 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/PackagingAssertionBuilder.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The one place a packaging assertion is declared. Every one judges a value, so its only face is ; its body completes synchronously. +internal static class PackagingAssertionBuilder +{ + /// Declares an assertion. + /// The stable public name, nfr-1.core-dependencies. + /// The requirement IDs it is evidence for. + /// The strength of the clause it checks; never above the strongest of . + /// The check; throws on a broken clause. + internal static ConformanceAssertion Define(string name, string[] ids, RequirementLevel level, Action body) => + ConformanceAssertion.Create( + name, + ids, + level, + [TransportFace.None], + (context, _) => + { + body(context); + return Task.CompletedTask; + }); +} diff --git a/src/Dexpace.Sdk.Conformance/Assertions/PackagingCatalogue.cs b/src/Dexpace.Sdk.Conformance/Assertions/PackagingCatalogue.cs new file mode 100644 index 00000000..331b3c17 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Assertions/PackagingCatalogue.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The packaging assertion catalogue, in the order of the design table (phase 10, section E): each group contributes its assertions in declaration order. +internal static class PackagingCatalogue +{ + /// Every assertion the packaging suite runs. + internal static IReadOnlyList All { get; } = + [ + .. DependencyPackagingAssertions.Assertions(), + .. AssemblyPackagingAssertions.Assertions(), + .. MetadataPackagingAssertions.Assertions(), + ]; +} diff --git a/src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs b/src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs new file mode 100644 index 00000000..6e27ab6c --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace Dexpace.Sdk.Conformance; + +/// +/// Reads the assembly-level attributes the packaging suite judges straight from a PE image's metadata with +/// System.Reflection.Metadata: the image is never loaded, so no packed code runs and nothing needs reflection (trim-safe, P10-10, V6). +/// Every attribute read has only string constructor arguments, so each is decoded from its blob by hand. +/// +internal static class AssemblyMetadataReader +{ + /// Reads the attributes of the assembly in . + /// The bytes of a managed assembly. + /// The bytes are not a managed assembly. + internal static AssemblyFacts Read(byte[] image) + { + try + { + using var pe = new PEReader(new MemoryStream(image, writable: false)); + if (!pe.HasMetadata) + { + throw new ConformanceException("the packed library is not a managed assembly"); + } + + var reader = pe.GetMetadataReader(); + var metadata = new Dictionary(StringComparer.Ordinal); + var visibleTo = new List(); + string? informational = null; + string? framework = null; + + foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(handle); + var arguments = Strings(reader, attribute); + switch (TypeName(reader, attribute)) + { + case "System.Reflection.AssemblyMetadataAttribute" when arguments.Count == 2: + metadata[arguments[0]] = arguments[1]; + break; + case "System.Reflection.AssemblyInformationalVersionAttribute" when arguments.Count >= 1: + informational = arguments[0]; + break; + case "System.Runtime.CompilerServices.InternalsVisibleToAttribute" when arguments.Count >= 1: + visibleTo.Add(arguments[0]); + break; + case "System.Runtime.Versioning.TargetFrameworkAttribute" when arguments.Count >= 1: + framework = arguments[0]; + break; + default: + break; + } + } + + return new AssemblyFacts(metadata, informational, visibleTo, framework); + } + catch (BadImageFormatException ex) + { + throw new ConformanceException("the packed library is not a readable managed assembly", ex); + } + } + + private static string TypeName(MetadataReader reader, CustomAttribute attribute) + { + if (attribute.Constructor.Kind != HandleKind.MemberReference) + { + return string.Empty; + } + + var member = reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor); + if (member.Parent.Kind != HandleKind.TypeReference) + { + return string.Empty; + } + + var type = reader.GetTypeReference((TypeReferenceHandle)member.Parent); + return reader.GetString(type.Namespace) + "." + reader.GetString(type.Name); + } + + // The fixed arguments of an attribute whose constructor takes only strings: the 0x0001 prolog, then one serialized string per + // constructor parameter (the count comes from the constructor's own signature, so named arguments after them are never read as strings). + private static List Strings(MetadataReader reader, CustomAttribute attribute) + { + var strings = new List(); + if (attribute.Constructor.Kind != HandleKind.MemberReference) + { + return strings; + } + + var signature = reader.GetBlobReader(reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor).Signature); + _ = signature.ReadSignatureHeader(); + var parameters = signature.ReadCompressedInteger(); + + var blob = reader.GetBlobReader(attribute.Value); + if (blob.Length < 2 || blob.ReadUInt16() != 1) + { + return strings; + } + + for (var i = 0; i < parameters && blob.RemainingBytes > 0; i++) + { + var next = blob.ReadSerializedString(); + if (next is null) + { + break; + } + + strings.Add(next); + } + + return strings; + } +} diff --git a/src/Dexpace.Sdk.Conformance/Packaging/NupkgReader.cs b/src/Dexpace.Sdk.Conformance/Packaging/NupkgReader.cs new file mode 100644 index 00000000..5221bd0c --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Packaging/NupkgReader.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.IO.Compression; + +namespace Dexpace.Sdk.Conformance; + +/// Reads one .nupkg with : the nuspec at its root, the lib/ folders, and the assembly named for the package id. +internal static class NupkgReader +{ + // A packed library is a few hundred kilobytes; a zip entry that claims more than this is a bomb or a mistake, not a library. + private const long MaxEntryBytes = 64L * 1024 * 1024; + + /// Reads the package at . + /// A .nupkg file. + /// The file does not exist; the message names it. + /// The file is not a readable package. + internal static PackageFacts Read(string path) + { + ArgumentNullException.ThrowIfNull(path); + if (!File.Exists(path)) + { + throw new FileNotFoundException($"The package '{Path.GetFileName(path)}' does not exist.", path); + } + + try + { + using var archive = ZipFile.OpenRead(path); + var nuspec = archive.Entries.SingleOrDefault(static e => e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase) && !e.FullName.Contains('/', StringComparison.Ordinal)) + ?? throw new ConformanceException($"'{Path.GetFileName(path)}' has no nuspec at its root"); + + var libFolders = archive.Entries + .Select(static e => e.FullName.Split('/')) + .Where(static parts => parts.Length == 3 && parts[0] == "lib" && parts[2].EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + .Select(static parts => parts[1]) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + var id = Path.GetFileNameWithoutExtension(nuspec.FullName); + var libraryEntry = archive.Entries.FirstOrDefault(e => e.FullName.StartsWith("lib/", StringComparison.Ordinal) && e.FullName.EndsWith("/" + id + ".dll", StringComparison.OrdinalIgnoreCase)); + var assembly = libraryEntry is null ? null : AssemblyMetadataReader.Read(ReadAll(libraryEntry, path)); + + return NuspecReader.Read(System.Text.Encoding.UTF8.GetString(ReadAll(nuspec, path)).TrimStart(''), path, libFolders, assembly); + } + catch (InvalidDataException ex) + { + throw new ConformanceException($"'{Path.GetFileName(path)}' is not a readable package (a corrupt zip)", ex); + } + } + + private static byte[] ReadAll(ZipArchiveEntry entry, string path) + { + if (entry.Length > MaxEntryBytes) + { + throw new ConformanceException($"'{entry.FullName}' in '{Path.GetFileName(path)}' claims {entry.Length} bytes, above the {MaxEntryBytes}-byte reading bound"); + } + + using var stream = entry.Open(); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Packaging/NuspecReader.cs b/src/Dexpace.Sdk.Conformance/Packaging/NuspecReader.cs new file mode 100644 index 00000000..d09e98bd --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Packaging/NuspecReader.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Xml; +using System.Xml.Linq; + +namespace Dexpace.Sdk.Conformance; + +/// Reads the metadata and dependency groups of a nuspec with , with no DTD and no resolver. +internal static class NuspecReader +{ + /// Parses . + /// The nuspec's XML text. + /// The package file, for the facts. + /// The lib/ folders found in the package. + /// The package's main assembly facts, or . + /// The nuspec is not well-formed or has no id or version. + internal static PackageFacts Read(string nuspec, string path, IReadOnlyList libFolders, AssemblyFacts? assembly) + { + XDocument document; + try + { + using var reader = XmlReader.Create(new StringReader(nuspec), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + document = XDocument.Load(reader); + } + catch (XmlException ex) + { + throw new ConformanceException($"the nuspec of '{Path.GetFileName(path)}' is not well-formed XML", ex); + } + + var metadata = document.Root?.Elements().FirstOrDefault(static e => e.Name.LocalName == "metadata") + ?? throw new ConformanceException($"the nuspec of '{Path.GetFileName(path)}' has no metadata element"); + string? Text(string name) => metadata.Elements().FirstOrDefault(e => e.Name.LocalName == name)?.Value.Trim(); + + var id = Text("id"); + var version = Text("version"); + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(version)) + { + throw new ConformanceException($"the nuspec of '{Path.GetFileName(path)}' has no id or version"); + } + + var license = metadata.Elements().FirstOrDefault(static e => e.Name.LocalName == "license" && (string?)e.Attribute("type") == "expression")?.Value.Trim(); + var groups = metadata.Elements().FirstOrDefault(static e => e.Name.LocalName == "dependencies")?.Elements().Where(static e => e.Name.LocalName == "group").Select(static group => + new DependencyGroup( + (string?)group.Attribute("targetFramework") ?? string.Empty, + [.. group.Elements().Where(static e => e.Name.LocalName == "dependency").Select(static d => new PackageDependency((string?)d.Attribute("id") ?? string.Empty, (string?)d.Attribute("version") ?? string.Empty))])) + .ToArray() ?? []; + + return new PackageFacts(path, id, version, license, Text("copyright"), groups, libFolders, assembly); + } +} diff --git a/src/Dexpace.Sdk.Conformance/Packaging/PackageFacts.cs b/src/Dexpace.Sdk.Conformance/Packaging/PackageFacts.cs new file mode 100644 index 00000000..35e37542 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Packaging/PackageFacts.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// What the packaging suite reads from one .nupkg (phase 10, P10-10): the nuspec's metadata and dependency groups, the lib/ folders, and the packed assembly's attributes. +/// The file the facts were read from. +/// The package id. +/// The package version. +/// The nuspec's license expression, or . +/// The nuspec's copyright, or . +/// The nuspec's dependency groups, each a target framework and its dependencies. +/// The target framework monikers of the lib/ folders that hold an assembly. +/// The facts of the package's main assembly (the one named for the package id), or when it is not packed. +internal sealed record PackageFacts( + string Path, + string Id, + string Version, + string? License, + string? Copyright, + IReadOnlyList DependencyGroups, + IReadOnlyList LibFolders, + AssemblyFacts? Assembly); + +/// One <group> of a nuspec's dependencies. +/// The group's target framework moniker, empty for a group with none. +/// The dependencies in the group. +internal sealed record DependencyGroup(string TargetFramework, IReadOnlyList Dependencies); + +/// One dependency of a nuspec group. +/// The dependency's package id. +/// The version range as the nuspec writes it. +internal sealed record PackageDependency(string Id, string Version); + +/// The attributes of a packed assembly the suite judges, read from its metadata and never by loading it. +/// The AssemblyMetadata(key, value) pairs. +/// The AssemblyInformationalVersion, or . +/// The assemblies named by InternalsVisibleTo. +/// The TargetFramework attribute's framework name, or . +internal sealed record AssemblyFacts( + IReadOnlyDictionary AssemblyMetadata, + string? InformationalVersion, + IReadOnlyList InternalsVisibleTo, + string? TargetFramework); diff --git a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt index d22b2354..eb2777b0 100644 --- a/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Conformance/PublicAPI.Unshipped.txt @@ -228,6 +228,30 @@ Dexpace.Sdk.Conformance.ModelFactories.RequestWithHeader.get -> System.Func void Dexpace.Sdk.Conformance.ModelFactories.WithHeader.get -> System.Func! Dexpace.Sdk.Conformance.ModelFactories.WithHeader.init -> void +Dexpace.Sdk.Conformance.PackagePolicy +Dexpace.Sdk.Conformance.PackagePolicy.$() -> Dexpace.Sdk.Conformance.PackagePolicy! +Dexpace.Sdk.Conformance.PackagePolicy.Deconstruct(out string? ThirdPartyRoot, out System.Collections.Generic.IReadOnlyList! FirstPartyIds) -> void +Dexpace.Sdk.Conformance.PackagePolicy.Equals(Dexpace.Sdk.Conformance.PackagePolicy? other) -> bool +Dexpace.Sdk.Conformance.PackagePolicy.FirstPartyIds.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Conformance.PackagePolicy.FirstPartyIds.init -> void +Dexpace.Sdk.Conformance.PackagePolicy.PackagePolicy(string? ThirdPartyRoot, System.Collections.Generic.IReadOnlyList! FirstPartyIds) -> void +Dexpace.Sdk.Conformance.PackagePolicy.ThirdPartyRoot.get -> string? +Dexpace.Sdk.Conformance.PackagePolicy.ThirdPartyRoot.init -> void +Dexpace.Sdk.Conformance.PackagingSubject +Dexpace.Sdk.Conformance.PackagingSubject.$() -> Dexpace.Sdk.Conformance.PackagingSubject! +Dexpace.Sdk.Conformance.PackagingSubject.CoreId.get -> string! +Dexpace.Sdk.Conformance.PackagingSubject.CoreId.init -> void +Dexpace.Sdk.Conformance.PackagingSubject.DeclaredFloor.get -> string! +Dexpace.Sdk.Conformance.PackagingSubject.DeclaredFloor.init -> void +Dexpace.Sdk.Conformance.PackagingSubject.Equals(Dexpace.Sdk.Conformance.PackagingSubject? other) -> bool +Dexpace.Sdk.Conformance.PackagingSubject.Name.get -> string! +Dexpace.Sdk.Conformance.PackagingSubject.Name.init -> void +Dexpace.Sdk.Conformance.PackagingSubject.PackagePaths.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Conformance.PackagingSubject.PackagePaths.init -> void +Dexpace.Sdk.Conformance.PackagingSubject.PackagingSubject() -> void +Dexpace.Sdk.Conformance.PackagingSubject.Policies.get -> System.Collections.Generic.IReadOnlyDictionary! +Dexpace.Sdk.Conformance.PackagingSubject.Policies.init -> void +Dexpace.Sdk.Conformance.PackagingSuite Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.May = 2 -> Dexpace.Sdk.Conformance.RequirementLevel Dexpace.Sdk.Conformance.RequirementLevel.Must = 0 -> Dexpace.Sdk.Conformance.RequirementLevel @@ -364,6 +388,12 @@ override Dexpace.Sdk.Conformance.InvariantSubject.ToString() -> string! override Dexpace.Sdk.Conformance.ModelFactories.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.ModelFactories.GetHashCode() -> int override Dexpace.Sdk.Conformance.ModelFactories.ToString() -> string! +override Dexpace.Sdk.Conformance.PackagePolicy.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.PackagePolicy.GetHashCode() -> int +override Dexpace.Sdk.Conformance.PackagePolicy.ToString() -> string! +override Dexpace.Sdk.Conformance.PackagingSubject.Equals(object? obj) -> bool +override Dexpace.Sdk.Conformance.PackagingSubject.GetHashCode() -> int +override Dexpace.Sdk.Conformance.PackagingSubject.ToString() -> string! override Dexpace.Sdk.Conformance.SeamResolution.Equals(object? obj) -> bool override Dexpace.Sdk.Conformance.SeamResolution.GetHashCode() -> int override Dexpace.Sdk.Conformance.SeamResolution.ToString() -> string! @@ -411,6 +441,14 @@ static Dexpace.Sdk.Conformance.InvariantSuite.RunAllAsync(Dexpace.Sdk.Conformanc static Dexpace.Sdk.Conformance.InvariantSuite.RunAsync(Dexpace.Sdk.Conformance.InvariantSubject! subject, Dexpace.Sdk.Conformance.ConformanceAssertion! assertion, Dexpace.Sdk.Conformance.TransportFace face, Dexpace.Sdk.Conformance.SuiteOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static Dexpace.Sdk.Conformance.ModelFactories.operator !=(Dexpace.Sdk.Conformance.ModelFactories? left, Dexpace.Sdk.Conformance.ModelFactories? right) -> bool static Dexpace.Sdk.Conformance.ModelFactories.operator ==(Dexpace.Sdk.Conformance.ModelFactories? left, Dexpace.Sdk.Conformance.ModelFactories? right) -> bool +static Dexpace.Sdk.Conformance.PackagePolicy.operator !=(Dexpace.Sdk.Conformance.PackagePolicy? left, Dexpace.Sdk.Conformance.PackagePolicy? right) -> bool +static Dexpace.Sdk.Conformance.PackagePolicy.operator ==(Dexpace.Sdk.Conformance.PackagePolicy? left, Dexpace.Sdk.Conformance.PackagePolicy? right) -> bool +static Dexpace.Sdk.Conformance.PackagingSubject.operator !=(Dexpace.Sdk.Conformance.PackagingSubject? left, Dexpace.Sdk.Conformance.PackagingSubject? right) -> bool +static Dexpace.Sdk.Conformance.PackagingSubject.operator ==(Dexpace.Sdk.Conformance.PackagingSubject? left, Dexpace.Sdk.Conformance.PackagingSubject? right) -> bool +static Dexpace.Sdk.Conformance.PackagingSuite.Assertions.get -> System.Collections.Generic.IReadOnlyList! +static Dexpace.Sdk.Conformance.PackagingSuite.Preamble.get -> string! +static Dexpace.Sdk.Conformance.PackagingSuite.Run(Dexpace.Sdk.Conformance.PackagingSubject! subject, Dexpace.Sdk.Conformance.ConformanceAssertion! assertion, Dexpace.Sdk.Conformance.SuiteOptions? options = null) -> Dexpace.Sdk.Conformance.ConformanceResult! +static Dexpace.Sdk.Conformance.PackagingSuite.RunAll(Dexpace.Sdk.Conformance.PackagingSubject! subject, Dexpace.Sdk.Conformance.SuiteOptions? options = null) -> Dexpace.Sdk.Conformance.ConformanceReport! static Dexpace.Sdk.Conformance.SeamResolution.operator !=(Dexpace.Sdk.Conformance.SeamResolution? left, Dexpace.Sdk.Conformance.SeamResolution? right) -> bool static Dexpace.Sdk.Conformance.SeamResolution.operator ==(Dexpace.Sdk.Conformance.SeamResolution? left, Dexpace.Sdk.Conformance.SeamResolution? right) -> bool static Dexpace.Sdk.Conformance.SuiteOptions.operator !=(Dexpace.Sdk.Conformance.SuiteOptions? left, Dexpace.Sdk.Conformance.SuiteOptions? right) -> bool diff --git a/src/Dexpace.Sdk.Conformance/Subjects/PackagePolicy.cs b/src/Dexpace.Sdk.Conformance/Subjects/PackagePolicy.cs new file mode 100644 index 00000000..625d4418 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/PackagePolicy.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// What one package may depend on beyond core and the logging facade (NFR-2, design §10 entry 2), mirroring the policy record of +/// scripts/ci/dependency-audit.cs: its one third-party root, whose lock-stepped family counts as one library, and the first-party +/// packages it may reference. A test fails if the two tables disagree. +/// +/// The package id of the one third-party library the package may depend on, or for none. +/// The package ids of the other first-party packages it may depend on, beyond core. +public sealed record PackagePolicy(string? ThirdPartyRoot, IReadOnlyList FirstPartyIds); diff --git a/src/Dexpace.Sdk.Conformance/Subjects/PackagingSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/PackagingSubject.cs new file mode 100644 index 00000000..cb45f263 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Subjects/PackagingSubject.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// The packages the packaging suite judges (phase 10, P10-10): the .nupkg files a pack produced, which package is the core one, what each +/// package may depend on, and the one target framework every package declares. The suite reads the published artefacts, not the build output, so a +/// package built from another tree is a different subject. +/// +public sealed record PackagingSubject +{ + /// The name the report carries. + public string Name + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } = "packages"; + + /// The .nupkg files to judge. Copied. Required: an empty list is an error the suite reports. + public IReadOnlyList PackagePaths + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = [.. value]; + } + } = []; + + /// The id of the core package, whose only dependency may be the logging facade (NFR-1). Dexpace.Sdk.Core by default. + public string CoreId + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } = "Dexpace.Sdk.Core"; + + /// What each non-core package may depend on, by package id. A package with no entry may depend on core and the facade only. Copied. + public IReadOnlyDictionary Policies + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = new Dictionary(value, StringComparer.Ordinal); + } + } = new Dictionary(StringComparer.Ordinal); + + /// The target framework moniker every package declares, once (NFR-10, roadmap decision D1). net10.0 by default. + public string DeclaredFloor + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } = "net10.0"; +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/PackagingContext.cs b/src/Dexpace.Sdk.Conformance/Suite/PackagingContext.cs new file mode 100644 index 00000000..52173031 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/PackagingContext.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// What one run of one packaging assertion owns: the subject, and the facts read from its packages (shared by a whole run, read once). +/// The packages under test. +/// The facts, loaded on first use; a missing or corrupt package surfaces when it is first read. +internal sealed class PackagingContext(PackagingSubject subject, Lazy> packages) +{ + /// The packages under test. + internal PackagingSubject Subject { get; } = subject; + + /// The facts of every package, in the order the subject lists them. + internal IReadOnlyList Packages => packages.Value; + + /// The facts of the core package. + /// The subject lists no package with the core id. + internal PackageFacts Core => + Packages.FirstOrDefault(package => string.Equals(package.Id, Subject.CoreId, StringComparison.Ordinal)) + ?? throw new ConformanceException($"the subject lists no package '{Subject.CoreId}'", Subject.CoreId, string.Join(", ", Packages.Select(package => package.Id))); + + /// The packages other than core. + internal IEnumerable Adapters => Packages.Where(package => !string.Equals(package.Id, Subject.CoreId, StringComparison.Ordinal)); + + /// Loads the facts of every path of . + /// The packages to read. + /// The subject lists no package. + internal static IReadOnlyList Load(PackagingSubject subject) + { + if (subject.PackagePaths.Count == 0) + { + throw new ConformanceException("the packaging subject lists no package to judge", "at least one .nupkg", "none"); + } + + return [.. subject.PackagePaths.Select(NupkgReader.Read)]; + } +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/PackagingIdentity.cs b/src/Dexpace.Sdk.Conformance/Suite/PackagingIdentity.cs new file mode 100644 index 00000000..83cce9a8 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/PackagingIdentity.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// The packaging suite's report identity: its name and what a green run does not prove. +internal static class PackagingIdentity +{ + /// What a green packaging run does not prove. + internal const string Preamble = + "Packaging conformance report. A green run does not prove: anything about a package it was not given (it reads the packages it was given; " + + "a package built from another tree is a different subject); how the assemblies behave (it reads their metadata and never loads them); " + + "the build's reproducibility, repository signing or the release path (NFR-12, NFR-16: other gates); or any requirement no assertion cites. " + + "It proves that every assertion that ran met its clause over the packages it read, or is waived below, and nothing more."; + + /// The identity the report carries. + internal static SuiteIdentity Identity { get; } = new("packaging", Preamble); +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/PackagingSuite.cs b/src/Dexpace.Sdk.Conformance/Suite/PackagingSuite.cs new file mode 100644 index 00000000..3b597311 --- /dev/null +++ b/src/Dexpace.Sdk.Conformance/Suite/PackagingSuite.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance; + +/// +/// The packaging conformance suite (phase 10, design section E): eight assertions over the NFR requirements that are properties of the +/// published packages (NFR-1 to -3, -8 to -10, -13 to -15), run against a . +/// It reads the .nupkg files with the framework's own zip, XML and metadata readers (no package is added) and never loads a packed assembly, +/// so it runs as a CI step after dotnet pack and a third party can run it over their own packages. +/// +/// +/// It is synchronous: nothing in it waits. Every assertion judges a value, so its face is . The dependency audit +/// (scripts/ci/dependency-audit.cs) stays beside it: it checks the closure of the build output, this suite the metadata that was published +/// (two checks, two subjects). +/// +public static class PackagingSuite +{ + /// What a green run of the packaging suite does not prove. + public static string Preamble { get; } = PackagingIdentity.Preamble; + + /// Every assertion, in a stable order. Names are stable public identifiers, frozen at the first shipped surface. + public static IReadOnlyList Assertions => PackagingCatalogue.All; + + /// Runs one assertion over . + /// The packages under test. + /// One of . + /// Waivers; defaults when . The assertion bound does not apply: nothing here waits. + /// The result. A missing or corrupt package is an errored or failed result, not an exception. + /// or is null. + /// is not one of . + public static ConformanceResult Run(PackagingSubject subject, ConformanceAssertion assertion, SuiteOptions? options = null) + { + ArgumentNullException.ThrowIfNull(subject); + ArgumentNullException.ThrowIfNull(assertion); + if (!Assertions.Contains(assertion)) + { + throw new ArgumentException($"Assertion '{assertion.Name}' is not a packaging assertion.", nameof(assertion)); + } + + return RunWith(subject, assertion, options ?? new SuiteOptions(), new Lazy>(() => PackagingContext.Load(subject))); + } + + /// Runs every assertion, in order, and builds the report. The packages are read once and shared by the run. + /// The packages under test. + /// Waivers; defaults when . + /// The report; its is the verdict. + /// is null. + public static ConformanceReport RunAll(PackagingSubject subject, SuiteOptions? options = null) + { + ArgumentNullException.ThrowIfNull(subject); + var effective = options ?? new SuiteOptions(); + var packages = new Lazy>(() => PackagingContext.Load(subject)); + return SuiteRunnerCore.RunAllSync( + subject.Name, + PackagingIdentity.Identity, + Assertions, + (assertion, _) => RunWith(subject, assertion, effective, packages), + effective.Waivers); + } + + private static ConformanceResult RunWith(PackagingSubject subject, ConformanceAssertion assertion, SuiteOptions options, Lazy> packages) => + SuiteRunnerCore.RunSync(assertion, TransportFace.None, () => new PackagingContext(subject, packages), options.Waivers); +} diff --git a/src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs b/src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs index ac9daaa1..58302f64 100644 --- a/src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs +++ b/src/Dexpace.Sdk.Conformance/Suite/SuiteRunnerCore.cs @@ -76,6 +76,74 @@ internal static async Task RunAllAsync( return ConformanceReport.Create(subjectName, results, waivers, suite); } + /// + /// Runs one assertion whose body completes synchronously (the packaging suite's, which reads files) on one face and applies the waivers. + /// No bound is applied: the body is CPU and file work with nothing to wait on. A body that returns an incomplete task is a kit bug (Errored). + /// + /// What one run owns. + /// The assertion. + /// The face; the assertion must declare it. + /// Builds the run's context. Called once, inside the guarded region. + /// The waivers in force. + /// does not declare . + internal static ConformanceResult RunSync( + ConformanceAssertion assertion, + TransportFace face, + Func create, + IReadOnlyList waivers) + where TContext : class + { + if (!assertion.Faces.Contains(face)) + { + throw new ArgumentException($"Assertion '{assertion.Name}' does not declare the {face} face.", nameof(face)); + } + + ConformanceResult outcome; + try + { + var body = assertion.Body(create(), CancellationToken.None); + outcome = body.IsCompletedSuccessfully + ? Result(assertion, face, ConformanceStatus.Passed, "passed") + : body.IsFaulted + ? Classify(assertion, face, body.Exception!.InnerExceptions[0]) + : Result(assertion, face, ConformanceStatus.Errored, "the body did not complete synchronously: a synchronous suite's assertion must"); + } +#pragma warning disable CA1031 // A subject crash or a kit bug is a result (Errored), never an unhandled exception that ends the run. + catch (Exception ex) +#pragma warning restore CA1031 + { + outcome = Classify(assertion, face, ex); + } + + return ApplyWaivers(outcome, waivers); + } + + /// The synchronous form of : runs every assertion on every face it declares, in order, and builds the report. + /// The subject's name. + /// The suite that produced the report. + /// The assertions, in order. + /// Runs one assertion on one face. + /// The waivers in force. + internal static ConformanceReport RunAllSync( + string subjectName, + SuiteIdentity suite, + IReadOnlyList assertions, + Func runOne, + IReadOnlyList waivers) + { + var results = new List(); + foreach (var assertion in assertions) + { + foreach (var face in assertion.Faces) + { + results.Add(runOne(assertion, face)); + } + } + + results.AddRange(UnknownWaiverTargets(assertions, waivers)); + return ConformanceReport.Create(subjectName, results, waivers, suite); + } + /// /// A waiver turns Failed and Errored into Waived; it turns a Passed result it covers into a Failed "no longer needed" /// one (P8a-8: a fixed row must lose its waiver); it never touches Vacuous or NotExercised. @@ -126,18 +194,6 @@ private static async Task ExecuteBodyAsync( await Bounded.RunAsync(token => assertion.Body(context, token), assertionTimeout, assertion.Name, cancellationToken).ConfigureAwait(false); return Result(assertion, face, ConformanceStatus.Passed, "passed"); } - catch (NotExercisedException ex) - { - return Result(assertion, face, ConformanceStatus.NotExercised, ex.Message); - } - catch (ConformanceVacuousException ex) - { - return Result(assertion, face, ConformanceStatus.Vacuous, ex.Message); - } - catch (ConformanceException ex) - { - return Result(assertion, face, ConformanceStatus.Failed, Describe(ex)) with { Exception = ex }; - } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; @@ -146,10 +202,20 @@ private static async Task ExecuteBodyAsync( catch (Exception ex) #pragma warning restore CA1031 { - return Result(assertion, face, ConformanceStatus.Errored, $"{ex.GetType().Name}: {ex.Message}") with { Exception = ex }; + return Classify(assertion, face, ex); } } + /// The result an exception thrown by an assertion's body stands for: not exercised, vacuous, failed (a broken clause) or errored (anything else). + private static ConformanceResult Classify(ConformanceAssertion assertion, TransportFace face, Exception exception) => + exception switch + { + NotExercisedException ex => Result(assertion, face, ConformanceStatus.NotExercised, ex.Message), + ConformanceVacuousException ex => Result(assertion, face, ConformanceStatus.Vacuous, ex.Message), + ConformanceException ex => Result(assertion, face, ConformanceStatus.Failed, Describe(ex)) with { Exception = ex }, + _ => Result(assertion, face, ConformanceStatus.Errored, $"{exception.GetType().Name}: {exception.Message}") with { Exception = exception }, + }; + // The context is disposed after the body, on every path. A failure to dispose is a result too, but never replaces a // result that is already a failure or an error: the first problem is the one the report shows. private static async Task DisposeAsync(IAsyncDisposable context, ConformanceAssertion assertion, TransportFace face, ConformanceResult outcome) diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs index f3af23ce..f5ba0753 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/KitCatalogues.cs @@ -15,5 +15,6 @@ internal static class KitCatalogues .. TransportSuite.Assertions, .. InvariantSuite.Assertions, .. CodecSuite.Assertions, + .. PackagingSuite.Assertions, ]; } diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Assertions/PackagingCatalogueTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/PackagingCatalogueTests.cs new file mode 100644 index 00000000..2f1b11ae --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Assertions/PackagingCatalogueTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.RegularExpressions; +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Assertions; + +/// The pin over the packaging catalogue (plan 3.1 to 3.4): the 8 names in order with their requirement IDs and levels, and a negative control for every one. +[Trait("Category", "Unit")] +public sealed partial class PackagingCatalogueTests +{ + private const RequirementLevel M = RequirementLevel.Must; + private const RequirementLevel S = RequirementLevel.Should; + + private static readonly (string Name, string[] Ids, RequirementLevel Level)[] s_expected = + [ + ("nfr-1.core-dependencies", ["NFR-1"], M), + ("nfr-2.adapter-budget", ["NFR-2"], S), + ("nfr-3.no-internals-granted", ["NFR-3"], S), + ("nfr-8.trim-and-aot-declared", ["NFR-8", "NFR-9"], M), + ("nfr-10.single-floor", ["NFR-10"], M), + ("nfr-13.license-declared", ["NFR-13"], S), + ("nfr-14.lockstep-version", ["NFR-14"], S), + ("nfr-15.version-resolvable", ["NFR-15"], S), + ]; + + [GeneratedRegex(@"^[a-z]+-\d+\.[a-z0-9-]+$", RegexOptions.CultureInvariant)] + private static partial Regex NamePattern(); + + private static IReadOnlyList All => PackagingSuite.Assertions; + + [Fact] + public void The_catalogue_is_the_8_named_assertions_in_order() + { + Assert.Equal(8, All.Count); + Assert.Equal(s_expected.Select(e => e.Name), All.Select(a => a.Name)); + for (var i = 0; i < s_expected.Length; i++) + { + Assert.Equal(s_expected[i].Ids, All[i].RequirementIds); + Assert.Equal(s_expected[i].Level, All[i].Level); + } + } + + [Fact] + public void Names_are_stable_identifiers_and_no_assertion_is_stronger_than_its_strongest_requirement() + { + foreach (var assertion in All) + { + Assert.Matches(NamePattern(), assertion.Name); + var strongest = assertion.RequirementIds.Select(id => { RequirementIndex.TryGetLevel(id, out var level); return level; }).Min(); + Assert.True(assertion.Level >= strongest, $"{assertion.Name} is {assertion.Level} but its strongest requirement is only {strongest}"); + } + } + + [Fact] + public void Every_packaging_assertion_has_a_negative_control() + { + var uncovered = All.Select(a => a.Name).Except(ControlClasses.Covered, StringComparer.Ordinal).ToArray(); + + Assert.Empty(uncovered); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs new file mode 100644 index 00000000..983a7331 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Conformance.Tests.Support; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for nfr-3, nfr-8 and nfr-10 (plan 3.3), over synthetic packages with emitted assemblies. +[Trait("Category", "Unit")] +public sealed class PackagingAssemblyControlTests +{ + private const string Core = "Dexpace.Sdk.Core"; + + internal static IReadOnlyList Rows { get; } = + [ + new("nfr-3.no-internals-granted", "an assembly grants its internals to another assembly", set => PackagingControlRow.Edit(set, Core, p => p with { InternalsVisibleTo = [Core + ".Tests", "Other.Assembly"] }), "grants its internals"), + new("nfr-3.no-internals-granted", "an assembly grants its internals to a sibling's tests", set => PackagingControlRow.Edit(set, Core, p => p with { InternalsVisibleTo = ["Dexpace.Sdk.Http.SystemNet.Tests"] }), "grants its internals"), + new("nfr-8.trim-and-aot-declared", "an assembly does not declare IsTrimmable", set => PackagingControlRow.Edit(set, Core, p => p with { Metadata = new Dictionary { ["IsAotCompatible"] = "True" } }), "IsTrimmable"), + new("nfr-8.trim-and-aot-declared", "an assembly declares IsAotCompatible False", set => PackagingControlRow.Edit(set, Core, p => p with { Metadata = new Dictionary { ["IsTrimmable"] = "True", ["IsAotCompatible"] = "False" } }), "IsAotCompatible"), + new("nfr-10.single-floor", "a package holds two lib folders", set => PackagingControlRow.Edit(set, Core, p => p with { LibFolders = ["net10.0", "net8.0"] }), "lib folders"), + new("nfr-10.single-floor", "a package declares two dependency groups", set => PackagingControlRow.Edit(set, Core, p => p with { Groups = [.. p.Groups, ("net8.0", [])] }), "dependency groups"), + new("nfr-10.single-floor", "an assembly targets another framework", set => PackagingControlRow.Edit(set, Core, p => p with { TargetFramework = ".NETCoreApp,Version=v8.0" }), "TargetFramework attribute"), + new("nfr-10.single-floor", "a package packs its assembly under another floor", set => PackagingControlRow.Edit(set, Core, p => p with { LibFolders = ["net8.0"] }), "lib folders"), + ]; + + public static IReadOnlyCollection Covered => PackagingControlRow.CoveredBy(Rows); + + public static TheoryData Cases => PackagingControlRow.Cases(Rows); + + public static TheoryData PositiveCases => PackagingControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public void The_broken_package_set_is_caught(string assertion, string control) => PackagingControlRow.Run(Rows, assertion, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public void The_conforming_set_passes(string assertion) => PackagingControlRow.RunPositive(assertion); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs new file mode 100644 index 00000000..d2a16365 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Conformance.Tests.Support; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for nfr-1 and nfr-2 (plan 3.2), over synthetic packages. +[Trait("Category", "Unit")] +public sealed class PackagingDependencyControlTests +{ + private const string Core = "Dexpace.Sdk.Core"; + private const string DependencyInjection = "Dexpace.Sdk.Extensions.DependencyInjection"; + private const string Facade = "Microsoft.Extensions.Logging.Abstractions"; + + internal static IReadOnlyList Rows { get; } = + [ + new("nfr-1.core-dependencies", "core carries an extra concrete dependency", set => PackagingControlRow.Edit(set, Core, c => c.DependingOn((Facade, "10.0.12"), ("Newtonsoft.Json", "13.0.3"))), "exactly the logging facade"), + new("nfr-1.core-dependencies", "core carries a second dependency group", set => PackagingControlRow.Edit(set, Core, c => c with { Groups = [.. c.Groups, ("net8.0", [(Facade, "8.0.0")])] }), "dependency groups"), + new("nfr-1.core-dependencies", "core is missing the facade", set => PackagingControlRow.Edit(set, Core, c => c.DependingOn()), "exactly the logging facade"), + new("nfr-1.core-dependencies", "core's group targets another framework", set => PackagingControlRow.Edit(set, Core, c => c with { Groups = [("net8.0", [(Facade, "10.0.12")])] }), "target framework"), + new("nfr-2.adapter-budget", "an adapter brings a second third-party root", set => PackagingControlRow.Edit(set, DependencyInjection, p => p.DependingOn((Core, "1.2.3"), ("Dexpace.Sdk.Http.SystemNet", "1.2.3"), ("Microsoft.Extensions.Http", "10.0.12"), ("Polly", "8.0.0"), (Facade, "10.0.12"))), "third-party root beyond"), + new("nfr-2.adapter-budget", "an adapter depends on a first-party package its policy does not name", set => PackagingControlRow.Edit(set, "Dexpace.Sdk.Http.SystemNet", p => p.DependingOn((Core, "1.2.3"), ("Dexpace.Sdk.Serialization.SystemTextJson", "1.2.3"), (Facade, "10.0.12"))), "its policy does not name"), + new("nfr-2.adapter-budget", "an adapter requires core at another version", set => PackagingControlRow.Edit(set, "Dexpace.Sdk.Conformance", p => p.DependingOn((Core, "9.9.9"), (Facade, "10.0.12"))), "lock-stepped"), + ]; + + public static IReadOnlyCollection Covered => PackagingControlRow.CoveredBy(Rows); + + public static TheoryData Cases => PackagingControlRow.Cases(Rows); + + public static TheoryData PositiveCases => PackagingControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public void The_broken_package_set_is_caught(string assertion, string control) => PackagingControlRow.Run(Rows, assertion, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public void The_conforming_set_passes(string assertion) => PackagingControlRow.RunPositive(assertion); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs new file mode 100644 index 00000000..d5b06a4c --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Drivers; +using Dexpace.Sdk.Conformance.Tests.Support; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Controls; + +/// Negative controls for nfr-13, nfr-14 and nfr-15 (plan 3.4), over synthetic packages. +[Trait("Category", "Unit")] +public sealed class PackagingMetadataControlTests +{ + private const string Core = "Dexpace.Sdk.Core"; + private const string Conformance = "Dexpace.Sdk.Conformance"; + + internal static IReadOnlyList Rows { get; } = + [ + new("nfr-13.license-declared", "a package declares no license expression", set => PackagingControlRow.Edit(set, Core, p => p with { License = null }), "no license expression"), + new("nfr-13.license-declared", "a package declares no copyright", set => PackagingControlRow.Edit(set, Core, p => p with { Copyright = null }), "no copyright"), + new("nfr-13.license-declared", "a package's copyright differs from its siblings'", set => PackagingControlRow.Edit(set, Conformance, p => p with { Copyright = "Copyright (c) 2025 someone else" }), "equal across packages"), + new("nfr-13.license-declared", "a package's license differs from its siblings'", set => PackagingControlRow.Edit(set, Conformance, p => p with { License = "Apache-2.0" }), "equal across packages"), + new("nfr-14.lockstep-version", "a package ships at another version", set => PackagingControlRow.Edit(set, Conformance, p => p with { Version = "1.2.4", InformationalVersion = "1.2.4" }), "one version"), + new("nfr-14.lockstep-version", "a package requires a sibling at another version", set => PackagingControlRow.Edit(set, Conformance, p => p.DependingOn((Core, "1.2.2"), ("Microsoft.Extensions.Logging.Abstractions", "10.0.12"))), "requires of"), + new("nfr-15.version-resolvable", "the informational version is unknown", set => PackagingControlRow.Edit(set, Core, p => p with { InformationalVersion = "unknown" }), "placeholder"), + new("nfr-15.version-resolvable", "the informational version is 0.0.0", set => PackagingControlRow.Edit(set, Core, p => p with { InformationalVersion = "0.0.0+local" }), "placeholder"), + new("nfr-15.version-resolvable", "the informational version differs from the nuspec version", set => PackagingControlRow.Edit(set, Core, p => p with { InformationalVersion = "1.2.4+abc" }), "against the nuspec version"), + new("nfr-15.version-resolvable", "the assembly carries no informational version", set => PackagingControlRow.Edit(set, Core, p => p with { InformationalVersion = null }), "no AssemblyInformationalVersion"), + ]; + + public static IReadOnlyCollection Covered => PackagingControlRow.CoveredBy(Rows); + + public static TheoryData Cases => PackagingControlRow.Cases(Rows); + + public static TheoryData PositiveCases => PackagingControlRow.PositiveCases(Rows); + + [Theory] + [MemberData(nameof(Cases))] + public void The_broken_package_set_is_caught(string assertion, string control) => PackagingControlRow.Run(Rows, assertion, control); + + [Theory] + [MemberData(nameof(PositiveCases))] + public void The_conforming_set_passes(string assertion) => PackagingControlRow.RunPositive(assertion); +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Drivers/PackagingControlRow.cs b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/PackagingControlRow.cs new file mode 100644 index 00000000..dd4fcd9a --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Drivers/PackagingControlRow.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Support; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Drivers; + +/// One negative control for the packaging suite: an assertion, and a synthetic set of packages with one thing wrong that the assertion must catch. +internal sealed record PackagingControlRow(string Assertion, string Name, Func Break, string Because) +{ + /// The distinct assertion names a table covers. + internal static IReadOnlyCollection CoveredBy(IEnumerable rows) => + [.. rows.Select(row => row.Assertion).Distinct(StringComparer.Ordinal)]; + + /// The theory data of a table: assertion and control name. + internal static TheoryData Cases(IEnumerable rows) + { + var data = new TheoryData(); + foreach (var row in rows) + { + data.Add(row.Assertion, row.Name); + } + + return data; + } + + /// The theory data of the positive counterparts: every assertion a table covers. + internal static TheoryData PositiveCases(IEnumerable rows) + { + var data = new TheoryData(); + foreach (var name in CoveredBy(rows)) + { + data.Add(name); + } + + return data; + } + + /// The packaging assertion named . + internal static ConformanceAssertion Find(string name) => + PackagingSuite.Assertions.SingleOrDefault(a => a.Name == name) + ?? throw new InvalidOperationException($"There is no packaging assertion named '{name}'."); + + /// Replaces the package with id by of it. + /// The set. + /// The package to change. + /// The change. + internal static PackageSpec[] Edit(PackageSpec[] specs, string id, Func edit) => + [.. specs.Select(spec => spec.Id == id ? edit(spec) : spec)]; + + /// Runs one control and fails unless the assertion failed for the row's reason. + internal static void Run(IEnumerable rows, string assertion, string name) + { + var row = rows.Single(r => r.Assertion == assertion && r.Name == name); + var directory = Directory.CreateTempSubdirectory("nupkg"); + try + { + var subject = NupkgBuilder.Subject(NupkgBuilder.WriteAll(directory.FullName, row.Break(NupkgBuilder.ConformingSet()))); + + var result = PackagingSuite.Run(subject, Find(assertion)); + + Assert.True(result.Status == ConformanceStatus.Failed, $"control '{name}' expected Failed but the assertion reported {result.Status}: {result.Detail}"); + Assert.True( + result.Detail.Contains(row.Because, StringComparison.OrdinalIgnoreCase), + $"control '{name}' failed, but not for the expected reason '{row.Because}': {result.Detail}"); + } + finally + { + directory.Delete(recursive: true); + } + } + + /// Runs the real assertion over the conforming synthetic set and fails unless it passed. + internal static void RunPositive(string assertion) + { + var directory = Directory.CreateTempSubdirectory("nupkg"); + try + { + var result = PackagingSuite.Run(NupkgBuilder.Subject(NupkgBuilder.WriteAll(directory.FullName, NupkgBuilder.ConformingSet())), Find(assertion)); + + Assert.True(result.Status == ConformanceStatus.Passed, $"{assertion} over the conforming set: {result.Status}: {result.Detail}"); + } + finally + { + directory.Delete(recursive: true); + } + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs new file mode 100644 index 00000000..f9661abf --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.IO.Compression; +using Dexpace.Sdk.Conformance.Tests.Support; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Packaging; + +/// Plan 3.1 (V6): the BCL readers of the packaging suite, over synthetic packages. +[Trait("Category", "Unit")] +public sealed class NupkgReaderTests : IDisposable +{ + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("nupkg-reader"); + + public void Dispose() => _directory.Delete(recursive: true); + + [Fact] + public void It_reads_the_nuspec_metadata_dependency_groups_and_lib_folders() + { + var spec = new PackageSpec("Dexpace.Sdk.Probe").DependingOn(("Dexpace.Sdk.Core", "1.2.3"), ("Microsoft.Extensions.Logging.Abstractions", "10.0.12")) with { License = "MIT", Copyright = "Copyright test" }; + + var facts = NupkgReader.Read(NupkgBuilder.Write(_directory.FullName, spec)); + + Assert.Equal("Dexpace.Sdk.Probe", facts.Id); + Assert.Equal("1.2.3", facts.Version); + Assert.Equal("MIT", facts.License); + Assert.Equal("Copyright test", facts.Copyright); + Assert.Equal(["net10.0"], facts.LibFolders); + var group = Assert.Single(facts.DependencyGroups); + Assert.Equal("net10.0", group.TargetFramework); + Assert.Equal(["Dexpace.Sdk.Core:1.2.3", "Microsoft.Extensions.Logging.Abstractions:10.0.12"], group.Dependencies.Select(d => $"{d.Id}:{d.Version}")); + } + + [Fact] + public void It_reads_the_assembly_attributes_from_the_metadata_without_loading_it() + { + var spec = new PackageSpec("Dexpace.Sdk.Probe") with { InformationalVersion = "1.2.3+abcdef", InternalsVisibleTo = ["Dexpace.Sdk.Probe.Tests", "Other,PublicKey=00"] }; + + var assembly = NupkgReader.Read(NupkgBuilder.Write(_directory.FullName, spec)).Assembly; + + Assert.NotNull(assembly); + Assert.Equal("True", assembly.AssemblyMetadata["IsTrimmable"]); + Assert.Equal("True", assembly.AssemblyMetadata["IsAotCompatible"]); + Assert.Equal("1.2.3+abcdef", assembly.InformationalVersion); + Assert.Equal(["Dexpace.Sdk.Probe.Tests", "Other,PublicKey=00"], assembly.InternalsVisibleTo); + Assert.Equal(".NETCoreApp,Version=v10.0", assembly.TargetFramework); + } + + [Fact] + public void A_package_without_its_assembly_has_no_assembly_facts() + { + var facts = NupkgReader.Read(NupkgBuilder.Write(_directory.FullName, new PackageSpec("Dexpace.Sdk.Probe") with { WithAssembly = false })); + + Assert.Null(facts.Assembly); + Assert.Empty(facts.LibFolders); + } + + [Fact] + public void A_missing_file_names_the_file() + { + var failure = Assert.Throws(() => NupkgReader.Read(Path.Combine(_directory.FullName, "Missing.1.0.0.nupkg"))); + + Assert.Contains("Missing.1.0.0.nupkg", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_corrupt_zip_is_a_clear_failure_not_a_crash() + { + var path = Path.Combine(_directory.FullName, "Corrupt.1.0.0.nupkg"); + File.WriteAllText(path, "this is not a zip archive"); + + var failure = Assert.Throws(() => NupkgReader.Read(path)); + + Assert.Contains("Corrupt.1.0.0.nupkg", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_package_without_a_nuspec_and_a_library_that_is_not_an_assembly_are_clear_failures() + { + var empty = Path.Combine(_directory.FullName, "Empty.1.0.0.nupkg"); + using (var archive = ZipFile.Open(empty, ZipArchiveMode.Create)) + { + _ = archive.CreateEntry("readme.txt"); + } + + var notAssembly = Path.Combine(_directory.FullName, "Dexpace.Sdk.Fake.1.0.0.nupkg"); + using (var archive = ZipFile.Open(notAssembly, ZipArchiveMode.Create)) + { + using (var writer = new StreamWriter(archive.CreateEntry("Dexpace.Sdk.Fake.nuspec").Open())) + { + writer.Write("Dexpace.Sdk.Fake1.0.0"); + } + + using var dll = new StreamWriter(archive.CreateEntry("lib/net10.0/Dexpace.Sdk.Fake.dll").Open()); + dll.Write("not a PE image"); + } + + Assert.Contains("no nuspec", Assert.Throws(() => NupkgReader.Read(empty)).Message, StringComparison.Ordinal); + Assert.Contains("managed assembly", Assert.Throws(() => NupkgReader.Read(notAssembly)).Message, StringComparison.Ordinal); + } + + [Fact] + public void The_nuspec_reader_refuses_malformed_xml_missing_ids_and_a_dtd() + { + Assert.Contains("not well-formed", Assert.Throws(() => NuspecReader.Read("", "x.nupkg", [], null)).Message, StringComparison.Ordinal); + Assert.Contains("no id or version", Assert.Throws(() => NuspecReader.Read("x", "x.nupkg", [], null)).Message, StringComparison.Ordinal); + Assert.Contains("no metadata", Assert.Throws(() => NuspecReader.Read("", "x.nupkg", [], null)).Message, StringComparison.Ordinal); + Assert.Throws(() => NuspecReader.Read("]>x1", "x.nupkg", [], null)); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Suite/PackagingSuiteTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Suite/PackagingSuiteTests.cs new file mode 100644 index 00000000..0593f6ec --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Suite/PackagingSuiteTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Conformance.Tests.Support; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Suite; + +/// Plan 3.1 (P10-10): the packaging suite's shell. +[Trait("Category", "Unit")] +public sealed class PackagingSuiteTests : IDisposable +{ + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("packaging-suite"); + + public void Dispose() => _directory.Delete(recursive: true); + + [Fact] + public void The_preamble_says_a_package_built_from_another_tree_is_a_different_subject() + { + Assert.Contains("it reads the packages it was given; a package built from another tree is a different subject", PackagingSuite.Preamble, StringComparison.Ordinal); + } + + [Fact] + public void Every_assertion_is_a_face_less_value_check() + { + Assert.All(PackagingSuite.Assertions, a => Assert.Equal([TransportFace.None], a.Faces)); + } + + [Fact] + public void A_conforming_set_is_green_with_the_packaging_suite_recorded() + { + var report = PackagingSuite.RunAll(NupkgBuilder.Subject(NupkgBuilder.WriteAll(_directory.FullName, NupkgBuilder.ConformingSet()))); + + Assert.True(report.IsGreen, report.ToString()); + Assert.Equal("packaging", report.Suite); + Assert.Equal(PackagingSuite.Preamble, report.PreambleText); + Assert.Equal(PackagingSuite.Assertions.Count, report.Results.Count(r => r.Status == ConformanceStatus.Passed)); + Assert.Equal("synthetic packages", report.SubjectName); + } + + [Fact] + public void A_missing_package_is_an_errored_result_naming_the_file_not_an_exception() + { + var subject = NupkgBuilder.Subject([Path.Combine(_directory.FullName, "Absent.1.0.0.nupkg")]); + + var report = PackagingSuite.RunAll(subject); + + Assert.False(report.IsGreen); + Assert.All(report.Results, r => Assert.Equal(ConformanceStatus.Errored, r.Status)); + Assert.All(report.Results, r => Assert.Contains("Absent.1.0.0.nupkg", r.Detail, StringComparison.Ordinal)); + } + + [Fact] + public void A_subject_with_no_package_fails_every_assertion() + { + var report = PackagingSuite.RunAll(new PackagingSubject()); + + Assert.All(report.Results, r => Assert.Equal(ConformanceStatus.Failed, r.Status)); + Assert.Contains("no package to judge", report.Results[0].Detail, StringComparison.Ordinal); + } + + [Fact] + public void A_set_without_the_core_package_fails_the_core_assertion_naming_it() + { + var paths = NupkgBuilder.WriteAll(_directory.FullName, NupkgBuilder.ConformingSet().Where(spec => spec.Id != "Dexpace.Sdk.Core")); + + var result = PackagingSuite.Run(NupkgBuilder.Subject(paths), PackagingSuite.Assertions[0]); + + Assert.Equal(ConformanceStatus.Failed, result.Status); + Assert.Contains("Dexpace.Sdk.Core", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public void A_waiver_turns_a_failure_into_Waived_and_a_stale_waiver_into_a_failure() + { + var broken = NupkgBuilder.ConformingSet().Select(spec => spec.Id == "Dexpace.Sdk.Core" ? spec with { License = null } : spec); + var subject = NupkgBuilder.Subject(NupkgBuilder.WriteAll(_directory.FullName, broken)); + var waiver = new ConformanceWaiver("NFR-13", "a known gap") { Assertion = "nfr-13.license-declared" }; + + var waived = PackagingSuite.Run(subject, PackagingSuite.Assertions.Single(a => a.Name == "nfr-13.license-declared"), new SuiteOptions { Waivers = [waiver] }); + var stale = PackagingSuite.RunAll(NupkgBuilder.Subject(NupkgBuilder.WriteAll(Directory.CreateTempSubdirectory("conforming").FullName, NupkgBuilder.ConformingSet())), new SuiteOptions { Waivers = [waiver] }); + + Assert.Equal(ConformanceStatus.Waived, waived.Status); + Assert.Contains(stale.Results, r => r.Status == ConformanceStatus.Failed && r.Detail.Contains("no longer needed", StringComparison.Ordinal)); + } + + [Fact] + public void Run_and_RunAll_reject_null_and_a_foreign_assertion() + { + var subject = new PackagingSubject(); + + Assert.Throws(() => PackagingSuite.Run(null!, PackagingSuite.Assertions[0])); + Assert.Throws(() => PackagingSuite.Run(subject, null!)); + Assert.Throws(() => PackagingSuite.RunAll(null!)); + Assert.Throws(() => PackagingSuite.Run(subject, TransportSuite.Assertions[0])); + } + + [Fact] + public void The_subject_copies_its_lists_and_rejects_null_and_blank() + { + var paths = new List { "a.nupkg" }; + var subject = new PackagingSubject { PackagePaths = paths, Policies = new Dictionary { ["x"] = new(null, []) } }; + paths.Clear(); + + Assert.Single(subject.PackagePaths); + Assert.Equal("Dexpace.Sdk.Core", subject.CoreId); + Assert.Equal("net10.0", subject.DeclaredFloor); + Assert.Throws(() => new PackagingSubject { PackagePaths = null! }); + Assert.Throws(() => new PackagingSubject { Policies = null! }); + Assert.Throws(() => new PackagingSubject { CoreId = " " }); + Assert.Throws(() => new PackagingSubject { DeclaredFloor = "" }); + Assert.Throws(() => new PackagingSubject { Name = " " }); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Support/NupkgBuilder.cs b/tests/Dexpace.Sdk.Conformance.Tests/Support/NupkgBuilder.cs new file mode 100644 index 00000000..efd2fd27 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Support/NupkgBuilder.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.IO.Compression; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using System.Runtime.Versioning; +using System.Text; +using System.Xml.Linq; + +namespace Dexpace.Sdk.Conformance.Tests.Support; + +/// +/// Builds synthetic .nupkg files for the packaging suite's negative controls (plan 3.1): a zip holding a nuspec and, for each lib/ folder, +/// a real managed assembly emitted with carrying exactly the attributes the spec states. Test code only; the +/// kit itself reads packages with no reflection. +/// +internal static class NupkgBuilder +{ + private const string Facade = "Microsoft.Extensions.Logging.Abstractions"; + + /// The five libraries as a conforming set: one version, the facade, DI with its one third-party root and the transport it names. + internal static PackageSpec[] ConformingSet() => + [ + new PackageSpec("Dexpace.Sdk.Core"), + new PackageSpec("Dexpace.Sdk.Http.SystemNet").DependingOn(("Dexpace.Sdk.Core", "1.2.3"), (Facade, "10.0.12")), + new PackageSpec("Dexpace.Sdk.Serialization.SystemTextJson").DependingOn(("Dexpace.Sdk.Core", "1.2.3"), (Facade, "10.0.12")), + new PackageSpec("Dexpace.Sdk.Extensions.DependencyInjection").DependingOn(("Dexpace.Sdk.Core", "1.2.3"), ("Dexpace.Sdk.Http.SystemNet", "1.2.3"), ("Microsoft.Extensions.Http", "10.0.12"), (Facade, "10.0.12")), + new PackageSpec("Dexpace.Sdk.Conformance").DependingOn(("Dexpace.Sdk.Core", "1.2.3"), (Facade, "10.0.12")), + ]; + + /// The subject over with the policies of scripts/ci/dependency-audit.cs. + /// The package files. + internal static PackagingSubject Subject(IEnumerable paths) => + new() + { + Name = "synthetic packages", + PackagePaths = [.. paths], + Policies = new Dictionary + { + ["Dexpace.Sdk.Extensions.DependencyInjection"] = new("Microsoft.Extensions.Http", ["Dexpace.Sdk.Http.SystemNet"]), + }, + }; + + /// Writes into as Id.Version.nupkg and returns its path. + /// An existing directory. + /// The package. + internal static string Write(string directory, PackageSpec spec) + { + var path = Path.Combine(directory, $"{spec.Id}.{spec.Version}.nupkg"); + using var file = File.Create(path); + using var archive = new ZipArchive(file, ZipArchiveMode.Create); + Add(archive, spec.Id + ".nuspec", Encoding.UTF8.GetBytes(Nuspec(spec))); + if (spec.WithAssembly) + { + var assembly = Assembly(spec); + foreach (var folder in spec.LibFolders) + { + Add(archive, $"lib/{folder}/{spec.Id}.dll", assembly); + } + } + + return path; + } + + /// Writes every spec and returns the paths in the same order. + /// An existing directory. + /// The packages. + internal static string[] WriteAll(string directory, IEnumerable specs) => [.. specs.Select(spec => Write(directory, spec))]; + + private static void Add(ZipArchive archive, string name, byte[] bytes) + { + using var stream = archive.CreateEntry(name).Open(); + stream.Write(bytes); + } + + private static string Nuspec(PackageSpec spec) + { + XNamespace ns = "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"; + var metadata = new XElement(ns + "metadata", new XElement(ns + "id", spec.Id), new XElement(ns + "version", spec.Version)); + if (spec.License is not null) + { + metadata.Add(new XElement(ns + "license", new XAttribute("type", "expression"), spec.License)); + } + + if (spec.Copyright is not null) + { + metadata.Add(new XElement(ns + "copyright", spec.Copyright)); + } + + metadata.Add(new XElement( + ns + "dependencies", + spec.Groups.Select(group => new XElement( + ns + "group", + new XAttribute("targetFramework", group.Tfm), + group.Dependencies.Select(d => new XElement(ns + "dependency", new XAttribute("id", d.Id), new XAttribute("version", d.Version))))))); + return new XDocument(new XElement(ns + "package", metadata)).ToString(); + } + + private static byte[] Assembly(PackageSpec spec) + { + var builder = new PersistedAssemblyBuilder(new AssemblyName(spec.Id) { Version = new Version(1, 0, 0, 0) }, typeof(object).Assembly); + _ = builder.DefineDynamicModule(spec.Id); + foreach (var (key, value) in spec.Metadata) + { + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(AssemblyMetadataAttribute).GetConstructor([typeof(string), typeof(string)])!, [key, value])); + } + + if (spec.InformationalVersion is { } informational) + { + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(AssemblyInformationalVersionAttribute).GetConstructor([typeof(string)])!, [informational])); + } + + foreach (var granted in spec.Grants) + { + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(InternalsVisibleToAttribute).GetConstructor([typeof(string)])!, [granted])); + } + + if (spec.TargetFramework is { } framework) + { + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(TargetFrameworkAttribute).GetConstructor([typeof(string)])!, [framework])); + } + + using var stream = new MemoryStream(); + builder.Save(stream); + return stream.ToArray(); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Support/PackageSpec.cs b/tests/Dexpace.Sdk.Conformance.Tests/Support/PackageSpec.cs new file mode 100644 index 00000000..9c9e2074 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Support/PackageSpec.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance.Tests.Support; + +/// One synthetic package for the packaging controls (plan 3.1): every fact the packaging suite reads, with the conforming value as the default, so a control states only what it breaks. +/// The package id. +internal sealed record PackageSpec(string Id) +{ + /// The package version, and the version dependencies on it require. + internal string Version { get; init; } = "1.2.3"; + + /// The lib/ folders that hold the assembly. + internal IReadOnlyList LibFolders { get; init; } = ["net10.0"]; + + /// Whether the assembly is packed at all. + internal bool WithAssembly { get; init; } = true; + + /// The dependency groups: a target framework and its (id, version) dependencies. + internal IReadOnlyList<(string Tfm, IReadOnlyList<(string Id, string Version)> Dependencies)> Groups { get; init; } = [("net10.0", [("Microsoft.Extensions.Logging.Abstractions", "10.0.12")])]; + + /// The license expression, or for none. + internal string? License { get; init; } = "MIT"; + + /// The copyright, or for none. + internal string? Copyright { get; init; } = "Copyright (c) 2026 test"; + + /// The assembly's AssemblyMetadata pairs. + internal IReadOnlyDictionary Metadata { get; init; } = new Dictionary { ["IsTrimmable"] = "True", ["IsAotCompatible"] = "True" }; + + /// The assembly's informational version, or for none; the package version when not set. + internal string? InformationalVersion { get; init; } = "1.2.3"; + + /// The assemblies the packed assembly grants its internals to. + internal IReadOnlyList? InternalsVisibleTo { get; init; } + + /// The assembly's TargetFramework attribute. + internal string? TargetFramework { get; init; } = ".NETCoreApp,Version=v10.0"; + + /// The internals grants, defaulting to the package's own test project. + internal IReadOnlyList Grants => InternalsVisibleTo ?? [Id + ".Tests"]; + + /// The dependencies of the single default group, replaced. + /// The dependencies. + internal PackageSpec DependingOn(params (string Id, string Version)[] dependencies) => this with { Groups = [("net10.0", dependencies)] }; +} From 2721daa74b7f5e7a517a5a8b1cb1ba2d3311c6d7 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:25:42 +0300 Subject: [PATCH 17/45] ci: packaging conformance step over the packed packages (phase 10, #99) --- .github/workflows/ci.yml | 6 ++ scripts/ci/packaging-conformance.cs | 66 ++++++++++++++++ .../Packaging/AssemblyMetadataReader.cs | 17 +++- .../Gates/PackagingPolicyParityTests.cs | 78 +++++++++++++++++++ .../Gates/RepositoryRoot.cs | 33 ++++++++ .../Packaging/NupkgReaderTests.cs | 14 ++++ 6 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 scripts/ci/packaging-conformance.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Gates/PackagingPolicyParityTests.cs create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Gates/RepositoryRoot.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 049511a5..cbf2a8c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,12 @@ jobs: - name: Dependency audit run: dotnet run scripts/ci/dependency-audit.cs -- Release artifacts/packages + # NFR-1/-2/-3/-8/-9/-10/-13/-14/-15 (phase 10, P10-10): the kit's PackagingSuite over the packed .nupkg files: the published metadata (nuspec + # dependencies, license, versions) and each packed assembly's attributes (IsTrimmable, IsAotCompatible, InternalsVisibleTo, the version), + # read without loading them. The audit above reads the build output's closure; this reads what was published (two checks, two subjects). + - name: Packaging conformance + run: dotnet run scripts/ci/packaging-conformance.cs -- artifacts/packages + - name: Tools - restore (locked) run: dotnet restore tools/Dexpace.Tools.sln --locked-mode diff --git a/scripts/ci/packaging-conformance.cs b/scripts/ci/packaging-conformance.cs new file mode 100644 index 00000000..1c2caefe --- /dev/null +++ b/scripts/ci/packaging-conformance.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// The packaging conformance step (NFR-1, NFR-2, NFR-3, NFR-8, NFR-9, NFR-10, NFR-13, NFR-14, NFR-15; phase 10, P10-10). +// +// dotnet run scripts/ci/packaging-conformance.cs -- +// +// Run from the repository root after `dotnet pack`. It runs Dexpace.Sdk.Conformance's PackagingSuite over every .nupkg in and +// prints the report: the suite reads the published artefacts (the nuspec metadata and the packed assemblies' attributes, never loading them), +// where scripts/ci/dependency-audit.cs reads the build output's closure. Two checks, two subjects; the policies table below mirrors the audit's, and +// tests/Dexpace.Sdk.Conformance.Tests/Gates/PackagingPolicyParityTests.cs fails if the two disagree. +// Exit 0 when every assertion passed, 1 on a red report, 2 on a usage or input error. + +#:project ../../src/Dexpace.Sdk.Conformance/Dexpace.Sdk.Conformance.csproj +#:property RestorePackagesWithLockFile=false +#:property PublishAot=false + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dexpace.Sdk.Conformance; + +const string Core = "Dexpace.Sdk.Core"; + +// The one place an adapter's third-party library and first-party projects are named; the same table as scripts/ci/dependency-audit.cs. +var policies = new Dictionary(StringComparer.Ordinal) +{ + [Core] = new(null, []), + ["Dexpace.Sdk.Http.SystemNet"] = new(null, []), + ["Dexpace.Sdk.Serialization.SystemTextJson"] = new(null, []), + ["Dexpace.Sdk.Conformance"] = new(null, []), + ["Dexpace.Sdk.Extensions.DependencyInjection"] = new("Microsoft.Extensions.Http", ["Dexpace.Sdk.Http.SystemNet"]), +}; + +if (args.Length != 1) +{ + Console.Error.WriteLine("usage: packaging-conformance.cs "); + return 2; +} + +var packageDir = args[0]; +if (!Directory.Exists(packageDir)) +{ + Console.Error.WriteLine($"packaging-conformance: {packageDir} does not exist; run `dotnet pack` first"); + return 2; +} + +var packages = Directory.GetFiles(packageDir, "*.nupkg").Order(StringComparer.Ordinal).ToArray(); +if (packages.Length == 0) +{ + Console.Error.WriteLine($"packaging-conformance: no .nupkg in {packageDir}; run `dotnet pack` first"); + return 2; +} + +var report = PackagingSuite.RunAll(new PackagingSubject +{ + Name = "the packed Dexpace.Sdk libraries", + PackagePaths = packages, + CoreId = Core, + Policies = policies, + DeclaredFloor = "net10.0", +}); + +Console.WriteLine(report.ToString()); +return report.IsGreen ? 0 : 1; diff --git a/src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs b/src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs index 6e27ab6c..a472688a 100644 --- a/src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs +++ b/src/Dexpace.Sdk.Conformance/Packaging/AssemblyMetadataReader.cs @@ -13,6 +13,15 @@ namespace Dexpace.Sdk.Conformance; /// internal static class AssemblyMetadataReader { + // The only attributes decoded: each has a constructor of string parameters only, which is what the blob reading below assumes. + private static readonly HashSet s_read = new(StringComparer.Ordinal) + { + "System.Reflection.AssemblyMetadataAttribute", + "System.Reflection.AssemblyInformationalVersionAttribute", + "System.Runtime.CompilerServices.InternalsVisibleToAttribute", + "System.Runtime.Versioning.TargetFrameworkAttribute", + }; + /// Reads the attributes of the assembly in . /// The bytes of a managed assembly. /// The bytes are not a managed assembly. @@ -35,8 +44,14 @@ internal static AssemblyFacts Read(byte[] image) foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) { var attribute = reader.GetCustomAttribute(handle); + var type = TypeName(reader, attribute); + if (!s_read.Contains(type)) + { + continue; + } + var arguments = Strings(reader, attribute); - switch (TypeName(reader, attribute)) + switch (type) { case "System.Reflection.AssemblyMetadataAttribute" when arguments.Count == 2: metadata[arguments[0]] = arguments[1]; diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Gates/PackagingPolicyParityTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Gates/PackagingPolicyParityTests.cs new file mode 100644 index 00000000..6d995671 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Gates/PackagingPolicyParityTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.RegularExpressions; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Gates; + +/// +/// Plan 3.5 (P10-10; phase 9's hand-off "the packaging suite's allowed-dependency check reads the same policy record"): the policies table of +/// scripts/ci/packaging-conformance.cs equals the one in scripts/ci/dependency-audit.cs, library by library, third-party root by root and +/// first-party list by list, so the audit of the build output and the suite over the published packages can never drift apart. +/// +[Trait("Category", "Unit")] +public sealed partial class PackagingPolicyParityTests +{ + private const string Audit = "scripts/ci/dependency-audit.cs"; + private const string Packaging = "scripts/ci/packaging-conformance.cs"; + + private static readonly string[] s_dependencyInjectionFirstParty = ["Dexpace.Sdk.Http.SystemNet"]; + + [GeneratedRegex("""\[(?:"(?[^"]+)"|(?Core))\]\s*=\s*new\((?null|"[^"]*"),\s*\[(?[^\]]*)\]\)""", RegexOptions.CultureInvariant)] + private static partial Regex PolicyLine(); + + internal static Dictionary Parse(string text) => + PolicyLine().Matches(text).ToDictionary( + match => match.Groups["core"].Success ? "Dexpace.Sdk.Core" : match.Groups["id"].Value, + match => ( + match.Groups["root"].Value == "null" ? null : match.Groups["root"].Value.Trim('"'), + match.Groups["first"].Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(static id => id.Trim('"')).ToArray()), + StringComparer.Ordinal); + + private static string Describe(Dictionary table) => + string.Join("; ", table.OrderBy(static pair => pair.Key, StringComparer.Ordinal).Select(static pair => $"{pair.Key} -> {pair.Value.Root ?? "none"} [{string.Join(", ", pair.Value.FirstParty)}]")); + + [Fact] + public void The_two_tables_name_the_same_five_libraries_with_the_same_roots_and_first_party_lists() + { + var audit = Parse(File.ReadAllText(RepositoryRoot.Of(Audit))); + var packaging = Parse(File.ReadAllText(RepositoryRoot.Of(Packaging))); + + Assert.Equal(5, audit.Count); + Assert.Equal(Describe(audit), Describe(packaging)); + } + + [Fact] + public void Every_library_under_src_has_a_policy_in_both_tables() + { + var libraries = Directory.GetDirectories(RepositoryRoot.Of("src")).Select(static d => System.IO.Path.GetFileName(d)).Order(StringComparer.Ordinal); + + Assert.Equal(libraries, Parse(File.ReadAllText(RepositoryRoot.Of(Packaging))).Keys.Order(StringComparer.Ordinal)); + Assert.Equal(libraries, Parse(File.ReadAllText(RepositoryRoot.Of(Audit))).Keys.Order(StringComparer.Ordinal)); + } + + [Fact] + public void The_parity_check_can_fail() + { + // Negative control: the audit text with one first-party entry removed must produce a mismatch. + var audit = File.ReadAllText(RepositoryRoot.Of(Audit)); + var edited = audit.Replace("[\"Dexpace.Sdk.Http.SystemNet\"])", "[])", StringComparison.Ordinal); + + Assert.NotEqual(audit, edited); + Assert.NotEqual(Describe(Parse(edited)), Describe(Parse(File.ReadAllText(RepositoryRoot.Of(Packaging))))); + } + + [Fact] + public void The_suite_policy_record_and_the_script_agree_on_the_dependency_injection_package() + { + var packaging = Parse(File.ReadAllText(RepositoryRoot.Of(Packaging))); + + var di = packaging["Dexpace.Sdk.Extensions.DependencyInjection"]; + Assert.Equal("Microsoft.Extensions.Http", di.Root); + Assert.Equal(s_dependencyInjectionFirstParty, di.FirstParty); + var core = packaging["Dexpace.Sdk.Core"]; + Assert.Null(core.Root); + Assert.Empty(core.FirstParty); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Gates/RepositoryRoot.cs b/tests/Dexpace.Sdk.Conformance.Tests/Gates/RepositoryRoot.cs new file mode 100644 index 00000000..b2d3eb90 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Gates/RepositoryRoot.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Conformance.Tests.Gates; + +/// +/// Finds the repository root by walking up from the test assembly to the directory holding Dexpace.Sdk.sln (plan 3.5, validation F13): the +/// gates of phase 10 read the repository's own files (scripts, workflow, editorconfig, sources, documents), which are not copied beside the test +/// assembly the way the specification appendices are. +/// +internal static class RepositoryRoot +{ + /// The repository root. + /// No parent directory holds the solution file. + internal static string Path { get; } = Find(); + + /// The path of under the repository root. + /// A repository-relative path. + internal static string Of(string relative) => System.IO.Path.Combine(Path, relative.Replace('/', System.IO.Path.DirectorySeparatorChar)); + + private static string Find() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(System.IO.Path.Combine(directory.FullName, "Dexpace.Sdk.sln"))) + { + return directory.FullName; + } + } + + throw new DirectoryNotFoundException("Dexpace.Sdk.sln was not found above the test output; the repository gates cannot locate the repository."); + } +} diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs index f9661abf..c6404b9c 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Packaging/NupkgReaderTests.cs @@ -56,6 +56,20 @@ public void A_package_without_its_assembly_has_no_assembly_facts() Assert.Empty(facts.LibFolders); } + [Fact] + public void A_real_compiled_assembly_with_attributes_of_every_shape_is_read() + { + // Phase 10 lesson (3.5): a compiled library carries attributes with integer and array arguments (CompilationRelaxations, Debuggable, Extension); + // only the four string-constructor attributes may be decoded, or a real package is "not a readable managed assembly". + var facts = AssemblyMetadataReader.Read(File.ReadAllBytes(typeof(Dexpace.Sdk.Core.Http.Request.Request).Assembly.Location)); + + Assert.Equal("True", facts.AssemblyMetadata["IsTrimmable"]); + Assert.Equal("True", facts.AssemblyMetadata["IsAotCompatible"]); + Assert.Contains("Dexpace.Sdk.Core.Tests", facts.InternalsVisibleTo); + Assert.Equal(".NETCoreApp,Version=v10.0", facts.TargetFramework); + Assert.False(string.IsNullOrEmpty(facts.InformationalVersion)); + } + [Fact] public void A_missing_file_names_the_file() { From f87efc120c601a818c15ef23e1953e7f5e4864cb Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:27:00 +0300 Subject: [PATCH 18/45] test: NFR-11 public-signature scan and NFR-4 public-API file presence (phase 10, #99) --- .../Architecture/NFR4ArchitectureTests.cs | 99 ++++++++++ .../Nfr11PublicSurfaceArchitectureTests.cs | 173 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs diff --git a/tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs b/tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs new file mode 100644 index 00000000..11d82e55 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Architecture; + +/// +/// NFR-4's source half (design §9.1; phase 10, plan 3.7): every library under src/ keeps both public-API files, so the analyzers +/// (RS0016 for an undeclared public member, RS0017 for a removed one) have a snapshot to compare against, and +/// PublicAPI.Shipped.txt stays empty (the #nullable enable line alone) until the first release, when phase 12 fills it and sets +/// PackageValidationBaselineVersion. The drift gate itself is the build: V4 added a public type and got three RS0016 errors. +/// +[Trait("Category", "Unit")] +public sealed class NFR4ArchitectureTests +{ + private static readonly string[] s_files = ["PublicAPI.Shipped.txt", "PublicAPI.Unshipped.txt"]; + + [Fact] + public void Every_library_has_both_public_api_files() + { + var missing = MissingFiles(SourceRoot()); + + Assert.Empty(missing); + } + + [Fact] + public void Shipped_is_empty_or_nullable_enable_only_until_phase_12() + { + var offenders = NonEmptyShipped(SourceRoot()); + + Assert.Empty(offenders); + } + + [Fact] + public void The_scan_sees_all_five_libraries() + { + var libraries = Directory.GetDirectories(SourceRoot()).Select(static d => Path.GetFileName(d)).Order(StringComparer.Ordinal); + + Assert.Equal( + [ + "Dexpace.Sdk.Conformance", + "Dexpace.Sdk.Core", + "Dexpace.Sdk.Extensions.DependencyInjection", + "Dexpace.Sdk.Http.SystemNet", + "Dexpace.Sdk.Serialization.SystemTextJson", + ], + libraries); + } + + [Fact] + public void The_scan_can_fail() + { + var root = Directory.CreateTempSubdirectory("nfr4"); + try + { + var both = Directory.CreateDirectory(Path.Combine(root.FullName, "Both")); + File.WriteAllText(Path.Combine(both.FullName, "PublicAPI.Shipped.txt"), "#nullable enable\n"); + File.WriteAllText(Path.Combine(both.FullName, "PublicAPI.Unshipped.txt"), "#nullable enable\n"); + var lacking = Directory.CreateDirectory(Path.Combine(root.FullName, "Lacking")); + File.WriteAllText(Path.Combine(lacking.FullName, "PublicAPI.Shipped.txt"), "#nullable enable\nSome.Type\n"); + + Assert.Equal(["Lacking/PublicAPI.Unshipped.txt"], MissingFiles(root.FullName)); + Assert.Equal(["Lacking/PublicAPI.Shipped.txt"], NonEmptyShipped(root.FullName)); + } + finally + { + root.Delete(recursive: true); + } + } + + private static List MissingFiles(string root) => + [.. Directory.GetDirectories(root) + .SelectMany(static library => s_files.Select(file => (Library: Path.GetFileName(library), File: file, Path: Path.Combine(library, file)))) + .Where(static entry => !File.Exists(entry.Path)) + .Select(static entry => $"{entry.Library}/{entry.File}") + .Order(StringComparer.Ordinal)]; + + private static List NonEmptyShipped(string root) => + [.. Directory.GetDirectories(root) + .Select(static library => (Library: Path.GetFileName(library), Path: Path.Combine(library, "PublicAPI.Shipped.txt"))) + .Where(static entry => File.Exists(entry.Path) + && File.ReadAllLines(entry.Path).Any(static line => !string.IsNullOrWhiteSpace(line) && line.Trim() != "#nullable enable")) + .Select(static entry => $"{entry.Library}/PublicAPI.Shipped.txt") + .Order(StringComparer.Ordinal)]; + + private static string SourceRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "Dexpace.Sdk.sln"))) + { + return Path.Combine(directory.FullName, "src"); + } + } + + throw new DirectoryNotFoundException("Dexpace.Sdk.sln was not found above the test output."); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs b/tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs new file mode 100644 index 00000000..d85094f6 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs @@ -0,0 +1,173 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Http.Request; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Architecture; + +/// +/// NFR-11 (design §9.2 and §11 item 2; phase 10, P10-11, V10): core's public and protected signatures name only types of the base class library's +/// System namespaces, Microsoft.Extensions.Logging and core's own, so a consumer is never forced to reference a third-party type to +/// call core. The design says "a test scanning PublicAPI.Shipped.txt"; that file is empty until phase 12 and its text format is not a type +/// model, so the subject is the compiled assembly, which is the surface those files describe. System.Net.Http and System.Text.Json sit +/// under System in the framework yet belong to the adapters: the scan refuses them beside the allow-list, and an allow-list under +/// System alone would have admitted HttpRequestMessage, which is the scan's own negative control. +/// +[Trait("Category", "Unit")] +public sealed class Nfr11PublicSurfaceArchitectureTests +{ + private static readonly string[] s_allowed = ["System", "Microsoft.Extensions.Logging", "Dexpace.Sdk.Core"]; + + private static readonly string[] s_refused = ["System.Net.Http", "System.Text.Json", "Microsoft.Extensions.DependencyInjection"]; + + private const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + [Fact] + public void Core_public_signatures_name_only_system_logging_and_core_types() + { + var violations = Scan(typeof(Request).Assembly.GetExportedTypes()); + + Assert.Empty(violations); + } + + [Fact] + public void The_scan_visits_a_real_surface() + { + // A scan that found nothing to look at would pass for the wrong reason. + var types = typeof(Request).Assembly.GetExportedTypes(); + + Assert.True(types.Length > 150, $"{types.Length} exported types"); + Assert.Contains(typeof(Request), types); + Assert.True(types.Sum(static t => t.GetMembers(All).Length) > 1500); + } + + [Fact] + public void The_scan_can_fail() + { + // Negative controls: System.Net.Http is under System but refused, and Xunit is outside every allowed namespace. + var violations = Scan([typeof(UsesHttpRequestMessage), typeof(UsesXunit), typeof(GenericOverHttp), typeof(Conforming)]); + + Assert.Contains(violations, static v => v.Contains("UsesHttpRequestMessage", StringComparison.Ordinal) && v.Contains("System.Net.Http.HttpRequestMessage", StringComparison.Ordinal)); + Assert.Contains(violations, static v => v.Contains("UsesXunit", StringComparison.Ordinal) && v.Contains("Xunit.FactAttribute", StringComparison.Ordinal)); + Assert.Contains(violations, static v => v.Contains("GenericOverHttp", StringComparison.Ordinal)); + Assert.DoesNotContain(violations, static v => v.Contains("Conforming", StringComparison.Ordinal)); + } + + private static List Scan(IEnumerable types) + { + var violations = new List(); + foreach (var type in types) + { + foreach (var (member, used) in SignatureTypes(type)) + { + foreach (var name in Flatten(used).Where(static t => !Allowed(t)).Select(static t => t.FullName ?? t.Name).Distinct()) + { + violations.Add($"{type.FullName}.{member}: {name}"); + } + } + } + + return violations; + } + + private static IEnumerable<(string Member, Type Used)> SignatureTypes(Type type) + { + if (type.BaseType is { } baseType) + { + yield return ("(base type)", baseType); + } + + foreach (var contract in type.GetInterfaces()) + { + yield return ("(interface)", contract); + } + + foreach (var member in type.GetMembers(All)) + { + switch (member) + { + case FieldInfo { IsPublic: true } or FieldInfo { IsFamily: true } or FieldInfo { IsFamilyOrAssembly: true }: + yield return (member.Name, ((FieldInfo)member).FieldType); + break; + case MethodBase method when method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly: + foreach (var parameter in method.GetParameters()) + { + yield return (method.Name, parameter.ParameterType); + } + + if (method is MethodInfo info) + { + yield return (method.Name, info.ReturnType); + } + + break; + case PropertyInfo property when (property.GetMethod?.IsPublic ?? false) || (property.GetMethod?.IsFamily ?? false) || (property.GetMethod?.IsFamilyOrAssembly ?? false) + || (property.SetMethod?.IsPublic ?? false) || (property.SetMethod?.IsFamily ?? false) || (property.SetMethod?.IsFamilyOrAssembly ?? false): + yield return (property.Name, property.PropertyType); + break; + case EventInfo { AddMethod: { IsPublic: true } or { IsFamily: true } or { IsFamilyOrAssembly: true } } evt: + yield return (evt.Name, evt.EventHandlerType!); + break; + default: + break; + } + } + } + + // The type and every type it is made of: element types, generic arguments, a nullable's underlying type. + private static IEnumerable Flatten(Type type) + { + if (type.IsGenericParameter) + { + yield break; + } + + if (type.HasElementType) + { + foreach (var inner in Flatten(type.GetElementType()!)) + { + yield return inner; + } + + yield break; + } + + yield return type.IsGenericType ? type.GetGenericTypeDefinition() : type; + if (type.IsGenericType) + { + foreach (var argument in type.GetGenericArguments().SelectMany(Flatten)) + { + yield return argument; + } + } + } + + private static bool Allowed(Type type) + { + var space = type.Namespace ?? string.Empty; + return !s_refused.Any(refused => space == refused || space.StartsWith(refused + ".", StringComparison.Ordinal)) + && s_allowed.Any(allowed => space == allowed || space.StartsWith(allowed + ".", StringComparison.Ordinal)); + } + + public sealed class UsesHttpRequestMessage + { + public static HttpRequestMessage Message() => new(); + } + + public sealed class UsesXunit + { + public static void Take(FactAttribute fact) => _ = fact; + } + + public sealed class GenericOverHttp + { + public List? Messages { get; set; } + } + + public sealed class Conforming + { + public static Task Take(Uri url, CancellationToken token, IReadOnlyList? names = null) => Task.FromResult(null); + } +} From 23d342cdd303e3869c9597a20ca1cfd23f913da9 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:27:33 +0300 Subject: [PATCH 19/45] test: NFR-13 license header over every C# file including scripts/ci (phase 10, #99) --- .../Gates/LicenseHeaderTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Gates/LicenseHeaderTests.cs diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Gates/LicenseHeaderTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Gates/LicenseHeaderTests.cs new file mode 100644 index 00000000..95fc60fc --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Gates/LicenseHeaderTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Gates; + +/// +/// NFR-13 (phase 10, P10-12, V11): every C# source file of the repository starts with the two-line MIT header, an <auto-generated> +/// block allowed before it. IDE0073 already enforces it, as a build error, for every file a project compiles; this test closes its one +/// blind spot, the file-based apps under scripts/ci/ that no project compiles. Project and build files are not "source files" under this reading. +/// +[Trait("Category", "Unit")] +public sealed class LicenseHeaderTests +{ + private const string First = "// Copyright (c) 2026 dexpace and Omar Aljarrah."; + private const string Second = "// Licensed under the MIT License. See LICENSE in the repository root for details."; + + private static readonly string[] s_roots = ["src", "tests", "tools", "scripts", ".claude"]; + + [Fact] + public void Every_cs_file_starts_with_the_two_line_header() + { + var offenders = Offenders(RepositoryRoot.Path); + + Assert.Empty(offenders); + } + + [Fact] + public void Scanned_at_least_900_files_and_the_ci_scripts_among_them() + { + // A scan that silently found nothing would pass for the wrong reason (V11: 956 at the time of the design; the repository only grows). + var files = SourceFiles(RepositoryRoot.Path).ToList(); + + Assert.True(files.Count >= 900, $"{files.Count} files scanned"); + Assert.Contains(files, static f => f.EndsWith(Path.Combine("scripts", "ci", "dependency-audit.cs"), StringComparison.Ordinal)); + Assert.Contains(files, static f => f.EndsWith(Path.Combine("scripts", "ci", "packaging-conformance.cs"), StringComparison.Ordinal)); + Assert.Contains(files, static f => f.EndsWith("RequirementCatalog.g.cs", StringComparison.Ordinal)); + } + + [Fact] + public void The_scan_can_fail() + { + var root = Directory.CreateTempSubdirectory("header"); + try + { + var scripts = Directory.CreateDirectory(Path.Combine(root.FullName, "scripts")); + File.WriteAllText(Path.Combine(scripts.FullName, "good.cs"), First + "\n" + Second + "\n\nclass A;\n"); + File.WriteAllText(Path.Combine(scripts.FullName, "none.cs"), "class B;\n"); + File.WriteAllText(Path.Combine(scripts.FullName, "second-line.cs"), "// a comment\n" + First + "\n" + Second + "\n"); + File.WriteAllText(Path.Combine(scripts.FullName, "half.cs"), First + "\nclass C;\n"); + File.WriteAllText(Path.Combine(scripts.FullName, "generated-good.cs"), "// \n// x\n// \n" + First + "\n" + Second + "\n"); + File.WriteAllText(Path.Combine(scripts.FullName, "generated-bad.cs"), "// \n// x\n// \nclass D;\n"); + var obj = Directory.CreateDirectory(Path.Combine(scripts.FullName, "obj")); + File.WriteAllText(Path.Combine(obj.FullName, "ignored.cs"), "class E;\n"); + + Assert.Equal( + [Path.Combine("scripts", "generated-bad.cs"), Path.Combine("scripts", "half.cs"), Path.Combine("scripts", "none.cs"), Path.Combine("scripts", "second-line.cs")], + Offenders(root.FullName)); + } + finally + { + root.Delete(recursive: true); + } + } + + private static List Offenders(string root) => + [.. SourceFiles(root).Where(static file => !HasHeader(File.ReadAllLines(file))).Select(file => Path.GetRelativePath(root, file)).Order(StringComparer.Ordinal)]; + + private static IEnumerable SourceFiles(string root) => + s_roots + .Select(sub => Path.Combine(root, sub)) + .Where(Directory.Exists) + .SelectMany(dir => Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories)) + .Where(static file => !InBuildOutput(file)); + + private static bool InBuildOutput(string file) + { + var parts = file.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return parts.Contains("bin") || parts.Contains("obj"); + } + + private static bool HasHeader(string[] lines) + { + var start = 0; + if (lines.Length > 0 && lines[0].StartsWith("// line.StartsWith("// ", StringComparison.Ordinal)) + 1; + if (start == 0) + { + return false; + } + } + + return lines.Length >= start + 2 && lines[start] == First && lines[start + 1] == Second; + } +} From 944951992f6c4eed8ce560e5be5c44022d0516f9 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:29:47 +0300 Subject: [PATCH 20/45] chore: re-enable conditions for every analyzer exemption, gated by AnalyzerWaiverTests (phase 10, #99) --- .editorconfig | 6 + .../Gates/AnalyzerWaiverTests.cs | 201 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs diff --git a/.editorconfig b/.editorconfig index cd8c5521..699c78e7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -125,16 +125,19 @@ dotnet_naming_style.pascal_case.capitalization = pascal_case # HTTP field names, media-type tokens, and protocol identifiers have a canonical *lower-case* # form (RFC 7230 §3.2). Lowercasing is correct here; ToUpperInvariant would be wrong. +# Re-enable when: a non-protocol string is lower-cased (an HTTP token never is). dotnet_diagnostic.CA1308.severity = none # The SDK deliberately accepts string URLs at its ergonomic entry points (e.g. Request.Get). # A System.Uri overload is also provided for callers that prefer it. +# Re-enable when: the string-URL entry points are removed. dotnet_diagnostic.CA1054.severity = none dotnet_diagnostic.CA1055.severity = none dotnet_diagnostic.CA1056.severity = none # Argument null-checks are applied where they matter via ArgumentNullException.ThrowIfNull; # CA1062's blanket requirement on every public parameter is noise for this surface. +# Re-enable when: nullable annotations are turned off for any library. dotnet_diagnostic.CA1062.severity = none # --- Application-shaped code ----------------------------------------------------------------- @@ -142,15 +145,18 @@ dotnet_diagnostic.CA1062.severity = none # Tests, repository tools and the AOT smoke consumer are applications, not libraries: none runs under a # SynchronizationContext it could deadlock, and xUnit's own analyzer (xUnit1030) forbids ConfigureAwait(false) # in test methods. Styleguide 9.4 enables CA2007 in library projects and suppresses it in app/host projects. +# Re-enable when: never for src/ (this section holds application-shaped code only; the library rule above stays a warning). [{tests,tools,.claude}/**/*.cs] dotnet_diagnostic.CA2007.severity = none [tests/**/*.cs] # Test method names use Pascal_Snake casing by convention (e.g. Method_DoesThing_WhenCondition). +# Re-enable when: never for src/ (test names only). dotnet_diagnostic.CA1707.severity = none # CA2000 in tests: a test hands its fakes to the pipeline or transport under test through a factory call or a # scripted-response list, which is an ownership hand-off the analyzer cannot follow. Method-call hand-off counts # here (never in src/), and Response is exempt because scripted Responses wrap in-memory bodies that the # scripted transport owns and hands on. A test that creates a Response it keeps still disposes it. +# Re-enable when: never for src/ (the method-call hand-off and the Response exemption are test-only relaxations). dotnet_code_quality.CA2000.dispose_ownership_transfer_at_method_call = true dotnet_code_quality.CA2000.excluded_type_names_with_derived_types = T:Dexpace.Sdk.Core.Http.Response.Response diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs new file mode 100644 index 00000000..652af355 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.RegularExpressions; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Gates; + +/// +/// NFR-7 (phase 10, P10-13, V12, plan reading R6): "analyzer exemptions are documented with a reason and a re-enable condition". Every +/// dotnet_diagnostic.*.severity = none line and every relaxing dotnet_code_quality.* option of .editorconfig sits in a block whose +/// comment gives a rationale and a Re-enable when: line, and every #pragma warning disable under src/ carries a why-comment. Consecutive +/// setting lines are one block sharing the comment above it (above the section header when the block opens the section). One option is exempt by +/// name, as a hand-off rule rather than a relaxation: CA2000.dispose_ownership_transfer_at_constructor in library scope, which makes the analyzer follow a +/// constructor that takes ownership (styleguide 13.1) and silences no diagnostic. +/// +[Trait("Category", "Unit")] +public sealed partial class AnalyzerWaiverTests +{ + private const string Exempt = "dotnet_code_quality.CA2000.dispose_ownership_transfer_at_constructor"; + + [GeneratedRegex(@"^\s*dotnet_diagnostic\.[A-Za-z0-9]+\.severity\s*=\s*none\s*$", RegexOptions.CultureInvariant)] + private static partial Regex SeverityNone(); + + [GeneratedRegex(@"^\s*(?dotnet_code_quality\.[^=\s]+)\s*=", RegexOptions.CultureInvariant)] + private static partial Regex CodeQuality(); + + [Fact] + public void Every_severity_none_has_a_rationale_and_a_re_enable_condition() + { + var violations = ExemptionViolations(File.ReadAllLines(RepositoryRoot.Of(".editorconfig"))); + + Assert.Empty(violations); + } + + [Fact] + public void The_scan_finds_the_seven_none_lines_and_the_three_code_quality_options() + { + // V12 and plan reading R6: a scan that found fewer would pass for the wrong reason. + var lines = File.ReadAllLines(RepositoryRoot.Of(".editorconfig")); + + Assert.Equal(7, lines.Count(static line => SeverityNone().IsMatch(line))); + Assert.Equal(3, lines.Count(static line => CodeQuality().IsMatch(line))); + } + + [Fact] + public void The_exemption_is_exactly_one_key() + { + var real = File.ReadAllLines(RepositoryRoot.Of(".editorconfig")); + + // Another library-scope option without a condition is a violation... + var another = InLibraryScope(real, "dotnet_code_quality.CA1000.api_surface = public"); + // ...and so is the exempt key's sibling in library scope. + var sibling = InLibraryScope(real, "dotnet_code_quality.CA2000.dispose_ownership_transfer_at_method_call = true"); + + Assert.Empty(ExemptionViolations(real)); + Assert.Single(ExemptionViolations(another)); + Assert.Single(ExemptionViolations(sibling)); + } + + [Fact] + public void Every_pragma_disable_under_src_has_a_why_comment() + { + var offenders = PragmaOffenders(RepositoryRoot.Of("src")); + + Assert.Empty(offenders); + } + + [Fact] + public void The_scan_sees_the_pragmas_under_src() + { + // V12 counted 43 in 31 files at the time of the design; phase 10 adds a few more. A scan that found none would pass for the wrong reason. + var count = Directory.EnumerateFiles(RepositoryRoot.Of("src"), "*.cs", SearchOption.AllDirectories) + .Where(static f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Sum(static f => File.ReadAllLines(f).Count(static line => line.Contains("#pragma warning disable", StringComparison.Ordinal))); + + Assert.True(count >= 43, $"{count} pragmas"); + } + + [Fact] + public void The_checks_can_fail() + { + string[] noRationale = ["[*.cs]", "dotnet_diagnostic.CA9999.severity = none"]; + string[] rationaleOnly = ["[*.cs]", "# Because we say so.", "dotnet_diagnostic.CA9999.severity = none"]; + string[] conditionOnly = ["[*.cs]", "# Re-enable when: never.", "dotnet_diagnostic.CA9999.severity = none"]; + string[] complete = ["[*.cs]", "# Because we say so.", "# Re-enable when: never for src/.", "dotnet_diagnostic.CA9999.severity = none"]; + string[] sharedBlock = ["[*.cs]", "# Because we say so.", "# Re-enable when: the URL entry points go.", "dotnet_diagnostic.CA1054.severity = none", "dotnet_diagnostic.CA1055.severity = none"]; + string[] aboveHeader = ["# Because we say so.", "# Re-enable when: never for src/.", "[tests/**/*.cs]", "dotnet_diagnostic.CA2007.severity = none"]; + + Assert.Single(ExemptionViolations(noRationale)); + Assert.Single(ExemptionViolations(rationaleOnly)); + Assert.Single(ExemptionViolations(conditionOnly)); + Assert.Empty(ExemptionViolations(complete)); + Assert.Empty(ExemptionViolations(sharedBlock)); + Assert.Empty(ExemptionViolations(aboveHeader)); + + var root = Directory.CreateTempSubdirectory("pragma"); + try + { + File.WriteAllText(Path.Combine(root.FullName, "a.cs"), "#pragma warning disable CA1031 // why\n#pragma warning restore CA1031\n"); + File.WriteAllText(Path.Combine(root.FullName, "b.cs"), "// why\n#pragma warning disable CA1031\n#pragma warning restore CA1031\n"); + File.WriteAllText(Path.Combine(root.FullName, "c.cs"), "int x;\n#pragma warning disable CA1031\n#pragma warning restore CA1031\n"); + + Assert.Equal(["c.cs:2"], PragmaOffenders(root.FullName)); + } + finally + { + root.Delete(recursive: true); + } + } + + // The option as its own block in the first (library-scope) section, after the first setting and a blank line, so it shares no comment. + private static string[] InLibraryScope(string[] lines, string option) + { + var at = Array.FindIndex(lines, static line => line.StartsWith("charset", StringComparison.Ordinal)) + 1; + return [.. lines[..at], string.Empty, option, string.Empty, .. lines[at..]]; + } + + private static List ExemptionViolations(string[] lines) + { + var violations = new List(); + var section = string.Empty; + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (line.StartsWith('[')) + { + section = line; + continue; + } + + if (!IsSetting(line) || (i > 0 && IsSetting(lines[i - 1].Trim()))) + { + continue; + } + + // The first line of a run of consecutive settings: collect the relaxing lines of the run. + var run = new List(); + for (var j = i; j < lines.Length && IsSetting(lines[j].Trim()); j++) + { + run.Add(lines[j].Trim()); + } + + var relaxing = run.Where(setting => SeverityNone().IsMatch(setting) || (CodeQuality().IsMatch(setting) && !IsExempt(setting, section))).ToArray(); + if (relaxing.Length == 0) + { + continue; + } + + var comment = CommentAbove(lines, i); + var conditions = comment.Count(static c => c.StartsWith("# Re-enable when:", StringComparison.Ordinal)); + var rationale = comment.Count(static c => !c.StartsWith("# Re-enable when:", StringComparison.Ordinal) && c.Length > 1); + if (rationale == 0 || conditions != 1) + { + violations.Add($"line {i + 1} ({relaxing[0]}): {(rationale == 0 ? "no rationale" : string.Empty)}{(rationale == 0 && conditions != 1 ? " and " : string.Empty)}{(conditions != 1 ? $"{conditions} 'Re-enable when:' lines" : string.Empty)}"); + } + } + + return violations; + } + + private static bool IsSetting(string line) => line.Length > 0 && !line.StartsWith('#') && !line.StartsWith('[') && line.Contains('=', StringComparison.Ordinal); + + private static bool IsExempt(string setting, string section) + { + var key = CodeQuality().Match(setting).Groups["key"].Value; + var testScope = section.Contains("tests", StringComparison.Ordinal) || section.Contains("tools", StringComparison.Ordinal) || section.Contains(".claude", StringComparison.Ordinal); + return key == Exempt && !testScope; + } + + // The contiguous comment lines directly above line i, looking above a section header when the setting opens its section. + private static List CommentAbove(string[] lines, int i) + { + var at = i - 1; + if (at >= 0 && lines[at].Trim().StartsWith('[')) + { + at--; + } + + var comment = new List(); + for (; at >= 0 && lines[at].Trim().StartsWith('#'); at--) + { + comment.Add(lines[at].Trim()); + } + + return comment; + } + + private static List PragmaOffenders(string root) => + [.. Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(static f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .SelectMany(file => + { + var lines = File.ReadAllLines(file); + return Enumerable.Range(0, lines.Length) + .Where(i => lines[i].Contains("#pragma warning disable", StringComparison.Ordinal)) + .Where(i => !lines[i].Contains("//", StringComparison.Ordinal) && !(i > 0 && lines[i - 1].TrimStart().StartsWith("//", StringComparison.Ordinal))) + .Select(i => $"{Path.GetRelativePath(root, file)}:{i + 1}"); + }) + .Order(StringComparer.Ordinal)]; +} From 40513c50438ec0e4a17c581b296c0aa6dda169ff Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:29:59 +0300 Subject: [PATCH 21/45] test: CI workflow completeness and no advisory gate (NFR-17) (phase 10, #99) --- .../Gates/CiWorkflowTests.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs new file mode 100644 index 00000000..ba216b63 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs @@ -0,0 +1,181 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.RegularExpressions; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Gates; + +/// +/// NFR-17's first half (phase 10, P10-16, plan reading R8): every gate of the gate table is a step of .github/workflows/ci.yml, and no step or job is +/// advisory. The second half, that a merge requires the checks, is a repository setting outside the file, recorded in the phase checklist. The workflow is read +/// as text (no YAML package is added): the rules are line-based and documented here. A gate added to the workflow without a row below is not checked, and a row +/// whose step is renamed or loses its command fails: this is the "every blocking gate stays blocking" pin. +/// +[Trait("Category", "Unit")] +public sealed partial class CiWorkflowTests +{ + /// + /// The gates: the step's name, and the command that must appear in its run:. The requirement-coverage gates join with the commit that adds + /// their steps (plan 4.9, R8). + /// + internal static IReadOnlyList<(string Step, string Command)> Gates { get; } = + [ + ("Restore (locked)", "dotnet restore Dexpace.Sdk.sln --locked-mode"), + ("Build", "dotnet build Dexpace.Sdk.sln --configuration Release"), + ("Format", "dotnet format Dexpace.Sdk.sln --verify-no-changes"), + ("Test", "--coverlet --coverlet-output-format cobertura"), + ("Coverage floor", "dotnet run scripts/ci/coverage-gate.cs -- artifacts/test-results 80"), + ("Coverage floor - self-test", "scripts/ci/coverage-gate-selftest.sh artifacts/test-results"), + ("Pack", "dotnet pack Dexpace.Sdk.sln --configuration Release"), + ("Dependency audit", "dotnet run scripts/ci/dependency-audit.cs -- Release artifacts/packages"), + ("Packaging conformance", "dotnet run scripts/ci/packaging-conformance.cs -- artifacts/packages"), + ("Tools - restore (locked)", "dotnet restore tools/Dexpace.Tools.sln --locked-mode"), + ("Tools - build", "dotnet build tools/Dexpace.Tools.sln --configuration Release"), + ("Tools - format", "dotnet format tools/Dexpace.Tools.sln --verify-no-changes"), + ("Tools - test", "dotnet test --solution tools/Dexpace.Tools.sln --configuration Release"), + ("Knowledge corpus structure", "scripts/knowledge verify-structure"), + ("Pack twice and compare", "scripts/ci/reproducible-pack.sh"), + ("Publish", "dotnet publish tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj"), + ("Run", "./artifacts/aot-smoke/Dexpace.Sdk.AotSmoke"), + ]; + + /// The steps allowed an if:, and the condition: the Linux-only structural checks, and the upload that must run after a failure (it is not a gate). + internal static IReadOnlyDictionary Conditionals { get; } = new Dictionary(StringComparer.Ordinal) + { + ["Knowledge corpus structure"] = "runner.os == 'Linux'", + ["Upload test results"] = "always()", + }; + + [GeneratedRegex(@"^\s{6}- (?:name|uses):", RegexOptions.CultureInvariant)] + private static partial Regex StepStart(); + + [GeneratedRegex(@"^\s{6}- name:\s*(?.+?)\s*$", RegexOptions.CultureInvariant)] + private static partial Regex StepName(); + + [GeneratedRegex(@"^\s+if:\s*(?.+?)\s*$", RegexOptions.CultureInvariant)] + private static partial Regex IfLine(); + + private static string Workflow => File.ReadAllText(RepositoryRoot.Of(".github/workflows/ci.yml")); + + [Fact] + public void Every_gate_of_the_gate_table_is_a_ci_step() + { + var missing = MissingGates(Workflow); + + Assert.Empty(missing); + } + + [Fact] + public void No_step_or_job_is_advisory() + { + Assert.Empty(Advisory(Workflow)); + Assert.DoesNotContain("continue-on-error", Workflow, StringComparison.Ordinal); + } + + [Fact] + public void Only_the_documented_gates_are_conditional() + { + var undocumented = UndocumentedConditions(Workflow); + + Assert.Empty(undocumented); + // The documented ones exist, so the allow-list is not vacuous. + Assert.Contains("runner.os == 'Linux'", Workflow, StringComparison.Ordinal); + Assert.Contains("if: always()", Workflow, StringComparison.Ordinal); + } + + [Fact] + public void The_three_jobs_and_the_three_operating_systems_are_present() + { + var workflow = Workflow; + + Assert.Contains("name: build & test (${{ matrix.os }})", workflow, StringComparison.Ordinal); + Assert.Contains("os: [ubuntu-latest, windows-latest, macos-latest]", workflow, StringComparison.Ordinal); + Assert.Contains("name: reproducible packages", workflow, StringComparison.Ordinal); + Assert.Contains("name: NativeAOT smoke", workflow, StringComparison.Ordinal); + } + + [Fact] + public void The_checks_can_fail() + { + var workflow = Workflow; + + // A gate step removed. + var withoutCoverage = Regex.Replace(workflow, @" - name: Coverage floor\r?\n run: [^\r\n]+", string.Empty, RegexOptions.CultureInvariant); + Assert.NotEqual(workflow, withoutCoverage); + Assert.Contains(MissingGates(withoutCoverage), static m => m.Contains("Coverage floor", StringComparison.Ordinal)); + + // A gate whose command was edited away. + var weakened = workflow.Replace("--verify-no-changes --no-restore\n", "--no-restore\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, weakened); + Assert.Contains(MissingGates(weakened), static m => m.Contains("Format", StringComparison.Ordinal)); + + // An advisory step. + var advisory = workflow.Replace(" - name: Dependency audit\n", " - name: Dependency audit\n continue-on-error: true\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, advisory); + Assert.NotEmpty(Advisory(advisory)); + + // A conditional gate. + var conditional = workflow.Replace(" - name: Pack\n", " - name: Pack\n if: github.event_name == 'push'\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, conditional); + Assert.Contains(UndocumentedConditions(conditional), static c => c.Contains("Pack", StringComparison.Ordinal)); + } + + private static List<(string Name, string Text)> Steps(string workflow) + { + var lines = workflow.Split('\n'); + var starts = Enumerable.Range(0, lines.Length).Where(i => StepStart().IsMatch(lines[i])).Append(lines.Length).ToArray(); + var steps = new List<(string, string)>(); + for (var s = 0; s < starts.Length - 1; s++) + { + var text = string.Join('\n', lines[starts[s]..starts[s + 1]]); + var name = StepName().Match(lines[starts[s]]); + if (name.Success) + { + steps.Add((name.Groups["name"].Value, text)); + } + } + + return steps; + } + + private static List MissingGates(string workflow) + { + var steps = Steps(workflow); + return + [ + .. Gates + .Where(gate => !steps.Any(step => step.Name == gate.Step && step.Text.Contains("run:", StringComparison.Ordinal) && Flatten(step.Text).Contains(gate.Command, StringComparison.Ordinal))) + .Select(static gate => $"{gate.Step}: {gate.Command}"), + ]; + } + + // A folded scalar (">-") wraps one command over lines; the command is compared with its lines joined by single spaces. + private static string Flatten(string text) => Regex.Replace(text, @"\s*\r?\n\s*", " ", RegexOptions.CultureInvariant); + + private static List Advisory(string workflow) => + [.. workflow.Split('\n').Select((line, i) => (line, i)).Where(static entry => entry.line.Contains("continue-on-error", StringComparison.Ordinal)).Select(static entry => $"line {entry.i + 1}: {entry.line.Trim()}")]; + + private static List UndocumentedConditions(string workflow) + { + var problems = new List(); + foreach (var (name, text) in Steps(workflow)) + { + foreach (var line in text.Split('\n')) + { + var condition = IfLine().Match(line); + if (!condition.Success) + { + continue; + } + + if (!Conditionals.TryGetValue(name, out var allowed) || allowed != condition.Groups["condition"].Value) + { + problems.Add($"{name}: if: {condition.Groups["condition"].Value}"); + } + } + } + + return problems; + } +} From 6412070935b76a2afcf059a444267483fdd317ee Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:30:22 +0300 Subject: [PATCH 22/45] ci: reproducible pack from two checkouts at different paths (phase 10, #99) --- .github/workflows/ci.yml | 5 ++-- scripts/ci/reproducible-pack.sh | 45 +++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbf2a8c9..5dabf2ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,8 +160,9 @@ jobs: with: global-json-file: global.json - # NFR-12's build half: two packs from a clean src/ tree with SOURCE_DATE_EPOCH at the commit time must be - # byte-identical. The release half (a published artifact rebuilt from its tag) is phase 12's. + # NFR-12's build half: two packs of HEAD from two checkouts at different paths (git worktrees, phase 10, P10-14), with + # SOURCE_DATE_EPOCH at the commit time, must be byte-identical, so a path leaking into an artefact fails the job. The release + # half (a published artifact rebuilt from its tag) is phase 12's. - name: Pack twice and compare run: scripts/ci/reproducible-pack.sh artifacts/reproducible diff --git a/scripts/ci/reproducible-pack.sh b/scripts/ci/reproducible-pack.sh index eb36ed31..3f60b8cf 100755 --- a/scripts/ci/reproducible-pack.sh +++ b/scripts/ci/reproducible-pack.sh @@ -2,10 +2,13 @@ # Copyright (c) 2026 dexpace and Omar Aljarrah. # Licensed under the MIT License. See LICENSE in the repository root for details. # -# NFR-12's build half (design §9.2): pack every library twice from a clean src/ tree, with SOURCE_DATE_EPOCH -# pinned to the commit time, and require byte-identical packages (.nupkg, and .snupkg once symbols are packed). The compiler is already -# deterministic (Deterministic + ContinuousIntegrationBuild); SOURCE_DATE_EPOCH fixes the zip-entry timestamps -# of the package containers. Exit 0 when both packs match, 1 when any file differs. +# NFR-12's build half (design §9.2; phase 10, P10-14): pack every library twice from two checkouts of HEAD at DIFFERENT paths, with +# SOURCE_DATE_EPOCH pinned to the commit time, and require byte-identical packages (.nupkg, and .snupkg once symbols are packed). The compiler is +# already deterministic (Deterministic + ContinuousIntegrationBuild); SOURCE_DATE_EPOCH fixes the zip-entry timestamps of the package containers; and +# the two different paths prove that no absolute path leaks into an artefact (the assembly embeds a pdb path when nothing maps it), which packing +# twice in one directory cannot see. Reproducibility across paths holds for a git checkout, which CI always is; a source tarball with no .git is not +# a reproducible input. The checkouts are `git worktree`s of HEAD, so what is packed is HEAD, not uncommitted edits (a dirty tree is warned about). +# The release half (a published artifact rebuilt from its tag) is phase 12's. Exit 0 when both packs match, 1 when any file differs. # # scripts/ci/reproducible-pack.sh [output-dir] (default: artifacts/reproducible) set -euo pipefail @@ -16,12 +19,34 @@ dotnet="${DOTNET:-dotnet}" export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$repo" log -1 --format=%ct)}" echo "reproducible-pack: SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" +if [[ -n "$(git -C "$repo" status --porcelain)" ]]; then + echo "reproducible-pack: warning: the working tree has uncommitted changes; the packs are of HEAD, not of those changes" +fi + +# Two different depths (V3): a path that leaks into an artefact differs between them. +checkouts=("$out/wt1/repo" "$out/wt2/yy/zz/repo2") + +cleanup() { + for checkout in "${checkouts[@]}"; do + git -C "$repo" worktree remove --force "$checkout" 2>/dev/null || true + done + git -C "$repo" worktree prune +} +trap cleanup EXIT + rm -rf "$out" -for run in 1 2; do - rm -rf "$repo"/src/*/bin "$repo"/src/*/obj - "$dotnet" restore "$repo/Dexpace.Sdk.sln" --locked-mode - "$dotnet" pack "$repo/Dexpace.Sdk.sln" --configuration Release --no-restore \ - -p:ContinuousIntegrationBuild=true --output "$out/$run" +git -C "$repo" worktree prune +run=0 +for checkout in "${checkouts[@]}"; do + run=$((run + 1)) + mkdir -p "$(dirname "$checkout")" + git -C "$repo" worktree add --detach "$checkout" HEAD + ( + cd "$checkout" + "$dotnet" restore Dexpace.Sdk.sln --locked-mode + "$dotnet" pack Dexpace.Sdk.sln --configuration Release --no-restore \ + -p:ContinuousIntegrationBuild=true --output "$out/$run" + ) done status=0 @@ -52,6 +77,6 @@ if [[ $count -eq 0 ]]; then fi if [[ $status -eq 0 ]]; then - echo "reproducible-pack: $count file(s) byte-identical across both packs" + echo "reproducible-pack: $count file(s) byte-identical across both packs, built at different paths" fi exit $status From 5a3214de85af995613939cdbaf27bda5465f9f65 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:45:40 +0300 Subject: [PATCH 23/45] feat: AppendixBMap covers all 61 appendix-B items (phase 10, #99) --- .../Requirements/AppendixBMap.cs | 66 +++++++++++++++++-- .../Requirements/AppendixBMapTests.cs | 49 ++++++++++---- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/Dexpace.Sdk.Conformance/Requirements/AppendixBMap.cs b/src/Dexpace.Sdk.Conformance/Requirements/AppendixBMap.cs index a4f0a75f..266ab638 100644 --- a/src/Dexpace.Sdk.Conformance/Requirements/AppendixBMap.cs +++ b/src/Dexpace.Sdk.Conformance/Requirements/AppendixBMap.cs @@ -7,27 +7,79 @@ namespace Dexpace.Sdk.Conformance; /// -/// The many-to-one view from requirement IDs to the checklist items of appendix B that cover them (design §9.3, P8a-14). -/// Phase 8a holds B.6 (transport, five items) and B.7 (asynchronous runtime adapter, six items: the design said five, plan -/// reading R1); phase 10 widens it to all 61 items. Each key is B.<section>.<n>, the n-th checklist bullet -/// of the section. A Unit test parses the appendix and compares, so the table cannot drift from it. +/// The many-to-one view from requirement IDs to the checklist items of appendix B that cover them (design §9.3, P8a-14, widened by phase 10, P10-7). +/// All 61 items of all nine sections, B.1 to B.9; phase 8a held only B.6 (transport, five items) and B.7 (asynchronous runtime adapter, six items: the +/// design said five, plan reading R1). Each key is B.<section>.<n>, the n-th checklist bullet of the section; each value is the IDs the +/// bullet cites, in the order the specification prints them (B.1.6 prints PAGE-20 before PAGE-19). Together they hold 276 distinct IDs, +/// none in two items. Appendix B is silent on PAGE-4 and PAGE-5 (design §11 item 9). A Unit test parses the appendix and compares ID sets, +/// so the table cannot drift from it, and another compares it with APPENDIX_B.md, the map of where each item is evidenced. /// internal static class AppendixBMap { /// The requirement IDs each checklist item covers, in appendix order. internal static FrozenDictionary Items { get; } = new Dictionary(StringComparer.Ordinal) { - ["B.6.1"] = [.. Ids("TRANSPORT", 1, 2)], + ["B.1.1"] = [.. Ids("PAGE", 1, 3)], + ["B.1.2"] = [.. Ids("PAGE", 6, 8)], + ["B.1.3"] = ["PAGE-9", "PAGE-10"], + ["B.1.4"] = ["PAGE-11", "PAGE-12", "PAGE-14", "PAGE-15"], + ["B.1.5"] = ["PAGE-13"], + ["B.1.6"] = [.. Ids("PAGE", 16, 18), "PAGE-20", "PAGE-19"], + ["B.1.7"] = [.. Ids("PAGE", 21, 24)], + ["B.1.8"] = [.. Ids("PAGE", 25, 33)], + ["B.1.9"] = ["PAGE-34", "PAGE-35"], + ["B.1.10"] = ["PAGE-36"], + ["B.2.1"] = [.. Ids("SSE", 1, 12)], + ["B.2.2"] = [.. Ids("SSE", 13, 19)], + ["B.2.3"] = [.. Ids("SSE", 20, 22)], + ["B.2.4"] = [.. Ids("SSE", 23, 32)], + ["B.2.5"] = [.. Ids("SSE", 33, 36)], + ["B.2.6"] = [.. Ids("SSE", 37, 41)], + ["B.3.1"] = ["SERDE-1", "SERDE-2"], + ["B.3.2"] = ["SERDE-3", "SERDE-4"], + ["B.3.3"] = [.. Ids("SERDE", 5, 8)], + ["B.3.4"] = [.. Ids("SERDE", 9, 13)], + ["B.3.5"] = [.. Ids("SERDE", 14, 20), "SERDE-30"], + ["B.3.6"] = [.. Ids("SERDE", 21, 26), "SERDE-29"], + ["B.3.7"] = ["SERDE-27", "SERDE-28"], + ["B.4.1"] = [.. Ids("OBS", 1, 9), "OBS-40"], + ["B.4.2"] = ["OBS-10"], + ["B.4.3"] = [.. Ids("OBS", 11, 19)], + ["B.4.4"] = ["OBS-20"], + ["B.4.5"] = [.. Ids("OBS", 21, 25)], + ["B.4.6"] = [.. Ids("OBS", 26, 30)], + ["B.4.7"] = [.. Ids("OBS", 31, 33)], + ["B.4.8"] = [.. Ids("OBS", 34, 39)], + ["B.5.1"] = [.. Ids("CFG", 1, 4), "CFG-38"], + ["B.5.2"] = [.. Ids("CFG", 5, 7)], + ["B.5.3"] = [.. Ids("CFG", 8, 14), "CFG-37"], + ["B.5.4"] = [.. Ids("CFG", 15, 21)], + ["B.5.5"] = [.. Ids("CFG", 22, 28)], + ["B.5.6"] = [.. Ids("CFG", 29, 36)], + ["B.6.1"] = ["TRANSPORT-1", "TRANSPORT-2"], ["B.6.2"] = [.. Ids("TRANSPORT", 3, 9)], ["B.6.3"] = [.. Ids("TRANSPORT", 10, 14)], ["B.6.4"] = [.. Ids("TRANSPORT", 15, 19)], ["B.6.5"] = [.. Ids("TRANSPORT", 20, 30)], - ["B.7.1"] = [.. Ids("ASYNC", 1, 2)], + ["B.7.1"] = ["ASYNC-1", "ASYNC-2"], ["B.7.2"] = [.. Ids("ASYNC", 3, 7)], ["B.7.3"] = [.. Ids("ASYNC", 8, 12)], - ["B.7.4"] = [.. Ids("ASYNC", 13, 14)], + ["B.7.4"] = ["ASYNC-13", "ASYNC-14"], ["B.7.5"] = [.. Ids("ASYNC", 15, 17)], ["B.7.6"] = [.. Ids("ASYNC", 18, 22)], + ["B.8.1"] = [.. Ids("XCUT", 1, 3)], + ["B.8.2"] = [.. Ids("XCUT", 4, 9)], + ["B.8.3"] = ["XCUT-10"], + ["B.8.4"] = [.. Ids("XCUT", 11, 13), "XCUT-22", "XCUT-23"], + ["B.8.5"] = ["XCUT-14", "XCUT-15"], + ["B.8.6"] = [.. Ids("XCUT", 16, 21), "XCUT-24"], + ["B.9.1"] = ["NFR-1", "NFR-2"], + ["B.9.2"] = ["NFR-3", "NFR-4"], + ["B.9.3"] = [.. Ids("NFR", 5, 7), "NFR-17"], + ["B.9.4"] = ["NFR-8", "NFR-9"], + ["B.9.5"] = ["NFR-10"], + ["B.9.6"] = ["NFR-11"], + ["B.9.7"] = [.. Ids("NFR", 12, 16)], }.ToFrozenDictionary(StringComparer.Ordinal); private static IEnumerable Ids(string prefix, int first, int last) => diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBMapTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBMapTests.cs index a241676a..6ce1f93c 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBMapTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBMapTests.cs @@ -8,21 +8,21 @@ namespace Dexpace.Sdk.Conformance.Tests.Catalogue; -/// P8a-14: the B.6 and B.7 view of appendix B, its drift test and the report's worst-of use of it. +/// P8a-14, widened by phase 10 (P10-7, plan 4.1): the view of all 61 items of appendix B, its drift test and the report's worst-of use of it. [Trait("Category", "Unit")] public sealed partial class AppendixBMapTests { - [GeneratedRegex(@"\b(?:TRANSPORT|ASYNC)-\d+\b", RegexOptions.CultureInvariant)] + [GeneratedRegex(@"\b(?:SEAM|HTTP|IO|BODY|CTX|PIPE|RECOV|RETRY|REDIR|AUTH|PAGE|SSE|SERDE|OBS|CFG|TRANSPORT|ASYNC|XCUT|NFR)-\d+\b", RegexOptions.CultureInvariant)] private static partial Regex IdPattern(); private static string[] Lines => File.ReadAllLines(Path.Combine(AppContext.BaseDirectory, "spec", "appendix-b.md")); /// The checklist bullets of one section, each as the requirement IDs it cites, keyed B.<section>.<n>. - private static Dictionary ParseSection(string section, string next) + private static Dictionary ParseSection(string section, string? next) { var lines = Lines; var start = Array.FindIndex(lines, line => line.StartsWith($"### {section} ", StringComparison.Ordinal)); - var end = Array.FindIndex(lines, start + 1, line => line.StartsWith($"### {next} ", StringComparison.Ordinal)); + var end = next is null ? lines.Length : Array.FindIndex(lines, start + 1, line => line.StartsWith($"### {next} ", StringComparison.Ordinal)); Assert.True(start >= 0 && end > start, $"sections {section} and {next} were not found in appendix B"); var items = new Dictionary(StringComparer.Ordinal); @@ -34,18 +34,32 @@ private static Dictionary ParseSection(string section, string return items; } + private static Dictionary ParseAll() + { + var all = new Dictionary(StringComparer.Ordinal); + for (var section = 1; section <= 9; section++) + { + foreach (var (key, ids) in ParseSection($"B.{section}", section == 9 ? null : $"B.{section + 1}")) + { + all[key] = ids; + } + } + + return all; + } + [Fact] - public void The_map_equals_the_checklist_items_of_appendix_B_6_and_B_7() + public void The_map_equals_the_checklist_items_of_appendix_B() { - var parsed = ParseSection("B.6", "B.7").Concat(ParseSection("B.7", "B.8")).ToDictionary(item => item.Key, item => item.Value, StringComparer.Ordinal); + var parsed = ParseAll(); - Assert.Equal(11, parsed.Count); - Assert.Equal(5, parsed.Keys.Count(key => key.StartsWith("B.6.", StringComparison.Ordinal))); - Assert.Equal(6, parsed.Keys.Count(key => key.StartsWith("B.7.", StringComparison.Ordinal))); + Assert.Equal(61, parsed.Count); + Assert.Equal([10, 6, 7, 8, 6, 5, 6, 6, 7], Enumerable.Range(1, 9).Select(section => parsed.Keys.Count(key => key.StartsWith($"B.{section}.", StringComparison.Ordinal)))); Assert.Equal(parsed.Keys.Order(StringComparer.Ordinal), AppendixBMap.Items.Keys.Order(StringComparer.Ordinal)); foreach (var (key, ids) in parsed) { - Assert.Equal(ids, AppendixBMap.Items[key]); + // Compared as sets: B.1.6 prints PAGE-20 before PAGE-19 (V8), and the map keeps the printed order. + Assert.Equal(ids.Order(StringComparer.Ordinal), AppendixBMap.Items[key].Order(StringComparer.Ordinal)); } } @@ -56,7 +70,20 @@ public void Every_id_of_the_map_exists_in_the_catalogue_and_none_is_mapped_twice Assert.All(all, id => Assert.True(RequirementIndex.Contains(id), id)); Assert.Equal(all.Length, all.Distinct(StringComparer.Ordinal).Count()); - Assert.Equal(30 + 22, all.Length); + Assert.Equal(276, all.Length); + Assert.Equal(61, AppendixBMap.Items.Count); + } + + [Fact] + public void Appendix_B_is_silent_on_PAGE_4_and_PAGE_5() + { + // A pin (design section 11 item 9, V8): the map states the silence; it does not invent coverage. + var all = AppendixBMap.Items.Values.SelectMany(ids => ids).ToHashSet(StringComparer.Ordinal); + + Assert.DoesNotContain("PAGE-4", all); + Assert.DoesNotContain("PAGE-5", all); + Assert.Contains("PAGE-3", all); + Assert.Contains("PAGE-6", all); } [Fact] From c4e5e01aeac9f75f52619ef3bbb0b26aa93d96a3 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 22:48:05 +0300 Subject: [PATCH 24/45] test: APPENDIX_B.md and its five-rule coverage gate (phase 10, #99) --- .../APPENDIX_B.md | 85 ++++++ .../Dexpace.Sdk.Conformance.Tests.csproj | 2 + .../Requirements/AppendixBCoverageTests.cs | 275 ++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md create mode 100644 tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBCoverageTests.cs diff --git a/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md b/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md new file mode 100644 index 00000000..ab8a8456 --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md @@ -0,0 +1,85 @@ +# Appendix B map + +Where each of the 61 items of the specification's conformance checklist +([`docs/product-spec/appendix-b-conformance-test-checklist.md`](../../docs/product-spec/appendix-b-conformance-test-checklist.md)) is evidenced in this +repository (phase 10, design P10-19). Two limits, before the table. + +- **Appendix B covers nine of the nineteen requirement prefixes and is silent on `PAGE-4` and `PAGE-5`** (design §11 item 9). This map lists the IDs each item + cites, 276 in all and none in two items, and states nothing about the 369 requirements the appendix never names; the appendix-C coverage script owns that count. +- **A `by reference` row proves a claim and a file, never the behaviour.** The gate checks that the file exists and that the IDs are the item's own; whether the + tests in the file assert the clause is what the checklist rows of each phase argue (`docs/work/mvp/`). A `suite` row is stronger: it names an assertion of + `Dexpace.Sdk.Conformance` that exists and cites one of the row's IDs, so a broken subject fails it (every assertion has a negative control). + +**Columns.** `Section` and `Item` are the checklist's (`B.
.`, the n-th bullet). `IDs` are the requirement IDs the item cites. `Evidence` is a +`; `-separated list: `:` names an assertion of a kit suite (`transport`, `invariant`, `codec` or `packaging`; a trailing `*` matches a name prefix), +and anything else is a repository-relative path. `Status` is one of four, a row taking that of its least portable evidence: `suite` (an assertion of the kit), +`by reference` (a test file or gate in this repository that owns the item), `restated` (the design restates the item for .NET, named in Notes) and `scoped out` +(the port declines the whole item; none). `Notes` name every clause that is vacuous, declined or deferred inside an otherwise-evidenced item, with its citation, so a +row never reads better than its worst clause. + +**Tally: 17 `suite`, 8 `restated`, 36 `by reference`, 0 `scoped out` (61).** The gate is +[`Requirements/AppendixBCoverageTests.cs`](Requirements/AppendixBCoverageTests.cs). + +| Section | Item | IDs | Evidence | Status | Notes | +|---|---|---|---|---|---| +| B.1 | B.1.1 | PAGE-1, PAGE-2, PAGE-3 | tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs | by reference | `PAGE-3` is vacuous: pages are materialized values and no page owns a live response (design §10 entry 17) | +| B.1 | B.1.2 | PAGE-6, PAGE-7, PAGE-8 | tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs | by reference | | +| B.1 | B.1.3 | PAGE-9, PAGE-10 | tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs; tests/Dexpace.Sdk.Core.Tests/Pagination/BlockingPageableTests.cs | by reference | | +| B.1 | B.1.4 | PAGE-11, PAGE-12, PAGE-14, PAGE-15 | tests/Dexpace.Sdk.Core.Tests/Pagination/PaginationLifecycleTests.cs | by reference | `PAGE-12`'s look-ahead and `PAGE-15`'s two-page case are vacuous (design §10 entry 17) | +| B.1 | B.1.5 | PAGE-13 | tests/Dexpace.Sdk.Core.Tests/Pagination/PageStepTests.cs | by reference | | +| B.1 | B.1.6 | PAGE-16, PAGE-17, PAGE-18, PAGE-20, PAGE-19 | tests/Dexpace.Sdk.Core.Tests/Pagination/LinkHeaderParserTests.cs; tests/Dexpace.Sdk.Core.Tests/Pagination/PaginationStrategiesTests.cs | by reference | 7c's `link-header.json` vectors are not lifted into the kit (P10-9): one implementation, not a seam | +| B.1 | B.1.7 | PAGE-21, PAGE-22, PAGE-23, PAGE-24 | tests/Dexpace.Sdk.Core.Tests/Pagination/QuerySpliceTests.cs; tests/Dexpace.Sdk.Core.Tests/Pagination/QuerySplicePropertyTests.cs | by reference | `PAGE-21` holds on a stated domain (design §10 entry 18) | +| B.1 | B.1.8 | PAGE-25, PAGE-26, PAGE-27, PAGE-28, PAGE-29, PAGE-30, PAGE-31, PAGE-32, PAGE-33 | tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs; tests/Dexpace.Sdk.Core.Tests/Pagination/BlockingPageableTests.cs | by reference | `PAGE-29` and `PAGE-30` declined: no executor mode (design §10 entry 19) | +| B.1 | B.1.9 | PAGE-34, PAGE-35 | tests/Dexpace.Sdk.Core.Tests/Pagination/FetcherPageableTests.cs | by reference | | +| B.1 | B.1.10 | PAGE-36 | tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs | by reference | | +| B.2 | B.2.1 | SSE-1, SSE-2, SSE-3, SSE-4, SSE-5, SSE-6, SSE-7, SSE-8, SSE-9, SSE-10, SSE-11, SSE-12 | tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/ServerSentEventParserTests.cs | by reference | | +| B.2 | B.2.2 | SSE-13, SSE-14, SSE-15, SSE-16, SSE-17, SSE-18, SSE-19 | tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/EventAccumulatorTests.cs; tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/ServerSentEventReaderTests.cs | by reference | `SSE-19`: the documented 1 MiB line cap (design §10 entry 20) | +| B.2 | B.2.3 | SSE-20, SSE-21, SSE-22 | tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/ServerSentEventTests.cs | by reference | | +| B.2 | B.2.4 | SSE-23, SSE-24, SSE-25, SSE-26, SSE-27, SSE-28, SSE-29, SSE-30, SSE-31, SSE-32 | tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/ServerSentEventStreamLifecycleTests.cs; tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/ServerSentEventStreamReleaseTableTests.cs | by reference | | +| B.2 | B.2.5 | SSE-33, SSE-34, SSE-35, SSE-36 | tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/ServerSentEventStreamTypedTests.cs | by reference | | +| B.2 | B.2.6 | SSE-37, SSE-38, SSE-39, SSE-40, SSE-41 | tests/Dexpace.Sdk.Core.Tests/Architecture/Sse37ArchitectureTests.cs; tests/Dexpace.Sdk.Core.Tests/Architecture/Sse38ArchitectureTests.cs | by reference | `SSE-41` is not applicable: no reactive facade (design §11 item 21) | +| B.3 | B.3.1 | SERDE-1, SERDE-2 | codec:serde-1.round-trip; codec:serde-2.declared-media-type | suite | | +| B.3 | B.3.2 | SERDE-3, SERDE-4 | codec:serde-3.target-not-closed; tests/Dexpace.Sdk.Core.Tests/Serialization/SerdeProfileTests.cs | by reference | `SERDE-4` is core's `SerdeExtensions.Serialize(ISerde, Span, T)`, not a codec property (validation F6), so the least portable evidence sets the status | +| B.3 | B.3.3 | SERDE-5, SERDE-6, SERDE-7, SERDE-8 | tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SystemTextJsonSerdeTests.cs | restated | the type witness is a generic argument (design §3.4) | +| B.3 | B.3.4 | SERDE-9, SERDE-10, SERDE-11, SERDE-12, SERDE-13 | codec:serde-9.no-library-type-escapes; codec:serde-10.directional-subtypes; codec:serde-12.stream-error-unwrapped; tests/Dexpace.Sdk.Core.Tests/Serialization/ReadValueTests.cs; tests/Dexpace.Sdk.Core.Tests/Serialization/SerdeValuesTests.cs | by reference | `SERDE-13` is core's typed readers, after the codec has returned (validation F6); `SERDE-11` restated: every .NET exception is unchecked | +| B.3 | B.3.5 | SERDE-14, SERDE-15, SERDE-16, SERDE-17, SERDE-18, SERDE-19, SERDE-20, SERDE-30 | codec:serde-15.tristate-emission; codec:serde-16.tristate-decode; codec:serde-17.missing-key-absent; codec:serde-19.default-wiring; codec:serde-20.top-level-degrades; tests/Dexpace.Sdk.Core.Tests/Serialization/TristateTests.cs; tests/Dexpace.Sdk.Core.Tests/Serialization/TristateSentinelTests.cs | by reference | `SERDE-14`, `-18` and `-30` are `Tristate`'s own API (validation F6); `SERDE-14`'s covariance SHOULD is design §10 entry 21 | +| B.3 | B.3.6 | SERDE-21, SERDE-22, SERDE-23, SERDE-24, SERDE-25, SERDE-26, SERDE-29 | tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/StrictCoercionTests.cs; tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/SerdeOptionsTests.cs | by reference | default-codec properties, not seam properties | +| B.3 | B.3.7 | SERDE-27, SERDE-28 | tests/Dexpace.Sdk.Core.Tests/Serialization/ResponseHandlersTests.cs | by reference | | +| B.4 | B.4.1 | OBS-1, OBS-2, OBS-3, OBS-4, OBS-5, OBS-6, OBS-7, OBS-8, OBS-9, OBS-40 | tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/DisabledPathAllocationTests.cs; tests/Dexpace.Sdk.Core.Tests/Diagnostics/LogCatalogueTests.cs | restated | `OBS-1`'s inert event is restated as zero allocation on the disabled path (design §9.3, §10 entry 22); `OBS-8` and `OBS-40` vacuous | +| B.4 | B.4.2 | OBS-10 | tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/DiagnosticContextFlowTests.cs | by reference | | +| B.4 | B.4.3 | OBS-11, OBS-12, OBS-13, OBS-14, OBS-15, OBS-16, OBS-17, OBS-18, OBS-19 | tests/Dexpace.Sdk.Core.Tests/Security/UrlRedactionDefaultDenyTests.cs; tests/Dexpace.Sdk.Core.Tests/Diagnostics/UrlRedactorTests.cs; tests/Dexpace.Sdk.Http.SystemNet.Tests/HeaderDropLogTests.cs | by reference | | +| B.4 | B.4.4 | OBS-20 | tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/EmissionGuardTests.cs | by reference | design §11 item 37: the SDK's own recording code is total; listener callbacks are `OBS-30`'s party | +| B.4 | B.4.5 | OBS-21, OBS-22, OBS-23, OBS-24, OBS-25 | tests/Dexpace.Sdk.Core.Tests/Diagnostics/ActivityScopeTests.cs; tests/Dexpace.Sdk.Core.Tests/Diagnostics/UntracedAllocationTests.cs | restated | `Activity` replaces the span-and-scope model (design §10 entry 23) | +| B.4 | B.4.6 | OBS-26, OBS-27, OBS-28, OBS-29, OBS-30 | tests/Dexpace.Sdk.Core.Tests/Diagnostics/TraceIdTests.cs; tests/Dexpace.Sdk.Core.Tests/Diagnostics/ListenerContractTests.cs | by reference | `OBS-27`'s Datadog flavour is design §10 entry 24 | +| B.4 | B.4.7 | OBS-31, OBS-32, OBS-33 | tests/Dexpace.Sdk.Core.Tests/Diagnostics/HttpClientMetricsTests.cs | by reference | design §11 item 38 | +| B.4 | B.4.8 | OBS-34, OBS-35, OBS-36, OBS-37, OBS-38, OBS-39 | tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/InstrumentationPolicyTests.cs; tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BodyPreviewTests.cs; tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/LogLevelBindingTests.cs | by reference | `OBS-35` is split (design §10 entry 26) | +| B.5 | B.5.1 | CFG-1, CFG-2, CFG-3, CFG-4, CFG-38 | tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/ConfigurationPrecedenceTests.cs | restated | `IConfiguration` tiers through the DI package's own reader, not the binder (design §10 entry 25) | +| B.5 | B.5.2 | CFG-5, CFG-6, CFG-7 | tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/DurationGrammarTests.cs; tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/IntegerGrammarTests.cs; tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/BooleanGrammarTests.cs | restated | fail fast at start, naming the key (design §10 entry 26) | +| B.5 | B.5.3 | CFG-8, CFG-9, CFG-10, CFG-11, CFG-12, CFG-13, CFG-14, CFG-37 | tests/Dexpace.Sdk.Core.Tests/Configuration/OptionsImmutabilityTests.cs; tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/KeyConstantsTests.cs; tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/NullGuardTests.cs | restated | `CFG-10` and `CFG-12` are not applicable; `CFG-13` declined | +| B.5 | B.5.4 | CFG-15, CFG-16, CFG-17, CFG-18, CFG-19, CFG-20, CFG-21 | tests/Dexpace.Sdk.Core.Tests/Configuration/TimeProviderWaitsTests.cs; tests/Dexpace.Sdk.Core.Tests/Internal/LateResultTests.cs | by reference | `CFG-20` retired (design §10 entry 8) | +| B.5 | B.5.5 | CFG-22, CFG-23, CFG-24, CFG-25, CFG-26, CFG-27, CFG-28 | tests/Dexpace.Sdk.Core.Tests/Configuration/ProxyOptionsTests.cs; tests/Dexpace.Sdk.Core.Tests/Configuration/ProxyGlobTests.cs; tests/Dexpace.Sdk.Core.Tests/Configuration/NoProxyListTests.cs; tests/Dexpace.Sdk.Core.Tests/Configuration/NoImplicitProxyReadTests.cs | by reference | | +| B.5 | B.5.6 | CFG-29, CFG-30, CFG-31, CFG-32, CFG-33, CFG-34, CFG-35, CFG-36 | tests/Dexpace.Sdk.Core.Tests/Http/Common/HttpDateTests.cs; tests/Dexpace.Sdk.Core.Tests/Internal/DeepValueTests.cs; tests/Dexpace.Sdk.Core.Tests/Configuration/BuildInfoTests.cs | by reference | | +| B.6 | B.6.1 | TRANSPORT-1, TRANSPORT-2 | transport:transport-1.redirect-not-followed; transport:transport-2.no-silent-resend | suite | `TRANSPORT-2`'s native retry for a body-less `GET`/`HEAD`/`OPTIONS`/`DELETE`/`TRACE` is a permanent waiver of the reference transport (design §10 entry 33) | +| B.6 | B.6.2 | TRANSPORT-3, TRANSPORT-4, TRANSPORT-5, TRANSPORT-6, TRANSPORT-7, TRANSPORT-8, TRANSPORT-9 | transport:transport-3.cancel-is-terminal; transport:transport-4.timeout-is-retryable | suite | the rest of the item is `transport:transport-5.*` to `transport-9.*` | +| B.6 | B.6.3 | TRANSPORT-10, TRANSPORT-11, TRANSPORT-12, TRANSPORT-13, TRANSPORT-14 | transport:transport-10.content-type-authoritative; transport:transport-14.value-control-dropped | suite | `TRANSPORT-14`'s malformed-name clause is a permanent waiver of the reference transport (design §10 entry 32) | +| B.6 | B.6.4 | TRANSPORT-15, TRANSPORT-16, TRANSPORT-17, TRANSPORT-18, TRANSPORT-19 | transport:transport-15.borrowed-survives; transport:transport-16.close-idempotent-nonblocking | suite | | +| B.6 | B.6.5 | TRANSPORT-20, TRANSPORT-21, TRANSPORT-22, TRANSPORT-23, TRANSPORT-24, TRANSPORT-25, TRANSPORT-26, TRANSPORT-27, TRANSPORT-28, TRANSPORT-29, TRANSPORT-30 | transport:transport-20.no-response-is-retryable; transport:transport-29.concurrent-no-crosstalk | suite | | +| B.7 | B.7.1 | ASYNC-1, ASYNC-2 | transport:async-1.single-non-null-response | suite | `ASYNC-2`'s scheduler clause is open for the lead (the 8a finding F-A2); its failure-channel clause is `transport:transport-21.pre-dispatch-failure-via-task` | +| B.7 | B.7.2 | ASYNC-3, ASYNC-4, ASYNC-5, ASYNC-6, ASYNC-7 | tests/Dexpace.Sdk.Core.Tests/Client/SyncToAsyncBridgeTests.cs; tests/Dexpace.Sdk.Core.Tests/Client/AsyncToSyncBridgeTests.cs | restated | cancellation is cooperative (design §10 entry 8); `ASYNC-4` vacuous | +| B.7 | B.7.3 | ASYNC-8, ASYNC-9, ASYNC-10, ASYNC-11, ASYNC-12 | tests/Dexpace.Sdk.Core.Tests/Client/BridgeLoggingContextTests.cs | restated | logging scopes over `AsyncLocal` flow (design §9.3) | +| B.7 | B.7.4 | ASYNC-13, ASYNC-14 | tests/Dexpace.Sdk.Core.Tests/Client/AsyncToSyncBridgeTests.cs | by reference | | +| B.7 | B.7.5 | ASYNC-15, ASYNC-16, ASYNC-17 | transport:transport-16.close-idempotent-nonblocking; transport:transport-15.owned-released | suite | `ASYNC-16` is not applicable | +| B.7 | B.7.6 | ASYNC-18, ASYNC-19, ASYNC-20, ASYNC-21, ASYNC-22 | transport:async-20.late-cancel-leaves-response-open; transport:transport-29.concurrent-no-crosstalk | suite | `ASYNC-21` is not applicable (design §11 item 21) | +| B.8 | B.8.1 | XCUT-1, XCUT-2, XCUT-3 | invariant:xcut-1.cancel-is-terminal; invariant:xcut-2.timeout-is-retryable; invariant:xcut-3.wait-promptly-cancellable | suite | `XCUT-1`'s interrupt-flag clause is cooperative (design §10 entry 8); the transport-level clauses are `transport:transport-3.*` and `transport-4.*` (P10-6) | +| B.8 | B.8.2 | XCUT-4, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9 | invariant:xcut-4.two-branches; invariant:xcut-5.one-classifier; invariant:xcut-6.capability-queried; invariant:xcut-7.configured-set-authoritative; invariant:xcut-8.strict-factory-rejects; invariant:xcut-8.convenience-returns-response; invariant:xcut-9.cycle-safe-walk | suite | `XCUT-4`'s "catchable as a generic I/O error" is a judged mechanism (design §10 entry 7, §11 item 23); `XCUT-8` reads as the SDK's mapping path, not the public constructor (P10-24) | +| B.8 | B.8.3 | XCUT-10 | invariant:xcut-10.safety-gate-uniform | suite | | +| B.8 | B.8.4 | XCUT-11, XCUT-12, XCUT-13, XCUT-22, XCUT-23 | invariant:xcut-11.shared-no-crosstalk; invariant:xcut-12.single-flight-scoped; invariant:xcut-13.dispose-latched; invariant:xcut-13.dispose-non-blocking; invariant:xcut-22.core-borrows; invariant:xcut-23.resolution-deterministic | suite | `XCUT-13`'s interrupt-flag clause is vacuous (design §10 entry 8, §3.7); `XCUT-23`'s discovery half is vacuous (design §10 entry 9, §11 item 8) | +| B.8 | B.8.5 | XCUT-14, XCUT-15 | invariant:xcut-14.bounded-under-burst; invariant:xcut-14.drains-after-insert; invariant:xcut-15.no-external-alias | suite | | +| B.8 | B.8.6 | XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-24 | invariant:xcut-16.no-credential-over-http; invariant:xcut-17.redirect-hygiene; invariant:xcut-18.model-validation; invariant:xcut-19.redaction-default-deny; invariant:xcut-20.observability-never-throws; invariant:xcut-21.cnonce-unpredictable; invariant:xcut-24.preview-capped-non-consuming | suite | `XCUT-24`'s non-consuming clause is declined for an error-body snapshot above 1 MiB (P10-23); `XCUT-21`'s CSPRNG source clause is cited by reference to `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` | +| B.9 | B.9.1 | NFR-1, NFR-2 | packaging:nfr-1.core-dependencies; packaging:nfr-2.adapter-budget | suite | design §10 entries 1 and 2 | +| B.9 | B.9.2 | NFR-3, NFR-4 | packaging:nfr-3.no-internals-granted; Directory.Build.props; tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs | by reference | `NFR-4`'s package-validation baseline waits for the first publish (phase 12) | +| B.9 | B.9.3 | NFR-5, NFR-6, NFR-7, NFR-17 | scripts/ci/coverage-gate.cs; Directory.Build.props; .editorconfig; tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs; tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs | by reference | `NFR-17`'s second half, that a merge requires the checks, is a repository setting recorded in the phase checklist | +| B.9 | B.9.4 | NFR-8, NFR-9 | packaging:nfr-8.trim-and-aot-declared; tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs | by reference | | +| B.9 | B.9.5 | NFR-10 | packaging:nfr-10.single-floor | suite | the "higher-floor unit isolated" clause is vacuous: none exists | +| B.9 | B.9.6 | NFR-11 | tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs; invariant:nfr-11.scheduler-agnostic | by reference | the scan reads the compiled assembly, not `PublicAPI.Shipped.txt`, which is empty until phase 12 (P10-11) | +| B.9 | B.9.7 | NFR-12, NFR-13, NFR-14, NFR-15, NFR-16 | scripts/ci/reproducible-pack.sh; tests/Dexpace.Sdk.Conformance.Tests/Gates/LicenseHeaderTests.cs; packaging:nfr-14.lockstep-version; packaging:nfr-15.version-resolvable | by reference | `NFR-12`'s release half and `NFR-16` wait for the first publish (phase 12) | diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj b/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj index f8d82a1e..5a9e7ad3 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj +++ b/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj @@ -26,6 +26,8 @@ the directory tree looking for the repository root. --> + + diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBCoverageTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBCoverageTests.cs new file mode 100644 index 00000000..3039a18a --- /dev/null +++ b/tests/Dexpace.Sdk.Conformance.Tests/Requirements/AppendixBCoverageTests.cs @@ -0,0 +1,275 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.RegularExpressions; +using Dexpace.Sdk.Conformance.Tests.Gates; +using Xunit; + +namespace Dexpace.Sdk.Conformance.Tests.Requirements; + +/// +/// The appendix-B gate (phase 10, P10-19, plan 4.2): APPENDIX_B.md maps every one of the 61 items of the specification's checklist to its evidence, and +/// five rules keep it honest: (1) 61 rows in the specification's section counts; (2) each row's ID set is the set its item cites; (3) every status is one of the +/// four and every path and assertion reference it names exists; (4) a suite row names a real kit assertion that cites one of its IDs; (5) the kit's +/// AppendixBMap equals the map's ID column. Each rule has a negative control built from the parsed map with one defect. +/// +[Trait("Category", "Unit")] +public sealed partial class AppendixBCoverageTests +{ + private static readonly string[] s_statuses = ["suite", "by reference", "restated", "scoped out"]; + private static readonly int[] s_sectionCounts = [10, 6, 7, 8, 6, 5, 6, 6, 7]; + private static readonly string[] s_suites = ["transport", "invariant", "codec", "packaging"]; + + [GeneratedRegex(@"\b(?:SEAM|HTTP|IO|BODY|CTX|PIPE|RECOV|RETRY|REDIR|AUTH|PAGE|SSE|SERDE|OBS|CFG|TRANSPORT|ASYNC|XCUT|NFR)-\d+\b", RegexOptions.CultureInvariant)] + private static partial Regex IdPattern(); + + private sealed record Row(string Section, string Item, string[] Ids, string[] Evidence, string Status, string Notes); + + private static string MapText => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "spec", "APPENDIX_B.md")); + + private static string[] SpecLines => File.ReadAllLines(Path.Combine(AppContext.BaseDirectory, "spec", "appendix-b.md")); + + private static List ParseMap(string text) => + [.. text.Split('\n') + .Select(static line => line.TrimEnd('\r')) + .Where(static line => line.StartsWith("| B.", StringComparison.Ordinal)) + .Select(static line => line.Trim('|').Split('|').Select(static cell => cell.Trim()).ToArray()) + .Select(static cells => new Row( + cells[0], + cells[1], + [.. cells[2].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)], + [.. cells[3].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)], + cells[4], + cells[5]))]; + + // Each checklist bullet of the specification, keyed B.
., as the IDs it cites. + private static Dictionary ParseSpecification() + { + var items = new Dictionary(StringComparer.Ordinal); + string? section = null; + var count = 0; + foreach (var line in SpecLines) + { + var header = Regex.Match(line, @"^### (B\.\d+) ", RegexOptions.CultureInvariant); + if (header.Success) + { + section = header.Groups[1].Value; + count = 0; + } + else if (section is not null && line.StartsWith("- [ ]", StringComparison.Ordinal)) + { + items[$"{section}.{++count}"] = [.. IdPattern().Matches(line).Select(static match => match.Value)]; + } + } + + return items; + } + + private static IReadOnlyList Catalogue(string suite) => suite switch + { + "transport" => TransportSuite.Assertions, + "invariant" => InvariantSuite.Assertions, + "codec" => CodecSuite.Assertions, + "packaging" => PackagingSuite.Assertions, + _ => [], + }; + + // An evidence item that names an assertion: ":", the name a prefix when it ends in "*". Null for a path. + private static List? Resolve(string evidence) + { + var colon = evidence.IndexOf(':', StringComparison.Ordinal); + if (colon <= 0 || !s_suites.Contains(evidence[..colon], StringComparer.Ordinal)) + { + return null; + } + + var name = evidence[(colon + 1)..]; + var catalogue = Catalogue(evidence[..colon]); + return name.EndsWith('*') + ? [.. catalogue.Where(a => a.Name.StartsWith(name[..^1], StringComparison.Ordinal))] + : [.. catalogue.Where(a => string.Equals(a.Name, name, StringComparison.Ordinal))]; + } + + private static List Problems(IReadOnlyList rows) + { + var specification = ParseSpecification(); + var problems = SectionProblems(rows); + foreach (var row in rows) + { + problems.AddRange(RowProblems(row, specification)); + } + + problems.AddRange(MapProblems(rows)); + return problems; + } + + // Rule 1: 61 rows, with the specification's per-section counts. + private static List SectionProblems(IReadOnlyList rows) + { + var problems = new List(); + if (rows.Count != 61) + { + problems.Add($"rule 1: {rows.Count} rows, expected 61"); + } + + for (var section = 1; section <= 9; section++) + { + var have = rows.Count(row => row.Section == $"B.{section}"); + if (have != s_sectionCounts[section - 1]) + { + problems.Add($"rule 1: section B.{section} has {have} rows, expected {s_sectionCounts[section - 1]}"); + } + } + + return problems; + } + + // Rules 2 to 4, for one row. + private static List RowProblems(Row row, Dictionary specification) + { + var problems = new List(); + + // Rule 2: the row's ID set is the set its item cites. + if (!specification.TryGetValue(row.Item, out var cited)) + { + problems.Add($"rule 2: {row.Item} is not an item of appendix B"); + } + else if (!row.Ids.ToHashSet(StringComparer.Ordinal).SetEquals(cited)) + { + problems.Add($"rule 2: {row.Item} lists {string.Join(", ", row.Ids.Order(StringComparer.Ordinal))} but the item cites {string.Join(", ", cited.Order(StringComparer.Ordinal))}"); + } + + // Rule 3: one of the four statuses, and every path and assertion reference exists. + if (!s_statuses.Contains(row.Status, StringComparer.Ordinal)) + { + problems.Add($"rule 3: {row.Item} has the status '{row.Status}'"); + } + + foreach (var evidence in row.Evidence) + { + var resolved = Resolve(evidence); + if (resolved is null) + { + if (!File.Exists(RepositoryRoot.Of(evidence))) + { + problems.Add($"rule 3: {row.Item} names the path '{evidence}', which does not exist"); + } + } + else if (resolved.Count == 0) + { + problems.Add($"rule 3: {row.Item} names the assertion '{evidence}', which no kit catalogue holds"); + } + } + + // Rule 4: a suite row names a real assertion that cites one of its IDs. + if (row.Status == "suite" && !row.Evidence.Any(evidence => Resolve(evidence) is { } found && found.Any(assertion => assertion.RequirementIds.Intersect(row.Ids, StringComparer.Ordinal).Any()))) + { + problems.Add($"rule 4: the suite row {row.Item} names no assertion that cites one of its IDs"); + } + + return problems; + } + + // Rule 5: the kit's own AppendixBMap is the map's ID column. + private static List MapProblems(IReadOnlyList rows) + { + var mapped = rows.ToDictionary(static row => row.Item, static row => row.Ids, StringComparer.Ordinal); + if (!mapped.Keys.Order(StringComparer.Ordinal).SequenceEqual(AppendixBMap.Items.Keys.Order(StringComparer.Ordinal))) + { + return ["rule 5: AppendixBMap and the map name different items"]; + } + + return + [ + .. AppendixBMap.Items + .Where(item => !item.Value.ToHashSet(StringComparer.Ordinal).SetEquals(mapped[item.Key])) + .Select(item => $"rule 5: AppendixBMap differs from the map at {item.Key}"), + ]; + } + + [Fact] + public void The_map_passes_all_five_rules() + { + var problems = Problems(ParseMap(MapText)); + + Assert.Empty(problems); + } + + [Fact] + public void The_map_opens_with_the_two_limits() + { + var text = MapText; + + Assert.Contains("covers nine of the nineteen requirement prefixes", text, StringComparison.Ordinal); + Assert.Contains("silent on `PAGE-4` and `PAGE-5`", text, StringComparison.Ordinal); + Assert.Contains("proves a claim and a file, never the behaviour", text, StringComparison.Ordinal); + } + + [Fact] + public void The_tally_is_17_8_36_0() + { + var rows = ParseMap(MapText); + + Assert.Equal(17, rows.Count(static row => row.Status == "suite")); + Assert.Equal(8, rows.Count(static row => row.Status == "restated")); + Assert.Equal(36, rows.Count(static row => row.Status == "by reference")); + Assert.Equal(0, rows.Count(static row => row.Status == "scoped out")); + Assert.Contains("17 `suite`, 8 `restated`, 36 `by reference`, 0 `scoped out` (61)", MapText, StringComparison.Ordinal); + Assert.Equal(276, rows.SelectMany(static row => row.Ids).Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void Rule_1_can_fail_a_map_missing_a_row() + { + var rows = ParseMap(MapText); + + Assert.Contains(Problems([.. rows.Where(static row => row.Item != "B.4.2")]), static p => p.StartsWith("rule 1", StringComparison.Ordinal)); + } + + [Fact] + public void Rule_2_can_fail_a_row_with_one_id_too_many() + { + var rows = ParseMap(MapText).Select(static row => row.Item == "B.3.7" ? row with { Ids = [.. row.Ids, "SERDE-30"] } : row).ToList(); + + Assert.Contains(Problems(rows), static p => p.StartsWith("rule 2: B.3.7", StringComparison.Ordinal)); + } + + [Fact] + public void Rule_3_can_fail_a_dangling_path_a_missing_assertion_and_an_unknown_status() + { + var rows = ParseMap(MapText); + + var dangling = rows.Select(static row => row.Item == "B.2.3" ? row with { Evidence = ["tests/Dexpace.Sdk.Core.Tests/ServerSentEvents/NoSuchTests.cs"] } : row).ToList(); + var missing = rows.Select(static row => row.Item == "B.8.3" ? row with { Evidence = ["invariant:xcut-10.no-such-assertion"] } : row).ToList(); + var status = rows.Select(static row => row.Item == "B.2.3" ? row with { Status = "mostly" } : row).ToList(); + + Assert.Contains(Problems(dangling), static p => p.Contains("does not exist", StringComparison.Ordinal)); + Assert.Contains(Problems(missing), static p => p.Contains("no kit catalogue holds", StringComparison.Ordinal)); + Assert.Contains(Problems(status), static p => p.Contains("the status 'mostly'", StringComparison.Ordinal)); + } + + [Fact] + public void Rule_4_can_fail_a_suite_row_naming_an_assertion_that_cites_none_of_its_ids() + { + var rows = ParseMap(MapText).Select(static row => row.Item == "B.8.3" ? row with { Evidence = ["invariant:xcut-1.cancel-is-terminal"] } : row).ToList(); + + Assert.Contains(Problems(rows), static p => p.StartsWith("rule 4: the suite row B.8.3", StringComparison.Ordinal)); + } + + [Fact] + public void Rule_5_can_fail_when_the_map_and_AppendixBMap_disagree() + { + var rows = ParseMap(MapText).Select(static row => row.Item == "B.9.5" ? row with { Ids = ["NFR-10", "NFR-11"] } : row).ToList(); + + Assert.Contains(Problems(rows), static p => p.StartsWith("rule 5", StringComparison.Ordinal)); + } + + [Fact] + public void A_wildcard_matches_a_name_prefix_and_a_path_is_not_an_assertion() + { + Assert.NotEmpty(Resolve("invariant:xcut-1*")!); + Assert.Empty(Resolve("invariant:zzz*")!); + Assert.Null(Resolve("tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs")); + Assert.Null(Resolve("scripts/ci/coverage-gate.cs")); + } +} From 3ae24c69ca1956ff6e501e33ba245974b5ede4b2 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:01:08 +0300 Subject: [PATCH 25/45] ci: requirement-coverage script over appendix C and every checklist (phase 10, #99) --- scripts/ci/requirement-coverage-selftest.sh | 97 +++++++++ scripts/ci/requirement-coverage.cs | 215 ++++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100755 scripts/ci/requirement-coverage-selftest.sh create mode 100644 scripts/ci/requirement-coverage.cs diff --git a/scripts/ci/requirement-coverage-selftest.sh b/scripts/ci/requirement-coverage-selftest.sh new file mode 100755 index 00000000..9cf4aab6 --- /dev/null +++ b/scripts/ci/requirement-coverage-selftest.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 dexpace and Omar Aljarrah. +# Licensed under the MIT License. See LICENSE in the repository root for details. +# +# Proves the appendix-C coverage check fails closed (phase 10, P10-20, plan reading R9): scripts/ci/requirement-coverage.cs has no test project, so this +# builds fixture trees (a miniature appendix C and two checklists) in a temporary directory and requires +# - a clean fixture: exit 0; +# - an appendix-C MUST with no owning row: exit 1 naming it; a SHOULD with none: reported, exit 0; +# - an owning row naming an unknown ID: exit 1; a row whose level disagrees: exit 1; a split level ("MUST (+SHOULD log)") that agrees: exit 0; +# - an owning row with no legend mark: exit 1; +# - a carried ID whose earlier row has no hourglass: exit 1; with one: exit 0; +# - a row in a contributed-evidence table (header "ID (owner)") owns nothing; +# - a missing appendix, no checklist, or a bad flag: exit 2; --report over a broken fixture: exit 0. +# Run from anywhere. Exit 0 when every case holds, 1 otherwise. +# +# scripts/ci/requirement-coverage-selftest.sh +set -uo pipefail +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +dotnet="${DOTNET:-dotnet}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$repo" || exit 1 + +appendix="docs/product-spec/appendix-c-consolidated-normative-requirement-index.md" +status=0 + +# fixture [a-rows] [b-rows]: a tree with a miniature appendix C and two checklists (phase 2a, phase 3a). +# The default rows own AAA-1, AAA-2 (a) and BBB-1, BBB-2 (b). +fixture() { + local dir="$work/$1" a="${2-}" b="${3-}" + mkdir -p "$dir/docs/product-spec" "$dir/docs/work/mvp/phase2/phase2a" "$dir/docs/work/mvp/phase3/phase3a" + { + echo '| ID | Level | Topic | Text |' + echo '|---|---|---|---|' + echo '| AAA-1 | MUST | t | one |' + echo '| AAA-2 | MUST | t | two |' + echo '| BBB-1 | SHOULD | t | three |' + echo '| BBB-2 | MUST | t | four |' + } >"$dir/$appendix" + [ -z "$a" ] && a='| `AAA-1` | MUST | ✅ | built | +| `AAA-2` | MUST | ✅ | built |' + [ -z "$b" ] && b='| `BBB-1` | SHOULD | ✅ | built | +| `BBB-2` | MUST | ✅ | built |' + printf '%s\n%s\n%s\n' '| ID | Level | Mark | Evidence |' '|---|---|---|---|' "$a" >"$dir/docs/work/mvp/phase2/phase2a/a-checklist.md" + printf '%s\n%s\n%s\n' '| ID | Level | Mark | Evidence |' '|---|---|---|---|' "$b" >"$dir/docs/work/mvp/phase3/phase3a/b-checklist.md" + echo "$dir" +} + +# expect +expect() { + local name="$1" code="$2" pattern="$3" root="$4" + shift 4 + local output actual + output="$("$dotnet" run scripts/ci/requirement-coverage.cs -- "$@" --root "$root" 2>&1)" + actual=$? + output="${output//$'\r'/}" + if [ "$actual" -eq "$code" ] && grep -Eq -- "$pattern" <<<"$output"; then + echo "requirement-coverage-selftest: ok $name (exit $actual)" + else + echo "requirement-coverage-selftest: FAILED $name: wanted exit $code matching '$pattern', got exit $actual" + echo "$output" | sed 's/^/ /' + status=1 + fi +} + +expect "a clean fixture" 0 "4 of 4 IDs owned" "$(fixture clean)" --check +expect "a MUST with no owning row" 1 "unowned +BBB-2" "$(fixture unowned-must '' '| `BBB-1` | SHOULD | ✅ | built |')" --check +expect "a SHOULD with no owning row is reported but does not fail" 0 "unowned +BBB-1" "$(fixture unowned-should '' '| `BBB-2` | MUST | ✅ | built |')" --check +expect "an owning row naming an unknown ID" 1 "unknown +ZZZ-9" "$(fixture unknown '| `AAA-1` | MUST | ✅ | built | +| `AAA-2` | MUST | ✅ | built | +| `ZZZ-9` | MUST | ✅ | built |')" --check +expect "a level cell that disagrees with appendix C" 1 "level +AAA-2" "$(fixture level '| `AAA-1` | MUST | ✅ | built | +| `AAA-2` | SHOULD | ✅ | built |')" --check +expect "a split level that agrees" 0 "4 of 4 IDs owned" "$(fixture split '| `AAA-1` | MUST (+SHOULD log) | ✅ | built | +| `AAA-2` | MUST | ✅ | built |')" --check +expect "an owning row with no legend mark" 1 "mark +AAA-1" "$(fixture mark '| `AAA-1` | MUST | built | built | +| `AAA-2` | MUST | ✅ | built |')" --check +expect "a carried ID whose earlier row has no hourglass" 1 "carried +AAA-1" "$(fixture carried '' '| `AAA-1` | MUST | ✅ | carried | +| `BBB-1` | SHOULD | ✅ | built | +| `BBB-2` | MUST | ✅ | built |')" --check +expect "a carried ID whose earlier row defers" 0 "4 of 4 IDs owned" "$(fixture carried-ok '| `AAA-1` | MUST | ⏳ 3a | later | +| `AAA-2` | MUST | ✅ | built |' '| `AAA-1` | MUST | ✅ | carried | +| `BBB-1` | SHOULD | ✅ | built | +| `BBB-2` | MUST | ✅ | built |')" --check +contributed="$(fixture contributed '| `AAA-2` | MUST | ✅ | built |')" +printf '\n%s\n%s\n%s\n' '| ID (owner) | Work | Evidence |' '|---|---|---|' '| `AAA-1` (a) | helped | x |' >>"$contributed/docs/work/mvp/phase2/phase2a/a-checklist.md" +expect "a contributed-evidence table owns nothing" 1 "unowned +AAA-1" "$contributed" --check +nothing="$(fixture no-appendix)" +rm "$nothing/$appendix" +expect "a missing appendix" 2 "was not found" "$nothing" --check +none="$(fixture no-checklist)" +rm -r "$none/docs/work/mvp/phase2" "$none/docs/work/mvp/phase3" +expect "no checklist" 2 "no checklist" "$none" --check +expect "a bad flag" 2 "usage" "$(fixture flag)" --bogus +expect "--report over a broken fixture" 0 "unowned +BBB-2" "$(fixture report '' '| `BBB-1` | SHOULD | ✅ | built |')" --report + +exit $status diff --git a/scripts/ci/requirement-coverage.cs b/scripts/ci/requirement-coverage.cs new file mode 100644 index 00000000..85a8dd27 --- /dev/null +++ b/scripts/ci/requirement-coverage.cs @@ -0,0 +1,215 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +// The appendix-C coverage check (phase 10, P10-20; roadmap "every MUST dispositioned", docs/first-release.md). +// +// dotnet run scripts/ci/requirement-coverage.cs -- --report # print the tallies; always exit 0 on a readable tree +// dotnet run scripts/ci/requirement-coverage.cs -- --check # exit 1 on a finding in a MUST row +// dotnet run scripts/ci/requirement-coverage.cs -- --check --root # read instead of the current directory +// +// Appendix C (docs/product-spec/appendix-c-consolidated-normative-requirement-index.md) is the canonical list of every normative requirement +// with its level. Every docs/work/**/*-checklist.md is read for its OWNING rows: a row whose first cell is a requirement ID, in a table whose header +// starts `| ID | Level |`. Tables with any other header (`| ID (owner) | ... |`, `| ID | As built | ... |`) hold contributed or carried evidence and own nothing. +// Four findings, each tied to a row: +// unowned an appendix-C ID with no owning row; +// unknown an owning row naming an ID appendix C does not hold; +// level an owning row whose level cell disagrees with appendix C (`MUST NOT` folds into MUST); +// mark an owning row whose first three cells carry no legend mark (a tick, a no-entry sign, an hourglass, N/A or "vacuous"); +// and one for carried IDs: an ID with owning rows in several checklists is resolved by phase order (the checklist's phaseN[x] directory); the latest row's +// mark is the ID's mark, and every earlier row must carry an hourglass (deferred to the later phase), or the pair is reported. +// Exit 0 when the check holds (or for --report), 1 on a finding in a MUST row, 2 on a usage or input error. + +#:property RestorePackagesWithLockFile=false +#:property PublishAot=false + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +const string AppendixPath = "docs/product-spec/appendix-c-consolidated-normative-requirement-index.md"; + +var mode = "--report"; +var root = Directory.GetCurrentDirectory(); +for (var i = 0; i < args.Length; i++) +{ + if (args[i] is "--report" or "--check") + { + mode = args[i]; + } + else if (args[i] == "--root" && i + 1 < args.Length) + { + root = Path.GetFullPath(args[++i]); + } + else + { + Console.Error.WriteLine("usage: requirement-coverage.cs [--report | --check] [--root ]"); + return 2; + } +} + +var appendixFile = Path.Combine(root, AppendixPath); +var workDir = Path.Combine(root, "docs", "work"); +if (!File.Exists(appendixFile) || !Directory.Exists(workDir)) +{ + Console.Error.WriteLine($"requirement-coverage: {AppendixPath} or docs/work was not found under {root}"); + return 2; +} + +var appendixRow = new Regex(@"^\| ([A-Z]+-\d+) \| (MUST NOT|MUST|SHOULD|MAY) \|", RegexOptions.CultureInvariant); +var levels = new Dictionary(StringComparer.Ordinal); +foreach (var match in File.ReadLines(appendixFile).Select(line => appendixRow.Match(line)).Where(match => match.Success)) +{ + levels[match.Groups[1].Value] = match.Groups[2].Value == "MUST NOT" ? "MUST" : match.Groups[2].Value; +} + +var checklists = Directory.GetFiles(workDir, "*-checklist.md", SearchOption.AllDirectories).Order(StringComparer.Ordinal).ToList(); +if (levels.Count == 0 || checklists.Count == 0) +{ + Console.Error.WriteLine($"requirement-coverage: {(levels.Count == 0 ? "no requirement rows in appendix C" : "no checklist under docs/work")}"); + return 2; +} + +var owning = new List(); +foreach (var file in checklists) +{ + owning.AddRange(OwningRows(file)); +} + +var findings = new List(); +var owners = owning.GroupBy(row => row.Id, StringComparer.Ordinal).ToDictionary(group => group.Key, group => group.OrderBy(row => row.Phase).ThenBy(row => row.File, StringComparer.Ordinal).ToList(), StringComparer.Ordinal); + +foreach (var id in levels.Keys.Where(id => !owners.ContainsKey(id))) +{ + findings.Add(new Finding("unowned", id, levels[id], "no checklist has an owning row for it")); +} + +foreach (var (id, rows) in owners) +{ + if (!levels.TryGetValue(id, out var level)) + { + findings.Add(new Finding("unknown", id, "MUST", $"{Relative(rows[0].File)} owns an ID that appendix C does not hold")); + continue; + } + + foreach (var row in rows) + { + if (Fold(row.Level) != level) + { + findings.Add(new Finding("level", id, level, $"{Relative(row.File)} says {row.Level}, appendix C says {level}")); + } + + if (Mark(row.MarkText) is null) + { + findings.Add(new Finding("mark", id, level, $"{Relative(row.File)} carries no legend mark in its first three cells")); + } + } + + foreach (var earlier in rows.Take(rows.Count - 1).Where(row => !row.RowText.Contains('⏳', StringComparison.Ordinal))) + { + findings.Add(new Finding("carried", id, level, $"{Relative(earlier.File)} (phase {earlier.Phase}) is superseded by {Relative(rows[^1].File)} but does not carry an hourglass")); + } +} + +Print(); +var failing = findings.Count(finding => finding.Level == "MUST"); +Console.WriteLine(failing == 0 + ? "requirement-coverage: no finding in a MUST row" + : $"requirement-coverage: {failing} finding(s) in MUST rows"); +return mode == "--check" && failing > 0 ? 1 : 0; + +string Relative(string file) => Path.GetRelativePath(root, file).Replace('\\', '/'); + +// The level cell's leading word: a checklist annotates a split row ("MUST (+SHOULD log)", "SHOULD (embedded MUST)"), and the strength of the row is the first word. +static string Fold(string level) +{ + var word = Regex.Match(level.Trim(), @"^(MUST NOT|MUST|SHOULD|MAY)", RegexOptions.CultureInvariant); + return !word.Success ? level.Trim() : word.Value == "MUST NOT" ? "MUST" : word.Value; +} + +// The first legend mark in the text, in order of appearance, or null. +static string? Mark(string text) +{ + var best = (Index: int.MaxValue, Mark: (string?)null); + foreach (var (token, mark) in new[] { ("✅", "✅"), ("🚫", "🚫"), ("⏳", "⏳"), ("N/A", "N/A"), ("vacuous", "N/A") }) + { + var at = text.IndexOf(token, StringComparison.OrdinalIgnoreCase); + if (at >= 0 && at < best.Index) + { + best = (at, mark); + } + } + + return best.Mark; +} + +// (phase number, sub-phase letter) from the nearest phaseN[x] directory; checklists outside one sort first. +static (int Number, string Letter) PhaseOf(string file) +{ + foreach (var directory in (Path.GetDirectoryName(file) ?? string.Empty).Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Reverse()) + { + var match = Regex.Match(directory, @"^phase(\d+)([a-z]?)$", RegexOptions.CultureInvariant); + if (match.Success) + { + return (int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture), match.Groups[2].Value); + } + } + + return (0, string.Empty); +} + +static IEnumerable OwningRows(string file) +{ + var phase = PhaseOf(file); + var inOwning = false; + var previousWasTable = false; + foreach (var line in File.ReadLines(file)) + { + var isTable = line.StartsWith('|'); + if (isTable && !previousWasTable) + { + inOwning = Regex.IsMatch(line, @"^\|\s*ID\s*\|\s*Level\s*\|", RegexOptions.CultureInvariant); + } + + previousWasTable = isTable; + if (!isTable || !inOwning) + { + continue; + } + + var cells = line.Trim().Trim('|').Split('|').Select(cell => cell.Trim()).ToArray(); + var id = cells[0].Trim('`', ' ', '*'); + if (cells.Length >= 3 && Regex.IsMatch(id, @"^[A-Z]+-\d+$", RegexOptions.CultureInvariant)) + { + yield return new Owning(id, cells[1], string.Join(' ', cells.Take(3)), line, file, phase); + } + } +} + +void Print() +{ + var owned = owners.Keys.Where(levels.ContainsKey).ToList(); + Console.WriteLine($"requirement-coverage: {levels.Count} IDs in appendix C ({levels.Values.Count(l => l == "MUST")} MUST, {levels.Values.Count(l => l == "SHOULD")} SHOULD, {levels.Values.Count(l => l == "MAY")} MAY); {checklists.Count} checklists read"); + Console.WriteLine($"requirement-coverage: {owned.Count} of {levels.Count} IDs owned by a checklist row ({owners.Count(pair => pair.Value.Count > 1)} carried across checklists)"); + foreach (var prefix in levels.Keys.Select(id => id[..id.LastIndexOf('-')]).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) + { + var ids = levels.Keys.Where(id => id.StartsWith(prefix + "-", StringComparison.Ordinal)).ToList(); + Console.WriteLine($" {prefix,-10} {ids.Count(owners.ContainsKey),3} of {ids.Count,3} owned"); + } + + foreach (var level in new[] { "MUST", "SHOULD", "MAY" }) + { + var marks = owned.Where(id => levels[id] == level).GroupBy(id => Mark(owners[id][^1].MarkText) ?? "(none)").OrderBy(group => group.Key, StringComparer.Ordinal); + Console.WriteLine($" {level,-6} rows by mark: {string.Join(", ", marks.Select(group => $"{group.Key} {group.Count()}"))}"); + } + + foreach (var finding in findings.OrderBy(f => f.Kind, StringComparer.Ordinal).ThenBy(f => f.Id, StringComparer.Ordinal)) + { + Console.WriteLine($" {finding.Kind,-8} {finding.Id,-14} {finding.Level,-6} {finding.Message}"); + } +} + +internal sealed record Owning(string Id, string Level, string MarkText, string RowText, string File, (int Number, string Letter) Phase); + +internal sealed record Finding(string Kind, string Id, string Level, string Message); From 8ded3d8f3cb2d7659168150e103b405ee573fd5d Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:03:07 +0300 Subject: [PATCH 26/45] test: AOT smoke runs the invariant and codec suites (phase 10, #99) --- .github/workflows/ci.yml | 9 ++- .../Dexpace.Sdk.AotSmoke.csproj | 6 +- .../SmokeChecks.Phase10.cs | 58 +++++++++++++++++++ tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs | 1 + .../Dexpace.Sdk.AotSmoke/SmokeProbeContext.cs | 20 +++++++ .../APPENDIX_B.md | 2 +- 6 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs create mode 100644 tests/Dexpace.Sdk.AotSmoke/SmokeProbeContext.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dabf2ca..b4f43410 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,10 +28,13 @@ name: CI # - SSE-37 architecture test (serde independence of SSE and paging): wired in # tests/Dexpace.Sdk.Core.Tests/Architecture/Sse37ArchitectureTests.cs, and reported as skipped until the # Dexpace.Sdk.Core.ServerSentEvents namespace holds a type; phase 7 lands it, and the test enforces with no edit. -# - NativeAOT smoke consumer: runs below over today's surface, with the Tristate, paging and SSE round trip of phase 7 -# and a conformance slice from phase 8a; phase 10 completes it. # - Package-validation baseline (PackageValidationBaselineVersion): phase 12, at the first published version. -# - NFR-11's public-signature type scan: phase 10. Strong naming and author signing (NFR-16): phase 12. +# - Strong naming and author signing (NFR-16): phase 12. +# +# Phase 10 completed what was inert: the NativeAOT smoke consumer below runs the invariant suite with core's defaults and the codec suite over the +# System.Text.Json adapter (tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs); NFR-11's public-signature type scan is +# tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs; the packaging suite runs after Pack (below); and +# tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs fails if a gate here is dropped or made advisory (NFR-17). on: push: diff --git a/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj b/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj index fcda4374..92795934 100644 --- a/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj +++ b/tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj @@ -4,8 +4,10 @@ CI's aot-smoke job. The analyzer proves the libraries are annotated; this binary proves the annotations are true. Phase 0 exercises the current surface over an in-process transport; phase 7 extends it to the Tristate, paging and SSE round trip, phase 8a adds a slice of the conformance kit, phase 9 exercises the DI package - (registration, the configuration reader, validation and the named client's transport), and phase 10 completes it - (roadmap constraint 1). --> + (registration, the configuration reader, validation and the named client's transport), and phase 10 completed it + (roadmap constraint 1): the invariant suite with core's defaults, the codec suite over the System.Text.Json adapter and the run-time version. + It keeps project references, not the packed .nupkg files: consuming packages would need a local feed and a restore that cannot be locked + across operating systems, and the packaging suite proves the packed assemblies carry the same declarations (P10-17). --> Exe Dexpace.Sdk.AotSmoke diff --git a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs new file mode 100644 index 00000000..ca366671 --- /dev/null +++ b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Linq; +using System.Reflection; +using Dexpace.Sdk.Conformance; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Serialization.SystemTextJson; + +namespace Dexpace.Sdk.AotSmoke; + +// Phase 10 completes the smoke (roadmap constraint 1, P10-17): the suites that now describe every package's runtime-wired surface run in the published +// binary. The invariant suite runs with core's defaults (every hook-less assertion, both faces) and must be green with exactly the pinned list not exercised; +// the codec suite runs over the System.Text.Json adapter and a source-generated context over the kit's probe models; and the version the library reads at run +// time is the version it was built with (NFR-15). The smoke keeps project references, not the packed .nupkg files: the packaging suite proves the packed +// assemblies carry the trim and AOT declarations, and they are the same build output. +internal static partial class SmokeChecks +{ + // The assertions that need a hook only a driver can supply: core's internals (xcut-8, xcut-9, xcut-14) and the DI package (xcut-23). Pinned (P10-28, R13). + private static readonly string[] s_invariantNotExercised = + [ + "xcut-14.bounded-under-burst", + "xcut-14.drains-after-insert", + "xcut-23.resolution-deterministic", + "xcut-8.strict-factory-rejects", + "xcut-9.cycle-safe-walk", + ]; + + private static async Task CheckPhase10Async() + { + var options = new SuiteOptions { AssertionTimeout = TimeSpan.FromSeconds(60) }; + + var invariant = await InvariantSuite.RunAllAsync(new InvariantSubject { Name = "core defaults (aot smoke)" }, options, CancellationToken.None); + Expect(invariant.IsGreen, $"the invariant suite is green under NativeAOT: {invariant}"); + Expect( + invariant.Results.Where(r => r.Status == ConformanceStatus.NotExercised).Select(r => r.Assertion.Name).Distinct().Order(StringComparer.Ordinal).SequenceEqual(s_invariantNotExercised), + "exactly the five hook-less invariant assertions are not exercised under NativeAOT"); + Expect(!invariant.Results.Any(r => r.Status is ConformanceStatus.Vacuous or ConformanceStatus.Waived), "no invariant assertion is vacuous or waived under NativeAOT"); + + var codec = await CodecSuite.RunAllAsync( + new CodecSubject + { + Name = "Dexpace.Sdk.Serialization.SystemTextJson (aot smoke)", + CreateSerde = static () => new SystemTextJsonSerde(SystemTextJsonSerde.CreateDefaultOptions(SmokeProbeContext.Default)), + ExpectedMediaType = CommonMediaTypes.ApplicationJson, + }, + options, + CancellationToken.None); + Expect(codec.IsGreen && codec.Results.All(r => r.Status == ConformanceStatus.Passed), $"the codec suite passes whole under NativeAOT: {codec}"); + + // NFR-15, the packaged artefact at run time: the SDK version is the assembly's informational version without its + metadata, and never a placeholder. + var informational = typeof(BuildInfo).Assembly.GetCustomAttribute()?.InformationalVersion; + Expect(!string.IsNullOrEmpty(informational), "the library carries an informational version"); + Expect(BuildInfo.SdkVersion == informational!.Split('+')[0], $"BuildInfo.SdkVersion ({BuildInfo.SdkVersion}) is the informational version ({informational}) without its + metadata"); + Expect(BuildInfo.SdkVersion is not ("unknown" or "0.0.0"), "BuildInfo.SdkVersion is not a placeholder"); + } +} diff --git a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs index 4595498b..2651b7bd 100644 --- a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs +++ b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs @@ -69,6 +69,7 @@ public static async Task RunAllAsync() await CheckPhase8aConformanceAsync(); await CheckPhase8bAsync(); await CheckPhase9Async(); + await CheckPhase10Async(); } catch (SmokeFailureException failure) { diff --git a/tests/Dexpace.Sdk.AotSmoke/SmokeProbeContext.cs b/tests/Dexpace.Sdk.AotSmoke/SmokeProbeContext.cs new file mode 100644 index 00000000..a9311704 --- /dev/null +++ b/tests/Dexpace.Sdk.AotSmoke/SmokeProbeContext.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text.Json.Serialization; +using Dexpace.Sdk.Conformance.Codec; +using Dexpace.Sdk.Core.Serialization; + +namespace Dexpace.Sdk.AotSmoke; + +/// +/// Source-generated metadata for the conformance kit's codec-suite probe models (phase 10, P10-8, P10-17): the only serialization path that survives +/// NativeAOT, in its own context so the shared one is not edited. One line per probe model, in the default generation mode. +/// +[JsonSerializable(typeof(ProbeAddress))] +[JsonSerializable(typeof(ProbeContact))] +[JsonSerializable(typeof(ProbePatch))] +[JsonSerializable(typeof(ProbeSequence))] +[JsonSerializable(typeof(Tristate))] +[JsonSerializable(typeof(ProbeUnencodable))] +internal sealed partial class SmokeProbeContext : JsonSerializerContext; diff --git a/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md b/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md index ab8a8456..2f2f11c9 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md +++ b/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md @@ -79,7 +79,7 @@ row never reads better than its worst clause. | B.9 | B.9.1 | NFR-1, NFR-2 | packaging:nfr-1.core-dependencies; packaging:nfr-2.adapter-budget | suite | design §10 entries 1 and 2 | | B.9 | B.9.2 | NFR-3, NFR-4 | packaging:nfr-3.no-internals-granted; Directory.Build.props; tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs | by reference | `NFR-4`'s package-validation baseline waits for the first publish (phase 12) | | B.9 | B.9.3 | NFR-5, NFR-6, NFR-7, NFR-17 | scripts/ci/coverage-gate.cs; Directory.Build.props; .editorconfig; tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs; tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs | by reference | `NFR-17`'s second half, that a merge requires the checks, is a repository setting recorded in the phase checklist | -| B.9 | B.9.4 | NFR-8, NFR-9 | packaging:nfr-8.trim-and-aot-declared; tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs | by reference | | +| B.9 | B.9.4 | NFR-8, NFR-9 | packaging:nfr-8.trim-and-aot-declared; tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs | by reference | | | B.9 | B.9.5 | NFR-10 | packaging:nfr-10.single-floor | suite | the "higher-floor unit isolated" clause is vacuous: none exists | | B.9 | B.9.6 | NFR-11 | tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs; invariant:nfr-11.scheduler-agnostic | by reference | the scan reads the compiled assembly, not `PublicAPI.Shipped.txt`, which is empty until phase 12 (P10-11) | | B.9 | B.9.7 | NFR-12, NFR-13, NFR-14, NFR-15, NFR-16 | scripts/ci/reproducible-pack.sh; tests/Dexpace.Sdk.Conformance.Tests/Gates/LicenseHeaderTests.cs; packaging:nfr-14.lockstep-version; packaging:nfr-15.version-resolvable | by reference | `NFR-12`'s release half and `NFR-16` wait for the first publish (phase 12) | From 725d44a137fddcb7bff30231fe2fc4e6529e81e4 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:05:53 +0300 Subject: [PATCH 27/45] docs: cross-cutting-invariants.md and the conformance page for the new suites (phase 10, #99) --- docs/README.md | 2 +- docs/sdk-documentation/conformance.md | 178 +++++++++++++++++- .../cross-cutting-invariants.md | 118 ++++++++++++ src/Dexpace.Sdk.Conformance/README.md | 22 ++- .../Dexpace.Sdk.Conformance.Tests.csproj | 2 + .../Docs/ConformancePageTests.cs | 72 +++++++ .../Conformance/InvariantConformanceTests.cs | 2 + 7 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 docs/sdk-documentation/cross-cutting-invariants.md diff --git a/docs/README.md b/docs/README.md index 8ca8304b..32397241 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ only describes what already exists is a table that gets edited after the fact. | [`superpowers/`](./superpowers/README.md) | Nothing, for long. The **inbox** the Superpowers skills write into | `brainstorming`, `writing-plans` | Yes — it drains it | | [`first-release.md`](./first-release.md) | Release-readiness register: package versions, blockers before first publish, the release path, what v1 ships without, post-release triggers | A human, updated as blockers close | No | | `deviations.md` **(planned)** | As-built audit of `sdk-design-dotnet/10`'s deviation ledger, plus deviations found outside a phase. Created by roadmap phase 11 | A human, following a phase or review | No — judgment, not a mechanical append | -| [`sdk-documentation/`](./sdk-documentation/http.md) | **As-built** user documentation — how the packages compose, worked examples — one page per phase that ships a layer, mirroring `ruby-sdk/docs/sdk-documentation/`. Today: [`http.md`](./sdk-documentation/http.md) (phase 2a), [`seams.md`](./sdk-documentation/seams.md) (phase 2b), [`io.md`](./sdk-documentation/io.md) (phase 3a), [`bodies.md`](./sdk-documentation/bodies.md) (phase 3b), [`execution-context.md`](./sdk-documentation/execution-context.md) (phase 4a), [`recovery.md`](./sdk-documentation/recovery.md) (phase 4b), [`pipelines.md`](./sdk-documentation/pipelines.md) (phase 4c), [`configuration.md`](./sdk-documentation/configuration.md) (phase 5a), [`logging-and-redaction.md`](./sdk-documentation/logging-and-redaction.md) (phase 5b), [`tracing-and-metrics.md`](./sdk-documentation/tracing-and-metrics.md) (phase 5c), [`retry.md`](./sdk-documentation/retry.md) (phase 6a), [`redirect.md`](./sdk-documentation/redirect.md) (phase 6b), [`auth.md`](./sdk-documentation/auth.md) (phase 6c), [`serde.md`](./sdk-documentation/serde.md) (phase 7a), [`sse.md`](./sdk-documentation/sse.md) (phase 7b), [`pagination.md`](./sdk-documentation/pagination.md) (phase 7c), [`conformance.md`](./sdk-documentation/conformance.md) (phase 8a), [`transport.md`](./sdk-documentation/transport.md) (phase 8b), [`dependency-injection.md`](./sdk-documentation/dependency-injection.md) (phase 9) | A human, or a skill on request | Yes | +| [`sdk-documentation/`](./sdk-documentation/http.md) | **As-built** user documentation — how the packages compose, worked examples — one page per phase that ships a layer, mirroring `ruby-sdk/docs/sdk-documentation/`. Today: [`http.md`](./sdk-documentation/http.md) (phase 2a), [`seams.md`](./sdk-documentation/seams.md) (phase 2b), [`io.md`](./sdk-documentation/io.md) (phase 3a), [`bodies.md`](./sdk-documentation/bodies.md) (phase 3b), [`execution-context.md`](./sdk-documentation/execution-context.md) (phase 4a), [`recovery.md`](./sdk-documentation/recovery.md) (phase 4b), [`pipelines.md`](./sdk-documentation/pipelines.md) (phase 4c), [`configuration.md`](./sdk-documentation/configuration.md) (phase 5a), [`logging-and-redaction.md`](./sdk-documentation/logging-and-redaction.md) (phase 5b), [`tracing-and-metrics.md`](./sdk-documentation/tracing-and-metrics.md) (phase 5c), [`retry.md`](./sdk-documentation/retry.md) (phase 6a), [`redirect.md`](./sdk-documentation/redirect.md) (phase 6b), [`auth.md`](./sdk-documentation/auth.md) (phase 6c), [`serde.md`](./sdk-documentation/serde.md) (phase 7a), [`sse.md`](./sdk-documentation/sse.md) (phase 7b), [`pagination.md`](./sdk-documentation/pagination.md) (phase 7c), [`conformance.md`](./sdk-documentation/conformance.md) (phase 8a), [`transport.md`](./sdk-documentation/transport.md) (phase 8b), [`dependency-injection.md`](./sdk-documentation/dependency-injection.md) (phase 9), [`cross-cutting-invariants.md`](./sdk-documentation/cross-cutting-invariants.md) (phase 10) | A human, or a skill on request | Yes | | [`architecture.md`](./architecture.md) | The original foundation-slice architecture note, marked superseded by `sdk-design-dotnet/01-overview.md`; kept until roadmap phase 12 writes `sdk-documentation/architecture.md` | A human | Yes | | [`README.md`](./README.md) | This index | A human | Yes | diff --git a/docs/sdk-documentation/conformance.md b/docs/sdk-documentation/conformance.md index 4a66f004..06e51683 100644 --- a/docs/sdk-documentation/conformance.md +++ b/docs/sdk-documentation/conformance.md @@ -1,6 +1,7 @@ # The transport conformance kit -**As built by phase 8a, written against source on 2026-10-09** (branch `88-phase-8a-conformance-kit`). This page describes how an +**As built by phase 8a, written against source on 2026-10-09** (branch `88-phase-8a-conformance-kit`); **extended by phase 10 on 2026-10-14** +with three more suites in the same package, described from [The invariant suite](#the-invariant-suite-phase-10) on. This page describes how an adapter author drives `Dexpace.Sdk.Conformance` against a transport: the suite and its runner, the subject and its capability hooks, the six result statuses and the waivers that accept a gap, the loopback wire fixture the assertions run over, and the catalogue of named assertions. The requirement IDs it cites are the normative text @@ -281,8 +282,9 @@ version: `0.1.0` is proposed, and `docs/first-release.md` makes the number phase `ASYNC-15` through `transport-16`); the rest are properties of the two bridges, pinned by `Dexpace.Sdk.Core.Tests/Client`. No reactive adapter ships, so `ASYNC-21` is not applicable (design §11 item 21) and `ASYNC-4` is vacuous (thread interruption is banned, §10 entry 8). -- **Codec and pagination vectors.** The `SERDE` lift of phase 7a and the pagination vectors of phase 7c are phase 10's invariant and - codec suites, in this same package. +- **Codec and pagination vectors.** Phase 10 lifted the `SERDE` rows a codec seam can be asked into [the codec suite](#the-codec-suite-phase-10). + The pagination vectors of phase 7c are **not** lifted (P10-9): the paging engine has one implementation and is not a seam, so appendix B's + `PAGE` rows are `by reference` to its own tests. ## The third driver (phase 9, P9-22) @@ -290,3 +292,173 @@ version: `0.1.0` is proposed, and `docs/first-release.md` makes the number phase single-logger factory, the hooks added as handlers of the named client (the faulting, re-sending and cancel-on-demand handlers are the SystemNet suite's, shared by linked source) and the proxy hook through the `Transport:Proxy:*` keys. It is green on both faces with the two permanent assertion-scoped waivers of the SystemNet drivers (design §10 entries 32 and 33); `transport-15.borrowed-survives` is `NotExercised` because the package never borrows a client. `transport-1.redirect-not-followed` passing on both faces is the proof the 8a hand-off named, and `transport-15.owned-released` is what the container-owned primary-handler lease earns: it failed on the first build of the driver, because the factory never releases a handler whose lifetime is infinite. + +## The invariant suite (phase 10) + +*Added 2026-10-14 (phase 10, P10-3 to P10-7).* `InvariantSuite` runs 28 assertions over the cross-cutting rows `XCUT-1` to `XCUT-24` and the +scheduler-agnostic clause of `NFR-11`, against an `InvariantSubject`: a set of **hooks**, each defaulting to core's own public component, so the same +assertions run over a composition you built (your own policies, credentials, redactor or codec) and so every assertion can have a negative control (a broken +member in place of the default). The transport-level clause of `XCUT-1`, `-2`, `-4` and `-22` stays an assertion of `TransportSuite` (`transport-3`, `-4`, `-20`, `-15`), +and `ConformanceReport.Combine` merges the two reports by requirement, so no clause has two owners (P10-6). + +> Invariant conformance report. A green run does not prove: any clause whose hook the driver did not supply (such an assertion is NotExercised, never passed, and a driver pins the list it expects); anything about core's internals except through the hooks; behaviour over a real socket (the transport suite's); or any requirement no assertion cites. It proves that every assertion that ran met its clause over the composition it was given, or is waived below, and nothing more. Run against the defaults it judges core, and nothing about a third party's composition until they supply theirs. + +**The subject.** `InvariantSubject` is a sealed record; every member is optional. + +- `CreatePipeline(transport, options, time, logger)` builds the `HttpPipeline` under test over the in-memory transport an assertion hands it, capturing the call-level + options, on the run's recording clock and logger. The default is the idempotency, client-identity and set-date policies plus `AddStandardResilience`. +- `CreateResponseException`, `EnumerateCauses`, `CreateTokenCache`, `RedactUrl`, `RedactHeaderValue`, `Models` (a `ModelFactories` of delegates), `Auth` (an `AuthPolicyFactories`), + `Credentials`, `CreateDisposables`, `AsAsync` and `AsBlocking` each default to the public core component they stand for. +- **Four hooks have no default**, because the thing they reach is internal to core or lives in another package: `MapStrict` (`XCUT-8`'s strict factory), `CreateCyclicException` + (`XCUT-9`; the suite only **walks** the exception and never throws it, because a thrown cyclic chain hangs the test host on Windows), `CreateBoundedMap` (`XCUT-14`, a + `BoundedMapProbe` of insert, count and capacity) and `SeamResolution` (`XCUT-23`). An assertion whose hook is `null` is `NotExercised` with a reason naming the hook, never passed. +- `DrawCnonce` is optional and is not one of the four: `null` means the suite draws through the public `DigestChallengeHandler.Authorize`, so `xcut-21` is always exercised. It judges the + draw's width (32 lower-case hex characters, 128 bits) and that ten thousand draws do not repeat; the operating-system CSPRNG source clause is cited by reference to + `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator`, because no output test can tell a CSPRNG from `System.Random`. + +**The catalogue.** `Faces` is `both` for the asynchronous and blocking surface of `HttpPipeline`, or `none` (`TransportFace.None`) for an assertion that judges a value and drives no call. +`Level` is the strength of the clause, never above the strongest of its IDs. + +| Assertion | Requirements | Level | Faces | Reads | +|---|---|---|---|---| +| `xcut-1.cancel-is-terminal` | XCUT-1 | MUST | both | `CreatePipeline` | +| `xcut-2.timeout-is-retryable` | XCUT-2 | MUST | both | `CreatePipeline` | +| `xcut-3.wait-promptly-cancellable` | XCUT-3 | MUST | both | `CreatePipeline`, the recording clock | +| `xcut-4.two-branches` | XCUT-4 | MUST | both | `CreatePipeline` | +| `xcut-5.one-classifier` | XCUT-5 | MUST | none | `CreateResponseException` | +| `xcut-6.capability-queried` | XCUT-6 | MUST | both | `CreatePipeline` | +| `xcut-7.configured-set-authoritative` | XCUT-7 | MUST | both | `CreatePipeline` | +| `xcut-8.strict-factory-rejects` | XCUT-8 | MUST | none | `MapStrict` (**no default**) | +| `xcut-8.convenience-returns-response` | XCUT-8 | MUST | both | `CreatePipeline` (error mapping on) | +| `xcut-9.cycle-safe-walk` | XCUT-9 | MUST | none | `CreateCyclicException` (**no default**), `EnumerateCauses` | +| `xcut-10.safety-gate-uniform` | XCUT-10 | MUST | both | `CreatePipeline` | +| `xcut-11.shared-no-crosstalk` | XCUT-11 | MUST | both | `CreatePipeline` | +| `xcut-12.single-flight-scoped` | XCUT-12 | SHOULD | both | `CreateTokenCache` | +| `xcut-13.dispose-latched` | XCUT-13 | MUST | both | `CreateDisposables` | +| `xcut-13.dispose-non-blocking` | XCUT-13 | MUST | both | `CreateDisposables` | +| `xcut-14.bounded-under-burst` | XCUT-14 | MUST | none | `CreateBoundedMap` (**no default**) | +| `xcut-14.drains-after-insert` | XCUT-14 | MUST | none | `CreateBoundedMap` (**no default**) | +| `xcut-15.no-external-alias` | XCUT-15 | MUST | none | `Models` | +| `xcut-16.no-credential-over-http` | XCUT-16 | MUST | both | `Auth`, `CreatePipeline` | +| `xcut-17.redirect-hygiene` | XCUT-17 | MUST | both | `CreatePipeline` | +| `xcut-18.model-validation` | XCUT-18 | MUST | none | `Models` | +| `xcut-19.redaction-default-deny` | XCUT-19 | MUST | both | `RedactUrl`, `RedactHeaderValue`, `Credentials`, `CreatePipeline` | +| `xcut-20.observability-never-throws` | XCUT-20 | MUST | both | `RedactUrl`, `CreatePipeline` | +| `xcut-21.cnonce-unpredictable` | XCUT-21 | MUST | none | `DrawCnonce` (optional) | +| `xcut-22.core-borrows` | XCUT-22 | MUST | both | `CreatePipeline`, `AsAsync`, `AsBlocking` | +| `xcut-23.resolution-deterministic` | XCUT-23 | MUST | none | `SeamResolution` (**no default**) | +| `xcut-24.preview-capped-non-consuming` | XCUT-24 | SHOULD | both | `CreatePipeline` | +| `nfr-11.scheduler-agnostic` | NFR-11 | SHOULD | both | `CreatePipeline` | + +**The first-party drivers pin what they leave `NotExercised`** (P10-28), so a hook not supplied never reads as conformance. The core driver +(`Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs`) supplies the three hooks that reach internals (`MapStrict`, `CreateCyclicException`, `CreateBoundedMap`) and leaves exactly +`xcut-23.resolution-deterministic`; the dependency-injection driver supplies `SeamResolution` over `AddDexpaceClient` and the DI-built pipeline and leaves the four assertions that need core's +internals; the NativeAOT smoke runs the defaults and leaves five. No assertion is waived. The core driver, kept identical to this page by `ConformancePageTests`: + + +```csharp +public static TheoryData Rows() +{ + var rows = new TheoryData(); + foreach (var assertion in InvariantSuite.Assertions) + { + foreach (var face in assertion.Faces) + { + rows.Add(assertion.Name, face); + } + } + + return rows; +} + +[Theory] +[MemberData(nameof(Rows))] +public async Task Assertion_holds(string name, TransportFace face) +{ + var assertion = InvariantSuite.Assertions.Single(a => a.Name == name); + var result = await InvariantSuite.RunAsync(InvariantCoreSubject.Create(), assertion, face, InvariantCoreSubject.Options, TestContext.Current.CancellationToken); + + if (result.Status == ConformanceStatus.Passed) + { + return; + } + + if (result.Status is ConformanceStatus.Failed or ConformanceStatus.Errored) + { + Assert.Fail(result.Detail); + } + else + { + Assert.Skip($"{result.Status}: {result.Detail}"); + } +} +``` + +**Running it over your own composition.** Build an `InvariantSubject` with the members you replace and run `InvariantSuite.RunAllAsync(subject)`; every member you leave alone judges core. A +custom pipeline policy, a credential-stamping step, a redactor or a token cache goes in through `CreatePipeline`, `Auth`, `RedactUrl` and `CreateTokenCache`; a model that must not alias its input goes +in through `Models`. A waiver by requirement ID accepts a gap the way it does for a transport, and a waived assertion that now passes fails the run. + +## The codec suite (phase 10) + +*Added 2026-10-14 (phase 10, P10-8).* `CodecSuite` runs 11 assertions over the `SERDE` rows a codec seam can be asked, on both faces of `ISerde` (the asynchronous stream pair and the synchronous buffer +and span pair), against a `CodecSubject { Name, CreateSerde, ExpectedMediaType, MalformedPayload }`. `CreateSerde` builds the codec fresh for each run **exactly as a consumer builds it**, with no extra +configuration: `SERDE-19` asks whether the defaults wire the PATCH semantics. The suite serializes only the **probe models** of `Dexpace.Sdk.Conformance.Codec` (`ProbeContact`, `ProbeAddress`, +`ProbePatch`, `ProbeSequence`, `ProbeUnencodable`), so a codec that needs compile-time metadata registers them in its own context, one line each, in the default generation mode (not the fast-path-only +mode, which cannot carry the tri-state wiring). The tri-state assertions judge what a codec preserves and refuses through its own round trip, not how it spells it on the wire: `Absent` must not +encode like `Null` and must encode shorter (it omits the key), and each state must come back. + +> Codec conformance report. A green run does not prove: the codec's own metadata for models it was not given (the probe models are the only types the suite serializes); configuration it was not handed (the codec is built exactly as the driver's factory builds it); the wire format byte for byte (the suite judges what a codec preserves and refuses, not how it spells it); core's typed readers, SerdeExtensions and Tristate's own API (SERDE-4, SERDE-13, SERDE-14, SERDE-18, SERDE-30, which judge core code and are cited by reference); or any requirement no assertion cites. It proves that every assertion that ran met its clause over the codec it was given, or is waived below, and nothing more. + +| Assertion | Requirements | Level | Faces | Reads | +|---|---|---|---|---| +| `serde-1.round-trip` | SERDE-1 | MUST | both | `CreateSerde` | +| `serde-2.declared-media-type` | SERDE-2 | MUST | none | `CreateSerde`, `ExpectedMediaType` | +| `serde-3.target-not-closed` | SERDE-3, SEAM-20 | MUST | both | `CreateSerde` | +| `serde-9.no-library-type-escapes` | SERDE-9 | MUST | both | `CreateSerde`, `MalformedPayload` | +| `serde-10.directional-subtypes` | SERDE-10 | MUST | both | `CreateSerde`, `MalformedPayload` | +| `serde-12.stream-error-unwrapped` | SERDE-12 | MUST | both | `CreateSerde` | +| `serde-15.tristate-emission` | SERDE-15 | MUST | both | `CreateSerde` | +| `serde-16.tristate-decode` | SERDE-16 | MUST | both | `CreateSerde` | +| `serde-17.missing-key-absent` | SERDE-17 | MUST | both | `CreateSerde` | +| `serde-19.default-wiring` | SERDE-19 | MUST | both | `CreateSerde` | +| `serde-20.top-level-degrades` | SERDE-20 | SHOULD | both | `CreateSerde` | + +`SERDE-4` (the fixed-buffer encode is core's `SerdeExtensions.Serialize(ISerde, Span, T)`), `SERDE-13` (the typed readers reject a wire null after the codec has returned) and `SERDE-14`, `-18`, `-30` +(`Tristate`'s own factories and sentinel text) judge core code, so no codec can be broken to fail them: appendix B cites core's tests for them (validation F6). The first driver is +`tests/Dexpace.Sdk.Serialization.SystemTextJson.Tests/Conformance/CodecConformanceTests.cs`, over `SystemTextJsonSerde` and a source-generated context of the probe models, with no waiver and nothing +not exercised. + +## The packaging suite (phase 10) + +*Added 2026-10-14 (phase 10, P10-10).* `PackagingSuite` runs 8 assertions over the `NFR` rows that are properties of the **published packages**, against a +`PackagingSubject { PackagePaths, CoreId, Policies, DeclaredFloor }`: `PackagePaths` are the `.nupkg` files a pack produced, `CoreId` names the core package (whose only dependency may be the logging +facade, design §10 entry 1), `Policies` says what each other package may depend on, and `DeclaredFloor` is the one target framework every package declares (`net10.0`). It is synchronous (`PackagingSuite.RunAll(subject)` returns the report) and reads the `.nupkg` files with the framework's own +zip, XML and metadata readers, adding no package, and never loads a packed assembly. `PackagePolicy(ThirdPartyRoot, FirstPartyIds)` mirrors the policy record of `scripts/ci/dependency-audit.cs`, and +`PackagingPolicyParityTests` fails if the two tables disagree. CI runs it after `dotnet pack` on every operating system: + +```bash +dotnet run scripts/ci/packaging-conformance.cs -- artifacts/packages +``` + +The dependency audit stays beside it: the audit reads the closure of the **build output**, the suite the **metadata that was published** (two checks, two subjects). + +> Packaging conformance report. A green run does not prove: anything about a package it was not given (it reads the packages it was given; a package built from another tree is a different subject); how the assemblies behave (it reads their metadata and never loads them); the build's reproducibility, repository signing or the release path (NFR-12, NFR-16: other gates); or any requirement no assertion cites. It proves that every assertion that ran met its clause over the packages it read, or is waived below, and nothing more. + +| Assertion | Requirements | Level | Faces | Reads | +|---|---|---|---|---| +| `nfr-1.core-dependencies` | NFR-1 | MUST | none | the core package's nuspec | +| `nfr-2.adapter-budget` | NFR-2 | SHOULD | none | each other package's nuspec, `Policies` | +| `nfr-3.no-internals-granted` | NFR-3 | SHOULD | none | each packed assembly | +| `nfr-8.trim-and-aot-declared` | NFR-8, NFR-9 | MUST | none | each packed assembly | +| `nfr-10.single-floor` | NFR-10 | MUST | none | `lib/`, the dependency groups, `DeclaredFloor` | +| `nfr-13.license-declared` | NFR-13 | SHOULD | none | each nuspec | +| `nfr-14.lockstep-version` | NFR-14 | SHOULD | none | each nuspec | +| `nfr-15.version-resolvable` | NFR-15 | SHOULD | none | each packed assembly and nuspec | + +## One report across suites + +`ConformanceReport.Combine(subjectName, reports)` merges the reports of several suites run against one subject: the results are concatenated, the worst status wins per requirement and per appendix-B +item, and the waivers are the union. Each report records the suite that produced it (`Suite`: `transport`, `invariant`, `codec` or `packaging`, or `combined`) and its own preamble (`PreambleText`; the static +`ConformanceReport.Preamble` is the transport suite's). `SuiteOptions { AssertionTimeout, Waivers }` bounds and waives a run of the three newer suites; `TransportSuiteOptions` keeps the release bound only a +socket-driving suite needs. `AppendixBMap` maps all 61 items of appendix B, and `tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md` says where each is evidenced; a five-rule test keeps the two honest +(see [cross-cutting invariants](./cross-cutting-invariants.md)). diff --git a/docs/sdk-documentation/cross-cutting-invariants.md b/docs/sdk-documentation/cross-cutting-invariants.md new file mode 100644 index 00000000..bc81058b --- /dev/null +++ b/docs/sdk-documentation/cross-cutting-invariants.md @@ -0,0 +1,118 @@ +# Cross-cutting invariants and the quality bar + +**As built by phase 10, written against source on 2026-10-14** (branch `99-phase-10-cross-cutting-invariants`). This page says where each of the 24 cross-cutting rows +(`XCUT-1` to `XCUT-24`) and the 17 non-functional rows (`NFR-1` to `NFR-17`) is enforced, which gate fails when one is broken, how to run the invariant, codec and packaging suites +against a composition of your own, and what the phase decided not to build. The requirement IDs it cites are the normative text (chapters 19 and 20 of the +[product spec](../product-spec.md), indexed by its appendix C); the decisions behind the suites are in the +[phase 10 design](../work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-design.md) (rulings `P10-1` to `P10-30`, facts `V1` to `V16`) and in +[design §9](../sdk-design-dotnet/09-toolchain-and-quality-gates.md). Neither is restated here. The suites themselves, their subjects, hooks and catalogues are in +[conformance.md](./conformance.md); the row-by-row verdicts, with the test that proves each, are in the +[phase 10 checklist](../work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md). + +Phase 10 **measures and routes**; it changes no production behaviour (P10-2). Every `XCUT` row already had a mechanism and a passing test when it started; what it added is the portable +suite form, a test that cites each row's ID, two mechanical gates, and the gates of the quality bar that no one had dispositioned. + +## Where each `XCUT` row is enforced + +The suite assertions are `invariant:` (the kit's `InvariantSuite`); a **split** row has a clause that rests on an existing deviation or judged reading instead of an assertion. + +| Row | Enforced by | Also proven by | Split clause | +|---|---|---|---| +| `XCUT-1` | `xcut-1.cancel-is-terminal` | `transport-3.cancel-is-terminal`; 6a `OperationPolicyTests`; 7b `ServerSentEventStreamFailureTests` | re-asserting an interrupt flag is cooperative: the caller's token stays signalled (design §10 entry 8) | +| `XCUT-2` | `xcut-2.timeout-is-retryable` | `transport-4.timeout-is-retryable`; 8b `SendFailuresTests` | none | +| `XCUT-3` | `xcut-3.wait-promptly-cancellable` | 6a `TimeProviderWaitsTests` | none | +| `XCUT-4` | `xcut-4.two-branches` | `transport-20.no-response-is-retryable` | "catchable as a generic I/O error" is a judged mechanism (design §10 entry 7, §11 item 23) | +| `XCUT-5` | `xcut-5.one-classifier` | `RetryFactsTests` | none | +| `XCUT-6`, `XCUT-7` | `xcut-6.capability-queried`, `xcut-7.configured-set-authoritative` | `RetryClassifierTests` | none | +| `XCUT-8` | `xcut-8.strict-factory-rejects` (hook `MapStrict`), `xcut-8.convenience-returns-response` | 4c `ErrorMappingFactoryTests` | the public `HttpResponseException` constructor accepts a 200: read as the SDK's mapping path, not a constructor a caller invokes (P10-24) | +| `XCUT-9` | `xcut-9.cycle-safe-walk` (hook `CreateCyclicException`); `Exception.GetBaseException` is banned in `src/` (`RS0030`) | `ExceptionFactsTests` | none | +| `XCUT-10` | `xcut-10.safety-gate-uniform` on the core and the DI drivers | 6a `RetryResendGateTests`; 9 `NoRetryingHandlerTests` | none | +| `XCUT-11`, `XCUT-12` | `xcut-11.shared-no-crosstalk`, `xcut-12.single-flight-scoped` | 4c `ConcurrencyTests`; 6c `AccessTokenCacheTests` | none | +| `XCUT-13` | `xcut-13.dispose-latched`, `xcut-13.dispose-non-blocking` | `transport-16.close-idempotent-nonblocking` (by reference) | preserving an ambient interrupt flag is vacuous (design §10 entry 8) | +| `XCUT-14` | `xcut-14.bounded-under-burst`, `xcut-14.drains-after-insert` (hook `CreateBoundedMap`); a second `ConcurrentDictionary` constructor outside `BoundedMap` is banned in `src/` (`RS0030`) | 4a `BoundedMapTests`; 6c `AccessTokenCacheTests` | none | +| `XCUT-15` | `xcut-15.no-external-alias` | 2a `ModelImmutabilityArchitectureTests` | none | +| `XCUT-16`, `XCUT-17` | `xcut-16.no-credential-over-http`, `xcut-17.redirect-hygiene` | phase 1 `AuthHttpsGuardTests`, `RedirectWireTests` (`Security`); 6b `RedirectCredentialLeakTests` | none | +| `XCUT-18` | `xcut-18.model-validation` | phase 1 `HeaderInjectionValidationTests` (`Security`); 8b `HeaderPartitionTests` | surrounding HTAB or space on a name is trimmed before validation (`HTTP-17`); the dispatch-time re-check is the transport suite's (P10-6) | +| `XCUT-19` | `xcut-19.redaction-default-deny` | phase 1 `UrlRedactionDefaultDenyTests` (`Security`); 5b `HttpLoggingDefaultsTests` | none | +| `XCUT-20` | `xcut-20.observability-never-throws` | 5b `EmissionGuardTests` | the SDK's own recording code is total; a listener's callbacks are `OBS-30`'s party (design §11 item 37) | +| `XCUT-21` | `xcut-21.cnonce-unpredictable` (optional hook `DrawCnonce`) | 6c `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` | the operating-system CSPRNG source is cited by reference: an output test cannot tell it from `System.Random` | +| `XCUT-22` | `xcut-22.core-borrows` | `transport-15.borrowed-survives`; 9 `PrimaryHandlerLeaseTests` | none | +| `XCUT-23` | `xcut-23.resolution-deterministic` (hook `SeamResolution`, supplied by the DI driver) | 9 `SingleRegistrationTests` | the discovery half is vacuous: a seam is a non-nullable parameter (design §10 entry 9, §11 item 8) | +| `XCUT-24` | `xcut-24.preview-capped-non-consuming` | 5b `BodyPreviewTests` | the non-consuming clause is **declined** for an error-body snapshot above 1 MiB, which is drained, truncated without a marker and replaces the original (P10-23, `docs/first-release.md` *SHOULD declined*) | + +A finding the measurement made and no phase fixed (P10-25): `ServiceResponseException` is public, documented as part of `XCUT-4`'s taxonomy, and never thrown. It is routed to phase 11 (delete, or argue it as part of +branch (b) in design §3.2) and to phase 12's public-surface choices. + +## The gate table + +"Blocking" means the gate fails its CI job. Whether a failing job also blocks a **merge** is a repository setting (a ruleset's required status checks) outside the workflow file: `CiWorkflowTests` pins that every gate below +is a step of `ci.yml` and that no step or job is advisory, and the checklist records the setting as read. + +| Gate | Requirement | Mechanism | State after phase 10 | +|---|---|---|---| +| Warnings as errors | `NFR-6` | `TreatWarningsAsErrors`, NuGet audit; `[Obsolete]` use is `CS0618`, an error | blocking | +| Analyzers, code style, format | `NFR-7` | `AnalysisLevel`, `EnforceCodeStyleInBuild`, `dotnet format --verify-no-changes`; every `none` severity in `.editorconfig` carries a rationale and a `Re-enable when:` line, every `#pragma warning disable` under `src/` a why-comment (`AnalyzerWaiverTests`) | blocking | +| Documentation gate | `NFR-3` | `GenerateDocumentationFile`, `CS1591` as an error; `InternalsVisibleTo` only to a library's own test project (`nfr-3.no-internals-granted`) | blocking | +| Public-API files | `NFR-4` | `RS0016`/`RS0017` over five libraries; `NFR4ArchitectureTests` (both files exist, `Shipped` empty until phase 12) | blocking; the package-validation baseline waits for the first publish | +| Coverage floor | `NFR-5` | `scripts/ci/coverage-gate.cs` at 80%, with its self-test | blocking | +| Dependency audit and packaging suite | `NFR-1`, `NFR-2` | `scripts/ci/dependency-audit.cs` (the build output's closure) and `scripts/ci/packaging-conformance.cs` (the published metadata) | blocking, both | +| Trim and AOT | `NFR-8`, `NFR-9` | `IsTrimmable`/`IsAotCompatible` analyzers; `nfr-8.trim-and-aot-declared` over the packed assemblies; the NativeAOT smoke runs the invariant and codec suites | blocking | +| Single floor | `NFR-10` | `net10.0` once in `Directory.Build.props`; `nfr-10.single-floor` | blocking | +| Public signatures | `NFR-11` | `Nfr11PublicSurfaceArchitectureTests` scans core's compiled public surface (the files `PublicAPI.Shipped.txt` describe are empty until phase 12, and are not a type model); `nfr-11.scheduler-agnostic` runs the same call from the thread pool, a dedicated thread, an exclusive scheduler and a single-threaded context | blocking | +| Reproducible pack | `NFR-12` | `scripts/ci/reproducible-pack.sh` packs `HEAD` from two `git worktree`s at different paths | blocking; the release half waits for the first publish | +| License headers | `NFR-13` | `IDE0073` for compiled files; `LicenseHeaderTests` for the `scripts/ci` apps no project compiles | blocking | +| Lock-step versions | `NFR-14`, `NFR-15` | `Directory.Packages.props`, `--locked-mode`; `nfr-14.lockstep-version`, `nfr-15.version-resolvable`; the smoke checks `BuildInfo.SdkVersion` against the assembly's own version | blocking | +| CI completeness | `NFR-17` | `CiWorkflowTests`; the ruleset's required checks are recorded in the checklist | blocking per job | +| Appendix-B map | (§9.3) | `APPENDIX_B.md` and `AppendixBCoverageTests` (five rules) | blocking | +| Appendix-C coverage | (roadmap) | `scripts/ci/requirement-coverage.cs --check`, with `requirement-coverage-selftest.sh` | blocking (Linux row) | +| Banned APIs | §9.1 | `RS0030`, including `Exception.GetBaseException` and every `ConcurrentDictionary` constructor outside `BoundedMap` | blocking | +| Mutation testing, benchmarks | (§9.3, optional) | not scheduled | see below | +| Signing | `NFR-16` | the release job | phase 12 | + +## Running the suites over your own composition + +```csharp +// The invariant suite: any member you leave alone judges core's own component. +var subject = new InvariantSubject +{ + Name = "MyClient", + CreatePipeline = (transport, options, time, logger) => MyPipeline.Build(transport, options, time, logger), + Models = new ModelFactories { /* only the factories you replace */ }, +}; +var invariants = await InvariantSuite.RunAllAsync(subject); + +// The codec suite: your codec, built the way a consumer builds it, with the probe models registered in your context. +var codec = await CodecSuite.RunAllAsync(new CodecSubject { Name = "MyCodec", CreateSerde = () => new MyCodec(MyContext.Default) }); + +// The packaging suite: the .nupkg files a pack produced. +var packaging = PackagingSuite.RunAll(new PackagingSubject { PackagePaths = Directory.GetFiles("artifacts/packages", "*.nupkg") }); + +var report = ConformanceReport.Combine("MyClient", [invariants, codec, packaging]); +Assert.True(report.IsGreen, report.ToString()); +``` + +An assertion whose hook you did not supply is `NotExercised`, and a green report with such rows does not say it conforms: pin the list you expect and fail on any other, as the first-party drivers do (P10-28). +Reproducibility holds for a **git checkout**: a source archive with no `.git` embeds absolute paths in the assemblies and is not a reproducible input. + +## The checks that count + +- **Appendix B** has 61 items. `tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md` maps each to its evidence (17 `suite`, 8 `restated`, 36 `by reference`, none scoped out), and `AppendixBCoverageTests` checks that + the row count and the IDs are the specification's, that every named path and assertion exists, that a `suite` row names an assertion citing one of its IDs, and that `AppendixBMap` agrees. A `by reference` row proves a + claim and a file, never the behaviour. +- **Appendix C** has 645 requirements. `dotnet run scripts/ci/requirement-coverage.cs -- --check` reads every `docs/work/**/*-checklist.md` and fails on a requirement with no owning row, a row naming an unknown ID or a + different level, a row with no legend mark, or a carried ID whose earlier row does not defer. `--report` prints the tallies. + +```bash +dotnet run scripts/ci/packaging-conformance.cs -- artifacts/packages +dotnet run scripts/ci/requirement-coverage.cs -- --check +scripts/ci/requirement-coverage-selftest.sh +scripts/ci/reproducible-pack.sh +``` + +## What the phase did not build + +- **Stryker.NET (P10-21).** Version 5.0.0 refuses every test project here: they run on Microsoft.Testing.Platform, which Stryker.NET does not support yet (stryker-net#3094). A VSTest shadow of each project would be a + second harness to keep in step for an optional, non-blocking report. Post-release trigger: Stryker.NET supports Microsoft.Testing.Platform (`docs/first-release.md`). +- **`benchmarks/` (P10-22).** Design §9.3 makes BenchmarkDotNet results a requirement of a performance-motivated change; there has been none, and the allocation guarantees that matter are already blocking tests. + The first performance-motivated change creates the project. +- **Lifting 7c's pagination vectors into the kit (P10-9).** The paging engine has one implementation and is not a seam. diff --git a/src/Dexpace.Sdk.Conformance/README.md b/src/Dexpace.Sdk.Conformance/README.md index 3e6064c1..d9e83004 100644 --- a/src/Dexpace.Sdk.Conformance/README.md +++ b/src/Dexpace.Sdk.Conformance/README.md @@ -1,8 +1,15 @@ # Dexpace.Sdk.Conformance -The transport conformance kit for the dexpace .NET SDK: framework-free assertions for the `TRANSPORT` and `ASYNC` requirements -of the product specification, a loopback HTTP/1.1 wire fixture, and per-requirement reporting with waivers. Run it against any -`Dexpace.Sdk.Core` transport (`IAsyncHttpClient` / `IHttpClient`) from the test framework you already use. +The conformance kit for the dexpace .NET SDK: four framework-free suites of named assertions over the product specification, a loopback +HTTP/1.1 wire fixture, and per-requirement reporting with waivers. Run them against a `Dexpace.Sdk.Core` transport (`IAsyncHttpClient` / +`IHttpClient`), a pipeline composition, a codec (`ISerde`) or a set of packed `.nupkg` files, from the test framework you already use. + +| Suite | Judges | Assertions | +|---|---|---| +| `TransportSuite` | a transport: the `TRANSPORT` and `ASYNC` requirements | 43 | +| `InvariantSuite` | a pipeline composition: `XCUT-1` to `XCUT-24` and the scheduler-agnostic clause of `NFR-11` | 28 | +| `CodecSuite` | a codec: the `SERDE` rows a codec seam can be asked, over the `Dexpace.Sdk.Conformance.Codec` probe models | 11 | +| `PackagingSuite` | the published packages: `NFR-1`, `-2`, `-3`, `-8` to `-10`, `-13` to `-15` | 8 | > **Pre-release.** Nothing is published yet, and the public API may change before 1.0. Targets `net10.0`. Install it in a test > project, never in production code: `dotnet add package Dexpace.Sdk.Conformance --prerelease`. @@ -21,7 +28,9 @@ Assert.True(report.IsGreen, report.ToString()); // the report lists ev One test case per (assertion, face) pair is `TransportSuite.RunAsync(subject, assertion, face)`. A gap you accept is a `ConformanceWaiver("TRANSPORT-8", "why")` in `TransportSuiteOptions.Waivers`; a waiver must stay needed, so a fixed requirement that keeps its waiver fails the run. A transport-specific capability (a borrowed native client, a proxy) is an optional hook on the -subject; a missing hook makes its assertions `NotExercised`, never green. The full guide is +subject; a missing hook makes its assertions `NotExercised`, never green. The other three suites follow the same shape (`InvariantSuite.RunAllAsync(new InvariantSubject())`, +`CodecSuite.RunAllAsync(new CodecSubject { CreateSerde = ... })`, `PackagingSuite.RunAll(new PackagingSubject { PackagePaths = ... })`), take `SuiteOptions { AssertionTimeout, Waivers }`, +and `ConformanceReport.Combine` merges their reports by requirement. The full guide is [`docs/sdk-documentation/conformance.md`](https://github.com/dexpace/dotnet-sdk/blob/main/docs/sdk-documentation/conformance.md). ## Versioning @@ -32,8 +41,9 @@ waiver by requirement ID. ## What a green run does not prove -TLS and certificate handling, HTTP/2 and HTTP/3, and connect-timeout behaviour: the fixture is plaintext HTTP/1.1 on the loopback -interface. The report says so on every run. +TLS and certificate handling, HTTP/2 and HTTP/3, and connect-timeout behaviour: the transport fixture is plaintext HTTP/1.1 on the loopback +interface. The invariant suite does not prove a clause whose hook you did not supply (it reports `NotExercised`); the codec suite judges what a codec preserves and refuses, not its byte +spelling; the packaging suite reads the packages it was given and never loads them. Each report says so on every run. ## Links diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj b/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj index 5a9e7ad3..449b2fa0 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj +++ b/tests/Dexpace.Sdk.Conformance.Tests/Dexpace.Sdk.Conformance.Tests.csproj @@ -32,6 +32,8 @@ the preamble identical to the code. --> + + diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Docs/ConformancePageTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Docs/ConformancePageTests.cs index 29bbe997..3f9345f7 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Docs/ConformancePageTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Docs/ConformancePageTests.cs @@ -82,6 +82,78 @@ public void Every_capability_hook_and_every_status_is_named_on_the_page() Assert.All(Enum.GetNames(), status => Assert.Contains($"`{status}`", text, StringComparison.Ordinal)); } + private static string[][] TableRows(string heading) => + [.. Section(heading) + .Where(line => line.StartsWith("| `", StringComparison.Ordinal)) + .Select(line => line.Trim('|').Split('|').Select(cell => cell.Trim()).ToArray())]; + + private static string FacesCell(ConformanceAssertion assertion) => + assertion.Faces.Count == 2 ? "both" : assertion.Faces[0] == TransportFace.None ? "none" : "async"; + + private static void AssertTable(string heading, IReadOnlyList catalogue) + { + var rows = TableRows(heading); + + Assert.Equal(catalogue.Count, rows.Length); + for (var i = 0; i < rows.Length; i++) + { + Assert.Equal($"`{catalogue[i].Name}`", rows[i][0]); + Assert.Equal(string.Join(", ", catalogue[i].RequirementIds), rows[i][1]); + Assert.Equal(catalogue[i].Level.ToString().ToUpperInvariant(), rows[i][2]); + Assert.Equal(FacesCell(catalogue[i]), rows[i][3]); + } + } + + private static string Quoted(string heading) => + string.Join(' ', Section(heading).Where(line => line.StartsWith("> ", StringComparison.Ordinal)).Select(line => line[2..])); + + [Fact] + public void The_invariant_codec_and_packaging_tables_are_their_catalogues_name_for_name() + { + AssertTable("The invariant suite (phase 10)", InvariantSuite.Assertions); + AssertTable("The codec suite (phase 10)", CodecSuite.Assertions); + AssertTable("The packaging suite (phase 10)", PackagingSuite.Assertions); + } + + [Fact] + public void The_three_quoted_preambles_are_the_suites_preambles() + { + Assert.Equal(InvariantSuite.Preamble, Quoted("The invariant suite (phase 10)")); + Assert.Equal(CodecSuite.Preamble, Quoted("The codec suite (phase 10)")); + Assert.Equal(PackagingSuite.Preamble, Quoted("The packaging suite (phase 10)")); + } + + [Fact] + public void The_invariant_driver_sample_is_the_compiled_core_driver_verbatim() + { + var source = File.ReadAllLines(Path.Combine(AppContext.BaseDirectory, "spec", "InvariantConformanceTests.cs.txt")); + var start = Array.FindIndex(source, line => line.Trim() == "// "); + var end = Array.FindIndex(source, line => line.Trim() == "// "); + Assert.True(start >= 0 && end > start, "the driver markers were not found in the compiled core driver"); + var compiled = string.Join('\n', source[(start + 1)..end].Select(line => line.StartsWith(" ", StringComparison.Ordinal) ? line[4..] : line)).Trim('\n'); + + var page = PageLines; + var marker = Array.FindIndex(page, line => line == ""); + Assert.True(marker >= 0 && page[marker + 1] == "```csharp", "the page has no marked invariant driver sample"); + var close = Array.FindIndex(page, marker + 2, line => line == "```"); + + Assert.Equal(compiled, string.Join('\n', page[(marker + 2)..close])); + } + + [Fact] + public void Every_subject_member_of_the_three_new_suites_is_named_on_the_page() + { + var text = string.Join('\n', PageLines); + var members = new[] { typeof(InvariantSubject), typeof(CodecSubject), typeof(PackagingSubject) } + .SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .Select(property => property.Name) + .Where(name => name is not ("Name" or "EqualityContract")) + .Distinct(StringComparer.Ordinal); + + Assert.All(members, member => Assert.Contains($"`{member}`", text, StringComparison.Ordinal)); + Assert.Contains("`ConformanceReport.Combine", text, StringComparison.Ordinal); + } + [Fact] public void The_page_can_be_checked_it_fails_when_a_name_is_missing() { diff --git a/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs b/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs index f80f0f84..2ebb4af1 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Conformance/InvariantConformanceTests.cs @@ -15,6 +15,7 @@ namespace Dexpace.Sdk.Core.Tests.Conformance; [Trait("Category", "Conformance")] public sealed class InvariantConformanceTests { + // public static TheoryData Rows() { var rows = new TheoryData(); @@ -50,6 +51,7 @@ public async Task Assertion_holds(string name, TransportFace face) Assert.Skip($"{result.Status}: {result.Detail}"); } } + // [Fact] public async Task The_whole_suite_is_green_and_its_report_is_written() From 39390ab951a8e1bd93e6a0277c88ce1fabd87a47 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:15:24 +0300 Subject: [PATCH 28/45] docs: phase 10 checklist (phase 10, #99) One row per requirement ID for XCUT-1..24 and NFR-1..17 (39 done, NFR-16 and NFR-17 deferred), the contributed-evidence and hand-off table, the gate table, deviations, rulings, findings and the ruleset request for the lead. --- ...se10-cross-cutting-invariants-checklist.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md diff --git a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md new file mode 100644 index 00000000..8adb5c58 --- /dev/null +++ b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md @@ -0,0 +1,192 @@ +# Phase 10 — Cross-Cutting Invariants and Non-Functional Requirements: Checklist + +The execution-time checklist for roadmap phase 10 ([phase card](../2026-09-27-dotnet-sdk-v1-roadmap-design.md)): one row per requirement ID, in the legend of the roadmap's cross-cutting constraint 3. Written from what was built on branch +`99-phase-10-cross-cutting-invariants` (issue #99), following the [design](2026-10-13-phase10-cross-cutting-invariants-design.md) and the [plan](2026-10-13-phase10-cross-cutting-invariants.md). User page: +[`cross-cutting-invariants.md`](../../../sdk-documentation/cross-cutting-invariants.md), with the suites in [`conformance.md`](../../../sdk-documentation/conformance.md). + +The scope is the **41 rows phase 10 owns** (`XCUT-1` to `XCUT-24`, `NFR-1` to `NFR-17`; 26 MUST and 15 SHOULD by appendix C: `XCUT` 22 MUST + 2 SHOULD, `NFR` 4 MUST + 13 SHOULD): **39 ✅ / 0 🚫 / 2 ⏳ / 0 N/A**. Ten of the 39 ✅ are split (`XCUT-1`, `-4`, `-13`, `-23`, `-24`, `NFR-1`, `-4`, `-12` on an existing §10 entry, a vacuous clause or +a declined one; `XCUT-8` and `XCUT-20` on readings) and none adds an unmet MUST. The two ⏳ are `NFR-16` (no release path yet, phase 12) and `NFR-17` (the workflow half is built and pinned; the repository-ruleset half is an admin action the lead has not made, see *Open items*). The plan's four stacked pull requests were built as +**four commit groups on the one branch**, in the plan's order (contract and invariant suite; codec suite; packaging suite and gates; appendix B, coverage and close-out), each ending green at its gate task; nothing was pushed. The kit gains three suites (47 assertions: 28 invariant, 11 codec, 8 packaging) beside the 43 transport assertions, each +with a negative control, and the transport drivers stay green with exactly their two permanent waivers per SystemNet-family driver. + +**Red evidence.** Taken where it was informative, and said as it was: most suites were written test-first against a deliberately broken subject (`Controls/`), so for every assertion the red is the control failing for its stated "Because" reason, which each control row pins (`InvariantControlRow` checks the failure text, not only that it failed). What was measured on top, mutations performed and reverted (never committed): +`BoundedMap`'s capacity multiplied by 100 turned the core driver red on `xcut-14.bounded-under-burst` and `xcut-14.drains-after-insert`; the single-registration check in `ServiceSlot.Install` made a no-op turned the DI driver (`xcut-23`) and `SingleRegistrationTests` red; `AddTristateSupport` commented out at both sites in `SystemTextJsonSerde` turned the STJ driver red on `serde-15` to `serde-20`; +a scratch file with a `ConcurrentDictionary` constructor and `Exception.GetBaseException` gave exactly two `RS0030` errors, and the two bans added without the `BoundedMap.cs` pragma gave one `RS0030` at `BoundedMap.cs(49,16)`; the six new `# Re-enable when:` lines stashed turned `AnalyzerWaiverTests` red naming six blocks; `Seam2ArchitectureTests` was red against the kit before its allow-list edit; a deliberately wrong pinned list +in the AOT smoke (`xcut-9` removed) made the published binary exit 1; the packaging step over a package whose nuspec dependency version was edited exits 1 naming `nfr-2` and `nfr-14`. The reproducible pack: with `-p:DeterministicSourcePaths=false` on both packs, the **old** same-directory script passes (it cannot see a path leak), the **new** cross-path script fails with all five packages differing, +and without the flag the new script passes with five byte-identical packages (design V3). Not taken: a mutation run (Stryker.NET refuses the Microsoft.Testing.Platform projects, P10-21). + +| Mark | Meaning | +|---|---| +| ✅ | Implemented and tested. The row names the test, the `[Trait]` category and the file. | +| 🚫 | Not built: a permanent simplification or an unmet clause on a stated domain, with the reason and the design §10 entry. | +| ⏳ | Deferred or declined. The row names the plan task that will do it, or the owner. | +| N/A | Not applicable in this port. | + +## Requirement rows + +A row's assertion is `invariant:`, `packaging:` or `transport:` as written; the invariant suite is driven by `InvariantConformanceTests` (`Dexpace.Sdk.Core.Tests/Conformance/`) and `InvariantResolutionConformanceTests` (`Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`), the packaging suite by `scripts/ci/packaging-conformance.cs` in CI. The kit's own tests are in +`tests/Dexpace.Sdk.Conformance.Tests/`. Every named test exists (checked mechanically against the tree when this table was written). + +| ID | Level | Mark | Evidence | +|---|---|---|---| +| `XCUT-1` | MUST | ✅ split | Assertion `invariant:xcut-1.cancel-is-terminal` (kit `InvariantSuite`, both faces unless noted). Controls: a retry loop that retries a cancelled send; a layer that reports a cancellation as a retryable transport failure (`InvariantCancellationControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantCancellationControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A send parked in the transport and cancelled surfaces `OperationCanceledException` carrying the caller's own token, the token stays signalled, one send, no retry. 🚫 the clause "re-assert the interrupt flag" is cooperative on .NET: the caller's token stays signalled (design §10 entry 8, sanctioned by the row's own text). Existing: 6a `OperationPolicyTests.A_caller_cancellation_still_surfaces_OperationCanceledException`; 7b `ServerSentEventStreamFailureTests`; the transport clause is `transport:transport-3.cancel-is-terminal` (P10-6). | +| `XCUT-2` | MUST | ✅ | Assertion `invariant:xcut-2.timeout-is-retryable` (kit `InvariantSuite`, both faces unless noted). Controls: a step that reports a deadline as a cancellation (`InvariantCancellationControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantCancellationControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). An `AttemptTimeout` over a transport that never answers: a retryable timeout (the timeout branch first), the caller's token clear, the call re-issued. Existing: 6a `AttemptTimeoutWireTests`; 8b `SendFailuresTests`; `transport:transport-4.timeout-is-retryable`. | +| `XCUT-3` | MUST | ✅ | Assertion `invariant:xcut-3.wait-promptly-cancellable` (kit `InvariantSuite`, both faces unless noted). Controls: a wait that does not honour the caller's token; one that leaves the timer it armed running (`InvariantCancellationControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantCancellationControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A 30 s backoff on the run's recording clock, cancelled: the call ends within the bound with the caller's cancellation and every armed timer is disposed. Existing: 6a `TimeProviderWaitsTests`. | +| `XCUT-4` | MUST | ✅ split | Assertion `invariant:xcut-4.two-branches` (kit `InvariantSuite`, both faces unless noted). Controls: a mapping that turns a refused connection into a non-retryable error; one that surfaces an error status as a bare error (`InvariantTaxonomyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A 404 maps to `HttpResponseException` exposing status, a header and a readable body; a refused connection is an `SdkException` that is retryable with an I/O-family cause. 🚫 "catchable as a generic I/O error" is a judged mechanism (design §10 entry 7, §11 item 23; the SDK's transport errors are not `IOException` subtypes). Existing: `FailureClassificationTests`; `transport:transport-20.no-response-is-retryable`. Finding P10-25: `ServiceResponseException` is public, documented in the taxonomy and never thrown (routed to phase 11 and 12). | +| `XCUT-5` | MUST | ✅ | Assertion `invariant:xcut-5.one-classifier` (kit `InvariantSuite`, both faces unless noted). Controls: a factory that bakes false for 507; one that bakes true for 501; one that hardcodes true for every status (`InvariantTaxonomyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`. The baked flag of the exception for 400, 404, 408, 429, 500 to 505, 507 and 599 follows the one classifier. Existing: `RetryFactsTests.IsRetryableStatus_is_exactly_408_429_and_5xx_except_501_and_505`. | +| `XCUT-6` | MUST | ✅ | Assertion `invariant:xcut-6.capability-queried` (kit `InvariantSuite`, both faces unless noted). Controls: a retry loop that retries only the concrete types it knows (`InvariantTaxonomyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A non-SDK exception declaring `IRetryableError` is retried (three sends), one declaring false is not. Existing: 6a `RetryClassifierTests`. | +| `XCUT-7` | MUST | ✅ | Assertion `invariant:xcut-7.configured-set-authoritative` (kit `InvariantSuite`, both faces unless noted). Controls: a retry loop that reads the baked flag instead of the configured set (`InvariantTaxonomyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Widening the set with 501 retries it, removing 500 stops its retry, and the same set re-classifies a re-sent error status. Existing: 6a `RetryFactsTests.The_configured_RetryPolicy_set_is_not_the_classifier`. | +| `XCUT-8` | MUST | ✅ split | Assertion `invariant:xcut-8.strict-factory-rejects` (kit `InvariantSuite`, both faces unless noted). Controls: a strict factory that maps a 200; one that returns a null-like value for an error status; for `xcut-8.convenience-returns-response`, a step that throws for a 3xx (`InvariantTaxonomyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantTaxonomyControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Two assertions: the strict factory (hook `MapStrict`, no default, face `none`; the core driver supplies the internal `ErrorMapping.ToException`, the DI driver leaves it `NotExercised`) throws `ArgumentException` for 1xx, 2xx and 3xx; the convenience form returns the response through the pipeline for 200, 201, 204 and 304. Reading (not a departure): the public `HttpResponseException(Response)` constructor accepts a 200; `XCUT-8` is read as the SDK's mapping path, not a constructor a caller invokes with a response of their choosing (P10-24, proposed as a design §11 item; the constructor guard is a phase 12 choice). Existing: 4c `ErrorMappingFactoryTests.A_non_error_status_is_rejected`. | +| `XCUT-9` | MUST | ✅ | Assertion `invariant:xcut-9.cycle-safe-walk` (face `none`; hook `CreateCyclicException`, no default: the core driver supplies `CyclicExceptions.SelfCycle`, the DI driver leaves it `NotExercised`). The suite **walks** the cyclic exception and never throws it (Windows hangs on a thrown cyclic chain); a chain of three distinct, `Equals`-equal exceptions must yield three, so a walk tracked by `Equals` fails. Controls: a walk tracked by `Equals`; a walk with no visited set (`InvariantResilienceControlTests`, `Unit`). The gate: `Exception.GetBaseException` is banned in `src/` (`BannedSymbols.txt`, `RS0030`; `BannedSymbolsSiteTests.GetBaseException_appears_nowhere_in_src`, `Unit`). Existing: `ExceptionFactsTests` (the cycle tests), `AsyncToSyncBridgeTests`. | +| `XCUT-10` | MUST | ✅ | Assertion `invariant:xcut-10.safety-gate-uniform` (kit `InvariantSuite`, both faces unless noted). Controls: a retry loop that special-cases a transport failure; one with no safety gate at all (`InvariantResilienceControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). The matrix of chapter 19 on both faces, seven cases at two retries: a body-less GET over a transport error is re-sent (three sends); a body-less POST over a protocol error, and over a transport error that never reached the server, is not (one); a POST with a replayable body is re-sent over either (three); a POST with a single-use stream is not over either (one). Passes over the **DI-built pipeline** too. Existing: 6a `RetryResendGateTests`; 9 `NoRetryingHandlerTests` (`Unit`), `ResilienceCompositionTests` (`Integration`). | +| `XCUT-11` | MUST | ✅ | Assertion `invariant:xcut-11.shared-no-crosstalk` (kit `InvariantSuite`, both faces unless noted). Controls: a step that holds the request in a field of the shared step (`InvariantResilienceControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). 64 concurrent distinct requests through one shared pipeline, each response echoing its own id. Existing: 4c `ConcurrencyTests.Concurrent_calls_share_no_per_call_state`; `transport:transport-29.concurrent-no-crosstalk`. | +| `XCUT-12` | SHOULD | ✅ | Assertion `invariant:xcut-12.single-flight-scoped` (kit `InvariantSuite`, both faces unless noted). Controls: a cache that fetches once per caller; one that serialises every cache behind one lock (`InvariantResilienceControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Sixteen readers racing one expiring token cost one fetch; a fetch parked in one credential's cache does not hold another's. Existing: 6c `AccessTokenCacheTests.N_concurrent_requests_at_expiry_cost_one_fetch`, `Different_keys_do_not_block_each_other`. | +| `XCUT-13` | MUST | ✅ split | Assertion `invariant:xcut-13.dispose-latched` (kit `InvariantSuite`, both faces unless noted). Controls: a closer that releases the resource on every call; `xcut-13.dispose-non-blocking`: a closer that waits for the read parked on the body (`InvariantLifecycleControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Two assertions over the hook `CreateDisposables` (default: a core `Response`, a `TypedResponse` and a `ServerSentEventStream` over a release-counting body, counted at the resource): closing twice, then from eight threads at once, releases once; closing with a read parked on the body returns without waiting for it. 🚫 "preserves the ambient interrupt flag" is vacuous on .NET (design §10 entry 8, §3.7). By reference: `transport:transport-16.close-idempotent-nonblocking` cites `TRANSPORT-16` and `ASYNC-15`, not `XCUT-13`. Existing: `ResponseDisposeLatchTests`, `SystemNetHttpClientDisposeTests`. | +| `XCUT-14` | MUST | ✅ | Assertions `invariant:xcut-14.bounded-under-burst` and `invariant:xcut-14.drains-after-insert` (face `none`; hook `CreateBoundedMap`, no default: the core driver supplies a `BoundedMap`-backed probe, the DI driver leaves both `NotExercised`). A single thread inserts eight times the cap (32) and never sees more than the cap after an insert; eight threads each inserting four times the cap end at or under it; starting one short of the cap, eight threads inserting at once drain back under it. Controls: a map that never evicts (both assertions); a map that checks above the cap instead of at it (`bounded-under-burst`); a map that checks once before it inserts (`drains-after-insert`) (`InvariantLifecycleControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs`; a conforming map passes both). The driver can fail: `BoundedMap`'s capacity multiplied by 100 turned both red (never committed). The gate: a `ConcurrentDictionary` constructor outside `BoundedMap` is banned in `src/` (seven `RS0030` entries in `BannedSymbols.txt` and one scoped pragma at the single construction site; `BannedSymbolsSiteTests.ConcurrentDictionary_is_constructed_only_in_BoundedMap`, `Unit`). Existing: 4a `BoundedMapTests`; 6c `AccessTokenCacheTests`; 8b `HeaderDropLogTests`. `HeaderDropLog` is the one lock-guarded capped set (128). | +| `XCUT-15` | MUST | ✅ | Assertion `invariant:xcut-15.no-external-alias` (kit `InvariantSuite`, both faces unless noted). Controls: factories that keep the caller's media-type parameter map, challenge parameter map or byte array; headers that show a builder's later fields; a header "setter" and a request "setter" that each return the same instance (`InvariantLifecycleControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; hook `Models` (a `ModelFactories` of delegates). Mutating the map or array a media type, a challenge or a bytes body was built from changes nothing; `With`-style helpers return a new instance. Existing: 2a `ModelImmutabilityArchitectureTests`; `HeadersTests`. | +| `XCUT-16` | MUST | ✅ | Assertion `invariant:xcut-16.no-credential-over-http` (kit `InvariantSuite`, both faces unless noted). Controls: an auth step that stamps its credential before it looks at the scheme; one that fetches the token before the https guard (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A Basic and a bearer step over `http://` fail before any fetch and before the request reaches the transport; over `https://` they stamp; a permitted https-to-http hop is re-issued credential-free and proceeds. Existing: phase 1 `AuthHttpsGuardTests` (`Security`, unedited); 6c `HttpsRequiredExceptionTests`; 6b `RedirectCredentialLeakTests`. | +| `XCUT-17` | MUST | ✅ | Assertion `invariant:xcut-17.redirect-hygiene` (kit `InvariantSuite`, both faces unless noted). Controls: a loop that re-issues with the caller's Authorization; one that keeps Cookie and Proxy-Authorization across origins; one that judges the origin against the previous hop (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Same-origin: Authorization stripped, Cookie and Proxy-Authorization kept; cross-origin and a second hop to the foreign origin: all three stripped, judged against the seed; userinfo in a `Location` dropped; an https-to-http hop refused by default. Existing: phase 1 `RedirectWireTests` and 9 `NamedClientRedirectWireTests` (`Security`, unedited); 6b `RedirectCredentialHygieneTests`. | +| `XCUT-18` | MUST | ✅ | Assertion `invariant:xcut-18.model-validation` (kit `InvariantSuite`, both faces unless noted). Controls: a header model that accepts HTAB inside a name; one that drops a CR from an outbound value and accepts it; one that accepts a CR or LF in an inbound value (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; hook `Models`. Every C0 control, DEL and non-ASCII character is refused inside a name (outbound and inbound) and in an outbound value, every control other than HTAB in an inbound value; HTAB inside an outbound or inbound value and obs-text in an inbound value are accepted. Reading (not a departure, proposed as a design §11 item): the spec states two MUSTs that meet at the edge of a name. `XCUT-18` says a name with HTAB is rejected; `HTTP-17` says surrounding whitespace is trimmed before validation. Core follows both: HTAB inside a name is rejected, HTAB (or space) at either end is trimmed with the trimmed name stored, and the suite asserts the stored name is `X`. By reference: the transport suite's outbound header assertions cite `TRANSPORT-1x`, not `XCUT-18`; the dispatch-time half stays the transport suite's (P10-6). Existing: phase 1 `HeaderInjectionValidationTests` (`Security`, unedited); 8b `HeaderPartitionTests`. | +| `XCUT-19` | MUST | ✅ | Assertion `invariant:xcut-19.redaction-default-deny` (kit `InvariantSuite`, both faces unless noted). Controls: a redactor that redacts nothing; a credential that renders its secret; a diagnostics step that logs request headers verbatim; one that logs the response body (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A URL with userinfo, secret query values and a fragment token, and a `Location`-style header value, are redacted; each default credential's string form hides its secret; at `None` and `Headers` log levels no secret header value, query value or body reaches a log line. Existing: phase 1 `UrlRedactionDefaultDenyTests` (`Security`, unedited); 5b `HttpLoggingDefaultsTests`; 6c `CredentialRedactionTests`. | +| `XCUT-20` | MUST | ✅ split | Assertion `invariant:xcut-20.observability-never-throws` (kit `InvariantSuite`, both faces unless noted). Controls: a renderer that throws on a malformed URL; a step that writes to the sink without a guard (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Six malformed URLs render a placeholder that echoes no secret; a request logged at `Body` level to a sink that throws on every touch completes. Reading (design §11 item 37, not a departure): the SDK's own recording code is total; a listener's callbacks are `OBS-30`'s party. Existing: 5b `EmissionGuardTests`, `UrlRedactorHeaderValueTests.The_method_is_total`. | +| `XCUT-21` | MUST | ✅ | Assertion `invariant:xcut-21.cnonce-unpredictable` (kit `InvariantSuite`, both faces unless noted). Controls: a draw of 64 bits; a draw cycling through eight values (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; optional hook `DrawCnonce` (null draws through the public `DigestChallengeHandler.Authorize`, so the assertion is never `NotExercised`): 32 lower-hex characters (128 bits) and no repeat in 10 000 draws. The operating-system CSPRNG source clause is **by reference** to 6c `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` (an output test cannot tell a CSPRNG from `System.Random`, so a seeded `System.Random` draw of 128 bits would pass: the stated limit). | +| `XCUT-22` | MUST | ✅ | Assertion `invariant:xcut-22.core-borrows` (kit `InvariantSuite`, both faces unless noted). Controls: a bridge that disposes the client it wraps (either direction); a pipeline layer that closes the caller's transport after a call (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). An `HttpPipeline`, `AsAsync` and `AsBlocking` over a counting transport dispose nothing of it, and the transport still works afterwards (hooks `AsAsync`, `AsBlocking`). Passes over the DI-built pipeline too. Existing: `transport:transport-15.borrowed-survives`; 8b `SystemNetHttpClientOwnershipTests`; 9 `PrimaryHandlerLeaseTests`. | +| `XCUT-23` | MUST | ✅ split | Assertion `invariant:xcut-23.resolution-deterministic` (face `none`; hook `SeamResolution`, no default: only the DI driver supplies it, over `AddDexpaceClient`; the core driver leaves it `NotExercised`). Resolving a seam with no candidate fails with a message, resolving with two and no choice fails with a message, and an explicit install wins over a discoverable candidate; the DI subject maps these to the missing-codec start failure with the install hint, the different second install failing the call naming both, and an explicit `UseTransport` winning. Controls: a resolver that picks the last of two; one that picks nothing silently; one where a discoverable candidate beats the explicit one (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). The driver can fail: the single-registration check in `ServiceSlot.Install` made a no-op turned the DI driver red (never committed). 🚫 the discovery half is vacuous: a seam is a non-nullable parameter and nothing is discovered (design §10 entry 9, §11 item 8). Existing: 9 `SingleRegistrationTests`, `ProviderValidationTests`. | +| `XCUT-24` | SHOULD | ✅ split | Assertion `invariant:xcut-24.preview-capped-non-consuming` (kit `InvariantSuite`, both faces unless noted). Controls: a step that buffers the whole body to preview it; one that buffers the whole error body for its snapshot (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A 10 MiB body logged at `Body` level with a 1 KiB preview: no more than the preview cap plus one 64 KiB read-ahead is pulled before the reader asks, every byte reaches the reader unchanged, and no log line exceeds a bounded length; a 10 MiB error body pulls no more than `Response.MaxBufferedErrorBytes` (1 MiB) plus a read-ahead and its snapshot holds at most that. 🚫 **declined** (P10-23, `docs/first-release.md` *SHOULD declined*): above `Response.MaxBufferedErrorBytes` the error snapshot is drained, truncated without a marker and replaces the original, so the non-consuming clause holds for body-log previews and not for that snapshot; a repair candidate for phase 11. Existing: 5b `BodyPreviewTests`; 3b `ErrorBodyPreviewTests`. | +| `NFR-1` | MUST | ✅ split | Assertion `packaging:nfr-1.core-dependencies` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: an extra concrete dependency; a second dependency group; the facade missing; a group for another framework (`PackagingDependencyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs`, synthetic packages with emitted assemblies). The core nuspec has one `net10.0` group listing exactly `Microsoft.Extensions.Logging.Abstractions`. 🚫 "compile-time-only" facade: the facade is a runtime dependency (design §10 entry 1, sanctioned in part by `SEAM-1`). Existing: `scripts/ci/dependency-audit.cs` (CI "Dependency audit"), `Seam1ArchitectureTests`. | +| `NFR-2` | SHOULD | ✅ | Assertion `packaging:nfr-2.adapter-budget` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: a second third-party root; a first-party package the policy does not name; core required at another version (`PackagingDependencyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs`, synthetic packages with emitted assemblies). Each adapter depends on core, the first-party packages its `PackagePolicy` names and at most one third-party root, every first-party dependency at the lockstep version (design §10 entry 2: the hosting family counts as one root). `PackagingPolicyParityTests` (`Unit`) fails if `scripts/ci/packaging-conformance.cs`'s policies disagree with the audit's. | +| `NFR-3` | SHOULD | ✅ | Assertion `packaging:nfr-3.no-internals-granted` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: an assembly granting its internals to another assembly; to a sibling's test project (`PackagingAssemblyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs`, synthetic packages with emitted assemblies). Every `InternalsVisibleTo` in a packed assembly names `.Tests`. Existing: `GenerateDocumentationFile` with `CS1591` as an error; `PublicAPI` files on all five libraries. | +| `NFR-4` | SHOULD | ✅ split | `NFR4ArchitectureTests` (`Unit`, `tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs`): every library under `src/` has both `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt`, `Shipped` empty or `#nullable enable` only until phase 12; the scan sees all five libraries and can fail over a temporary tree. The drift gate is the build: `RS0016`/`RS0017` (design V4: a new public type is three `RS0016` errors; every kit surface change of this phase was a reviewed `Unshipped` diff). ⏳ the binary half (`PackageValidationBaselineVersion`): `docs/first-release.md` *After the first publish* (phase 12). | +| `NFR-5` | SHOULD | ✅ | `scripts/ci/coverage-gate.cs` at 80% over merged Cobertura reports (CI "Coverage floor"), with `coverage-gate-selftest.sh` as the negative control; measured at the final gate (see Totals and the gate table). `CiWorkflowTests` pins that both steps stay in `ci.yml`. | +| `NFR-6` | SHOULD | ✅ | `TreatWarningsAsErrors` in `Directory.Build.props` for every project; design V4: a call to an `[Obsolete]` member in core is `CS0618`, an error. `CiWorkflowTests` pins that the Build step stays blocking. Evidence recorded, no new test: the build is the test. | +| `NFR-7` | SHOULD | ✅ | `AnalyzerWaiverTests` (`Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs`): every `dotnet_diagnostic.*.severity = none` line (seven) and every relaxing `dotnet_code_quality.*` option (two) of `.editorconfig` sits in a block whose comment gives a rationale and exactly one `# Re-enable when:` line (consecutive settings form one block; the test-scope `CA2000` pair shares one), and every `#pragma warning disable` under `src/` carries a why-comment. `dotnet_code_quality.CA2000.dispose_ownership_transfer_at_constructor` (library scope) is exempt **by name** as a hand-off rule, not a relaxation (it silences nothing; styleguide 13.1), and the test pins the exemption to that one key. Red: with the six new `Re-enable when:` lines stashed the test named all six blocks. The scan can fail over temporary inputs. Existing: `AnalysisLevel`, `EnforceCodeStyleInBuild`, `dotnet format --verify-no-changes` (CI "Format"). | +| `NFR-8` | MUST | ✅ | Assertion `packaging:nfr-8.trim-and-aot-declared` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: an assembly without `IsTrimmable`; one declaring `IsAotCompatible` False (`PackagingAssemblyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs`, synthetic packages with emitted assemblies). Every packed library carries `AssemblyMetadata("IsTrimmable","True")` and `("IsAotCompatible","True")` (design §9.2: the keep configuration is the declaration plus the annotations carried in the assembly). The trim, single-file and AOT analyzers are errors on every library; the NativeAOT smoke publishes without a warning and runs `CheckPhase10Async` (`tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs`, exit code; the wrong pin made it exit 1). | +| `NFR-9` | SHOULD | ✅ | The rule-file clause is the packed assembly's `AssemblyMetadata` (`packaging:nfr-8.trim-and-aot-declared`, which cites `NFR-9`); the shrink-and-run regression guard is CI's `aot-smoke` job, which now runs the invariant suite with core's defaults (green with exactly the five pinned `NotExercised` assertions) and the codec suite over `SystemTextJsonSerde` and a source-generated context of the probe models, besides the earlier phases' checks (`CheckPhase7aSerdeAsync`, `CheckPhase9Async`, the 8a slice). `CheckPhase10Async`: `tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs` (`AotSmoke`, exit code). | +| `NFR-10` | MUST | ✅ | Assertion `packaging:nfr-10.single-floor` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: two lib folders; two dependency groups; an assembly targeting another framework; an assembly packed under another floor (`PackagingAssemblyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs`, synthetic packages with emitted assemblies). Each package has one `lib/net10.0/` folder, at most one dependency group for it, and a `TargetFrameworkAttribute` of `.NETCoreApp,Version=v10.0`. The "higher-floor unit isolated" clause is vacuous: none exists. Existing: `TargetFramework` once in `Directory.Build.props` (roadmap decision D1); the audit requires exactly one `net10.0` group. | +| `NFR-11` | SHOULD | ✅ | `Nfr11PublicSurfaceArchitectureTests` (`Unit`, `tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs`): reflection over `typeof(Request).Assembly.GetExportedTypes()`; every public and protected member's parameter, return, field, property and event types, base types and interfaces, recursively through generic arguments, arrays and by-ref, sit under `System`, `Microsoft.Extensions.Logging` or `Dexpace.Sdk.Core`, **with an explicit ban list beside the allow-list** (`System.Net.Http`, `System.Text.Json`, `Microsoft.Extensions.DependencyInjection`: the first two sit under `System`, so an allow-list under `System` alone would have admitted `HttpRequestMessage`, the scan's own negative control). Core is clean (no stop). The behavioural half is `invariant:nfr-11.scheduler-agnostic` (the same call from the thread pool, a dedicated thread, an exclusive scheduler and a single-threaded `SynchronizationContext`, both faces; control: a step that blocks on the caller's context, `InvariantHostControlTests`). The design says "a test scanning `PublicAPI.Shipped.txt`"; that file is empty until phase 12 and is not a type model, so the subject is the assembly (P10-11). | +| `NFR-12` | SHOULD | ✅ split | `scripts/ci/reproducible-pack.sh` (CI `reproducible` job) now packs `HEAD` from **two `git worktree`s at different paths** (`wt1/repo`, `wt2/yy/zz/repo2`) with `SOURCE_DATE_EPOCH` at the commit time and requires byte-identical packages, warning when the tree is dirty. Evidence of the structure (flag `-p:DeterministicSourcePaths=false` on both packs, never committed): the old same-directory script **passes** with it (it cannot see a path leak), the new cross-path script **fails** (all five packages differ), and without the flag it **passes** (five byte-identical). Holds for a git checkout; a source archive with no `.git` embeds absolute paths and is not a reproducible input (design V3). ⏳ the release half (a published artifact rebuilt from its tag): `docs/first-release.md` *After the first publish* (phase 12). | +| `NFR-13` | SHOULD | ✅ | `LicenseHeaderTests` (`Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Gates/LicenseHeaderTests.cs`): every `*.cs` under `src/`, `tests/`, `tools/`, `scripts/` and `.claude/` outside `bin`/`obj` (over 1 000 files) starts with the two-line header, an `` block allowed first; it closes the one blind spot of `IDE0073` (the `scripts/ci` file-based apps). Project and build files are not "source files" under this reading. Package face: `packaging:nfr-13.license-declared` (a license expression and a copyright, equal across packages; controls in `PackagingMetadataControlTests`, `Unit`). | +| `NFR-14` | SHOULD | ✅ | Assertion `packaging:nfr-14.lockstep-version` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: a package at another version; a sibling required at another version (`PackagingMetadataControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs`, synthetic packages with emitted assemblies). Every package ships at one version and every dependency between them requires it. Existing: `Directory.Packages.props` (central versions, transitive pinning), `VersionPrefix`/`VersionSuffix` once, `global.json`, `DexpaceToolchainPackVersion`. | +| `NFR-15` | SHOULD | ✅ | Assertion `packaging:nfr-15.version-resolvable` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: an informational version of `unknown`; of `0.0.0`; one that differs from the nuspec version; none (`PackagingMetadataControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs`, synthetic packages with emitted assemblies). The assembly's informational version without its `+` metadata equals the nuspec version and is never a placeholder; the NativeAOT smoke asserts `BuildInfo.SdkVersion` equals the library's own informational version at run time. Existing: `BuildInfoTests`. | +| `NFR-16` | SHOULD | ⏳ | No release path exists yet, so the rule has no subject: `docs/first-release.md` *Blockers before first publish*, "Signing decided" (phase 12; design §9.2: repository signature on the release job, author signing optional). Nothing in this phase signs or strong-names anything. | +| `NFR-17` | MUST | ⏳ | The first half is built: `CiWorkflowTests` (`Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs`) pins that every gate of the gate table below is a named step of `.github/workflows/ci.yml` with its command, that no step or job sets `continue-on-error`, and that the only conditional steps are the Linux-only structural checks and the result upload that must run after a failure (it can fail over edited copies of the workflow). So every gate fails its CI job. ⏳ the second half is a repository setting outside the file and **was not changed**: design V9 found the `main` ruleset has no `required_status_checks` rule, so a red job does not block a merge. The request for the lead is under *Open items*; if the setting is not made before the first publish it is a `docs/first-release.md` blocker. | + +**Totals.** 41 rows, 26 MUST and 15 SHOULD. 39 ✅ (of which 10 split: `XCUT-1`, `-4`, `-8`, `-13`, `-20`, `-23`, `-24`, `NFR-1`, `-4`, `-12`), 0 🚫, 2 ⏳ (`NFR-16` phase 12; `NFR-17` the ruleset), 0 N/A: 39 + 2 = 41. By level: `XCUT` 22 MUST + 2 SHOULD, `NFR` 4 MUST + 13 SHOULD. **The unmet-MUST count is unchanged by this phase** +(the 8b status note's seven over the parity target of five): no row adds a clause to it. One count question is raised for phase 11 and not decided here: `XCUT-4`'s "catchable as a generic I/O error" is unmet in letter on .NET, and §10 entry 7 classes it as a judged mechanism change (outside the count), as entry 25 does for `CFG-2`'s fall-through; the auditor decides +whether either belongs in the count (P10-25). `XCUT-24`'s declined clause is a SHOULD clause and goes to `docs/first-release.md` *SHOULD declined*, not to the MUST count. + +## Rows owned elsewhere and non-row hand-offs + +| ID / hand-off (owner) | Disposition | Evidence | +|---|---|---| +| `XCUT-10` (9) | taken: the row above is the phase 10 row; phase 9's clause stays ✅ | `xcut-10.safety-gate-uniform` passes over the DI-built pipeline; `NoRetryingHandlerTests`, `ResilienceCompositionTests` | +| `XCUT-8` (4c) | taken | `xcut-8.*`; the public constructor's reading is P10-24 | +| `XCUT-14` (4a, "not a 4a row") | taken | `xcut-14.*`, the `ConcurrentDictionary` constructor ban | +| `XCUT-19`(c), (e), `XCUT-20`, `XCUT-24` (5b); `XCUT-19`, `XCUT-20` (5c) | taken; `XCUT-24`'s error-snapshot clause is P10-23 | `xcut-19.*`, `xcut-20.*`, `xcut-24.*` | +| `XCUT-1` to `XCUT-10` audit (6a); `XCUT-17` audit (6b) | taken: each row cites the 6a or 6b test it rests on, and the suite re-measures the public behaviour | rows above | +| `XCUT-12`, `-14`, `-16`, `-18`, `-19`(d), `-21` (6c) | taken | rows above | +| 7a: `CheckPhase7aSerdeAsync`; the kit lifts `SERDE-3`, `-9`, `-10`, `-12`, `-15` to `-17`, `-19`, `-20` | the smoke keeps the check; the lift is the codec suite, 11 assertions (`serde-1`, `-2`, `-3`, `-9`, `-10`, `-12`, `-15`, `-16`, `-17`, `-19`, `-20`); `SERDE-4`, `-13`, `-14`, `-18`, `-30` judge core code and stay by reference (design F6) | `CodecConformanceTests` (STJ suite, `Conformance`), 11 controls' worth of `Codec*ControlTests` (`Unit`), `CheckPhase10Async` | +| 7b: `XCUT-1` evidence | taken | `xcut-1.cancel-is-terminal`, `ServerSentEventStreamFailureTests` | +| 7c (via 8a P8a-17): `link-header.json` and the splice properties as kit candidates | **declined** (P10-9): the paging engine has one implementation and is not a seam; appendix B.1 rows are `by reference` | `APPENDIX_B.md` | +| 8a: the invariant, packaging and codec suites; `APPENDIX_B.md` over 61 items; `AppendixBMap` widened | taken; `RequirementCatalog` reused for the row-count gate; the coverage script reads appendix C directly (a repository gate, not a kit feature) and the kit's catalogue test keeps the two in step | `AppendixBCoverageTests` (`Unit`), `AppendixBMapTests`, `scripts/ci/requirement-coverage.cs` | +| 8b: `XCUT-2` (the classification table), `XCUT-18` (the wire re-check, the drop log), `XCUT-22` | taken | rows above | +| 9: `XCUT-10`, `XCUT-22`, `XCUT-23`, `NFR-2`, `NFR-8`/`NFR-9`; the packaging suite reads the audit's policy record | taken; `PackagingPolicyParityTests` fails if `scripts/ci/packaging-conformance.cs`'s policies disagree with the audit's | rows above; `PackagingPolicyParityTests` (`Unit`) | +| Phase 0 (card, roadmap constraint 1): "phase 10 completes" the AOT smoke; the NFR gates stood up as machinery, dispositioned here; CI's header comment "NFR-11's public-signature type scan: phase 10" | taken: the gate table below is the first disposition (phase 0 left no gate checklist); the comment is past tense | `CheckPhase10Async`, the gate table, `ci.yml` | +| 8a status note (F-A2, `ASYNC-2`): "rule it into a later phase that may touch core" | **not taken**: not a phase 10 row, and P10-2 keeps core code out of this phase; it stays with the lead and phase 11's inbound list | roadmap status note | +| `SEAM-2` (2b/8a) | the architecture test's allow-list gains `Dexpace.Sdk.Conformance` (the core test project now drives the kit, P10-5); `The_kit_brings_no_transport_or_codec` keeps the kit itself core-only | `Seam2ArchitectureTests` (`Unit`) | + +**`Security`.** None is added and none is edited: `git diff --stat main...HEAD -- tests/Dexpace.Sdk.Core.Tests/Security tests/Dexpace.Sdk.Http.SystemNet.Tests/Security tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Security` is empty. The only change under `src/Dexpace.Sdk.Core` is `Internal/BoundedMap.cs` (one `RS0030` pragma pair with its why-comment at the single +`ConcurrentDictionary` construction site, a no-behaviour gate wiring, P10-18); `Dexpace.Sdk.Http.SystemNet`, `Dexpace.Sdk.Serialization.SystemTextJson` and `Dexpace.Sdk.Extensions.DependencyInjection` are untouched (source and `PublicAPI.Unshipped.txt`). Of the `PublicAPI.Unshipped.txt` files only the kit's changed. + +## The gate table + +Phase 0 left no gate checklist, so this is the first disposition of design §9's table and the roadmap's Maturity Definition gate list, with each gate's state as measured at close-out. "Blocking" means it fails its CI job; merge blocking is `NFR-17`'s second half (open item 1). `CiWorkflowTests` (`Unit`) pins that each gate with a command below is a named step of +`.github/workflows/ci.yml`, that no step or job sets `continue-on-error`, and that only the documented steps carry an `if:`. + +| Gate | Requirement | Mechanism | State after phase 10 | Phase 10 action | +|---|---|---|---|---| +| Warnings as errors | `NFR-6` | `TreatWarningsAsErrors`, NuGet audit | blocking | none | +| Analyzers, code style, format | `NFR-7` | `AnalysisLevel`, `EnforceCodeStyleInBuild`, `dotnet format --verify-no-changes` | blocking | six `Re-enable when:` lines and `AnalyzerWaiverTests` | +| Documentation gate | `NFR-3` | `GenerateDocumentationFile`, `CS1591` | blocking | `nfr-3.no-internals-granted` beside it | +| Public-API files | `NFR-4` | `RS0016`/`RS0017`, five libraries | blocking | `NFR4ArchitectureTests` | +| Package validation | `NFR-4` | `EnablePackageValidation` in pack | blocking; the baseline waits for the first publish | none | +| Coverage floor at 80% | `NFR-5` | `coverage-gate.cs` and its self-test | blocking | none (see *Gates run* for the measured figure) | +| Dependency audit | `NFR-1`, `NFR-2`, `SEAM-1` | `dependency-audit.cs` | blocking | the packaging suite runs beside it | +| `SEAM-2`, `SSE-37`, `SSE-38` architecture tests | `SEAM-2`, `SSE-37`, `SSE-38` | `Core.Tests/Architecture` | blocking | `SEAM-2`'s allow-list gains the kit (P10-5) | +| Trim and AOT analyzers | `NFR-8` | `IsTrimmable`, `IsAotCompatible` on `src/` | blocking | `nfr-8.trim-and-aot-declared` | +| NativeAOT smoke | `NFR-9` | `aot-smoke` job | blocking per job | `CheckPhase10Async` (P10-17) | +| Lock files | `NFR-14` | `--locked-mode` | blocking | none | +| License headers | `NFR-13` | `IDE0073` | blocking | `LicenseHeaderTests` closes the `scripts/ci` blind spot (P10-12) | +| Banned APIs | §9.1 | `RS0030` | blocking | `GetBaseException` and seven `ConcurrentDictionary` constructor entries (P10-18) | +| Reproducible pack | `NFR-12` | `reproducible-pack.sh` | blocking | now cross-path: two `git worktree`s at different paths (P10-14) | +| 3-OS matrix | `NFR-10`, `NFR-17` | `build` job matrix | blocking per job | `CiWorkflowTests` pins it | +| Tools solution | none | tools restore, build, format, test | blocking | none | +| Knowledge structure | none | `verify-structure` | blocking (Linux row) | none | +| Conformance kit | `TRANSPORT`, `ASYNC` | three transport drivers | blocking | three suites and three new drivers beside them | +| `NFR-11` signature scan | `NFR-11` | `Nfr11PublicSurfaceArchitectureTests` | blocking (was not wired) | built (P10-11) | +| Appendix-B gate | none | `AppendixBCoverageTests` | blocking (was not wired) | built (P10-19) | +| Appendix-C coverage | none | `requirement-coverage.cs --check` and its self-test | blocking, Linux row (was not wired) | built (P10-20); wired in the last commit group | +| Packaging conformance | `NFR-1`, `-2`, `-3`, `-8`, `-10`, `-13`, `-14`, `-15` | `scripts/ci/packaging-conformance.cs` after Pack | blocking (was not wired) | built (P10-10) | +| CI workflow completeness | `NFR-17` | `CiWorkflowTests` | blocking (was not wired) | built (P10-16) | +| Merge requires the checks | `NFR-17` | the `main` ruleset | **absent**: not changed by this phase | the request for the lead (open item 1); not read back (see deviations) | +| Mutation testing | (§9.3, optional) | Stryker.NET | cannot run: it refuses Microsoft.Testing.Platform projects (stryker-net#3094) | declined with a post-release trigger (P10-21) | +| Benchmarks | (§9.3, optional) | BenchmarkDotNet | none | declined with a post-release trigger (P10-22) | +| Ledger audit | none | phase 11 | not started | none | +| Strong naming, author signing | `NFR-16` | the release job | phase 12 | none | + +## Styleguide audit groups + +`docs/styleguide/` binds every `.cs` file written here. **Conforming:** file-scoped namespaces, the license header on every file (`LicenseHeaderTests` now enforces it repository-wide), `ConfigureAwait(false)` on every library `await` in the kit (`CA2007` is on for `src/`), no `Task.Delay` anywhere in the kit (waits are recorded-clock waits and bounded polls), no wall-clock +assertion in any new test (the Windows and macOS legs share the machine), no thrown cyclic `InnerException` chain (it hangs the Windows host: `xcut-9` walks the cycle and never throws it), and every `RS0016` change a reviewed `PublicAPI.Unshipped.txt` diff. **Departure, recorded:** styleguide 11.8 (a mutation gate) cannot be met: Stryker.NET does not support +Microsoft.Testing.Platform (V5, stryker-net#3094), recorded as a `Conflicts` note in `docs/knowledge/notes/testing.md` and as a post-release trigger in `docs/first-release.md`. + +## Deviations from the plan and the design + +1. **Four commit groups on one branch**, not four branches: the plan's per-PR branch names were ignored by instruction; each group ends at its own gate task (1.13, 2.x, 3.12, 4.10) and each was run green before the next began. +2. **27 skipped tests, not 22.** The plan (0.1) expected no new skipped rows. The five additional skips are the pinned `NotExercised` rows of the first-party invariant drivers (P10-28, R13): the core driver's `xcut-23.resolution-deterministic` (only the DI driver supplies `SeamResolution`) and the DI driver's four (`xcut-8.strict-factory-rejects`, `xcut-9.cycle-safe-walk`, + `xcut-14.bounded-under-burst`, `xcut-14.drains-after-insert`; they need core internals). Each driver pins its list and fails on any other `NotExercised`. The baseline 22 (the transport drivers' waived and not-exercised rows) are unchanged. +3. **Plan tasks combined into fewer commits** than the plan's task count: 2.1 to 2.4 are one kit commit, 3.1 to 3.4 one packaging commit, 3.6 and 3.7 one commit, and the 3.11 script commit was amended from a work-in-progress commit; the task order is the plan's. +4. **Hooks beyond the design's list.** `InvariantSubject` gained `CreateDisposables` (with `DisposableUnderTest`; default: a core `Response`, a `TypedResponse` and a `ServerSentEventStream` over a release-counting body), `AsAsync` and `AsBlocking` (both default to core's bridges), because `xcut-13` and `xcut-22` cannot carry a negative control without a hook over what is closed and what is bridged. + `ModelFactories` gained `HeadersAfterBuilderMutation`, `WithHeader` and `RequestWithHeader`, and the subject `AuthPolicyFactories` and `CredentialSample`, as R3's delegate-form hooks over sealed core types. A plan-level reading beyond R2 and R3, flagged here for the lead. +5. **`xcut-18` and HTAB.** The spec's `XCUT-18` conformance line says a name with HTAB is rejected; `HTTP-17` says surrounding whitespace is trimmed before validation. Core trims SP and HTAB at either end of a name and rejects HTAB inside it. The suite asserts exactly that ("HTAB by position"), instead of the design's "HTAB rejected in a name". Proposed as a design §11 item. +6. **Codec suite.** A factory crash is `Failed` or `Errored` (a crash is not always an error), and `ProbeUnencodable` must be registered in a driver's context for the cause to chain: an unregistered System.Text.Json type throws a `SerializationException` with no inner cause, which is the codec's behaviour, noted; the STJ driver registers it. +7. **Runner.** `SuiteRunner`'s transport behaviour is unchanged except that `TransportFace.None` is rejected there; `Check` gained a synchronous `Guard`. The packaging suite is synchronous (`RunSync`/`RunAllSync`) and `AssertionTimeout` does not apply to it. +8. **Design and plan facts corrected against the tree.** R6 (already in the plan): seven `severity = none` lines and three `dotnet_code_quality.*` lines, no `CA2000` severity, 43 pragma sites in 31 files. The plan's check "`rg Task.Delay` over `src/Dexpace.Sdk.Conformance` is empty" required rewording two existing doc comments that named the method. +9. **V9 was not repeated.** The design read the `main` ruleset with `gh api` (no `required_status_checks`, no classic protection); this run may not use `gh` at all, so the state was not re-read and `NFR-17`'s second half is unverified here (open item 1). +10. **Gates were run with limited parallelism** (`-m:3`, one test module at a time, the `dotnet` commands serialized under one lock) so the machine stayed usable; no result depends on it. + +## Rulings + +Every open ruling of the design (P10-1 to P10-30) was **accepted by the lead (2026-10-14)** at its recommended default, as the plan assumes: P10-1 (no sub-phases), P10-2 (option A: measure and route, no behaviour change outside the kit, tests, scripts and gate wiring), P10-5 (option A: the core driver lives in `Dexpace.Sdk.Core.Tests`, `SEAM-2`'s +allow-list gains the kit), P10-7 (`TransportFace.None` added), P10-8 (public probe models), P10-9 (7c's vectors not lifted), P10-16 (`CiWorkflowTests` and the ruleset request), P10-17 (project references in the smoke), P10-21 (Stryker.NET declined), P10-22 (`benchmarks/` declined), P10-23 (`XCUT-24`'s error-snapshot clause declined), P10-24 (`XCUT-8` reading), P10-25 +(`ServiceResponseException` routed), P10-27 (the phase page), and the unmarked rulings P10-3, -4, -6, -10 to -15, -18 to -20, -26, -28 to -30 as written. The plan's readings **R2, R3, R5, R6, R7, R15 and R17 were accepted by the lead (2026-10-14) as written**: R2 (`CreatePipeline` takes an `ILogger`), R3 (hooks over sealed core types are delegates), R5 (`AppendixBMapTests` order +and hard-codes widened), R6 (the measured `.editorconfig` and pragma counts), R7 (the constructor ban, one `BoundedMap` pragma), R15 (the reproducible pack packs `HEAD` and warns on a dirty tree), R17 (`XCUT-21`'s source clause by reference). + +## Findings the measurement produced + +- **P10-23, `XCUT-24`'s error-body snapshot.** `ErrorBodyBuffer.Capture` drains at most `Response.MaxBufferedErrorBytes` (1 MiB), truncates without a marker and replaces the original body. The byte cap holds; the non-consuming clause does not above 1 MiB. A SHOULD clause: `docs/first-release.md` *SHOULD declined*; a repair candidate for phase 11 (retain the stream, or mark the truncation). +- **P10-24, `XCUT-8` and the public constructor.** Every core mapping path goes through the guarded internal factory, and `HttpResponseException(Response)` accepts a non-error status (a caller can build one around a 200). Read as the SDK's mapping path; proposed as a design §11 item; whether the constructor should guard is a phase 12 public-surface choice. +- **P10-25, `ServiceResponseException`.** Public, documented in the taxonomy as "always retryable (XCUT-4, RETRY-4)" and constructed nowhere in `src/` (design V15): dead public surface beside the two branches `XCUT-4` asks for. Routed to phase 11 (delete, or argue it into §3.2) and to phase 12's one-time public-surface choices. Related count question: *Totals*. +- **The HTAB edge** (deviation 5): two MUSTs of the specification meet at the edge of a name; core follows `HTTP-17`. +- **No MUST was found broken.** Measured at the start (design V2): every one of the 24 `XCUT` rows already had a mechanism and a passing test; what was missing was the portable suite form, a citing test for nine rows and two mechanical gates (`XCUT-9`, `XCUT-14`), all built here. + +## Open items for the lead + +1. **The `main` ruleset needs a `required_status_checks` rule (`NFR-17`'s merge half).** Request text for the repository admin: *Add a `required_status_checks` rule to the `main` ruleset naming every job of `.github/workflows/ci.yml`: `build & test (ubuntu-latest)`, `build & test (windows-latest)`, `build & test (macos-latest)`, `reproducible packages` and `NativeAOT smoke`. Why: design V9 read the + repository settings and found that `main` has no classic branch protection and no `required_status_checks` rule, so a red CI job does not block a merge; `NFR-17` ("CI MUST be blocking") needs both the jobs to fail on a red gate (built and pinned by `CiWorkflowTests`) and the merge to require them. It is an admin-only repository setting, outside the workflow file.* After it is applied, read + it back (`gh api repos/dexpace/dotnet-sdk/rulesets`) and record the result in this row; until then `NFR-17` is ⏳ and `docs/first-release.md` carries it as a blocker before the first publish. This phase did not apply it (no remote action was taken) and could not read it (deviation 9). +2. **Frozen-tree corrections are proposed, not made.** `docs/sdk-design-dotnet/` and `docs/product-spec/` are frozen to routine work, so the dated corrections the design lists (§9, §9.1 to §9.3, §10 entry 7's note on `ServiceResponseException`, a §11 item for `XCUT-8`'s reading (P10-24), a §11 item for the HTAB edge, the §12 notes) are listed under *Proposed corrections* below + for a human's deliberate edit. +3. **The phase 11 inbound list** (also in the roadmap's dated status note): `ServiceResponseException` (P10-25); `XCUT-24`'s error-snapshot repair candidate (P10-23); the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25; `XCUT-8`'s reading as a §11 item to confirm (P10-24); the HTAB edge as a §11 item; F-A2 (`ASYNC-2`) unchanged. + Phase 12's one-time public-surface choices gain the `HttpResponseException` constructor guard (P10-24) and `ServiceResponseException` (P10-25); `NFR-4`'s baseline, `NFR-12`'s release half and `NFR-16` stay *After the first publish* and *Blockers*. + +## Proposed corrections (frozen trees) + +Each is dated 2026-10-14 and append-only when a human makes it. **Design §9**: the gate table is now evidenced (this checklist's gate table); §9.1 `RS0030`: the `GetBaseException` and `ConcurrentDictionary` constructor bans exist; §9.2: `NFR-8`'s keep configuration is the declaration plus annotations, asserted on the packed assembly; §9.3: Stryker.NET unsupported on Microsoft.Testing.Platform (V5), benchmarks not built +(post-release triggers). **§10 entry 7**: add that `ServiceResponseException` is never raised in the tree (P10-25). **§11, new items**: (a) `XCUT-8` is read as the SDK's mapping path, not the public constructor (P10-24); (b) a header name with HTAB at either end is trimmed under `HTTP-17` and a name with HTAB inside is rejected under `XCUT-18` (deviation 5); +(c) the `NFR-11` scan's subject is the compiled public surface of the assembly, not `PublicAPI.Shipped.txt` (P10-11). **§12**: the `XCUT` and `NFR` rows' notes (24 and 17 rows dispositioned by phase 10). No new §10 entry is opened (P10-26). + +## Gates run + +The final figures are recorded in the last commit of this phase; the group gates before it were: after the invariant suite and the core and DI drivers, 7 149 tests, 0 failed, 27 skipped, coverage 97.53%; after the codec suite, 7 231 tests, 0 failed, 27 skipped, coverage 97.49%; after the packaging suite and gates, 7 309 tests, 0 failed, 27 skipped, coverage 97.47% (`Dexpace.Sdk.Conformance` 97.19%, +`Dexpace.Sdk.Core` 97.78%, `Dexpace.Sdk.Extensions.DependencyInjection` 97.67%, `Dexpace.Sdk.Http.SystemNet` 95.17%, `Dexpace.Sdk.Serialization.SystemTextJson` 99.04%), the packaging step green over the five packages (eight assertions passed), the dependency audit `5 libraries conform`, `dotnet format --verify-no-changes` clean, and a NativeAOT publish with no warning whose binary prints `aot-smoke: all checks passed`. From 550b3ea85497a6e98f4a962131b1dda1dc6e5e27 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:15:41 +0300 Subject: [PATCH 29/45] docs: changelog for phase 10 (phase 10, #99) --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12578120..03748130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -409,9 +409,25 @@ Roadmap phase 7b, issue #10 (a later addition, not one of S1–S9), pinned by a - **Guidance (phase 9, `XCUT-10`; documentation only):** composing `AddStandardResilienceHandler` under a named client is `o.Retry.ShouldHandle = _ => ValueTask.FromResult(false)`. The older guidance to set the SDK's own `MaxRetryAttempts = 0` is withdrawn: the handler would still re-send a `POST` body behind the SDK's re-send gate, which measurably multiplies the attempts, and the handler-side `o.Retry.MaxRetryAttempts = 0` is refused by the handler's own options validation. See `docs/sdk-documentation/dependency-injection.md`. +- **Phase 10, additive (`XCUT-1`..`XCUT-24`, `NFR-1`..`NFR-17`):** `TransportFace` gains `None` (value `2`), the face of an assertion that has none; `SuiteRunner` rejects it, the new suite runners accept it, and no existing value moves. + `Seam2ArchitectureTests` admits `Dexpace.Sdk.Conformance` as a reference of `Dexpace.Sdk.Core.Tests` (the core driver of the invariant suite), the kit itself staying core-only. `scripts/ci/reproducible-pack.sh` now packs `HEAD` from two + `git worktree`s at different paths, so an absolute path embedded in a package fails the job (a git checkout is the reproducible input; it warns on a dirty tree). `BannedSymbols.txt` bans `Exception.GetBaseException` and the seven public + `ConcurrentDictionary` constructors in `src/` (`RS0030`; `BoundedMap` is the one construction site, under one scoped pragma). Every `severity = none` block of `.editorconfig` states when to re-enable it. No production behaviour changed. ### Added +- **Phase 10 cross-cutting invariants and non-functional requirements (`XCUT-1`..`XCUT-24`, `NFR-1`..`NFR-17`; design and plan under `docs/work/mvp/phase10/`):** `Dexpace.Sdk.Conformance` gains three suites beside the transport suite, with one result model + (`TransportFace.None`, `SuiteRunnerCore`, `SuiteOptions`, a per-suite preamble, `ConformanceReport.Combine`). `InvariantSuite` runs 28 assertions over an `InvariantSubject` whose hooks default to core (a hook left `null` is `NotExercised`): cancellation and + timeout (`XCUT-1`..`-3`), the error taxonomy and the one classifier (`XCUT-4`..`-9`), retry safety, concurrency and the single-flight cache (`XCUT-10`..`-12`), lifecycle, bounded maps and no external aliasing (`XCUT-13`..`-15`), security by default + (`XCUT-16`..`-19`) and the host invariants (`XCUT-20`..`-24`, `NFR-11`'s scheduler-agnostic call). `CodecSuite` runs 11 assertions over an `ISerde` and the public probe models in `Dexpace.Sdk.Conformance.Codec` (`SERDE-1`..`-3`, `-9`, `-10`, `-12`, `-15`..`-17`, + `-19`, `-20`); `PackagingSuite` runs 8 assertions over the packed `.nupkg` files with BCL readers (`NFR-1`, `-2`, `-3`, `-8`, `-10`, `-13`, `-14`, `-15`). Each assertion has a deliberately broken subject in `tests/Dexpace.Sdk.Conformance.Tests/Controls/` that it must + fail. Three first-party drivers run them: the core driver, the dependency-injection driver (`xcut-23`) and the System.Text.Json driver, each pinning its expected `NotExercised` list; the NativeAOT smoke runs the invariant and codec suites (`CheckPhase10Async`). + Gates: `scripts/ci/packaging-conformance.cs` (a CI step after Pack); `scripts/ci/requirement-coverage.cs --check` with its self-test (every one of the 645 requirements of appendix C is owned by a checklist row, with its level and a legend mark; a Linux CI step); + `AppendixBCoverageTests` over the new `tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md` (61 items); `Nfr11PublicSurfaceArchitectureTests`, `NFR4ArchitectureTests`, `LicenseHeaderTests` (the `scripts/ci` apps included), `AnalyzerWaiverTests` and `CiWorkflowTests`. + `AppendixBMap` covers all 61 appendix-B items. `docs/sdk-documentation/cross-cutting-invariants.md` is the phase page (where each `XCUT` row is enforced, the gate table, how to run the suites over your own composition); `conformance.md` describes the suites; the checklist + `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` has the 41 rows. Declined with triggers: Stryker.NET (it refuses Microsoft.Testing.Platform projects) and `benchmarks/`; `XCUT-24`'s non-consuming clause for an error-body snapshot above 1 MiB + is a recorded SHOULD decline. + - **Phase 9 host integration (`CFG-1`, `-2`, `-3`, `-4`, `-5`, `-6`, `-7`, `-10`, `-11`, `-14`, `-37`, `-38`, `SEAM-5`, `SEAM-6`, `OBS-35`; design and plan under `docs/work/mvp/phase9/`):** the new package `Dexpace.Sdk.Extensions.DependencyInjection`. `AddDexpaceClient(name[, IConfigurationSection])` returns a sealed `DexpaceClientBuilder` (`Configure`, `ConfigureTransport`, `ConfigureCredentials`, `UseTransport`, `UseSerde`, `UseAuth`, `UseAuthPolicy`, `AddPolicy`, `UseProxyFromEnvironment` with and without a lookup, and `HttpClient`, the `IHttpClientBuilder` of the named client) and registers the client as keyed singletons (`HttpPipeline`, From 38b6a169179712ed9fe872c0c7c365105f479d7e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:19:04 +0300 Subject: [PATCH 30/45] docs: phase 10 corrections, roadmap note and CLAUDE.md (phase 10, #99) Roadmap Phase 10 card, Phase List row and the dated phase 10 exit note; first-release.md (SHOULD declined, post-release triggers, the ruleset blocker, public-surface candidates); the earlier checklists' hand-off rows marked taken by phase 10; CLAUDE.md and README.md for the four suites and the new scripts; knowledge notes for Stryker on Microsoft.Testing.Platform, the cross-path reproducible pack, the missing required status checks and the HTAB edge. --- CLAUDE.md | 29 ++++++++++++------- README.md | 8 ++--- docs/first-release.md | 25 ++++++++++++---- docs/knowledge/notes/testing.md | 4 +++ .../notes/tooling-and-quality-gates.md | 6 ++++ ...2026-09-27-dotnet-sdk-v1-roadmap-design.md | 20 ++++++++++++- ...se10-cross-cutting-invariants-checklist.md | 3 +- .../2026-10-07-phase4a-context-checklist.md | 2 ++ .../2026-10-07-phase4c-pipeline-checklist.md | 2 ++ .../2026-10-07-phase5b-logging-checklist.md | 2 ++ .../2026-10-07-phase5c-tracing-checklist.md | 2 ++ .../2026-10-08-phase6a-retry-checklist.md | 2 ++ .../2026-10-08-phase6b-redirect-checklist.md | 2 ++ .../2026-10-08-phase6c-auth-checklist.md | 2 ++ .../2026-10-09-phase7b-sse-checklist.md | 1 + ...10-09-phase8a-conformance-kit-checklist.md | 2 ++ ...9-phase8b-reference-transport-checklist.md | 2 ++ ...10-12-phase9-host-integration-checklist.md | 2 +- 18 files changed, 94 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9821830..8f9dd0e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,8 @@ dotnet format Dexpace.Sdk.sln --verify-no-changes # formatting gate dotnet test --solution Dexpace.Sdk.sln --configuration Release dotnet test --project tests/Dexpace.Sdk.Core.Tests --configuration Release --filter-trait "Category=Security" dotnet test --project tests/Dexpace.Sdk.Http.SystemNet.Tests --configuration Release --filter-trait "Category=Conformance" # the kit against the reference transport -dotnet test --project tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests --configuration Release --filter-trait "Category=Conformance" # the kit against the DI-built named-client transport +dotnet test --project tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests --configuration Release --filter-trait "Category=Conformance" # the kit against the DI-built named-client transport (and the invariant suite's resolution driver) +dotnet test --project tests/Dexpace.Sdk.Core.Tests --configuration Release --filter-trait "Category=Conformance" # the invariant suite against core's own composition dotnet pack Dexpace.Sdk.sln --configuration Release --output artifacts/packages ``` @@ -37,7 +38,10 @@ The rest of CI (`.github/workflows/ci.yml`) is runnable locally, and should be b dotnet run scripts/ci/coverage-gate.cs -- artifacts/test-results 80 # after a clean `dotnet test … --coverlet --coverlet-output-format cobertura --results-directory artifacts/test-results` scripts/ci/coverage-gate-selftest.sh artifacts/test-results # the gate fails closed on a missing report (#33) dotnet run scripts/ci/dependency-audit.cs -- Release artifacts/packages # constraint 2, over deps.json and nuspecs -scripts/ci/reproducible-pack.sh # pack twice, byte-compare (NFR-12) +dotnet run scripts/ci/packaging-conformance.cs -- artifacts/packages # the kit's packaging suite over the packed .nupkg files (NFR-1, -2, -3, -8, -10, -13 to -15) +dotnet run scripts/ci/requirement-coverage.cs -- --check # every appendix-C ID owned by a checklist row, with its level and a mark (--report prints the tallies) +scripts/ci/requirement-coverage-selftest.sh # the coverage gate fails on each kind of finding +scripts/ci/reproducible-pack.sh # pack HEAD from two git checkouts at different paths, byte-compare (NFR-12) dotnet publish tests/Dexpace.Sdk.AotSmoke --configuration Release --output artifacts/aot-smoke && ./artifacts/aot-smoke/Dexpace.Sdk.AotSmoke dotnet build tools/Dexpace.Tools.sln --configuration Release && dotnet test --solution tools/Dexpace.Tools.sln --configuration Release scripts/knowledge verify-structure @@ -87,7 +91,7 @@ tests and tools — targets **`net10.0` only**, set once in `Directory.Build.pro `libs.versions.toml` analog), with transitive pinning on. `PackageReference`s carry no `Version` attribute. - **Tests** carry `[Trait("Category", …)]`: `Unit`, `Integration`, `Conformance`, `AotSmoke`, or `Security` (the permanent phase-1 regression tests — never delete or loosen one). Each suite's `TestCategoryTests` - enforces it. `Dexpace.Sdk.Core.Tests` references core and in-memory fakes only, never a transport (SEAM-2, + enforces it. `Dexpace.Sdk.Core.Tests` references core, the core-only conformance kit and in-memory fakes, never a transport or a codec (SEAM-2, an architecture test). - **MIT license header on every `.cs` file** — the two-line block, src and tests alike: @@ -146,17 +150,17 @@ dotnet-sdk/ │ ├── Dexpace.Sdk.Http.SystemNet/ # reference transport over System.Net.Http.HttpClient; SystemNetHttpClientOptions, HeaderDropLogging │ ├── Dexpace.Sdk.Serialization.SystemTextJson/ # ISerde over source-generated System.Text.Json │ ├── Dexpace.Sdk.Extensions.DependencyInjection/ # host integration: AddDexpaceClient, DexpaceClientBuilder, the configuration reader and its grammars, validation, the named client over IHttpClientFactory -│ └── Dexpace.Sdk.Conformance/ # the transport conformance kit (core only): TransportSuite, TransportSubject, the report and waivers, Wire/ (the loopback fixture) +│ └── Dexpace.Sdk.Conformance/ # the conformance kit (core only), four suites: TransportSuite, InvariantSuite, CodecSuite, PackagingSuite, their subjects, the one report and waivers, Wire/ (the loopback fixture) ├── tests/ -│ ├── Dexpace.Sdk.Core.Tests/ # core + fakes only; Architecture/ holds SEAM-1, SEAM-2, SEAM-22, SSE-37 and SSE-38 +│ ├── Dexpace.Sdk.Core.Tests/ # core + fakes only; Architecture/ holds SEAM-1, SEAM-2, SEAM-22, SSE-37, SSE-38, NFR-4 and NFR-11; Conformance/ is the invariant suite's core driver │ ├── Dexpace.Sdk.Http.SystemNet.Tests/ # the transport, incl. wire tests over the kit's Wire/ server and SystemNetConformanceTests (the kit's driver over both constructions, with its two permanent waivers) -│ ├── Dexpace.Sdk.Conformance.Tests/ # the kit's own tests, a negative control per assertion (Controls/), the raw-socket client (RawSocket/) and its driver (Drivers/) +│ ├── Dexpace.Sdk.Conformance.Tests/ # the kit's own tests, a negative control per assertion (Controls/), the raw-socket client (RawSocket/) and its driver (Drivers/), APPENDIX_B.md and its gate (Requirements/), the repository gates (Gates/) │ ├── Dexpace.Sdk.Serialization.SystemTextJson.Tests/ -│ ├── Dexpace.Sdk.Extensions.DependencyInjection.Tests/ # the reader, registration, the named client, the third conformance driver (Conformance/) and NamedClientRedirectWireTests (Security/) +│ ├── Dexpace.Sdk.Extensions.DependencyInjection.Tests/ # the reader, registration, the named client, the third transport driver and the invariant suite's resolution driver (Conformance/) and NamedClientRedirectWireTests (Security/) │ ├── Dexpace.Sdk.TestSupport/ # fake transports, time, diagnostics listeners (not packed) │ └── Dexpace.Sdk.AotSmoke/ # NativeAOT smoke consumer, published and run in CI ├── scripts/ -│ ├── ci/ # coverage-gate{.cs,-selftest.sh}, dependency-audit.cs (one policy record per library), reproducible-pack.sh +│ ├── ci/ # coverage-gate{.cs,-selftest.sh}, dependency-audit.cs (one policy record per library), packaging-conformance.cs, requirement-coverage{.cs,-selftest.sh}, reproducible-pack.sh │ └── knowledge # the knowledge-lookup CLI ├── tools/Dexpace.Tools.sln # tools/Knowledge{,.Tests} + .claude/skills/{housekeeping,knowledge-harvest}/{src,tests} ├── .github/ # ci.yml, labels.yml, dependabot.yml, CODEOWNERS, issue/PR templates @@ -228,6 +232,10 @@ Layered, bottom-up: `tests/Dexpace.Sdk.Conformance.Tests/Controls/` that it must fail (`AssertionCatalogueTests` fails an assertion with none); a leaked-socket control must root what it leaks, because a finalizer closes it otherwise. A `ConformanceWaiver` whose assertion now passes fails the run, so fixing a requirement means deleting its waiver in the same change. The kit never calls `Task.Delay`, and a release check needs a keep-alive reply, never a `Connection: close` one. +- **An invariant hook left `null` is `NotExercised`, and a driver pins the list it expects.** The invariant suite reads each subject through a hook that defaults to core; four have no default (`MapStrict`, `CreateCyclicException`, `CreateBoundedMap`, + `SeamResolution`) and an assertion whose hook is absent reports `NotExercised`, which is not a pass. Each first-party driver (`InvariantConformanceTests` in the core tests, `InvariantResolutionConformanceTests` in the DI tests, `CheckPhase10Async` in the AOT smoke) asserts the exact + list it leaves unexercised and fails on any other, so supplying a hook means deleting its name from the pin. A new `ConcurrentDictionary` constructor or `Exception.GetBaseException` in `src/` fails `RS0030` (`BoundedMap` is the one construction site, under one scoped pragma). A suite + must walk a cyclic exception chain and never throw one (Windows hangs), and the repository gates under `tests/Dexpace.Sdk.Conformance.Tests/Gates/` (license header on every `.cs`, a re-enable condition on every analyzer relaxation, every CI gate a blocking step) fail in the pull request that breaks them (`docs/sdk-documentation/cross-cutting-invariants.md`). - **Host integration is configured by a reader, not the binder, and fails at start naming the key.** `Dexpace.Sdk.Extensions.DependencyInjection` reads the `IConfigurationSection` the caller hands `AddDexpaceClient` with its own grammars (`ConfigurationBinder` is banned in `src/`: the generator binds no `init` accessor, the reflection mode is not AOT-safe); every bad value, rejected setting and unknown key fails `ValidateOnStart` at once as `": "`, never quoting a configured value (a numeric accessor message may echo the number), and with no host `IStartupValidator.Validate()` or the first resolution fails the same way. An empty value is absent (it silently shadows a lower provider); there is no reload; a codec is required @@ -312,5 +320,6 @@ the six result statuses and the by-ID waivers that must stay needed, the loopbac catalogue generated from appendix C — see `docs/sdk-documentation/conformance.md`; and phase 8b's hardening of the reference transport — the options record and four constructors, the handler check, the installed proxy, the per-call deadline with its one failure classification, the real synchronous send, the header partition and drop policy, the bounded re-send capture and the `ObjectDisposedException` latch, with the kit green against both constructions and two permanent assertion-scoped waivers (`TRANSPORT-14`'s malformed-name clause and `TRANSPORT-2`'s native retry for a body-less `GET`/`HEAD`/`OPTIONS`/`DELETE`/`TRACE`, design §10 entries 32 and 33) — see `docs/sdk-documentation/transport.md`; and phase 9's host integration — the DI package `Dexpace.Sdk.Extensions.DependencyInjection`: `AddDexpaceClient` and the builder, the hand-written configuration reader and its grammars, the validator with `ValidateOnStart`, the named client over -`IHttpClientFactory` with its redirect-off primary and container-owned lease, the single-registration check, `DexpaceConfigurationKeys`, the audit's policy record, the third conformance driver — see `docs/sdk-documentation/dependency-injection.md`): only the cross-cutting invariant and -conformance suites (10), the deviation audit (11) and the release path (12). +`IHttpClientFactory` with its redirect-off primary and container-owned lease, the single-registration check, `DexpaceConfigurationKeys`, the audit's policy record, the third conformance driver — see `docs/sdk-documentation/dependency-injection.md`; and phase 10's cross-cutting invariants — the kit's invariant (28), codec (11) and packaging (8) suites with a negative control per assertion and three first-party drivers, `TransportFace.None`, the one combined report, the packaging and +requirement-coverage scripts, the appendix-B map and its gate, the cross-path reproducible pack, the `NFR-11`, `NFR-4`, license-header, analyzer-waiver and CI-completeness tests, the `GetBaseException` and `ConcurrentDictionary` constructor bans and `CheckPhase10Async`, with `NFR-17`'s repository ruleset the one open action — see +`docs/sdk-documentation/cross-cutting-invariants.md`): only the deviation audit (11) and the release path (12). diff --git a/README.md b/README.md index d586f228..993489ac 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ Pre-release: nothing is published yet, and every package is at `0.0.1-alpha.1`. | [`Dexpace.Sdk.Http.SystemNet`](src/Dexpace.Sdk.Http.SystemNet/README.md) | The reference transport over `System.Net.Http.HttpClient` | | [`Dexpace.Sdk.Serialization.SystemTextJson`](src/Dexpace.Sdk.Serialization.SystemTextJson/README.md) | The `ISerde` codec over source-generated `System.Text.Json` | | [`Dexpace.Sdk.Extensions.DependencyInjection`](src/Dexpace.Sdk.Extensions.DependencyInjection/README.md) | Host integration: named clients as keyed services over `Microsoft.Extensions.DependencyInjection`, configured from an `IConfigurationSection` with strict grammars and fail-at-start validation ([guide](docs/sdk-documentation/dependency-injection.md)) | -| [`Dexpace.Sdk.Conformance`](src/Dexpace.Sdk.Conformance/README.md) | The transport conformance kit: framework-free assertions for the `TRANSPORT` and `ASYNC` requirements, a loopback HTTP/1.1 wire fixture, and per-requirement reporting with waivers; for adapter authors' test projects ([guide](docs/sdk-documentation/conformance.md)) | +| [`Dexpace.Sdk.Conformance`](src/Dexpace.Sdk.Conformance/README.md) | The conformance kit: framework-free suites for the `TRANSPORT` and `ASYNC` requirements, the cross-cutting invariants, codecs and package metadata, a loopback HTTP/1.1 wire fixture, and per-requirement reporting with waivers; for adapter authors' test projects ([guide](docs/sdk-documentation/conformance.md), [invariants](docs/sdk-documentation/cross-cutting-invariants.md)) | -What is not built yet — the cross-cutting invariant and conformance suites (roadmap phase 10), the deviation audit (phase 11) and the release path (phase 12) — is +What is not built yet — the deviation audit (roadmap phase 11) and the release path (phase 12) — is scheduled by the [v1 roadmap](docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md), and what the first release needs is in [docs/first-release.md](docs/first-release.md). The normative specification is [docs/product-spec/](docs/product-spec.md) and the .NET design is [docs/sdk-design-dotnet/](docs/sdk-design-dotnet.md); @@ -56,11 +56,11 @@ dotnet-sdk/ │ ├── Dexpace.Sdk.Http.SystemNet/ # reference transport over System.Net.Http.HttpClient │ ├── Dexpace.Sdk.Serialization.SystemTextJson/ # ISerde over source-generated System.Text.Json │ ├── Dexpace.Sdk.Extensions.DependencyInjection/ # named clients as keyed services, configuration reader, validation -│ └── Dexpace.Sdk.Conformance/ # transport conformance kit + Wire/ loopback fixture +│ └── Dexpace.Sdk.Conformance/ # conformance kit (transport, invariant, codec, packaging suites) + Wire/ loopback fixture ├── tests/ │ ├── Dexpace.Sdk.Core.Tests/ # core against in-memory fakes only │ ├── Dexpace.Sdk.Http.SystemNet.Tests/ # the transport, incl. loopback wire tests and its conformance driver -│ ├── Dexpace.Sdk.Conformance.Tests/ # the kit's own tests, the negative controls and the raw-socket driver +│ ├── Dexpace.Sdk.Conformance.Tests/ # the kit's own tests, the negative controls, the raw-socket driver, APPENDIX_B.md and the repository gates │ ├── Dexpace.Sdk.Serialization.SystemTextJson.Tests/ │ ├── Dexpace.Sdk.Extensions.DependencyInjection.Tests/ │ ├── Dexpace.Sdk.TestSupport/ # shared fakes (not packed) diff --git a/docs/first-release.md b/docs/first-release.md index 1b6ebaa6..2c8ee682 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -26,7 +26,7 @@ release. | `Dexpace.Sdk.Core` | yes | `net8.0` | no — `0.0.1-alpha.1` | phases 0–7, 10 | | `Dexpace.Sdk.Http.SystemNet` | yes | `net8.0` | no — `0.0.1-alpha.1` | phases 1, 8b | | `Dexpace.Sdk.Serialization.SystemTextJson` | yes | `net8.0;net10.0` | no — `0.0.1-alpha.1` | phase 7a | -| `Dexpace.Sdk.Conformance` | yes (phase 8a) | `net10.0` | no — `0.0.1-alpha.1`; the first published version is the first release's lockstep version, `0.1.0` proposed (a release that adds an assertion is at least a minor version) | phase 10 completes it (the invariant, packaging and codec suites); phase 12 publishes it and decides the number | +| `Dexpace.Sdk.Conformance` | yes (phase 8a) | `net10.0` | no — `0.0.1-alpha.1`; the first published version is the first release's lockstep version, `0.1.0` proposed (a release that adds an assertion is at least a minor version) | phase 10 completed it on 2026-10-14 (the invariant, codec and packaging suites beside the transport suite: four suites); phase 12 publishes it and decides the number | | `Dexpace.Sdk.Extensions.DependencyInjection` | yes (phase 9) | `net10.0` | no — `0.0.1-alpha.1`; it references `Dexpace.Sdk.Core`, `Dexpace.Sdk.Http.SystemNet` and `Microsoft.Extensions.Http` (the hosting family's one root), at the lockstep version | phase 12 (the release path) | The target frameworks changed in phase 0. The lead approved the roadmap's decision D1 on 2026-09-28: every package @@ -35,20 +35,29 @@ targets `net10.0` only, set once in `Directory.Build.props` (PR #21), because .N ## Blockers before first publish - [ ] **CI green on every matrix row.** The green build, format check, locked restore, coverage floor, `PublicAPI` - files, package validation, dependency audit and 3-OS matrix are phase 0's; the complete gate set is phase 10's. + files, package validation, dependency audit and 3-OS matrix are phase 0's; the complete gate set is phase 10's + (built and dispositioned on 2026-10-14: the gate table in the [phase 10 checklist](./work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md); + `CiWorkflowTests` pins that every gate is a step and none is advisory). This line is ticked when the first CI run on `main` after phase 10 is green on every row. +- [ ] **The `main` ruleset requires the CI jobs (`NFR-17`'s merge half).** A `required_status_checks` rule naming `build & test (ubuntu-latest)`, `build & test (windows-latest)`, + `build & test (macos-latest)`, `reproducible packages` and `NativeAOT smoke`; design V9 found none (no classic protection either), so a red job does not block a merge. + An admin-only repository setting that phase 10 did not apply (request text in the phase 10 checklist's *Open items*). Read it back with `gh api repos/dexpace/dotnet-sdk/rulesets` and tick this line. - [ ] **The nine P0 defects closed, with permanent `Security` regression tests.** The defects are header CR/LF injection, a caller-set `Host`, transport auto-redirect with cross-origin credential forwarding, credentials over `http`, deny-list URL redaction, the attempt-leaking `PipelineContext`, the `Retry-After` overflow, the throw on 304, and the leak on a malformed `Content-Type`. Phase 1. - [ ] **Every MUST dispositioned.** Every one of the 532 MUSTs has a checklist row: ✅ with a named test; N/A or vacuous with a design §10 or §11 citation; or listed below under *Unsatisfied MUSTs*. Phases 2–10 own the - rows, and phase 10's row-count gate proves the total. + rows, and phase 10's row-count gate proves the total. **Phase 10 (2026-10-14) built the gate and it is green:** `dotnet run scripts/ci/requirement-coverage.cs -- --check` reports all 645 IDs of appendix C owned by a checklist row, each with its level and a legend mark, + and no finding in a MUST row; it is a blocking Linux step of `ci.yml`. **The line stays open** because the script's tally still reads 16 MUST rows as ⏳ in the row that owns them: `NFR-17` (the ruleset line above), and hand-offs whose closing evidence sits in a later phase's checklist in a table + the script does not read as owning (the `RECOV` rows closed in 6a's table, `HTTP-44` and `HTTP-45` closed by a dated correction in the same cell). Phase 11 gives those rows their dated corrections and ticks this line; it also decides whether `NFR-17` is the only MUST left open. - [ ] **Conformance green.** `Dexpace.Sdk.Conformance` passes against every shipped transport on every CI row, with - waivers listed by requirement ID. It is written by phase 8a and completed by phase 10. + waivers listed by requirement ID. It is written by phase 8a and completed by phase 10 (2026-10-14: the invariant, codec and packaging suites, with three first-party drivers that each pin their `NotExercised` list). - [ ] **Deviations audited.** `docs/deviations.md` exists and every row reads `as-built: …`. Phase 11. - [ ] **One-time public-surface choices decided before the first `PublicAPI.Shipped.txt`.** Adding a public member later is compatible; removing or narrowing one after the tag is a break. Phase 11 lists the choices here, and - phase 12 decides them. + phase 12 decides them. Candidates found so far (phase 10, 2026-10-14): whether `HttpResponseException(Response)` should refuse a non-error status (`XCUT-8`, P10-24; + today every core mapping path goes through a guarded internal factory and the public constructor accepts a 200), and whether `ServiceResponseException`, public and never + thrown (`XCUT-4`, P10-25), is deleted or argued into design §3.2. - [ ] **A reachable `SECURITY.md` contact.** Phase 0 creates the file; this line is ticked only when the contact is confirmed to be monitored. - [ ] **NuGet ownership settled.** @@ -118,6 +127,9 @@ Each phase adds an entry for each requirement it declines, with the requirement `HttpClient` writes content to its own connection stream, possibly an `SslStream`, so no sendfile path is reachable through it and the transport does not special-case file bodies. Phase 8b (2026-10-11); P8b-15. +- **`XCUT-24` (SHOULD), the non-consuming clause for an error-body snapshot above 1 MiB.** A body-log preview is byte-capped and does not pull the body ahead of its reader (the embedded MUST and the clause are met there, `invariant:xcut-24.preview-capped-non-consuming`), + but `ErrorBodyBuffer.Capture` drains at most `Response.MaxBufferedErrorBytes` (1 MiB), truncates without a marker and replaces the original body, so the original stream is consumed. A repair candidate for phase 11 (retain the stream, or mark the truncation). Phase 10 (2026-10-14); P10-23. + - **Authentication features outside the requirements (phase 6c, P6c-13, P6c-19, P6c-35, P6c-40).** Not built, and not planned for v1: **claims challenges** (`WWW-Authenticate: Bearer … claims="…"`, continuous access evaluation; the bearer hook re-fetches with the same `TokenRequestContext`), an **`apiKey` in a query string or a cookie** (`AUTH-26` is header-only; no policy stamps either), a **preemptive Digest** (the handler answers a challenge and remembers only counts), @@ -211,6 +223,9 @@ Not yet wired. Phase 12 owns this path: Each entry names an event that no v1 phase can produce, and the one job to do when the event fires. +- **Stryker.NET supports Microsoft.Testing.Platform** (stryker-net#3094). Version 5.0.0 refuses every test project here, and a VSTest shadow of each would be a second harness for an optional, non-blocking report (design §9.3; styleguide 11.8). The job is to schedule a non-blocking + mutation run over the core tests (phase 10, P10-21, 2026-10-14). +- **The first performance-motivated change.** Design §9.3 makes BenchmarkDotNet results a requirement of such a change; there has been none, and the allocation guarantees that matter are blocking tests already. The job is to create `benchmarks/` with that change (phase 10, P10-22, 2026-10-14). - **The first external adapter author.** The job is to confirm that `Dexpace.Sdk.Conformance` runs under a test framework other than xUnit, which is the claim design §9.3 makes and nothing in this repository exercises. - **The first consumer that needs a call's context below the transport.** The `CallKey` carrier on `RequestOptions`, the stamp from `SystemNetHttpClient` and a reader (a DI-registered `DelegatingHandler`, say) are not built: phase 9 registered no handler diff --git a/docs/knowledge/notes/testing.md b/docs/knowledge/notes/testing.md index fdfd1356..5b4d1dad 100644 --- a/docs/knowledge/notes/testing.md +++ b/docs/knowledge/notes/testing.md @@ -5,3 +5,7 @@ Hand-written. `../harvested/testing.md` is what the documents say; this is what ## Conflicts - **Tests assert with xUnit's `Assert` alone; Shouldly is not adopted.** Resolves `testing/48e77518` in favour of the SDK and overrides `testing/c54172a7` and `testing/12cfc26a`: no test project references Shouldly and `Directory.Packages.props` pins no such package. The runner half of the overlay's xUnit row is conformed (`testing/b1ecd34a` was narrowed to it): xUnit v3 on Microsoft.Testing.Platform is in use. This note records the assertion style the repository actually has; adopting Shouldly later is a reviewed change that retires it. FluentAssertions v8+ stays banned. This is a phase 0 judgement not named in roadmap constraint 6's table, which the lead can reverse. review · `docs/styleguide/csharp/11-testing.md` · high · sha:manual-p0-shouldly +- **Stryker.NET mutation testing is not scheduled; styleguide 11.8's optional gate waits on Microsoft.Testing.Platform support.** Overrides `testing/ee661494`, `testing/5d2d8cb1` and `testing/ef3707b4` in favour of the SDK: Stryker.NET 5.0.0 run over `Dexpace.Sdk.Core.Tests` (mutating `Http/Common/HttpHeaderSyntax.cs`) fails with "Project … is using Microsoft.Testing.Platform which is not yet supported by Stryker, see stryker-net#3094" and discovers 0 tests, and every test project here is xUnit v3 on Microsoft.Testing.Platform (roadmap constraint 6). A VSTest shadow of each project would be a second harness to keep in step for an optional, non-blocking report. What stands in for the gate is the 80% coverage floor with its self-test and a deliberately broken subject per conformance assertion (the kit's `Controls/`). Post-release trigger in `docs/first-release.md`: Stryker.NET supports Microsoft.Testing.Platform. Design §9.3 and the roadmap's Phase 10 card call it optional, so nothing in the exit criteria is missed. + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-design.md` · high · sha:manual-p10-v5-stryker +- **A hook an invariant suite cannot reach is `NotExercised`, never a pass, and a driver pins the list it leaves unexercised.** Two measured facts of the conformance suites, recorded with the testing notes because they change how the evidence reads: a `Dexpace.Sdk.Conformance` assertion whose hook a driver leaves `null` reports `NotExercised`, so each first-party driver pins the exact list it leaves unexercised and fails on any other (the core driver `xcut-23`; the DI driver `xcut-8.strict-factory-rejects`, `xcut-9`, `xcut-14` twice), which is why the full run skips 27 tests, not the transport drivers' 22. A suite that needs a cyclic exception chain walks it and never throws it: a thrown cyclic `InnerException` chain hangs the test host on Windows. + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-suites diff --git a/docs/knowledge/notes/tooling-and-quality-gates.md b/docs/knowledge/notes/tooling-and-quality-gates.md index a23411aa..185b09a8 100644 --- a/docs/knowledge/notes/tooling-and-quality-gates.md +++ b/docs/knowledge/notes/tooling-and-quality-gates.md @@ -5,3 +5,9 @@ Hand-written. `../harvested/tooling-and-quality-gates.md` is what the documents ## Conflicts - **`LangVersion` stays `latest`.** Resolves `tooling-and-quality-gates/db6f60f0` in favour of the SDK and overrides `tooling-and-quality-gates/a31d5855`: `Directory.Build.props` sets the C# language version to `latest` rather than the named `14.0`, the unchanged last clause of the SDK overlay's `.NET 10 / C# 14` row. The `net10.0` half of that row is conformed (`tooling-and-quality-gates/4fbbe369`) and owes nothing. The SDK is pinned to SDK `10.0.401` by `global.json`, so the language version already tracks a reviewed diff; roadmap constraint 6 (phase 0 decision: the target framework conformed, the language version kept). review · `docs/styleguide/README.md` · high · sha:manual-p0-langversion +- **The reproducible-pack gate packs `HEAD` from two git checkouts at different paths; a source archive is not a reproducible input.** From two `git clone`s at different paths all five `.nupkg` are byte-identical with `SOURCE_DATE_EPOCH` and `ContinuousIntegrationBuild=true`; from two `git archive` extractions (no `.git`) every package differs, only in the DLL, which embeds the absolute `obj/…/*.pdb` path because there is no `SourceRoot` and so no path map (V3). `scripts/ci/reproducible-pack.sh` therefore adds two `git worktree`s of `HEAD` (`wt1/repo`, `wt2/yy/zz/repo2`) and packs from each; it packs the commit, not the working tree (a dirty tree prints a warning). The old same-directory script passed with `-p:DeterministicSourcePaths=false` on both packs; the cross-path script fails it with all five packages differing. This sharpens `NFR-12`'s build half to a git checkout; the release half (a published artifact rebuilt from its tag) is phase 12's. + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-design.md` · high · sha:manual-p10-v3-reproducible +- **`main` has no `required_status_checks` rule, so a red CI job does not block a merge (`NFR-17`'s merge half).** Read with `gh api` before phase 10: no classic branch protection (404) and one active ruleset with `deletion`, `non_fast_forward`, `pull_request` (one approval, code-owner review, squash only) and `code_quality`. `ci.yml` has no `continue-on-error` and `CiWorkflowTests` pins that, so every gate fails its job; the second half is an admin-only repository setting naming `build & test (ubuntu-latest | windows-latest | macos-latest)`, `reproducible packages` and `NativeAOT smoke`, which phase 10 requested and did not apply (V9). + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-v9-ruleset +- **Surrounding whitespace in a header name is trimmed before validation, so `XCUT-18`'s "a name with HTAB is rejected" holds for HTAB inside a name only.** `Headers.Builder.Add` and `AddInbound` trim SP and HTAB at either end of a name per `HTTP-17` and store the trimmed name; HTAB or any other control inside a name, and any other control at an end, throws `ArgumentException`. The invariant suite's `xcut-18.model-validation` asserts that by position; a design §11 item is proposed. + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-xcut18-htab diff --git a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md index 5c987ae2..a55aafe3 100644 --- a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md @@ -271,7 +271,7 @@ ID in the clause is stated in that chapter. | 7 | Serde, SSE and Pagination (7a serde · 7b SSE · 7c pagination) | `Dexpace.Sdk.Core`, `Dexpace.Sdk.Serialization.SystemTextJson` | ch.14 — `SERDE-1`–`SERDE-30` (30); ch.13 — `SSE-1`–`SSE-41` (41); ch.12 — `PAGE-1`–`PAGE-36` (36) | §3.4, §7.1–§7.3; [7a design](phase7/phase7a/2026-10-09-phase7a-serde-design.md), [7a plan](phase7/phase7a/2026-10-09-phase7a-serde.md), [7b design](phase7/phase7b/2026-10-09-phase7b-sse-design.md), [7b plan](phase7/phase7b/2026-10-09-phase7b-sse.md), [7c design](phase7/phase7c/2026-10-09-phase7c-pagination-design.md), [7c plan](phase7/phase7c/2026-10-09-phase7c-pagination.md), [7c checklist](phase7/phase7c/2026-10-09-phase7c-pagination-checklist.md) | | 8 | Transports and the Conformance Kit (8a kit · 8b reference-transport hardening) | the new `Dexpace.Sdk.Conformance` package (phase 8 owns its csproj, version and first release), `Dexpace.Sdk.Http.SystemNet`, `tests/Dexpace.Sdk.Http.SystemNet.Tests` | ch.17 — `TRANSPORT-1`–`TRANSPORT-30` (30); ch.18 — `ASYNC-1`–`ASYNC-22` (22) | §2.1, §2.2, §3.2, §3.3, §3.7, §9.3; [8a design](phase8/phase8a/2026-10-09-phase8a-conformance-kit-design.md), [8a plan](phase8/phase8a/2026-10-09-phase8a-conformance-kit.md), [8b design](phase8/phase8b/2026-10-09-phase8b-reference-transport-design.md), [8b plan](phase8/phase8b/2026-10-09-phase8b-reference-transport.md), [8a checklist](phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md), [8b checklist](phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md) | | 9 | Host Integration | the new `Dexpace.Sdk.Extensions.DependencyInjection` package, plus its test project | ch.16 — `CFG-1`–`CFG-7`, `CFG-10`, `CFG-11`, `CFG-14`, `CFG-37`, `CFG-38`; `SEAM-5` and `SEAM-6`'s single-registration check (rows carried from 2b); `TRANSPORT-1` for named clients; `XCUT-10`'s one-retry-layer rule | §2.1, §3.6, §8.2; [design](phase9/2026-10-12-phase9-host-integration-design.md), [plan](phase9/2026-10-12-phase9-host-integration.md), [checklist](phase9/2026-10-12-phase9-host-integration-checklist.md) | -| 10 | Cross-Cutting Invariants and Conformance | every package, audited; `Dexpace.Sdk.Conformance` gains the invariant, packaging and codec suites, but phase 10 does not own that package's csproj or its release | ch.19 — `XCUT-1`–`XCUT-24` (24); ch.20 — `NFR-1`–`NFR-17` (17); appendix B (61 items) | §9, §9.2, §9.3, §11; [design](phase10/2026-10-13-phase10-cross-cutting-invariants-design.md) | +| 10 | Cross-Cutting Invariants and Conformance | every package, audited; `Dexpace.Sdk.Conformance` gains the invariant, packaging and codec suites, but phase 10 does not own that package's csproj or its release | ch.19 — `XCUT-1`–`XCUT-24` (24); ch.20 — `NFR-1`–`NFR-17` (17); appendix B (61 items) | §9, §9.2, §9.3, §11; [design](phase10/2026-10-13-phase10-cross-cutting-invariants-design.md), [plan](phase10/2026-10-13-phase10-cross-cutting-invariants.md), [checklist](phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md) | | 11 | Deviation Reconciliation | every package; the audit leads, and the phase ships code wherever the audit finds a defect | appendix C — every ID named by a design §10 entry; the §11 resolutions; the §12 coverage index | §10, §11, §12 | | 12 | First Release | every package; `.github/workflows/release.yml`; `docs/sdk-documentation/` | `NFR-4`'s baseline, and the release halves of `NFR-12` and `NFR-16` | §2.3, §9.1, §9.2 | @@ -1019,6 +1019,12 @@ Their precedence assertions are re-expressed against `IConfiguration` providers. - `nodejs-sdk/tests/conformance/xcut/` (six suites). - `ruby-sdk/tools/requirement_levels.rb` (ID→level). +- *Status, 2026-10-14: phase 10 is built (issue #99, branch `99-phase-10-cross-cutting-invariants`); see the Phase Status Notes.* +- *Dated correction, 2026-10-14 (phase 10 measurement V2, V10): the Scope's starting counts (XCUT: 5 met, 9 partial, 7 missing, 1 N/A candidate; NFR: every MUST partial) are from `d45e64b`. Measured at `af603d2`, every one of the 24 `XCUT` rows already had a mechanism and a passing test, and 11 of the 17 `NFR` rows were met and evidenced; what was missing was the portable suite form, a citing test for nine rows and the mechanical gates (`XCUT-9`, `XCUT-14`, `NFR-11`, `NFR-15`, `NFR-17`).* +- *Dated correction, 2026-10-14 (P10-11): "the `NFR-11` public-signature type scan over `PublicAPI.Shipped.txt` candidates" is a reflection scan over the compiled public surface of `Dexpace.Sdk.Core` (`Nfr11PublicSurfaceArchitectureTests`): `PublicAPI.Shipped.txt` is empty until phase 12 and is not a type model.* +- *Dated correction, 2026-10-14 (P10-20): "a script counts rows against appendix C" is `scripts/ci/requirement-coverage.cs --check`, a Linux CI step; at close-out all 645 IDs are owned by a checklist row, with their levels and a legend mark.* +- *Dated correction, 2026-10-14 (P10-21, P10-22): Stryker.NET is not scheduled (it refuses Microsoft.Testing.Platform projects, stryker-net#3094) and `benchmarks/` is not built; both are post-release triggers in `docs/first-release.md`.* + ### Phase 11 — Deviation Reconciliation **Scope.** This phase asks whether the design documents' claims are true of the shipped code. Phase 10 asked a @@ -1815,3 +1821,15 @@ waivers (`transport-14.name-malformed-dropped`, design §10 entry 32; `transport - **Dated corrections** filed: design §2.1, §3.2, §3.6, §6.1, §8.2, §9.1 to §9.3, §10 entries 2, 9, 25 and 26, §11 items 8, 24, 30 and two new items, §12; `docs/first-release.md`; the phase 1, 2b, 5b, 8a and 8b checklists; `retry.md`, `transport.md`, `conformance.md`; `CLAUDE.md`; this roadmap's Phase 9 card, Phase List row and the phase-1 hand-off table's row 9. - **Hand-offs.** 10: `XCUT-10` cites `NoRetryingHandlerTests` and `ResilienceCompositionTests`; `XCUT-22` cites `PrimaryHandlerLeaseTests`; `XCUT-23` cites `SingleRegistrationTests`; `NFR-2` cites the audit's policy record and §10 entry 2's measurement; `NFR-8`/`NFR-9` keep `CheckPhase9Async`. 11: §10 entries 25 and 26 gain the as-built facts; the auditor decides whether `CFG-2`'s shadowing clause belongs in the unmet-MUST count; §11 item 30's reader is unbuilt (P9-20). 12: the package joins the lockstep release; the release notes name the `ShouldHandle` guidance and the no-reload rule; the `CallKey` trigger stays in `docs/first-release.md`. + +**2026-10-14 — Phase 10 exit (cross-cutting invariants and non-functional requirements).** Phase 10 (one phase, no sub-phases, P10-1) closes on branch `99-phase-10-cross-cutting-invariants` (issue #99): the **41 rows it owns** (`XCUT-1` to `-24`, `NFR-1` to `-17`; 26 MUST and 15 SHOULD), **39 ✅ (10 split: `XCUT-1`, `-4`, `-8`, `-13`, `-20`, `-23`, `-24`, `NFR-1`, `-4`, `-12`) / 0 🚫 / 2 ⏳ (`NFR-16`, phase 12; `NFR-17`, the repository ruleset) / 0 N/A**. The phase changes no production behaviour: its only `src/` edits are `Dexpace.Sdk.Conformance`, +two `BannedSymbols.txt` bans and the one scoped pragma in `BoundedMap.cs`. Checklist: [`phase10/…-checklist.md`](phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md); user page: `docs/sdk-documentation/cross-cutting-invariants.md`. + +- **Built.** `Dexpace.Sdk.Conformance` gains three suites with one result model (`TransportFace.None`, `SuiteRunnerCore`, `SuiteOptions`, `ConformanceReport.Combine`): the invariant suite (28 assertions over `InvariantSubject`'s core-defaulted hooks), the codec suite (11) and the packaging suite (8, over the packed `.nupkg`s with BCL readers), each assertion with a negative control; the core, dependency-injection and System.Text.Json drivers, each pinning its `NotExercised` list; + `scripts/ci/packaging-conformance.cs` and `scripts/ci/requirement-coverage.cs` with their CI steps; the cross-path reproducible pack (two `git worktree`s at different paths); `APPENDIX_B.md` over all 61 items and its five-rule gate; the `NFR-11` scan, `NFR4ArchitectureTests`, `LicenseHeaderTests`, `AnalyzerWaiverTests` and `CiWorkflowTests`; the `GetBaseException` and `ConcurrentDictionary` constructor bans; `CheckPhase10Async` in the AOT smoke. +- **Rulings taken by the lead (2026-10-14), recorded in the checklist:** every open ruling P10-1 to P10-30 at its recommended default, notably P10-2 (measure and route, no behaviour change), P10-5 (the core driver lives in `Core.Tests`), P10-21 and P10-22 (Stryker.NET and `benchmarks/` declined with triggers), P10-23 (`XCUT-24`'s error-snapshot clause declined), P10-24 and P10-25; and the plan's readings R2, R3, R5, R6, R7, R15 and R17. +- **Findings.** (1) **`XCUT-24`'s non-consuming clause is unmet for an error-body snapshot above 1 MiB** (drained, truncated without a marker, replaces the original): a SHOULD decline. (2) **`HttpResponseException(Response)` accepts a non-error status**; `XCUT-8` is read as the SDK's mapping path. (3) **`ServiceResponseException` is public and constructed nowhere.** (4) The spec's `XCUT-18` ("a name with HTAB is rejected") and `HTTP-17` ("surrounding whitespace is trimmed") meet at the edge of a name; core follows `HTTP-17` and the suite asserts it. + (5) The `main` ruleset has no `required_status_checks` rule (design V9, read before the phase; not re-read here). (6) `reproducible-pack.sh` could not see an embedded absolute path before this phase (the old script passes with `DeterministicSourcePaths` off on both packs; the new one fails). (7) The Digest cnonce source cannot be asserted by output. (8) 27 tests are skipped, not 22: the pinned `NotExercised` rows of the first-party invariant drivers. +- **Dated corrections** filed: the earlier checklists' hand-off rows (4a, 4c, 5b, 5c, 6a, 6b, 6c, 7b, 8a, 8b, 9: taken by phase 10); `docs/first-release.md`; `CLAUDE.md`; this roadmap's Phase 10 card and Phase List row; the knowledge notes. **Not made, proposed in the checklist for a human's deliberate edit:** the frozen design's §9, §9.1 to §9.3, §10 entry 7, three new §11 items and §12 notes. +- **Hand-offs.** 11: `ServiceResponseException` (delete, or argue it into §3.2); `XCUT-24`'s error-snapshot repair candidate; the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25 (both judged mechanisms today); `XCUT-8`'s reading and the HTAB edge as §11 items to confirm; F-A2 (`ASYNC-2`) unchanged; the 8b `TRACE` and parity over-budget questions unchanged; the coverage script reads 16 MUST rows as ⏳ in the row that owns them (`NFR-17` and hand-offs closed in a later checklist's differently shaped table, such as the `RECOV` rows in 6a's), so give those rows their dated corrections and tick the *Every MUST dispositioned* line in `docs/first-release.md`. 12: the one-time public-surface + choices gain the `HttpResponseException` constructor guard and `ServiceResponseException`; `NFR-4`'s baseline, `NFR-12`'s release half and `NFR-16` stay *After the first publish* and *Blockers*; **the lead's open action: add a `required_status_checks` rule naming the five CI jobs to the `main` ruleset (`NFR-17`'s merge half; request text in the checklist's *Open items*)**, a blocker if it is not done. diff --git a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md index 8adb5c58..7ad4a8a0 100644 --- a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md +++ b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md @@ -177,7 +177,8 @@ and hard-codes widened), R6 (the measured `.editorconfig` and pragma counts), R7 it back (`gh api repos/dexpace/dotnet-sdk/rulesets`) and record the result in this row; until then `NFR-17` is ⏳ and `docs/first-release.md` carries it as a blocker before the first publish. This phase did not apply it (no remote action was taken) and could not read it (deviation 9). 2. **Frozen-tree corrections are proposed, not made.** `docs/sdk-design-dotnet/` and `docs/product-spec/` are frozen to routine work, so the dated corrections the design lists (§9, §9.1 to §9.3, §10 entry 7's note on `ServiceResponseException`, a §11 item for `XCUT-8`'s reading (P10-24), a §11 item for the HTAB edge, the §12 notes) are listed under *Proposed corrections* below for a human's deliberate edit. -3. **The phase 11 inbound list** (also in the roadmap's dated status note): `ServiceResponseException` (P10-25); `XCUT-24`'s error-snapshot repair candidate (P10-23); the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25; `XCUT-8`'s reading as a §11 item to confirm (P10-24); the HTAB edge as a §11 item; F-A2 (`ASYNC-2`) unchanged. +3. **The phase 11 inbound list** (also in the roadmap's dated status note): `ServiceResponseException` (P10-25); `XCUT-24`'s error-snapshot repair candidate (P10-23); the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25; `XCUT-8`'s reading as a §11 item to confirm (P10-24); the HTAB edge as a §11 item; F-A2 (`ASYNC-2`) unchanged; the 16 MUST rows the coverage script reads as ⏳ in the row that owns them (`NFR-17`, and hand-offs closed in a later checklist's table that the script does not read as owning, such as the `RECOV` rows in 6a's), which need dated corrections before `docs/first-release.md`'s *Every MUST dispositioned* line can be ticked + (phase 10 left it open and built the gate). Phase 12's one-time public-surface choices gain the `HttpResponseException` constructor guard (P10-24) and `ServiceResponseException` (P10-25); `NFR-4`'s baseline, `NFR-12`'s release half and `NFR-16` stay *After the first publish* and *Blockers*. ## Proposed corrections (frozen trees) diff --git a/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md b/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md index 9c2835a4..a4bc47e0 100644 --- a/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md +++ b/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md @@ -47,3 +47,5 @@ Written 2026-10-07 from what was built, against the [design](2026-10-05-phase4a- - **Dated correction, 2026-10-07 (phase 5a, P5a-24):** the hand-off "5a may make `ContextStore.DefaultCapacity` configurable" is closed as declined. The store is process-wide, so a per-client option cannot size it coherently, and a process-wide setter is the global slot `CFG-13` declines; `CTX-11` to `CTX-13` require a bound, not a knob. - **Dated correction, 2026-10-08 (phase 6c):** the hand-off "6c's `AUTH-19` nonce store and token cache build on `BoundedMap`" is closed: `AccessTokenCache` keeps a `BoundedMap` of 1024 keys and `DigestChallengeHandler` a `BoundedMap` of 1024 nonces, both through the new internal `BoundedMapExtensions.GetOrAdd` (`BoundedMap` has no get-or-add). + +*Dated correction, 2026-10-14 (phase 10): `XCUT-14` ("phase 10, not a 4a row") is taken: the phase 10 checklist's `XCUT-14` row (the `xcut-14.*` invariants over a `BoundedMap` probe and the `ConcurrentDictionary` constructor ban). See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md b/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md index 9c7f3cac..3a9c6076 100644 --- a/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md +++ b/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md @@ -153,3 +153,5 @@ P4c-13's hand-off to 6c is closed: `AuthorizationPolicy.GetCredential` is abstra `PIPE-28`'s ⏳ 8b clause, the real synchronous terminal, is closed: `HttpPipeline.Send` reaches `SystemNetHttpClient.Execute`, which calls `HttpClient.Send` with no sync-over-async anywhere in the package (`SyncExecuteTests.Execute_runs_through_a_handler_that_implements_only_Send`; `FailureClassificationTests` drives the same pipeline rows through `SendAsync` and `Send`). The row is ✅. The documented bridge of this checklist's `SystemNetHttpClient.Execute` is gone. One residual is the runtime's: a handler that implements only `SendAsync` is not run by `Send` (design §3.2; `docs/first-release.md`, `sync-borrowed-handlers`). The row above stands as written. + +*Dated correction, 2026-10-14 (phase 10): `XCUT-8` (phase 10) is taken: the phase 10 checklist's `XCUT-8` row (`xcut-8.strict-factory-rejects` over `ErrorMapping.ToException`, and `xcut-8.convenience-returns-response`). The public `HttpResponseException(Response)` constructor accepting a non-error status is that phase's reading P10-24, a phase 12 public-surface choice. See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md b/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md index 6260318c..9301c918 100644 --- a/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md +++ b/docs/work/mvp/phase5/phase5b/2026-10-07-phase5b-logging-checklist.md @@ -188,3 +188,5 @@ filled to 113 with `http.transport.header_dropped` (110), `framing_header_droppe `OBS-35` is **✅** (a split, §10 entry 26 now touching it): `Logging:Level` is read by name only, case-insensitive and trimmed, from the section the caller passes (no baked key); a number or an unknown name fails the boot naming the key and listing the names (`LogLevelBindingTests`, `Unit`). **V4 was run** (phase 9 V1): both binder modes admit a numeric level and an undefined one, so the binder alone cannot satisfy the row; the DI package's hand-written reader does. + +*Dated correction, 2026-10-14 (phase 10): `XCUT-19`(c), (e), `XCUT-20` and `XCUT-24` (phase 10) are taken: the phase 10 checklist's rows of those IDs. `XCUT-24`'s non-consuming clause is declined for an error-body snapshot above 1 MiB (P10-23, `docs/first-release.md` *SHOULD declined*); body-log previews are asserted capped and non-consuming. See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase5/phase5c/2026-10-07-phase5c-tracing-checklist.md b/docs/work/mvp/phase5/phase5c/2026-10-07-phase5c-tracing-checklist.md index 306a2ca6..79ec979c 100644 --- a/docs/work/mvp/phase5/phase5c/2026-10-07-phase5c-tracing-checklist.md +++ b/docs/work/mvp/phase5/phase5c/2026-10-07-phase5c-tracing-checklist.md @@ -161,3 +161,5 @@ plus the new wire test `Without_a_runtime_listener_the_sdk_stamp_survives_even_a The `RETRY-*` row's "`IsExhausted` is the one method 6a replaces" is closed: `RetryPolicy.IsExhausted` is gone, the engine's observer calls `OperationTelemetry.RetriesExhausted` under the final predicate (the cap was spent while the condition and the re-send gate held and the effective retry count was above zero), and the three calls stay at the same decisions (`RetryTraceEventsTests.The_exhausted_event_follows_the_final_predicate`). The rows above stand as written at 5c's exit. + +*Dated correction, 2026-10-14 (phase 10): `XCUT-19` and `XCUT-20` (phase 10) are taken: the phase 10 checklist's rows of those IDs (`xcut-19.redaction-default-deny`, `xcut-20.observability-never-throws`; §11 item 37's reading stands). See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase6/phase6a/2026-10-08-phase6a-retry-checklist.md b/docs/work/mvp/phase6/phase6a/2026-10-08-phase6a-retry-checklist.md index a18c97ab..7d215319 100644 --- a/docs/work/mvp/phase6/phase6a/2026-10-08-phase6a-retry-checklist.md +++ b/docs/work/mvp/phase6/phase6a/2026-10-08-phase6a-retry-checklist.md @@ -205,3 +205,5 @@ The rulings below are the design's, taken as designed because the lead has not r | P6a-26 | both stacks stamp from ordinal 1 | built | | P6a-27 | the internal random seam | built as designed (open for the lead) | | P6a-33 | the interfaces assumed of 6b and 6c | unchanged by 6a; to be confirmed when 6b and 6c land | + +*Dated correction, 2026-10-14 (phase 10): `XCUT-1` to `XCUT-7`, `XCUT-9` and `XCUT-10` (phase 10) are taken: the phase 10 checklist's rows of those IDs each cite the 6a test they rest on and add the portable `xcut-*` assertions; `XCUT-9` also gains the `Exception.GetBaseException` ban in `src/`. See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md b/docs/work/mvp/phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md index 11e91818..d6b3875d 100644 --- a/docs/work/mvp/phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md +++ b/docs/work/mvp/phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md @@ -129,3 +129,5 @@ The exact allowed diff of `tests/Dexpace.Sdk.Core.Tests/Security` and `tests/Dex The lead had not ruled, so each landed as the design argued it: P6b-2, P6b-3 (the names `FollowSeeOther` and `AllowedMethods`), P6b-4, P6b-7, P6b-8, P6b-10 (two `Location` values), P6b-16, P6b-19 (the hop at `Information`), P6b-21, P6b-25 (the `Security` tag). + +*Dated correction, 2026-10-14 (phase 10): `XCUT-17` (10) is taken: the phase 10 checklist's `XCUT-17` row (`xcut-17.redirect-hygiene` through the subject pipeline, both faces). See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md b/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md index f2c5f372..cac6cebe 100644 --- a/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md +++ b/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md @@ -208,3 +208,5 @@ Run on the branch head after rebasing onto `main` at `29a7754` (6b merged), .NET | `tools/Dexpace.Tools.sln` build and test | 0 warnings; 438 tests passed | | `scripts/knowledge verify-structure` | OK (12 notes) | | `housekeeping probe` (full, then `--only links,citations`) | no drift found | + +*Dated correction, 2026-10-14 (phase 10): `XCUT-12`, `-14`, `-16`, `-18`, `-19`(d) and `-21` (10) are taken: the phase 10 checklist's rows of those IDs. `XCUT-21`'s CSPRNG-source clause stays cited by reference to `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` (an output test cannot tell it from `System.Random`). See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase7/phase7b/2026-10-09-phase7b-sse-checklist.md b/docs/work/mvp/phase7/phase7b/2026-10-09-phase7b-sse-checklist.md index 8c303e15..2feb0de6 100644 --- a/docs/work/mvp/phase7/phase7b/2026-10-09-phase7b-sse-checklist.md +++ b/docs/work/mvp/phase7/phase7b/2026-10-09-phase7b-sse-checklist.md @@ -182,3 +182,4 @@ The lead had not ruled, so each landed as the design argued it: **P7b-5** (no ag > The regression is `Security/ServerSentEventLineCapTests` (an unterminated 8 MiB line, a line of exactly the cap, a custom cap), permanent. The line layer under it is the byte-level reader the issue's `StreamReader.ReadLineAsync` sketch lacked: a CR ends a line at once > and a CRLF split across reads is one terminator (row `SSE-2`, proven on a real socket), and a leading BOM is consumed once (row `SSE-12`). One residual is documented and left open: the cap bounds a line, not an event (`sse.md`, P7b-5). +*Dated correction, 2026-10-14 (phase 10): `XCUT-1` (10) is taken: the phase 10 checklist's `XCUT-1` row cites `ServerSentEventStreamFailureTests` beside `xcut-1.cancel-is-terminal`. See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md b/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md index 0448419e..b2d07e2c 100644 --- a/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md +++ b/docs/work/mvp/phase8/phase8a/2026-10-09-phase8a-conformance-kit-checklist.md @@ -210,3 +210,5 @@ hook (`transport-8`, `-15.borrowed`, `-18`, `-22`, `-30`) and two `Vacuous` (`se The third driver is built: `tests/Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/` runs the kit over the transport `AddDexpaceClient` builds from `IHttpClientFactory`, green on both faces with the two permanent assertion-scoped waivers of the SystemNet drivers; `transport-1.redirect-not-followed` passes on both faces, and `transport-15.owned-released` is what the container-owned primary-handler lease earns (it failed on the driver's first build). `transport-15.borrowed-survives` is `NotExercised` there: the package never borrows a client. + +*Dated correction, 2026-10-14 (phase 10): `XCUT-1`, `XCUT-2`, `XCUT-4` and `XCUT-22` (10 and 5b) are taken by phase 10 (`OBS-19` stays 5b's): the phase 10 checklist's rows of those IDs cite `transport-3`, `transport-4`, `transport-20.no-response-is-retryable` and `transport-15.borrowed-survives` by reference. The kit now has four suites (the invariant, codec and packaging suites beside the transport suite) and the requirement-coverage script is the row-count gate. See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md b/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md index 5138b45d..287cc84c 100644 --- a/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md +++ b/docs/work/mvp/phase8/phase8b/2026-10-09-phase8b-reference-transport-checklist.md @@ -155,3 +155,5 @@ and the roadmap's Phase Status Notes and Phase List row. `TRANSPORT-1` stays **✅**, with the named-client clause added: the DI package's primary handler is `SystemNetHttpClient.CreatePrimaryHandler` (`AllowAutoRedirect = false`) and the handler constructor's check refuses a caller-substituted redirect-following primary at first resolution (`NamedClientTests`, `NamedClientRedirectWireTests` (`Security`), `transport-1.redirect-not-followed` on the third driver). **P8b-18's disposition:** the `CallKey` stamp is **not built** in phase 9 either (P9-20): phase 9 registers no `DelegatingHandler` and reads no call context below the transport, so there is still no reader; it is a post-release trigger in `docs/first-release.md`. + +*Dated correction, 2026-10-14 (phase 10): The hand-off to 10 (audit `XCUT-2`, `XCUT-18` and `XCUT-22` against this checklist) is taken: the phase 10 checklist's rows of those IDs cite `SendFailuresTests`, `HeaderPartitionTests`, `HeaderDropLogTests` and `SystemNetHttpClientOwnershipTests`. See the [phase 10 checklist](../../phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md).* diff --git a/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md b/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md index ae8c4e56..3b7a211d 100644 --- a/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md +++ b/docs/work/mvp/phase9/2026-10-12-phase9-host-integration-checklist.md @@ -54,7 +54,7 @@ clauses are §10 entry 26 and `CFG-2`'s fall-through is entry 25's judged mechan |---|---|---| | `SEAM-7` (2b) | N/A stays; each client's transport, codec, pipeline and options are keyed singletons created once ("an unresolved state stays re-evaluable" has nothing to apply to) | `KeyedSingletonTests` (`Unit`): 64 concurrent first resolutions run each factory once | | `TRANSPORT-1` (8b) | ✅; the named-client clause added | `PrimaryHandlerTests` (SystemNet suite, `Unit`), `NamedClientTests` (`Unit`), `NamedClientRedirectWireTests` (`Security`), `transport-1.redirect-not-followed` on the third driver (`Conformance`) | -| `XCUT-10` (10) | ⏳ 10; phase 9's clause ✅ | `NoRetryingHandlerTests` (`Unit`), `ResilienceCompositionTests` (`Integration`) | +| `XCUT-10` (10) | ✅ taken by phase 10 (dated correction, 2026-10-14; it was ⏳ 10): the phase 10 checklist's `XCUT-10` row, with `xcut-10.safety-gate-uniform` passing over the DI-built pipeline; phase 9's clause ✅ | `NoRetryingHandlerTests` (`Unit`), `ResilienceCompositionTests` (`Integration`) | | 5a: bind the records | taken by a hand-written reader (a third route: neither binder mode serves, V1) | the reader's tests (`RecordBindingTests`, the grammar tests) | | 5a (P5a-4): `IValidateOptions` | the reader's failures plus the wiring checks in one validator; no cross-property failure (P9-10) | `ValidationTests` (`Unit`) | | 5a (P5a-16), 8b (P8b-4): `FromEnvironment` | code-only `UseProxyFromEnvironment()` and `(lookup)`; no configuration switch (P9-15) | `ProxyRegistrationTests`, `NoImplicitEnvironmentReadArchitectureTests` (`Unit`) | From a27525a52d4f923886da5988d76a34774ef52c12 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:20:09 +0300 Subject: [PATCH 31/45] ci: requirement-coverage check blocks every pull request (phase 10, #99) Two Linux-row steps after the knowledge structure check: the coverage self-test and requirement-coverage.cs --check. CiWorkflowTests gains both rows in its gate table and its conditional allow-list in the same change; dropping the check turns it red. --- .github/workflows/ci.yml | 11 +++++++++++ .../Gates/CiWorkflowTests.cs | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4f43410..7bedcc92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ name: CI # System.Text.Json adapter (tests/Dexpace.Sdk.AotSmoke/SmokeChecks.Phase10.cs); NFR-11's public-signature type scan is # tests/Dexpace.Sdk.Core.Tests/Architecture/Nfr11PublicSurfaceArchitectureTests.cs; the packaging suite runs after Pack (below); and # tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs fails if a gate here is dropped or made advisory (NFR-17). +# The requirement-coverage check (scripts/ci/requirement-coverage.cs --check, with its self-test) blocks every pull request on the Linux row. on: push: @@ -145,6 +146,16 @@ jobs: if: runner.os == 'Linux' run: scripts/knowledge verify-structure + # Blocking. Every ID of appendix C is owned by a checklist row under docs/work/ with its level and a legend mark (roadmap phase 10 exit); a + # structural check over committed files, so one row suffices. The self-test first proves the check fails on each kind of finding. + - name: Requirement coverage self-test + if: runner.os == 'Linux' + run: scripts/ci/requirement-coverage-selftest.sh + + - name: Requirement coverage + if: runner.os == 'Linux' + run: dotnet run scripts/ci/requirement-coverage.cs -- --check + - name: Upload test results if: always() uses: actions/upload-artifact@v7 diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs index ba216b63..9d8201a5 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs @@ -16,7 +16,7 @@ namespace Dexpace.Sdk.Conformance.Tests.Gates; public sealed partial class CiWorkflowTests { /// - /// The gates: the step's name, and the command that must appear in its run:. The requirement-coverage gates join with the commit that adds + /// The gates: the step's name, and the command that must appear in its run:. The requirement-coverage gates joined with the commit that added /// their steps (plan 4.9, R8). /// internal static IReadOnlyList<(string Step, string Command)> Gates { get; } = @@ -35,6 +35,8 @@ public sealed partial class CiWorkflowTests ("Tools - format", "dotnet format tools/Dexpace.Tools.sln --verify-no-changes"), ("Tools - test", "dotnet test --solution tools/Dexpace.Tools.sln --configuration Release"), ("Knowledge corpus structure", "scripts/knowledge verify-structure"), + ("Requirement coverage self-test", "scripts/ci/requirement-coverage-selftest.sh"), + ("Requirement coverage", "dotnet run scripts/ci/requirement-coverage.cs -- --check"), ("Pack twice and compare", "scripts/ci/reproducible-pack.sh"), ("Publish", "dotnet publish tests/Dexpace.Sdk.AotSmoke/Dexpace.Sdk.AotSmoke.csproj"), ("Run", "./artifacts/aot-smoke/Dexpace.Sdk.AotSmoke"), @@ -44,6 +46,8 @@ public sealed partial class CiWorkflowTests internal static IReadOnlyDictionary Conditionals { get; } = new Dictionary(StringComparer.Ordinal) { ["Knowledge corpus structure"] = "runner.os == 'Linux'", + ["Requirement coverage self-test"] = "runner.os == 'Linux'", + ["Requirement coverage"] = "runner.os == 'Linux'", ["Upload test results"] = "always()", }; From ab8be720ee6e0360f06d9099bc726b72e3f322c4 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:24:54 +0300 Subject: [PATCH 32/45] docs: record the phase 10 gate results in the checklist (phase 10, #99) --- ...026-10-13-phase10-cross-cutting-invariants-checklist.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md index 7ad4a8a0..0370b5e6 100644 --- a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md +++ b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md @@ -189,5 +189,8 @@ Each is dated 2026-10-14 and append-only when a human makes it. **Design §9**: ## Gates run -The final figures are recorded in the last commit of this phase; the group gates before it were: after the invariant suite and the core and DI drivers, 7 149 tests, 0 failed, 27 skipped, coverage 97.53%; after the codec suite, 7 231 tests, 0 failed, 27 skipped, coverage 97.49%; after the packaging suite and gates, 7 309 tests, 0 failed, 27 skipped, coverage 97.47% (`Dexpace.Sdk.Conformance` 97.19%, -`Dexpace.Sdk.Core` 97.78%, `Dexpace.Sdk.Extensions.DependencyInjection` 97.67%, `Dexpace.Sdk.Http.SystemNet` 95.17%, `Dexpace.Sdk.Serialization.SystemTextJson` 99.04%), the packaging step green over the five packages (eight assertions passed), the dependency audit `5 libraries conform`, `dotnet format --verify-no-changes` clean, and a NativeAOT publish with no warning whose binary prints `aot-smoke: all checks passed`. +Run from the worktree root at the last code commit (`a27525a`), one command at a time, with limited parallelism. `dotnet restore Dexpace.Sdk.sln --locked-mode` and the tools solution (4 of 6 projects up to date); `dotnet build Dexpace.Sdk.sln -c Release` (0 warnings, 0 errors: `RS0016`/`RS0017` for the kit's `PublicAPI.Unshipped.txt`, `RS0030` with the two new bans, `MA0051`, `CA2007`, `IDE0073`); `dotnet format --verify-no-changes` (clean); `dotnet test --solution Dexpace.Sdk.sln -c Release` +with coverage: **7 323 tests, 0 failed, 27 skipped** (the 22 transport-driver rows and the 5 pinned `NotExercised` invariant rows, deviation 2); the coverage gate **97.46% aggregate against 80%** (`Dexpace.Sdk.Conformance` 97.19%, `Dexpace.Sdk.Core` 97.77%, `Dexpace.Sdk.Extensions.DependencyInjection` 97.67%, `Dexpace.Sdk.Http.SystemNet` 95.17%, `Dexpace.Sdk.Serialization.SystemTextJson` 99.04%) and its self-test (four cases ok); +`dotnet pack` (5 packages) and the dependency audit (`5 libraries conform`); `scripts/ci/packaging-conformance.cs` (all eight packaging assertions and the appendix-B items it covers passed over the five packages); `scripts/ci/requirement-coverage.cs --check` (645 of 645 owned, no finding in a MUST row) and its self-test (14 cases ok); the tools solution build, format and tests (438 tests, 0 failed); +`scripts/knowledge verify-structure` (57 notes, every cited key live); `scripts/ci/reproducible-pack.sh` (from a clean tree: five packages byte-identical across two checkouts at different paths); the NativeAOT publish (no trim or AOT warning) and its binary (`aot-smoke: all checks passed`, exit 0); the housekeeping probe (`no drift found`). `git diff --stat main...HEAD` over the three `Security` folders is empty, over `Directory.Packages.props` is empty, +over `src/` is the kit and `Internal/BoundedMap.cs` (two lines) only, over the `PublicAPI.Unshipped.txt` files is the kit's only, and over the lock files is `Dexpace.Sdk.Core.Tests` and `Dexpace.Sdk.Serialization.SystemTextJson.Tests` (six lines each, the new kit reference). The group gates before it (invariant suite and drivers: 7 149 tests, coverage 97.53%; codec suite: 7 231, 97.49%; packaging suite and gates: 7 309, 97.47%) were green likewise. From a54aaf22a70a151e62380484980357ee48ce2dac Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:46:55 +0300 Subject: [PATCH 33/45] fix: xcut-17 composes the subject's auth step under the redirect loop and covers a retried downgrade (phase 10, #99) --- .../Assertions/SecurityInvariants.cs | 72 +++++++++++++++++-- .../Subjects/AuthPolicyFactories.cs | 6 +- .../Subjects/InvariantSubject.cs | 6 +- .../Controls/BrokenPipelines.cs | 35 +++++++++ .../Controls/InvariantSecurityControlTests.cs | 10 +++ 5 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs index 0563597b..a0313a74 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs @@ -14,8 +14,9 @@ namespace Dexpace.Sdk.Conformance; /// -/// XCUT-16 to XCUT-19: no credential over a non-https scheme, credential hygiene on every redirect hop, header names -/// and values validated before a transport sees them, and secrets redacted by default in every rendering. +/// XCUT-16 to XCUT-19: no credential over a non-https scheme, credential hygiene on every redirect hop (the redirect +/// loop on its own, and with the subject's auth step under it), header names and values validated before a transport sees them, +/// and secrets redacted by default in every rendering. /// internal static class SecurityInvariants { @@ -92,11 +93,70 @@ private static async Task RedirectHygieneAsync(InvariantContext context, Cancell var refused = await context.FailureOfAsync(context.Build(downgrade, options), seed, "a redirect from https to http", token).ConfigureAwait(false); Check.True(refused is not null, "an https to http redirect must be refused unless the downgrade is enabled", "a failure", "the call succeeded"); Check.Equal(downgrade.Sends, 1, "the requests sent for a refused downgrade (the http hop is never sent)"); + + // XCUT-17(b), the half the hand-seeded headers above cannot show: the subject's own credential-stamping step under the redirect loop. + await AuthUnderRedirectAsync(context, "a Basic step", "Basic ", () => context.Subject.Auth.Basic("svc-user", "svc-secret"), token).ConfigureAwait(false); + await AuthUnderRedirectAsync(context, "a bearer step", "Bearer ", () => context.Subject.Auth.Bearer(new GatedCredential(context.Time, gated: false)), token).ConfigureAwait(false); + } + + // The auth step sits inside the redirect loop, so it stamps every hop it judges to be the seed's origin and none it does not: the + // same-origin re-issue is stamped again, a foreign hop (and a retry of one) carries no credential, and coming back to the seed's + // origin stamps again. The first request of each call must carry the stamp, or "no credential on the hop" would prove nothing. + private static async Task AuthUnderRedirectAsync(InvariantContext context, string step, string scheme, Func auth, CancellationToken token) + { + var seed = Request.Get("https://a.example.test/start") + .WithHeader("Cookie", "sid=" + SecurityVectors.CookieSecret) + .WithHeader("Proxy-Authorization", "Basic " + SecurityVectors.AuthorizationSecret); + var plain = new DexpaceClientOptions { Retry = InvariantOptions.Fast(0) }; + + var sameOrigin = context.Transport(Script(("https://a.example.test/start", 302, "https://a.example.test/next"))); + await Followed(context, WithAuth(context, sameOrigin, plain, auth()), sameOrigin, seed, $"a same-origin redirect under {step}", token).ConfigureAwait(false); + AssertStamped(sameOrigin.Requests[0], scheme, $"the seed request under {step}"); + AssertStamped(sameOrigin.Requests[1], scheme, $"the same-origin re-issue under {step} (Authorization is stripped before the hop, and the credential is re-stamped by the auth step)"); + + var crossOrigin = context.Transport(Script(("https://a.example.test/start", 302, "https://b.example.test/x"))); + await Followed(context, WithAuth(context, crossOrigin, plain, auth()), crossOrigin, seed, $"a cross-origin redirect under {step}", token).ConfigureAwait(false); + AssertStamped(crossOrigin.Requests[0], scheme, $"the seed request under {step}"); + AssertNoCredentials(crossOrigin.Requests[1], $"a cross-origin redirect under {step} (no credential re-stamped on the foreign host)"); + + var there = context.Transport(Script( + ("https://a.example.test/start", 302, "https://b.example.test/x"), + ("https://b.example.test/x", 302, "https://a.example.test/back"))); + await Followed(context, WithAuth(context, there, plain, auth()), there, seed, $"a hop to a foreign origin and back under {step}", token).ConfigureAwait(false); + AssertNoCredentials(there.Requests[1], $"the foreign hop under {step}"); + AssertStamped(there.Requests[2], scheme, $"the hop back to the seed's origin under {step} (origin is judged against the seed, so the credential is re-stamped)"); + Check.True(!there.Requests[2].Headers.Contains("Cookie") && !there.Requests[2].Headers.Contains("Proxy-Authorization"), $"a stripped Cookie or Proxy-Authorization must not be restored on the hop back under {step}", "neither", Describe(there.Requests)); + + // A permitted https to http hop is foreign to the seed, so it is credential-free and the https guard does not refuse it; its retry + // re-sends the stripped request and must not attach the credential again. + var retried = context.Transport(Replies( + request => ProbeTransport.Empty(request, 302, new Headers.Builder().Add("Location", "http://a.example.test/next").Build()), + request => ProbeTransport.Empty(request, 503), + request => ProbeTransport.Empty(request, 200))); + var permitted = new DexpaceClientOptions { Retry = InvariantOptions.Fast(1), Redirect = new RedirectOptions { AllowHttpsToHttpDowngrade = true } }; + await Followed(context, WithAuth(context, retried, permitted, auth()), retried, seed, $"a permitted downgrade followed by a retry under {step}", token).ConfigureAwait(false); + Check.Equal(retried.Requests.Count, 3, $"the requests sent for a permitted downgrade whose first answer is a 503 under {step} (seed, the http hop, its retry)"); + AssertStamped(retried.Requests[0], scheme, $"the seed request under {step}"); + AssertNoCredentials(retried.Requests[1], $"the permitted https to http hop under {step}"); + AssertNoCredentials(retried.Requests[2], $"the retry of the permitted https to http hop under {step} (a retried foreign hop is re-sent with no credential)"); + } + + private static void AssertStamped(Request request, string scheme, string where) + { + var values = request.Headers.GetAll("Authorization"); + Check.True( + values.Count == 1 && values[0].StartsWith(scheme, StringComparison.OrdinalIgnoreCase), + $"{where} must carry exactly the credential its auth step stamps", + $"one Authorization starting with \"{scheme}\"", + values.Count == 0 ? "no Authorization" : string.Join(" | ", values.Select(value => value.Length > scheme.Length ? value[..scheme.Length] + "..." : value))); } - private static async Task Followed(InvariantContext context, ProbeTransport transport, DexpaceClientOptions options, Request seed, string what, CancellationToken token) + private static Task Followed(InvariantContext context, ProbeTransport transport, DexpaceClientOptions options, Request seed, string what, CancellationToken token) => + Followed(context, context.Build(transport, options), transport, seed, what, token); + + private static async Task Followed(InvariantContext context, HttpPipeline pipeline, ProbeTransport transport, Request seed, string what, CancellationToken token) { - var failure = await context.FailureOfAsync(context.Build(transport, options), seed, what, token).ConfigureAwait(false); + var failure = await context.FailureOfAsync(pipeline, seed, what, token).ConfigureAwait(false); Check.True(failure is null, $"{what} must be followed", "success", Outcome.Name(failure)); Check.True(transport.Requests.Count >= 2, $"{what} must be re-issued", "at least 2 requests", transport.Requests.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)); } @@ -239,6 +299,10 @@ private static ProbeTransport.Behavior Script(params (string From, int Status, s : ProbeTransport.Empty(request, hop.Status, new Headers.Builder().Add("Location", hop.Location!).Build()); }); + // The n-th send is answered by the n-th reply; the last one answers every send after it. + private static ProbeTransport.Behavior Replies(params Func[] replies) => + ProbeTransport.Behavior.Of((request, send) => replies[Math.Min(send, replies.Length) - 1](request)); + private static string Describe(IReadOnlyList requests) => string.Join("; ", requests.Select(request => $"{request.Url.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.Unescaped)} [{string.Join(", ", request.Headers.Names)}]")); } diff --git a/src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs b/src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs index 9de92ca1..ceed9a76 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/AuthPolicyFactories.cs @@ -9,8 +9,10 @@ namespace Dexpace.Sdk.Conformance; /// /// The credential-stamping policies the invariant suite drives for XCUT-16 and XCUT-17: one that stamps a -/// credential it already holds and one that fetches a token first. A negative control replaces one with a policy that stamps (or -/// fetches) before it checks the scheme. +/// credential it already holds and one that fetches a token first. XCUT-16 sends through each over http:// and over +/// a permitted https-to-http hop; XCUT-17 composes each under the redirect loop and requires it to stamp the seed's origin +/// again after a hop and to put no credential on a foreign host or on a retry of a foreign hop. A negative control replaces one +/// with a policy that stamps (or fetches) before it checks the scheme, or that stamps across origins. /// public sealed record AuthPolicyFactories { diff --git a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs index 34d24086..9a244a7a 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs @@ -144,7 +144,11 @@ public ModelFactories Models } } = new(); - /// The credential-stamping policies XCUT-16 and XCUT-17 drive. Default: and . + /// + /// The credential-stamping policies XCUT-16 (no credential over http) and XCUT-17 (re-stamped by the auth + /// step on the seed's origin, absent on a foreign host) drive, each added to the pillar the subject's pipeline leaves open. + /// Default: and . + /// public AuthPolicyFactories Auth { get; diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs index 127d3a5d..9ff3445a 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs @@ -388,6 +388,41 @@ public override async ValueTask ProcessAsync(Request request, Pipeline private static string Origin(Uri url) => $"{url.Scheme}://{url.Host}:{url.Port}"; } + /// What a broken credential-stamping step gets wrong under a redirect loop (XCUT-17(b)). + internal enum RestampBug + { + /// It stamps its credential on every request, the foreign origin's included. + AcrossOrigins, + + /// It stamps only the seed's own request, so a same-origin re-issue goes out without the credential the auth step owes it. + OnlyTheSeedRequest, + + /// It withholds the credential from a foreign hop the first time, then attaches it when that hop is retried. + OnRetryAcrossOrigins, + } + + /// A credential-stamping step with one restamp bug (XCUT-17(b)): it stamps as the Authorization value. + internal sealed class BrokenRestamp(RestampBug bug, string header) : HttpPipelinePolicy + { + private readonly HashSet _foreignSeen = []; + + public override PipelineStage Stage => PipelineStage.Auth; + + public override ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var sameOrigin = Origin(request.Url) == Origin(context.SeedRequest.Url); + var stamp = bug switch + { + RestampBug.AcrossOrigins => true, + RestampBug.OnlyTheSeedRequest => request.Url == context.SeedRequest.Url, + _ => sameOrigin || !_foreignSeen.Add(request.Url.AbsoluteUri), + }; + return continuation.RunAsync(stamp ? request.WithHeader("Authorization", header) : request, context); + } + + private static string Origin(Uri url) => $"{url.Scheme}://{url.Host}:{url.Port}"; + } + /// A diagnostics step that logs every request header verbatim (XCUT-19). internal sealed class LogsHeadersVerbatim(Microsoft.Extensions.Logging.ILogger logger) : HttpPipelinePolicy { diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs index ef82fc5d..674c31de 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs @@ -19,6 +19,10 @@ public sealed class InvariantSecurityControlTests .. Both("xcut-17.redirect-hygiene", "re-issues with the caller's Authorization", "even a same-origin one", () => Redirecting(BrokenPipelines.RedirectBug.KeepsAuthorization)), .. Both("xcut-17.redirect-hygiene", "keeps Cookie and Proxy-Authorization across origins", "cross-origin redirect", () => Redirecting(BrokenPipelines.RedirectBug.KeepsCookieAcrossOrigins)), .. Both("xcut-17.redirect-hygiene", "judges the origin against the previous hop", "judged against the seed", () => Redirecting(BrokenPipelines.RedirectBug.JudgesAgainstPreviousHop)), + .. Both("xcut-17.redirect-hygiene", "re-stamps its credential on a foreign origin", "no credential re-stamped", () => Restamping(BrokenPipelines.RestampBug.AcrossOrigins)), + .. Both("xcut-17.redirect-hygiene", "re-stamps a bearer credential on a foreign origin", "no credential re-stamped", () => RestampingBearer(BrokenPipelines.RestampBug.AcrossOrigins)), + .. Both("xcut-17.redirect-hygiene", "stamps only the seed's own request", "re-stamped by the auth step", () => Restamping(BrokenPipelines.RestampBug.OnlyTheSeedRequest)), + .. Both("xcut-17.redirect-hygiene", "re-attaches its credential when a foreign hop is retried", "retried", () => Restamping(BrokenPipelines.RestampBug.OnRetryAcrossOrigins)), new("xcut-18.model-validation", TransportFace.None, "accepts HTAB in a header name", () => Modelling(new ModelFactories { OutboundHeader = (name, value) => new Headers.Builder().Add(name.Contains('\t', StringComparison.Ordinal) ? "X-Tab" : name, value).Build() }), Because: "outbound header name"), new("xcut-18.model-validation", TransportFace.None, "drops a CR from an outbound value and accepts it", () => Modelling(new ModelFactories { OutboundHeader = (name, value) => new Headers.Builder().Add(name, value.Replace("\r", string.Empty, StringComparison.Ordinal)).Build() }), Because: "outbound header value"), new("xcut-18.model-validation", TransportFace.None, "accepts a CR or LF in an inbound value", () => Modelling(new ModelFactories { InboundHeader = (name, value) => new Headers.Builder().AddInbound(name, value.Replace("\r", string.Empty, StringComparison.Ordinal).Replace("\n", string.Empty, StringComparison.Ordinal)).Build() }), Because: "inbound header value"), @@ -45,6 +49,12 @@ .. Both("xcut-17.redirect-hygiene", "judges the origin against the previous hop" private static InvariantSubject Redirecting(BrokenPipelines.RedirectBug bug) => BrokenPipelines.Subject("redirect: " + bug, _ => [new BrokenPipelines.BrokenRedirect(bug)]); + private static InvariantSubject Restamping(BrokenPipelines.RestampBug bug) => + new() { Name = "broken: restamp " + bug, Auth = new AuthPolicyFactories { Basic = (_, _) => new BrokenPipelines.BrokenRestamp(bug, "Basic c3ZjOnNlY3JldA==") } }; + + private static InvariantSubject RestampingBearer(BrokenPipelines.RestampBug bug) => + new() { Name = "broken: restamp bearer " + bug, Auth = new AuthPolicyFactories { Bearer = _ => new BrokenPipelines.BrokenRestamp(bug, "Bearer broken-token") } }; + private static InvariantSubject Modelling(ModelFactories models) => new() { Name = "broken: models", Models = models }; private static InvariantSubject Logging(Func extra) => From 42eece14e7e18542876d18dc0ad3692bb2d6a1c6 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:48:16 +0300 Subject: [PATCH 34/45] fix: xcut-18 accepts a refused or trimmed HTAB at the edge of a name, outbound and inbound alike (phase 10, #99) --- .../Assertions/SecurityInvariants.cs | 37 +++++++++++++++++-- .../Subjects/ModelFactories.cs | 8 +++- .../Controls/InvariantSecurityControlTests.cs | 17 +++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs index a0313a74..578cb123 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/SecurityInvariants.cs @@ -178,14 +178,18 @@ private static Task ModelValidationAsync(InvariantContext context, CancellationT RejectsArgument(() => models.OutboundHeader("X-" + c + "-Y", "v"), $"an outbound header name containing {SecurityVectors.Describe(c)} inside it"); RejectsArgument(() => models.InboundHeader("X-" + c + "-Y", "v"), $"an inbound header name containing {SecurityVectors.Describe(c)} inside it"); - // Surrounding whitespace (SP and HTAB) is trimmed before validation and the trimmed name stored (HTTP-17), so an HTAB at - // either end is not part of the name; every other character of the set is refused at the ends too. + // HTAB at the end of a name is where two MUSTs meet: XCUT-18 refuses a name with HTAB, and HTTP-17 trims an outbound + // (caller-set) name's surrounding whitespace before validating it. A model may do either, so the check is the safety property both + // share: the name is refused, or it is stored as exactly the trimmed token and never with the tab. The same is accepted + // of an inbound name, which HTTP-19 says stays strictly validated: pinning the trim there would assert what no row supports, and + // a model that refuses it follows the text, so the inbound trim is reported as a split clause (checklist, XCUT-18) and not as a pass condition. + // Every other character of the set is refused at the ends too. foreach (var edged in new[] { c + "X", "X" + c }) { if (c == '\t') { - Check.Equal(string.Join(",", models.OutboundHeader(edged, "v").Names), "X", "the stored name of an outbound header name with HTAB at one end (surrounding whitespace is trimmed, HTTP-17)"); - Check.Equal(string.Join(",", models.InboundHeader(edged, "v").Names), "X", "the stored name of an inbound header name with HTAB at one end (surrounding whitespace is trimmed, HTTP-17)"); + RefusesOrStoresTrimmed(() => models.OutboundHeader(edged, "v"), "an outbound header name with HTAB at one end (refused, or trimmed under HTTP-17)"); + RefusesOrStoresTrimmed(() => models.InboundHeader(edged, "v"), "an inbound header name with HTAB at one end (refused, or trimmed as the lenient path does)"); } else { @@ -211,6 +215,31 @@ private static Task ModelValidationAsync(InvariantContext context, CancellationT return Task.CompletedTask; } + // Refused with an argument error, or built with the one name "X": the trimmed token, never the name with its tab. + private static void RefusesOrStoresTrimmed(Func build, string what) + { + Headers? built = null; + Exception? thrown = null; + try + { + built = build(); + } +#pragma warning disable CA1031 // The point of the method: what the model does with a hostile character is the answer. + catch (Exception ex) +#pragma warning restore CA1031 + { + thrown = ex; + } + + if (thrown is not null) + { + Check.True(thrown is ArgumentException, $"{what} must be refused with an argument error or stored trimmed", "ArgumentException or the stored name X", Outcome.Name(thrown)); + return; + } + + Check.Equal(string.Join(",", built!.Names), "X", $"the stored name of {what}"); + } + private static void RejectsArgument(Func build, string what) { Exception? thrown = null; diff --git a/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs b/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs index 67e733b2..df642f2b 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/ModelFactories.cs @@ -17,13 +17,17 @@ public sealed record ModelFactories { /// /// Builds a header collection holding one field through the outbound path (name, value) and returns it; throws - /// for a name or value the outbound grammar refuses. Default: Headers.Builder.Add. + /// for a name or value the outbound grammar refuses. A name with HTAB at either end may be refused + /// or stored trimmed (XCUT-18 refuses HTAB in a name and HTTP-17 trims surrounding whitespace); it must not be stored + /// with the tab. Default: Headers.Builder.Add. /// public Func OutboundHeader { get; init; } = static (name, value) => new Headers.Builder().Add(name, value).Build(); /// /// Builds a header collection holding one field through the inbound (lenient) path; throws for a - /// control byte other than HTAB. Default: Headers.Builder.AddInbound. + /// control byte other than HTAB. A name with HTAB at either end is judged as on the outbound path: refused, or stored trimmed + /// (HTTP-19 keeps inbound names strictly validated, so the trim is reported as a split clause and not required). Default: + /// Headers.Builder.AddInbound. /// public Func InboundHeader { get; init; } = static (name, value) => new Headers.Builder().AddInbound(name, value).Build(); diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs index 674c31de..0cb83d83 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs @@ -26,6 +26,8 @@ .. Both("xcut-17.redirect-hygiene", "re-attaches its credential when a foreign h new("xcut-18.model-validation", TransportFace.None, "accepts HTAB in a header name", () => Modelling(new ModelFactories { OutboundHeader = (name, value) => new Headers.Builder().Add(name.Contains('\t', StringComparison.Ordinal) ? "X-Tab" : name, value).Build() }), Because: "outbound header name"), new("xcut-18.model-validation", TransportFace.None, "drops a CR from an outbound value and accepts it", () => Modelling(new ModelFactories { OutboundHeader = (name, value) => new Headers.Builder().Add(name, value.Replace("\r", string.Empty, StringComparison.Ordinal)).Build() }), Because: "outbound header value"), new("xcut-18.model-validation", TransportFace.None, "accepts a CR or LF in an inbound value", () => Modelling(new ModelFactories { InboundHeader = (name, value) => new Headers.Builder().AddInbound(name, value.Replace("\r", string.Empty, StringComparison.Ordinal).Replace("\n", string.Empty, StringComparison.Ordinal)).Build() }), Because: "inbound header value"), + new("xcut-18.model-validation", TransportFace.None, "stores an outbound name with HTAB at one end under another name", () => Modelling(new ModelFactories { OutboundHeader = (name, value) => new Headers.Builder().Add(EdgedByTab(name) ? "X-Edge" : name, value).Build() }), Because: "stored name of an outbound"), + new("xcut-18.model-validation", TransportFace.None, "stores an inbound name with HTAB at one end under another name", () => Modelling(new ModelFactories { InboundHeader = (name, value) => new Headers.Builder().AddInbound(EdgedByTab(name) ? "X-Edge" : name, value).Build() }), Because: "stored name of an inbound"), .. Both("xcut-19.redaction-default-deny", "redacts nothing", "redacted URL", () => new InvariantSubject { Name = "broken: allow-all redactor", RedactUrl = url => url }), .. Both("xcut-19.redaction-default-deny", "renders a credential with its secret", "must not reveal its secret", () => new InvariantSubject { Name = "broken: leaky credential", Credentials = [new CredentialSample("Leaky", "leaky-secret-123", () => "Leaky { Secret = leaky-secret-123 }")] }), .. Both("xcut-19.redaction-default-deny", "logs request headers verbatim", "logging must redact", () => Logging(logger => new BrokenPipelines.LogsHeadersVerbatim(logger))), @@ -46,6 +48,21 @@ .. Both("xcut-17.redirect-hygiene", "re-attaches its credential when a foreign h [MemberData(nameof(PositiveCases))] public Task The_default_subject_passes(string assertion, TransportFace face) => InvariantControlRow.RunPositiveAsync(assertion, face); + // XCUT-18 refuses a name with HTAB and HTTP-17 trims surrounding whitespace: a model may do either at the edge of a name (core + // trims; this one refuses), and both pass. Only a model that keeps the name under another form is caught. + [Fact] + public Task A_model_that_refuses_a_name_with_HTAB_at_either_end_passes() => + InvariantControlRow.RunPositiveAsync( + "xcut-18.model-validation", + TransportFace.None, + Modelling(new ModelFactories + { + OutboundHeader = (name, value) => EdgedByTab(name) ? throw new ArgumentException("a header name must be a token") : new Headers.Builder().Add(name, value).Build(), + InboundHeader = (name, value) => EdgedByTab(name) ? throw new ArgumentException("a header name must be a token") : new Headers.Builder().AddInbound(name, value).Build(), + })); + + private static bool EdgedByTab(string name) => name.Length > 0 && (name[0] == '\t' || name[^1] == '\t'); + private static InvariantSubject Redirecting(BrokenPipelines.RedirectBug bug) => BrokenPipelines.Subject("redirect: " + bug, _ => [new BrokenPipelines.BrokenRedirect(bug)]); From acd363800bd697c89450a382fd5564718d89de4a Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:50:03 +0300 Subject: [PATCH 35/45] fix: xcut-13.dispose-non-blocking fails a subject with nothing to close; every built component is closed (phase 10, #99) --- .../Assertions/LifecycleInvariants.cs | 113 +++++++++++++----- .../Subjects/InvariantSubject.cs | 2 +- .../InvariantLifecycleControlTests.cs | 67 +++++++++++ 3 files changed, 149 insertions(+), 33 deletions(-) diff --git a/src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs index 18ba911e..da22269d 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/LifecycleInvariants.cs @@ -34,22 +34,29 @@ private static async Task DisposeLatchedAsync(InvariantContext context, Cancella { using var body = new ReleaseCountingBody(parked: false); var disposables = context.Subject.CreateDisposables(body); - if (index >= disposables.Count) + try { - Check.True(index > 0, "the subject supplies no disposable component to close", "at least one", "none"); - return; - } + if (index >= disposables.Count) + { + Check.True(index > 0, "the subject supplies no disposable component to close", "at least one", "none"); + return; + } - var component = disposables[index]; - await CloseAsync(context, component, token).ConfigureAwait(false); - await CloseAsync(context, component, token).ConfigureAwait(false); - await Bounded.WaitForAllAsync( - [.. Enumerable.Range(0, InvariantOptions.Racers).Select(_ => CloseAsync(context, component, token).ContinueWith(static _ => 0, TaskScheduler.Default))], - context.Bound, - $"closing a {component.Kind} from {InvariantOptions.Racers} threads at once", - token).ConfigureAwait(false); + var component = disposables[index]; + await CloseAsync(context, component, token).ConfigureAwait(false); + await CloseAsync(context, component, token).ConfigureAwait(false); + await Bounded.WaitForAllAsync( + [.. Enumerable.Range(0, InvariantOptions.Racers).Select(_ => CloseAsync(context, component, token).ContinueWith(static _ => 0, TaskScheduler.Default))], + context.Bound, + $"closing a {component.Kind} from {InvariantOptions.Racers} threads at once", + token).ConfigureAwait(false); - Check.Equal(body.Releases, 1, $"the releases of the body behind a {component.Kind} closed twice and then from {InvariantOptions.Racers} threads at once (a close is latched)"); + Check.Equal(body.Releases, 1, $"the releases of the body behind a {component.Kind} closed twice and then from {InvariantOptions.Racers} threads at once (a close is latched)"); + } + finally + { + await CloseOthersAsync(context, disposables, index).ConfigureAwait(false); + } } } @@ -60,33 +67,75 @@ private static async Task DisposeNonBlockingAsync(InvariantContext context, Canc { using var body = new ReleaseCountingBody(parked: true); var disposables = context.Subject.CreateDisposables(body); - if (index >= disposables.Count) - { - return; - } - - var component = disposables[index]; - var parked = body.Parked!; - var stream = await body.OpenReadAsync(token).ConfigureAwait(false); - await using var streamScope = stream.ConfigureAwait(false); - var read = stream.ReadAsync(new byte[16], token).AsTask(); try { - await Bounded.WaitAsync(parked.ReadStarted, context.Bound, "a read to park on the body", token).ConfigureAwait(false); + if (index >= disposables.Count) + { + Check.True(index > 0, "the subject supplies no disposable component to close", "at least one", "none"); + return; + } - await Bounded.WaitAsync( - CloseAsync(context, component, token), - context.Bound, - $"closing a {component.Kind} while a read is parked on its body (a close must not wait for it)", - token).ConfigureAwait(false); + await CloseWithReadParkedAsync(context, body, disposables[index], token).ConfigureAwait(false); } finally { - parked.Release(); - await ObserveAsync(read).ConfigureAwait(false); + await CloseOthersAsync(context, disposables, index).ConfigureAwait(false); + } + } + } + + private static async Task CloseWithReadParkedAsync(InvariantContext context, ReleaseCountingBody body, DisposableUnderTest component, CancellationToken token) + { + var parked = body.Parked!; + var stream = await body.OpenReadAsync(token).ConfigureAwait(false); + await using var streamScope = stream.ConfigureAwait(false); + var read = stream.ReadAsync(new byte[16], token).AsTask(); + try + { + await Bounded.WaitAsync(parked.ReadStarted, context.Bound, "a read to park on the body", token).ConfigureAwait(false); + + await Bounded.WaitAsync( + CloseAsync(context, component, token), + context.Bound, + $"closing a {component.Kind} while a read is parked on its body (a close must not wait for it)", + token).ConfigureAwait(false); + } + finally + { + parked.Release(); + await ObserveAsync(read).ConfigureAwait(false); + } + + Check.Equal(body.Releases, 1, $"the releases of the body behind a {component.Kind} closed with a read parked on it"); + } + + // Each round builds a whole set over its own body and judges one member; the rest are closed when the round's counts have been read, so + // none outlives its round (they share the body, which is why they are not closed while the member under test is being counted). + private static async Task CloseOthersAsync(InvariantContext context, IReadOnlyList components, int underTest) + { + for (var i = 0; i < components.Count; i++) + { + if (i == underTest) + { + continue; } - Check.Equal(body.Releases, 1, $"the releases of the body behind a {component.Kind} closed with a read parked on it"); + try + { + if (context.Face == TransportFace.Blocking) + { + components[i].Dispose(); + } + else + { + await components[i].DisposeAsync().ConfigureAwait(false); + } + } +#pragma warning disable CA1031 // A member not under test this round is closed only so it outlives nothing; how it closes is judged in its own round. + catch (Exception) +#pragma warning restore CA1031 + { + } } } diff --git a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs index 9a244a7a..20e4744e 100644 --- a/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs +++ b/src/Dexpace.Sdk.Conformance/Subjects/InvariantSubject.cs @@ -238,7 +238,7 @@ public Func AsBlocking ///
public SeamResolution? SeamResolution { get; init; } -#pragma warning disable CA2000 // Ownership passes to the returned disposables, which the assertion closes. +#pragma warning disable CA2000 // Ownership passes to the returned disposables: each XCUT-13 round closes the one it judges and then every other (LifecycleInvariants.CloseOthersAsync). private static DisposableUnderTest[] DefaultDisposables(ResponseBody body) { var request = Request.Get("https://example.test/"); diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs index 96f0e11f..93b4cb0c 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs @@ -12,6 +12,8 @@ namespace Dexpace.Sdk.Conformance.Tests.Controls; [Trait("Category", "Unit")] public sealed class InvariantLifecycleControlTests { + private static readonly string[] s_kinds = ["first", "second", "third"]; + internal static IReadOnlyList Rows { get; } = [ .. Both("xcut-13.dispose-latched", "releases the resource on every close", "closed twice", () => WithDisposable(body => new DisposableUnderTest( @@ -69,6 +71,71 @@ public async Task The_bounded_map_assertions_are_NotExercised_naming_their_hook_ public Task A_conforming_bounded_map_passes(string assertion) => InvariantControlRow.RunPositiveAsync(assertion, TransportFace.None, new InvariantSubject { Name = "conforming map", CreateBoundedMap = BoundedProbes.Conforming }); + // A subject that supplies nothing to close has exercised nothing: both XCUT-13 assertions fail it, never pass it. + [Theory] + [InlineData("xcut-13.dispose-latched", TransportFace.Async)] + [InlineData("xcut-13.dispose-latched", TransportFace.Blocking)] + [InlineData("xcut-13.dispose-non-blocking", TransportFace.Async)] + [InlineData("xcut-13.dispose-non-blocking", TransportFace.Blocking)] + public async Task A_subject_that_supplies_no_disposable_fails_the_assertion(string assertion, TransportFace face) + { + var result = await InvariantSuite.RunAsync(new InvariantSubject { Name = "broken: nothing to close", CreateDisposables = _ => [] }, InvariantControlRow.Find(assertion), face, InvariantControlRow.Options, TestContext.Current.CancellationToken); + + Assert.Equal(ConformanceStatus.Failed, result.Status); + Assert.Contains("no disposable component", result.Detail, StringComparison.Ordinal); + } + + // The assertion builds a fresh set of components for each component it judges; every one it builds is closed, not only the one under test. + [Theory] + [InlineData("xcut-13.dispose-latched", TransportFace.Async)] + [InlineData("xcut-13.dispose-latched", TransportFace.Blocking)] + [InlineData("xcut-13.dispose-non-blocking", TransportFace.Async)] + [InlineData("xcut-13.dispose-non-blocking", TransportFace.Blocking)] + public async Task Every_component_the_assertion_builds_is_closed(string assertion, TransportFace face) + { + var built = new List(); + var subject = new InvariantSubject + { + Name = "conforming: tracked components", + CreateDisposables = body => + { + var set = s_kinds.Select(kind => new TrackedComponent(kind, (ReleaseCountingBody)body)).ToArray(); + built.AddRange(set); + return [.. set.Select(component => component.AsDisposable())]; + }, + }; + + await InvariantControlRow.RunPositiveAsync(assertion, face, subject); + + Assert.Equal(12, built.Count); + Assert.All(built, component => Assert.True(component.Closes > 0, $"the {component.Kind} component built for {assertion} on {face} was never closed")); + } + + private sealed class TrackedComponent(string kind, ReleaseCountingBody body) + { + private int _closes; + private int _released; + + public string Kind { get; } = kind; + + public int Closes => Volatile.Read(ref _closes); + + public DisposableUnderTest AsDisposable() => new(Kind, Close, () => + { + Close(); + return ValueTask.CompletedTask; + }); + + private void Close() + { + Interlocked.Increment(ref _closes); + if (Interlocked.Exchange(ref _released, 1) == 0) + { + body.ReleaseResource(); + } + } + } + private static InvariantSubject WithDisposable(Func make) => new() { Name = "broken: disposable", CreateDisposables = body => [make((ReleaseCountingBody)body)] }; From 4891ca0d9fcdc7e682c68af53d2741404c3e7b37 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:52:22 +0300 Subject: [PATCH 36/45] fix: xcut-24 requires a body preview in the log and a non-empty prefix of the body as the error snapshot (phase 10, #99) --- .../Assertions/HostInvariants.cs | 21 ++++++++++++++++++- .../Internals/PatternStream.cs | 13 +++++++++--- .../Controls/BrokenPipelines.cs | 13 ++++++++++++ .../Controls/InvariantHostControlTests.cs | 17 +++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs index d2f07676..bcccc9b8 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs @@ -192,7 +192,7 @@ private static Task ResolutionDeterministicAsync(InvariantContext context, Cance return Task.CompletedTask; } - // A preview is byte-capped and does not pull the body ahead of its reader; the reader still sees every byte. An error snapshot is byte-capped. + // A preview is byte-capped, carries the start of the body and does not pull the body ahead of its reader; the reader still sees every byte. An error snapshot is byte-capped, non-empty and a prefix of the body. private static async Task PreviewCappedNonConsumingAsync(InvariantContext context, CancellationToken token) { PatternStream? source = null; @@ -222,9 +222,20 @@ private static async Task PreviewCappedNonConsumingAsync(InvariantContext contex var seen = await ReadAndVerifyAsync(stream, token).ConfigureAwait(false); Check.Equal(seen, LargeBody, "the bytes the consumer read after a preview was taken (a preview does not disturb the primary read path)"); + // The preview is in the log: some line carries the start of the payload. A pipeline that takes no preview has none to cap. + if (!context.Logger.Entries.Any(entry => entry.Contains(PatternStream.Prefix, StringComparison.Ordinal))) + { + throw new ConformanceVacuousException("the pipeline logged no body preview at the Body level, so there was no preview to cap"); + } + var largest = context.Logger.Entries.Select(entry => entry.Message.Length).DefaultIfEmpty(0).Max(); Check.True(largest <= (PreviewCap * 8) + 4096, "a logged body preview must be byte-capped", $"at most {(PreviewCap * 8) + 4096} characters in any log line", largest.ToString(CultureInfo.InvariantCulture)); + await ErrorSnapshotAsync(context, token).ConfigureAwait(false); + } + + private static async Task ErrorSnapshotAsync(InvariantContext context, CancellationToken token) + { PatternStream? failing = null; var erroring = context.Transport(ProbeTransport.Behavior.Of((request, _) => { @@ -242,6 +253,14 @@ private static async Task PreviewCappedNonConsumingAsync(InvariantContext contex failing.BytesPulled.ToString(CultureInfo.InvariantCulture)); var kept = await Check.GuardAsync(() => http.Response.Body.ReadAsBytesAsync(token), "reading the error's snapshot", token).ConfigureAwait(false); Check.True(kept.Length <= Response.MaxBufferedErrorBytes, "the error's snapshot must be byte-capped", $"at most {Response.MaxBufferedErrorBytes} bytes", kept.Length.ToString(CultureInfo.InvariantCulture)); + Check.True(kept.Length > 0, "the error's snapshot must carry the truncated body, not an empty one", "the start of the body", "no bytes"); + for (var i = 0; i < kept.Length; i++) + { + if (kept[i] != PatternStream.At(i)) + { + Check.Fail("the error's snapshot must be a prefix of the body the server sent", $"byte {i} = {PatternStream.At(i)}", kept[i].ToString(CultureInfo.InvariantCulture)); + } + } } private static async Task ReadAndVerifyAsync(Stream stream, CancellationToken token) diff --git a/src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs b/src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs index a664f329..588557e3 100644 --- a/src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs +++ b/src/Dexpace.Sdk.Conformance/Internals/PatternStream.cs @@ -4,11 +4,18 @@ namespace Dexpace.Sdk.Conformance; /// -/// A forward-only stream of a fixed length whose byte at position i is i mod 251, generated on demand: a large body -/// that costs no memory to hold, whose every byte a consumer can verify, and that counts how much of it was pulled (XCUT-24). +/// A forward-only stream of a fixed length, generated on demand: a large body that costs no memory to hold, whose every byte a +/// consumer can verify, and that counts how much of it was pulled (XCUT-24). It opens with , printable +/// ASCII, so a log line or an error snapshot that carries the start of the body can be recognised as carrying it; every byte after +/// that at position i is i mod 251. /// internal sealed class PatternStream(long length) : Stream { + /// The opening of every body this stream produces: what a preview or a snapshot of it must show. + internal const string Prefix = "XCUT24-PREVIEW-MARKER|"; + + private static readonly byte[] s_prefix = System.Text.Encoding.ASCII.GetBytes(Prefix); + private long _position; private long _pulled; @@ -36,7 +43,7 @@ public override long Position /// The byte at . /// A zero-based position. - internal static byte At(long position) => (byte)(position % 251); + internal static byte At(long position) => position < s_prefix.Length ? s_prefix[position] : (byte)(position % 251); /// public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs index 9ff3445a..fbf44c85 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/BrokenPipelines.cs @@ -509,6 +509,19 @@ public override async ValueTask ProcessAsync(Request request, Pipeline } } + /// A step that hands the caller an error response whose body is not the server's: the given bytes in its place (XCUT-24's snapshot clause). + /// What the error response's body becomes. + internal sealed class ReplacesErrorBody(byte[] replacement) : HttpPipelinePolicy + { + public override PipelineStage Stage => PipelineStage.PerAttempt; + + public override async ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) + { + var response = await continuation.RunAsync(request, context); + return response.Status.IsError ? response.WithBody(ResponseBody.FromBytes(replacement, response.Body.ContentType)) : response; + } + } + /// A step that waits for work posted to the caller's synchronization context, which a single-threaded caller cannot run while it waits (NFR-11). internal sealed class BlocksOnTheCallersContext : HttpPipelinePolicy { diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs index 8b5e49f9..a22801de 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using Dexpace.Sdk.Conformance.Tests.Drivers; using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; using Xunit; namespace Dexpace.Sdk.Conformance.Tests.Controls; @@ -33,6 +34,8 @@ public sealed class InvariantHostControlTests new("xcut-23.resolution-deterministic", TransportFace.None, "lets a discoverable candidate beat the explicit one", () => Seam(new SeamResolution(Throws, Throws, () => false)), Because: "explicit install must win"), .. Both("xcut-24.preview-capped-non-consuming", "buffers the whole body to preview it", "materialize the payload", () => Previewing(new BrokenPipelines.ReadsWholeBody(onlyErrors: false))), .. Both("xcut-24.preview-capped-non-consuming", "buffers the whole error body for its snapshot", "error-body snapshot must be byte-capped", () => Previewing(new BrokenPipelines.ReadsWholeBody(onlyErrors: true))), + .. Both("xcut-24.preview-capped-non-consuming", "hands back an error with an empty body", "snapshot must carry the truncated body", () => Previewing(new BrokenPipelines.ReplacesErrorBody([]))), + .. Both("xcut-24.preview-capped-non-consuming", "hands back an error whose body is not the server's", "prefix of the body", () => Previewing(new BrokenPipelines.ReplacesErrorBody([0xFF, 0xFE, 0xFD]))), .. Both("nfr-11.scheduler-agnostic", "blocks on the caller's synchronization context", "single-threaded synchronization context", () => BrokenPipelines.Subject("blocks on the context", _ => [new BrokenPipelines.BlocksOnTheCallersContext()])), ]; @@ -59,6 +62,20 @@ public async Task The_resolution_assertion_is_NotExercised_naming_its_hook_when_ Assert.Contains(nameof(InvariantSubject.SeamResolution), result.Detail, StringComparison.Ordinal); } + // A subject that takes no body preview has no preview to cap: the assertion says so (Vacuous) and never passes it. + [Theory] + [InlineData(TransportFace.Async)] + [InlineData(TransportFace.Blocking)] + public async Task A_pipeline_that_logs_no_body_preview_is_vacuous_and_never_passed(TransportFace face) + { + var silent = BrokenPipelines.Subject("logs nothing", _ => [new ErrorMappingPolicy()]); + + var result = await InvariantSuite.RunAsync(silent, InvariantControlRow.Find("xcut-24.preview-capped-non-consuming"), face, InvariantControlRow.Options, TestContext.Current.CancellationToken); + + Assert.Equal(ConformanceStatus.Vacuous, result.Status); + Assert.Contains("no body preview", result.Detail, StringComparison.Ordinal); + } + [Fact] public Task A_conforming_resolution_passes() => InvariantControlRow.RunPositiveAsync("xcut-23.resolution-deterministic", TransportFace.None, Seam(new SeamResolution(Throws, Throws, () => true))); From c9f862550b2306b3148013d916debff823aa5a5f Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:53:27 +0300 Subject: [PATCH 37/45] fix: xcut-21 accepts any nonce of at least 128 bits in hex or base64, not only 32 lower-case hex characters (phase 10, #99) --- .../Assertions/HostInvariants.cs | 29 +++++++++++++++---- .../Controls/InvariantHostControlTests.cs | 22 ++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs b/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs index bcccc9b8..18ec7193 100644 --- a/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs +++ b/src/Dexpace.Sdk.Conformance/Assertions/HostInvariants.cs @@ -24,6 +24,7 @@ namespace Dexpace.Sdk.Conformance; internal static class HostInvariants { private const int Draws = 10_000; + private const int MinimumNonceBits = 128; private const long LargeBody = 10L * 1024 * 1024; private const int PreviewCap = 1024; private const int ReadAhead = 64 * 1024; @@ -91,8 +92,8 @@ private static async Task ObservabilityNeverThrowsAsync(InvariantContext context } } - // 128 bits of lower-case hex, never repeating over ten thousand draws. The source clause (the operating system's CSPRNG) is - // not an output property: it is cited by reference to DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator. + // At least 128 bits, in hex of either case or in base64 or base64url, and never repeating over ten thousand draws. The source clause (the + // operating system's CSPRNG) is not an output property: it is cited by reference to DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator. private static Task CnonceUnpredictableAsync(InvariantContext context, CancellationToken token) { var draw = context.Subject.DrawCnonce ?? DigestDraw(); @@ -100,11 +101,12 @@ private static Task CnonceUnpredictableAsync(InvariantContext context, Cancellat for (var i = 0; i < Draws; i++) { var value = draw() ?? string.Empty; + var bits = NonceBits(value); Check.True( - value.Length == 32 && value.All(c => c is (>= '0' and <= '9') or (>= 'a' and <= 'f')), - "a nonce must carry at least 128 bits, drawn as 32 lower-case hex characters", - "32 hex characters (128 bits)", - $"{value.Length} characters"); + bits >= MinimumNonceBits, + "a nonce must carry at least 128 bits (written as hex, base64 or base64url, so a longer draw or another case also counts)", + $"at least {MinimumNonceBits} bits", + bits == 0 ? $"{value.Length} characters that are neither hex nor base64" : $"{bits} bits in {value.Length} characters"); if (!seen.Add(value)) { Check.Fail("a nonce must not repeat: a strong source does not repeat within ten thousand draws", "no repeat", $"a repeat at draw {i + 1}"); @@ -114,6 +116,21 @@ private static Task CnonceUnpredictableAsync(InvariantContext context, Cancellat return Task.CompletedTask; } + // The whole bytes a nonce spells: 4 bits a hex digit, 6 bits a base64 or base64url character (padding is not a character). Anything else is + // not countable and reads as 0, so an encoding the suite cannot measure fails instead of passing. + private static int NonceBits(string value) + { + if (value.Length > 0 && value.All(Uri.IsHexDigit)) + { + return value.Length * 4 / 8 * 8; + } + + var characters = value.TrimEnd('='); + return characters.Length > 0 && characters.All(static c => c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '+' or '/' or '-' or '_') + ? characters.Length * 6 / 8 * 8 + : 0; + } + private static Func DigestDraw() { var handler = new DigestChallengeHandler(new DigestCredential("svc-user", "svc-secret")); diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs index a22801de..539f3564 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs @@ -20,6 +20,8 @@ public sealed class InvariantHostControlTests .. Both("xcut-20.observability-never-throws", "throws on a malformed URL", "must not throw", () => new InvariantSubject { Name = "broken: renderer", RedactUrl = url => new Uri(url).Host }), .. Both("xcut-20.observability-never-throws", "writes to the sink without a guard", "must not fail the request", () => Logging(logger => new BrokenPipelines.LogsHeadersVerbatim(logger))), new("xcut-21.cnonce-unpredictable", TransportFace.None, "draws 64 bits", () => Drawing(() => Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(8))), Because: "128 bits"), + new("xcut-21.cnonce-unpredictable", TransportFace.None, "draws 120 bits as base64url", () => Drawing(() => Base64Url(15)), Because: "128 bits"), + new("xcut-21.cnonce-unpredictable", TransportFace.None, "draws a string the suite cannot count", () => Drawing(() => "nonce:" + RandomNumberGenerator.GetHexString(32, lowercase: true)), Because: "128 bits"), new("xcut-21.cnonce-unpredictable", TransportFace.None, "cycles through eight values", () => Drawing(new Cycle().Next), Because: "must not repeat"), .. Both("xcut-22.core-borrows", "closes the asynchronous bridge's client", "asynchronous bridge", () => new InvariantSubject { Name = "broken: async bridge", AsAsync = client => new BrokenPipelines.DisposingAsyncBridge(client) }), .. Both("xcut-22.core-borrows", "closes the blocking bridge's client", "blocking bridge", () => new InvariantSubject { Name = "broken: blocking bridge", AsBlocking = client => new BrokenPipelines.DisposingBlockingBridge(client) }), @@ -84,6 +86,26 @@ public Task A_conforming_resolution_passes() => public Task A_supplied_cryptographic_draw_passes() => InvariantControlRow.RunPositiveAsync("xcut-21.cnonce-unpredictable", TransportFace.None, Drawing(() => RandomNumberGenerator.GetHexString(32, lowercase: true))); + // XCUT-21 asks for at least 128 bits, not for one encoding of them: a longer draw, upper-case hex and base64 / base64url all count. + [Fact] + public Task A_longer_hex_draw_passes() => + InvariantControlRow.RunPositiveAsync("xcut-21.cnonce-unpredictable", TransportFace.None, Drawing(() => RandomNumberGenerator.GetHexString(64, lowercase: true))); + + [Fact] + public Task An_upper_case_hex_draw_of_128_bits_passes() => + InvariantControlRow.RunPositiveAsync("xcut-21.cnonce-unpredictable", TransportFace.None, Drawing(() => RandomNumberGenerator.GetHexString(32, lowercase: false))); + + [Fact] + public Task A_base64url_draw_of_128_bits_passes() => + InvariantControlRow.RunPositiveAsync("xcut-21.cnonce-unpredictable", TransportFace.None, Drawing(() => Base64Url(16))); + + [Fact] + public Task A_padded_base64_draw_of_128_bits_passes() => + InvariantControlRow.RunPositiveAsync("xcut-21.cnonce-unpredictable", TransportFace.None, Drawing(() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)))); + + private static string Base64Url(int bytes) => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static void Throws() => throw new InvalidOperationException("no unambiguous candidate: install one explicitly"); private static InvariantSubject Drawing(Func draw) => new() { Name = "broken: draw", DrawCnonce = draw }; From 7947c0ba9f24de7165b295cca4f9a7a681fc577e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Wed, 14 Oct 2026 23:54:52 +0300 Subject: [PATCH 38/45] test: CiWorkflowTests pins the triggers and rejects a job-level if, continue-on-error or matrix exclude (phase 10, #99) --- .../Gates/CiWorkflowTests.cs | 143 +++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs b/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs index 9d8201a5..6f6777da 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs +++ b/tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs @@ -10,7 +10,9 @@ namespace Dexpace.Sdk.Conformance.Tests.Gates; /// NFR-17's first half (phase 10, P10-16, plan reading R8): every gate of the gate table is a step of .github/workflows/ci.yml, and no step or job is /// advisory. The second half, that a merge requires the checks, is a repository setting outside the file, recorded in the phase checklist. The workflow is read /// as text (no YAML package is added): the rules are line-based and documented here. A gate added to the workflow without a row below is not checked, and a row -/// whose step is renamed or loses its command fails: this is the "every blocking gate stays blocking" pin. +/// whose step is renamed or loses its command fails: this is the "every blocking gate stays blocking" pin. Two more pins keep the gates reachable: +/// the workflow must trigger on push and pull_request to main with no filter that skips a pull request, and no job may carry a +/// job-level if:, continue-on-error or matrix exclude: that would stop a job, or one operating-system leg, from running. /// [Trait("Category", "Unit")] public sealed partial class CiWorkflowTests @@ -60,7 +62,25 @@ public sealed partial class CiWorkflowTests [GeneratedRegex(@"^\s+if:\s*(?.+?)\s*$", RegexOptions.CultureInvariant)] private static partial Regex IfLine(); - private static string Workflow => File.ReadAllText(RepositoryRoot.Of(".github/workflows/ci.yml")); + [GeneratedRegex(@"^on:\s*$", RegexOptions.CultureInvariant)] + private static partial Regex OnBlockStart(); + + [GeneratedRegex(@"^ {2}(?[a-z_]+):\s*$", RegexOptions.CultureInvariant)] + private static partial Regex TriggerEvent(); + + [GeneratedRegex(@"^ {4}(?[a-z-]+):\s*(?.*?)\s*$", RegexOptions.CultureInvariant)] + private static partial Regex TriggerFilter(); + + [GeneratedRegex(@"^ {2}(?[A-Za-z0-9_-]+):\s*$", RegexOptions.CultureInvariant)] + private static partial Regex JobHeader(); + + [GeneratedRegex(@"^ {4}steps:\s*$", RegexOptions.CultureInvariant)] + private static partial Regex StepsKey(); + + [GeneratedRegex(@"^\s+(?:if|continue-on-error|exclude):", RegexOptions.CultureInvariant)] + private static partial Regex JobGuard(); + + private static string Workflow => File.ReadAllText(RepositoryRoot.Of(".github/workflows/ci.yml")).ReplaceLineEndings("\n"); [Fact] public void Every_gate_of_the_gate_table_is_a_ci_step() @@ -99,6 +119,22 @@ public void The_three_jobs_and_the_three_operating_systems_are_present() Assert.Contains("name: NativeAOT smoke", workflow, StringComparison.Ordinal); } + [Fact] + public void The_workflow_runs_on_every_pull_request_and_every_push_to_main() + { + Assert.Empty(TriggerProblems(Workflow)); + } + + [Fact] + public void No_job_can_be_skipped_whole_or_dropped_from_the_matrix() + { + var workflow = Workflow; + + Assert.Empty(JobProblems(workflow)); + // The jobs the other pins name exist, so the scan is not vacuous. + Assert.Equal(["build", "reproducible", "aot-smoke"], Jobs(workflow).Select(static job => job.Id)); + } + [Fact] public void The_checks_can_fail() { @@ -123,11 +159,112 @@ public void The_checks_can_fail() var conditional = workflow.Replace(" - name: Pack\n", " - name: Pack\n if: github.event_name == 'push'\n", StringComparison.Ordinal); Assert.NotEqual(workflow, conditional); Assert.Contains(UndocumentedConditions(conditional), static c => c.Contains("Pack", StringComparison.Ordinal)); + + // A workflow that no longer runs on pull requests. + var withoutPullRequests = workflow.Replace(" pull_request:\n branches: [main]\n", string.Empty, StringComparison.Ordinal); + Assert.NotEqual(workflow, withoutPullRequests); + Assert.Contains(TriggerProblems(withoutPullRequests), static p => p.Contains("pull_request", StringComparison.Ordinal)); + + // A trigger that skips some pull requests, or runs for another branch only. + var filtered = workflow.Replace(" pull_request:\n branches: [main]\n", " pull_request:\n branches: [main]\n paths-ignore: ['docs/**']\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, filtered); + Assert.Contains(TriggerProblems(filtered), static p => p.Contains("paths-ignore", StringComparison.Ordinal)); + var elsewhere = workflow.Replace(" push:\n branches: [main]\n", " push:\n branches: [release]\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, elsewhere); + Assert.Contains(TriggerProblems(elsewhere), static p => p.Contains("push", StringComparison.Ordinal)); + + // A job that is skipped on pull requests, whichever job it is, and a job that cannot fail the run. + var skippedBuild = workflow.Replace(" runs-on: ${{ matrix.os }}\n", " runs-on: ${{ matrix.os }}\n if: github.event_name == 'push'\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, skippedBuild); + Assert.Contains(JobProblems(skippedBuild), static p => p.Contains("build", StringComparison.Ordinal)); + var skippedReproducible = workflow.Replace(" name: reproducible packages\n", " name: reproducible packages\n if: github.event_name == 'push'\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, skippedReproducible); + Assert.Contains(JobProblems(skippedReproducible), static p => p.Contains("reproducible", StringComparison.Ordinal)); + var tolerant = workflow.Replace(" name: NativeAOT smoke\n", " name: NativeAOT smoke\n continue-on-error: true\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, tolerant); + Assert.Contains(JobProblems(tolerant), static p => p.Contains("aot-smoke", StringComparison.Ordinal)); + + // A matrix leg removed. + var excluded = workflow.Replace(" os: [ubuntu-latest, windows-latest, macos-latest]\n", " os: [ubuntu-latest, windows-latest, macos-latest]\n exclude:\n - os: windows-latest\n", StringComparison.Ordinal); + Assert.NotEqual(workflow, excluded); + Assert.Contains(JobProblems(excluded), static p => p.Contains("exclude", StringComparison.Ordinal)); } - private static List<(string Name, string Text)> Steps(string workflow) + private sealed record Job(string Id, string Settings, List<(string Name, string Text)> Steps); + + // The jobs, each as its own settings block (everything before its steps: name, strategy, runs-on, ...) and its own steps, so a job-level + // line is never read as part of the previous job's last step. + private static List Jobs(string workflow) { var lines = workflow.Split('\n'); + var first = Array.FindIndex(lines, static line => line.TrimEnd() == "jobs:"); + Assert.True(first >= 0, "ci.yml has no jobs: block"); + var headers = Enumerable.Range(first + 1, lines.Length - first - 1).Where(i => JobHeader().IsMatch(lines[i])).Append(lines.Length).ToArray(); + var jobs = new List(); + for (var j = 0; j < headers.Length - 1; j++) + { + var body = lines[headers[j]..headers[j + 1]]; + var steps = Array.FindIndex(body, static line => StepsKey().IsMatch(line)); + var settings = steps < 0 ? body : body[..steps]; + var stepLines = steps < 0 ? [] : body[steps..]; + jobs.Add(new Job(JobHeader().Match(lines[headers[j]]).Groups["id"].Value, string.Join('\n', settings), StepsOf(stepLines))); + } + + return jobs; + } + + // The triggers: each event under on:, with the filters it carries. Only push and pull_request to main, with no other filter, are accepted. + private static List TriggerProblems(string workflow) + { + var lines = workflow.Split('\n'); + var start = Array.FindIndex(lines, static line => OnBlockStart().IsMatch(line)); + if (start < 0) + { + return ["there is no on: block"]; + } + + var events = new Dictionary>(StringComparer.Ordinal); + List<(string Key, string Value)>? current = null; + foreach (var line in lines.Skip(start + 1).TakeWhile(static line => line.Length == 0 || line.StartsWith(' ') || line.StartsWith('#'))) + { + if (TriggerEvent().Match(line) is { Success: true } trigger) + { + current = []; + events[trigger.Groups["event"].Value] = current; + } + else if (TriggerFilter().Match(line) is { Success: true } filter && current is not null) + { + current.Add((filter.Groups["key"].Value, filter.Groups["value"].Value)); + } + } + + var problems = new List(); + foreach (var name in new[] { "push", "pull_request" }) + { + if (!events.TryGetValue(name, out var filters)) + { + problems.Add($"the workflow does not run on {name}"); + continue; + } + + problems.AddRange(filters.Where(static f => f.Key != "branches").Select(f => $"{name} carries the filter {f.Key}: a run can be skipped")); + if (!filters.Contains(("branches", "[main]"))) + { + problems.Add($"{name} does not run for exactly the branch main (branches: [main])"); + } + } + + return problems; + } + + // A job-level if:, continue-on-error or matrix exclude: stops a job, or one operating-system leg of it, from running or from failing the run. + private static List JobProblems(string workflow) => + [.. Jobs(workflow).SelectMany(job => job.Settings.Split('\n').Where(static line => JobGuard().IsMatch(line)).Select(line => $"job {job.Id}: {line.Trim()}"))]; + + private static List<(string Name, string Text)> Steps(string workflow) => [.. Jobs(workflow).SelectMany(static job => job.Steps)]; + + private static List<(string Name, string Text)> StepsOf(string[] lines) + { var starts = Enumerable.Range(0, lines.Length).Where(i => StepStart().IsMatch(lines[i])).Append(lines.Length).ToArray(); var steps = new List<(string, string)>(); for (var s = 0; s < starts.Length - 1; s++) From 55cf0e08e2dd643ad5033198aab52cda14aefa1f Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 15 Oct 2026 00:10:58 +0300 Subject: [PATCH 39/45] docs: first-release names the 16 MUST rows the coverage script reads as hourglass (phase 10, #99) --- docs/first-release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/first-release.md b/docs/first-release.md index 2c8ee682..91ff3361 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -49,7 +49,7 @@ targets `net10.0` only, set once in `Directory.Build.props` (PR #21), because .N vacuous with a design §10 or §11 citation; or listed below under *Unsatisfied MUSTs*. Phases 2–10 own the rows, and phase 10's row-count gate proves the total. **Phase 10 (2026-10-14) built the gate and it is green:** `dotnet run scripts/ci/requirement-coverage.cs -- --check` reports all 645 IDs of appendix C owned by a checklist row, each with its level and a legend mark, and no finding in a MUST row; it is a blocking Linux step of `ci.yml`. **The line stays open** because the script's tally still reads 16 MUST rows as ⏳ in the row that owns them: `NFR-17` (the ruleset line above), and hand-offs whose closing evidence sits in a later phase's checklist in a table - the script does not read as owning (the `RECOV` rows closed in 6a's table, `HTTP-44` and `HTTP-45` closed by a dated correction in the same cell). Phase 11 gives those rows their dated corrections and ticks this line; it also decides whether `NFR-17` is the only MUST left open. + the script does not read as owning (the 12 `RECOV` rows `RECOV-17` to `-24`, `-27`, `-28`, `-29` and `-34`, which 4b's table leaves ⏳ and 6a's table closes, and `BODY-4`, `BODY-5` and `BODY-10`, 3b's hand-offs to 6a, 6b, 6c and 3a: `BODY-4` and `BODY-5` carry a 2026-10-08 correction under 3b's table, not in the row, and `BODY-10` rests on 3a's `HTTP-39` row). `HTTP-44` and `HTTP-45` are not among them: 7a's checklist owns them last, at ✅. Phase 11 gives those 15 rows their dated corrections and ticks this line; it also decides whether `NFR-17` is the only MUST left open. - [ ] **Conformance green.** `Dexpace.Sdk.Conformance` passes against every shipped transport on every CI row, with waivers listed by requirement ID. It is written by phase 8a and completed by phase 10 (2026-10-14: the invariant, codec and packaging suites, with three first-party drivers that each pin their `NotExercised` list). - [ ] **Deviations audited.** `docs/deviations.md` exists and every row reads `as-built: …`. Phase 11. From f1a58c0b611138ad72465fa3103932169dd0770e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 15 Oct 2026 00:10:58 +0300 Subject: [PATCH 40/45] docs: phase 10 review resolution in the checklist, appendix B, user pages, changelog and notes (phase 10, #99) --- CHANGELOG.md | 3 ++ docs/knowledge/notes/testing.md | 4 ++ .../notes/tooling-and-quality-gates.md | 4 +- docs/sdk-documentation/conformance.md | 2 +- .../cross-cutting-invariants.md | 12 ++--- ...2026-09-27-dotnet-sdk-v1-roadmap-design.md | 7 +-- ...se10-cross-cutting-invariants-checklist.md | 54 ++++++++++++------- .../APPENDIX_B.md | 4 +- 8 files changed, 59 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03748130..2161fba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -424,6 +424,9 @@ Roadmap phase 7b, issue #10 (a later addition, not one of S1–S9), pinned by a fail. Three first-party drivers run them: the core driver, the dependency-injection driver (`xcut-23`) and the System.Text.Json driver, each pinning its expected `NotExercised` list; the NativeAOT smoke runs the invariant and codec suites (`CheckPhase10Async`). Gates: `scripts/ci/packaging-conformance.cs` (a CI step after Pack); `scripts/ci/requirement-coverage.cs --check` with its self-test (every one of the 645 requirements of appendix C is owned by a checklist row, with its level and a legend mark; a Linux CI step); `AppendixBCoverageTests` over the new `tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md` (61 items); `Nfr11PublicSurfaceArchitectureTests`, `NFR4ArchitectureTests`, `LicenseHeaderTests` (the `scripts/ci` apps included), `AnalyzerWaiverTests` and `CiWorkflowTests`. + Review resolution (2026-10-14): `xcut-17` composes the subject's Basic and bearer auth steps under the redirect loop and covers a permitted downgrade followed by a retry; `xcut-18` accepts a refusal or a trim for HTAB at the edge of a name + (`XCUT-18` and `HTTP-17` meet there; core's inbound trim is a possible `HTTP-19` gap, routed to phase 11); `xcut-13.dispose-non-blocking` fails a subject with nothing to close; `xcut-21` accepts any nonce of at least 128 bits in hex or base64; `xcut-24` requires a + body preview in the log and a non-empty prefix of the body as the error snapshot; `CiWorkflowTests` also pins the triggers and rejects a job-level `if:`, `continue-on-error` or matrix `exclude:`. `AppendixBMap` covers all 61 appendix-B items. `docs/sdk-documentation/cross-cutting-invariants.md` is the phase page (where each `XCUT` row is enforced, the gate table, how to run the suites over your own composition); `conformance.md` describes the suites; the checklist `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` has the 41 rows. Declined with triggers: Stryker.NET (it refuses Microsoft.Testing.Platform projects) and `benchmarks/`; `XCUT-24`'s non-consuming clause for an error-body snapshot above 1 MiB is a recorded SHOULD decline. diff --git a/docs/knowledge/notes/testing.md b/docs/knowledge/notes/testing.md index 5b4d1dad..af06221c 100644 --- a/docs/knowledge/notes/testing.md +++ b/docs/knowledge/notes/testing.md @@ -9,3 +9,7 @@ Hand-written. `../harvested/testing.md` is what the documents say; this is what review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-design.md` · high · sha:manual-p10-v5-stryker - **A hook an invariant suite cannot reach is `NotExercised`, never a pass, and a driver pins the list it leaves unexercised.** Two measured facts of the conformance suites, recorded with the testing notes because they change how the evidence reads: a `Dexpace.Sdk.Conformance` assertion whose hook a driver leaves `null` reports `NotExercised`, so each first-party driver pins the exact list it leaves unexercised and fails on any other (the core driver `xcut-23`; the DI driver `xcut-8.strict-factory-rejects`, `xcut-9`, `xcut-14` twice), which is why the full run skips 27 tests, not the transport drivers' 22. A suite that needs a cyclic exception chain walks it and never throws it: a thrown cyclic `InnerException` chain hangs the test host on Windows. review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-suites +- **An invariant assertion about a credential after a redirect needs the auth step composed, and it must not hand-seed the credential it is judging.** With `Authorization` set by hand on the seed the first stamp of an auth step overwrites it, so "stripped before every re-issue" and "re-stamped by the auth step" cannot both be read from one pass: `xcut-17` judges the redirect loop with hand-seeded headers and no auth step, then composes `Auth.Basic` and `Auth.Bearer` under it (seed carrying only Cookie and Proxy-Authorization) and checks the stamp on the seed's origin, none on a foreign host and none on a retried foreign hop. A suite that stops at the first pass cannot catch an auth step that re-stamps across origins (a control: `BrokenRestamp`). + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-review-xcut17 +- **A "closes everything it builds" claim over components that share one resource is checked after the count, and a vacuous pass is a failure.** The `xcut-13` components of a round share one release-counting body, so the others are closed only after the round's release count is read; an empty `CreateDisposables` fails both assertions (the non-blocking one once returned `Passed` having closed nothing). A first-party driver treats `Vacuous` as red, so a pipeline that loses its body preview turns `xcut-24` red, not silent. + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-review-xcut13 diff --git a/docs/knowledge/notes/tooling-and-quality-gates.md b/docs/knowledge/notes/tooling-and-quality-gates.md index 185b09a8..8b0dde69 100644 --- a/docs/knowledge/notes/tooling-and-quality-gates.md +++ b/docs/knowledge/notes/tooling-and-quality-gates.md @@ -9,5 +9,7 @@ Hand-written. `../harvested/tooling-and-quality-gates.md` is what the documents review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-design.md` · high · sha:manual-p10-v3-reproducible - **`main` has no `required_status_checks` rule, so a red CI job does not block a merge (`NFR-17`'s merge half).** Read with `gh api` before phase 10: no classic branch protection (404) and one active ruleset with `deletion`, `non_fast_forward`, `pull_request` (one approval, code-owner review, squash only) and `code_quality`. `ci.yml` has no `continue-on-error` and `CiWorkflowTests` pins that, so every gate fails its job; the second half is an admin-only repository setting naming `build & test (ubuntu-latest | windows-latest | macos-latest)`, `reproducible packages` and `NativeAOT smoke`, which phase 10 requested and did not apply (V9). review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-v9-ruleset -- **Surrounding whitespace in a header name is trimmed before validation, so `XCUT-18`'s "a name with HTAB is rejected" holds for HTAB inside a name only.** `Headers.Builder.Add` and `AddInbound` trim SP and HTAB at either end of a name per `HTTP-17` and store the trimmed name; HTAB or any other control inside a name, and any other control at an end, throws `ArgumentException`. The invariant suite's `xcut-18.model-validation` asserts that by position; a design §11 item is proposed. +- **Surrounding whitespace in a header name is trimmed before validation, so `XCUT-18`'s "a name with HTAB is rejected" holds for HTAB inside a name only, and the inbound trim is a possible `HTTP-19` gap.** `Headers.Builder.Add` and `AddInbound` trim SP and HTAB at either end of a name and store the trimmed name (one `WriteKey`); HTAB or any other control inside a name, and any other control at an end, throws `ArgumentException`. `HTTP-17` backs the trim for outbound names only; `HTTP-19` says inbound names stay strictly validated, so the inbound trim is unbacked and routed to phase 11. The invariant suite's `xcut-18.model-validation` therefore accepts a refusal or a trim at an edge (outbound and inbound) and pins neither; a design §11 item is proposed. review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-xcut18-htab +- **A text check of `ci.yml` must parse jobs, not steps: the triggers and a job's own settings sit above its first step and belong to none.** `CiWorkflowTests` once parsed from the first `- name:` line, so removing `pull_request:` from `on:` or adding a job-level `if:` to the build job left every test green while no gate ran on a pull request (shown by editing `ci.yml` and running the five tests). It now reads the `on:` block (only `push` and `pull_request` to `[main]`, no other filter) and each job's settings block (no `if:`, `continue-on-error` or matrix `exclude:`) apart from its steps, so a job-level line is never mistaken for part of the previous job's last step. + review · `docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md` · high · sha:manual-p10-review-ciworkflow diff --git a/docs/sdk-documentation/conformance.md b/docs/sdk-documentation/conformance.md index 06e51683..275193c6 100644 --- a/docs/sdk-documentation/conformance.md +++ b/docs/sdk-documentation/conformance.md @@ -313,7 +313,7 @@ and `ConformanceReport.Combine` merges the two reports by requirement, so no cla (`XCUT-9`; the suite only **walks** the exception and never throws it, because a thrown cyclic chain hangs the test host on Windows), `CreateBoundedMap` (`XCUT-14`, a `BoundedMapProbe` of insert, count and capacity) and `SeamResolution` (`XCUT-23`). An assertion whose hook is `null` is `NotExercised` with a reason naming the hook, never passed. - `DrawCnonce` is optional and is not one of the four: `null` means the suite draws through the public `DigestChallengeHandler.Authorize`, so `xcut-21` is always exercised. It judges the - draw's width (32 lower-case hex characters, 128 bits) and that ten thousand draws do not repeat; the operating-system CSPRNG source clause is cited by reference to + draw's width (at least 128 bits, written as hex of either case, base64 or base64url; core draws 32 lower-case hex characters) and that ten thousand draws do not repeat; the operating-system CSPRNG source clause is cited by reference to `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator`, because no output test can tell a CSPRNG from `System.Random`. **The catalogue.** `Faces` is `both` for the asynchronous and blocking surface of `HttpPipeline`, or `none` (`TransportFace.None`) for an assertion that judges a value and drives no call. diff --git a/docs/sdk-documentation/cross-cutting-invariants.md b/docs/sdk-documentation/cross-cutting-invariants.md index bc81058b..fbe7e407 100644 --- a/docs/sdk-documentation/cross-cutting-invariants.md +++ b/docs/sdk-documentation/cross-cutting-invariants.md @@ -27,18 +27,18 @@ The suite assertions are `invariant:` (the kit's `InvariantSuite`); a **split** | `XCUT-8` | `xcut-8.strict-factory-rejects` (hook `MapStrict`), `xcut-8.convenience-returns-response` | 4c `ErrorMappingFactoryTests` | the public `HttpResponseException` constructor accepts a 200: read as the SDK's mapping path, not a constructor a caller invokes (P10-24) | | `XCUT-9` | `xcut-9.cycle-safe-walk` (hook `CreateCyclicException`); `Exception.GetBaseException` is banned in `src/` (`RS0030`) | `ExceptionFactsTests` | none | | `XCUT-10` | `xcut-10.safety-gate-uniform` on the core and the DI drivers | 6a `RetryResendGateTests`; 9 `NoRetryingHandlerTests` | none | -| `XCUT-11`, `XCUT-12` | `xcut-11.shared-no-crosstalk`, `xcut-12.single-flight-scoped` | 4c `ConcurrencyTests`; 6c `AccessTokenCacheTests` | none | +| `XCUT-11`, `XCUT-12` | `xcut-11.shared-no-crosstalk`, `xcut-12.single-flight-scoped` | 4c `ConcurrencyTests`; 6c `AccessTokenCacheTests` | `XCUT-12`'s "cached reads take no lock" clause is cited by reference to `AccessTokenCacheTests.The_hot_path_takes_no_lock` and is not asserted by the suite | | `XCUT-13` | `xcut-13.dispose-latched`, `xcut-13.dispose-non-blocking` | `transport-16.close-idempotent-nonblocking` (by reference) | preserving an ambient interrupt flag is vacuous (design §10 entry 8) | | `XCUT-14` | `xcut-14.bounded-under-burst`, `xcut-14.drains-after-insert` (hook `CreateBoundedMap`); a second `ConcurrentDictionary` constructor outside `BoundedMap` is banned in `src/` (`RS0030`) | 4a `BoundedMapTests`; 6c `AccessTokenCacheTests` | none | | `XCUT-15` | `xcut-15.no-external-alias` | 2a `ModelImmutabilityArchitectureTests` | none | -| `XCUT-16`, `XCUT-17` | `xcut-16.no-credential-over-http`, `xcut-17.redirect-hygiene` | phase 1 `AuthHttpsGuardTests`, `RedirectWireTests` (`Security`); 6b `RedirectCredentialLeakTests` | none | -| `XCUT-18` | `xcut-18.model-validation` | phase 1 `HeaderInjectionValidationTests` (`Security`); 8b `HeaderPartitionTests` | surrounding HTAB or space on a name is trimmed before validation (`HTTP-17`); the dispatch-time re-check is the transport suite's (P10-6) | +| `XCUT-16`, `XCUT-17` | `xcut-16.no-credential-over-http`, `xcut-17.redirect-hygiene` (also composes the subject's Basic and bearer steps under the redirect loop: stamped again on the seed's origin, absent on a foreign host and on a retry of a foreign hop) | phase 1 `AuthHttpsGuardTests`, `RedirectWireTests` (`Security`); 6b `RedirectCredentialLeakTests` | none | +| `XCUT-18` | `xcut-18.model-validation` | phase 1 `HeaderInjectionValidationTests` (`Security`); 8b `HeaderPartitionTests` | HTAB at the edge of a name is where `XCUT-18` (reject) and `HTTP-17` (trim) meet, so the suite accepts a refusal or a name stored trimmed, outbound and inbound; core trims on both, and the inbound trim is a possible gap against `HTTP-19`, not pinned and routed to phase 11. The dispatch-time re-check is the transport suite's (P10-6) | | `XCUT-19` | `xcut-19.redaction-default-deny` | phase 1 `UrlRedactionDefaultDenyTests` (`Security`); 5b `HttpLoggingDefaultsTests` | none | | `XCUT-20` | `xcut-20.observability-never-throws` | 5b `EmissionGuardTests` | the SDK's own recording code is total; a listener's callbacks are `OBS-30`'s party (design §11 item 37) | -| `XCUT-21` | `xcut-21.cnonce-unpredictable` (optional hook `DrawCnonce`) | 6c `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` | the operating-system CSPRNG source is cited by reference: an output test cannot tell it from `System.Random` | +| `XCUT-21` | `xcut-21.cnonce-unpredictable` (optional hook `DrawCnonce`) | 6c `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` | the draw must carry at least 128 bits (hex of either case, base64 or base64url); the operating-system CSPRNG source is cited by reference: an output test cannot tell it from `System.Random` | | `XCUT-22` | `xcut-22.core-borrows` | `transport-15.borrowed-survives`; 9 `PrimaryHandlerLeaseTests` | none | | `XCUT-23` | `xcut-23.resolution-deterministic` (hook `SeamResolution`, supplied by the DI driver) | 9 `SingleRegistrationTests` | the discovery half is vacuous: a seam is a non-nullable parameter (design §10 entry 9, §11 item 8) | -| `XCUT-24` | `xcut-24.preview-capped-non-consuming` | 5b `BodyPreviewTests` | the non-consuming clause is **declined** for an error-body snapshot above 1 MiB, which is drained, truncated without a marker and replaces the original (P10-23, `docs/first-release.md` *SHOULD declined*) | +| `XCUT-24` | `xcut-24.preview-capped-non-consuming` | 5b `BodyPreviewTests` | the non-consuming clause is **declined** for an error-body snapshot above 1 MiB, which is drained, truncated without a marker and replaces the original (P10-23, `docs/first-release.md` *SHOULD declined*). A pipeline that logs no body preview is `Vacuous`, and the error's snapshot must be a non-empty prefix of the body | A finding the measurement made and no phase fixed (P10-25): `ServiceResponseException` is public, documented as part of `XCUT-4`'s taxonomy, and never thrown. It is routed to phase 11 (delete, or argue it as part of branch (b) in design §3.2) and to phase 12's public-surface choices. @@ -46,7 +46,7 @@ branch (b) in design §3.2) and to phase 12's public-surface choices. ## The gate table "Blocking" means the gate fails its CI job. Whether a failing job also blocks a **merge** is a repository setting (a ruleset's required status checks) outside the workflow file: `CiWorkflowTests` pins that every gate below -is a step of `ci.yml` and that no step or job is advisory, and the checklist records the setting as read. +is a step of `ci.yml`, that no step or job is advisory, that the workflow triggers on `push` and `pull_request` to `main` with no filter that skips a run, and that no job carries a job-level `if:`, `continue-on-error` or matrix `exclude:`; the checklist records the setting as read. | Gate | Requirement | Mechanism | State after phase 10 | |---|---|---|---| diff --git a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md index a55aafe3..6b625e5b 100644 --- a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md @@ -1822,14 +1822,15 @@ waivers (`transport-14.name-malformed-dropped`, design §10 entry 32; `transport - **Hand-offs.** 10: `XCUT-10` cites `NoRetryingHandlerTests` and `ResilienceCompositionTests`; `XCUT-22` cites `PrimaryHandlerLeaseTests`; `XCUT-23` cites `SingleRegistrationTests`; `NFR-2` cites the audit's policy record and §10 entry 2's measurement; `NFR-8`/`NFR-9` keep `CheckPhase9Async`. 11: §10 entries 25 and 26 gain the as-built facts; the auditor decides whether `CFG-2`'s shadowing clause belongs in the unmet-MUST count; §11 item 30's reader is unbuilt (P9-20). 12: the package joins the lockstep release; the release notes name the `ShouldHandle` guidance and the no-reload rule; the `CallKey` trigger stays in `docs/first-release.md`. -**2026-10-14 — Phase 10 exit (cross-cutting invariants and non-functional requirements).** Phase 10 (one phase, no sub-phases, P10-1) closes on branch `99-phase-10-cross-cutting-invariants` (issue #99): the **41 rows it owns** (`XCUT-1` to `-24`, `NFR-1` to `-17`; 26 MUST and 15 SHOULD), **39 ✅ (10 split: `XCUT-1`, `-4`, `-8`, `-13`, `-20`, `-23`, `-24`, `NFR-1`, `-4`, `-12`) / 0 🚫 / 2 ⏳ (`NFR-16`, phase 12; `NFR-17`, the repository ruleset) / 0 N/A**. The phase changes no production behaviour: its only `src/` edits are `Dexpace.Sdk.Conformance`, +**2026-10-14 — Phase 10 exit (cross-cutting invariants and non-functional requirements).** Phase 10 (one phase, no sub-phases, P10-1) closes on branch `99-phase-10-cross-cutting-invariants` (issue #99): the **41 rows it owns** (`XCUT-1` to `-24`, `NFR-1` to `-17`; 26 MUST and 15 SHOULD), **39 ✅ (11 split: `XCUT-1`, `-4`, `-8`, `-13`, `-18`, `-20`, `-23`, `-24`, `NFR-1`, `-4`, `-12`) / 0 🚫 / 2 ⏳ (`NFR-16`, phase 12; `NFR-17`, the repository ruleset) / 0 N/A**. The phase changes no production behaviour: its only `src/` edits are `Dexpace.Sdk.Conformance`, two `BannedSymbols.txt` bans and the one scoped pragma in `BoundedMap.cs`. Checklist: [`phase10/…-checklist.md`](phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md); user page: `docs/sdk-documentation/cross-cutting-invariants.md`. - **Built.** `Dexpace.Sdk.Conformance` gains three suites with one result model (`TransportFace.None`, `SuiteRunnerCore`, `SuiteOptions`, `ConformanceReport.Combine`): the invariant suite (28 assertions over `InvariantSubject`'s core-defaulted hooks), the codec suite (11) and the packaging suite (8, over the packed `.nupkg`s with BCL readers), each assertion with a negative control; the core, dependency-injection and System.Text.Json drivers, each pinning its `NotExercised` list; `scripts/ci/packaging-conformance.cs` and `scripts/ci/requirement-coverage.cs` with their CI steps; the cross-path reproducible pack (two `git worktree`s at different paths); `APPENDIX_B.md` over all 61 items and its five-rule gate; the `NFR-11` scan, `NFR4ArchitectureTests`, `LicenseHeaderTests`, `AnalyzerWaiverTests` and `CiWorkflowTests`; the `GetBaseException` and `ConcurrentDictionary` constructor bans; `CheckPhase10Async` in the AOT smoke. - **Rulings taken by the lead (2026-10-14), recorded in the checklist:** every open ruling P10-1 to P10-30 at its recommended default, notably P10-2 (measure and route, no behaviour change), P10-5 (the core driver lives in `Core.Tests`), P10-21 and P10-22 (Stryker.NET and `benchmarks/` declined with triggers), P10-23 (`XCUT-24`'s error-snapshot clause declined), P10-24 and P10-25; and the plan's readings R2, R3, R5, R6, R7, R15 and R17. -- **Findings.** (1) **`XCUT-24`'s non-consuming clause is unmet for an error-body snapshot above 1 MiB** (drained, truncated without a marker, replaces the original): a SHOULD decline. (2) **`HttpResponseException(Response)` accepts a non-error status**; `XCUT-8` is read as the SDK's mapping path. (3) **`ServiceResponseException` is public and constructed nowhere.** (4) The spec's `XCUT-18` ("a name with HTAB is rejected") and `HTTP-17` ("surrounding whitespace is trimmed") meet at the edge of a name; core follows `HTTP-17` and the suite asserts it. +- **Findings.** (1) **`XCUT-24`'s non-consuming clause is unmet for an error-body snapshot above 1 MiB** (drained, truncated without a marker, replaces the original): a SHOULD decline. (2) **`HttpResponseException(Response)` accepts a non-error status**; `XCUT-8` is read as the SDK's mapping path. (3) **`ServiceResponseException` is public and constructed nowhere.** (4) The spec's `XCUT-18` ("a name with HTAB is rejected") and `HTTP-17` ("surrounding whitespace is trimmed") meet at the edge of a name; the suite accepts a refusal or a trim there, outbound and inbound alike, and core's inbound trim (`HTTP-19` says inbound names stay strictly validated) is a possible unmet clause routed to phase 11. (5) The `main` ruleset has no `required_status_checks` rule (design V9, read before the phase; not re-read here). (6) `reproducible-pack.sh` could not see an embedded absolute path before this phase (the old script passes with `DeterministicSourcePaths` off on both packs; the new one fails). (7) The Digest cnonce source cannot be asserted by output. (8) 27 tests are skipped, not 22: the pinned `NotExercised` rows of the first-party invariant drivers. +- **Review (2026-10-14).** Eight verified findings were fixed on the branch: `xcut-17` now composes the subject's auth steps under the redirect loop and covers a retried downgrade; `xcut-18` accepts a refusal or a trim at the HTAB edge; `xcut-13.dispose-non-blocking` no longer passes with nothing to close; `xcut-21` judges at least 128 bits in any of three encodings; `xcut-24` requires the preview and a non-empty prefix snapshot; `CiWorkflowTests` pins the triggers and the job settings; two documentation rows were corrected (the checklist's *Review resolution*). - **Dated corrections** filed: the earlier checklists' hand-off rows (4a, 4c, 5b, 5c, 6a, 6b, 6c, 7b, 8a, 8b, 9: taken by phase 10); `docs/first-release.md`; `CLAUDE.md`; this roadmap's Phase 10 card and Phase List row; the knowledge notes. **Not made, proposed in the checklist for a human's deliberate edit:** the frozen design's §9, §9.1 to §9.3, §10 entry 7, three new §11 items and §12 notes. -- **Hand-offs.** 11: `ServiceResponseException` (delete, or argue it into §3.2); `XCUT-24`'s error-snapshot repair candidate; the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25 (both judged mechanisms today); `XCUT-8`'s reading and the HTAB edge as §11 items to confirm; F-A2 (`ASYNC-2`) unchanged; the 8b `TRACE` and parity over-budget questions unchanged; the coverage script reads 16 MUST rows as ⏳ in the row that owns them (`NFR-17` and hand-offs closed in a later checklist's differently shaped table, such as the `RECOV` rows in 6a's), so give those rows their dated corrections and tick the *Every MUST dispositioned* line in `docs/first-release.md`. 12: the one-time public-surface +- **Hand-offs.** 11: `ServiceResponseException` (delete, or argue it into §3.2); `XCUT-24`'s error-snapshot repair candidate; the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25 (both judged mechanisms today); `XCUT-8`'s reading and the HTAB edge as §11 items to confirm; F-A2 (`ASYNC-2`) unchanged; the 8b `TRACE` and parity over-budget questions unchanged; the coverage script reads 16 MUST rows as ⏳ in the row that owns them (`NFR-17`; the 12 `RECOV` rows 4b leaves ⏳ and 6a's table closes; `BODY-4`, `BODY-5` and `BODY-10`, 3b's hand-offs), so give those rows their dated corrections and tick the *Every MUST dispositioned* line in `docs/first-release.md`. 12: the one-time public-surface choices gain the `HttpResponseException` constructor guard and `ServiceResponseException`; `NFR-4`'s baseline, `NFR-12`'s release half and `NFR-16` stay *After the first publish* and *Blockers*; **the lead's open action: add a `required_status_checks` rule naming the five CI jobs to the `main` ruleset (`NFR-17`'s merge half; request text in the checklist's *Open items*)**, a blocker if it is not done. diff --git a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md index 0370b5e6..0b623729 100644 --- a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md +++ b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md @@ -4,8 +4,8 @@ The execution-time checklist for roadmap phase 10 ([phase card](../2026-09-27-do `99-phase-10-cross-cutting-invariants` (issue #99), following the [design](2026-10-13-phase10-cross-cutting-invariants-design.md) and the [plan](2026-10-13-phase10-cross-cutting-invariants.md). User page: [`cross-cutting-invariants.md`](../../../sdk-documentation/cross-cutting-invariants.md), with the suites in [`conformance.md`](../../../sdk-documentation/conformance.md). -The scope is the **41 rows phase 10 owns** (`XCUT-1` to `XCUT-24`, `NFR-1` to `NFR-17`; 26 MUST and 15 SHOULD by appendix C: `XCUT` 22 MUST + 2 SHOULD, `NFR` 4 MUST + 13 SHOULD): **39 ✅ / 0 🚫 / 2 ⏳ / 0 N/A**. Ten of the 39 ✅ are split (`XCUT-1`, `-4`, `-13`, `-23`, `-24`, `NFR-1`, `-4`, `-12` on an existing §10 entry, a vacuous clause or -a declined one; `XCUT-8` and `XCUT-20` on readings) and none adds an unmet MUST. The two ⏳ are `NFR-16` (no release path yet, phase 12) and `NFR-17` (the workflow half is built and pinned; the repository-ruleset half is an admin action the lead has not made, see *Open items*). The plan's four stacked pull requests were built as +The scope is the **41 rows phase 10 owns** (`XCUT-1` to `XCUT-24`, `NFR-1` to `NFR-17`; 26 MUST and 15 SHOULD by appendix C: `XCUT` 22 MUST + 2 SHOULD, `NFR` 4 MUST + 13 SHOULD): **39 ✅ / 0 🚫 / 2 ⏳ / 0 N/A**. Eleven of the 39 ✅ are split (`XCUT-1`, `-4`, `-13`, `-23`, `-24`, `NFR-1`, `-4`, `-12` on an existing §10 entry, a vacuous clause or +a declined one; `XCUT-8`, `-18` and `-20` on readings) and none adds an unmet MUST to the count (`XCUT-18`'s inbound HTAB edge is a possible one, raised to phase 11 and not counted: *Findings*). The two ⏳ are `NFR-16` (no release path yet, phase 12) and `NFR-17` (the workflow half is built and pinned; the repository-ruleset half is an admin action the lead has not made, see *Open items*). The plan's four stacked pull requests were built as **four commit groups on the one branch**, in the plan's order (contract and invariant suite; codec suite; packaging suite and gates; appendix B, coverage and close-out), each ending green at its gate task; nothing was pushed. The kit gains three suites (47 assertions: 28 invariant, 11 codec, 8 packaging) beside the 43 transport assertions, each with a negative control, and the transport drivers stay green with exactly their two permanent waivers per SystemNet-family driver. @@ -40,19 +40,19 @@ A row's assertion is `invariant:`, `packaging:` or `transport:` as written; the | `XCUT-9` | MUST | ✅ | Assertion `invariant:xcut-9.cycle-safe-walk` (face `none`; hook `CreateCyclicException`, no default: the core driver supplies `CyclicExceptions.SelfCycle`, the DI driver leaves it `NotExercised`). The suite **walks** the cyclic exception and never throws it (Windows hangs on a thrown cyclic chain); a chain of three distinct, `Equals`-equal exceptions must yield three, so a walk tracked by `Equals` fails. Controls: a walk tracked by `Equals`; a walk with no visited set (`InvariantResilienceControlTests`, `Unit`). The gate: `Exception.GetBaseException` is banned in `src/` (`BannedSymbols.txt`, `RS0030`; `BannedSymbolsSiteTests.GetBaseException_appears_nowhere_in_src`, `Unit`). Existing: `ExceptionFactsTests` (the cycle tests), `AsyncToSyncBridgeTests`. | | `XCUT-10` | MUST | ✅ | Assertion `invariant:xcut-10.safety-gate-uniform` (kit `InvariantSuite`, both faces unless noted). Controls: a retry loop that special-cases a transport failure; one with no safety gate at all (`InvariantResilienceControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). The matrix of chapter 19 on both faces, seven cases at two retries: a body-less GET over a transport error is re-sent (three sends); a body-less POST over a protocol error, and over a transport error that never reached the server, is not (one); a POST with a replayable body is re-sent over either (three); a POST with a single-use stream is not over either (one). Passes over the **DI-built pipeline** too. Existing: 6a `RetryResendGateTests`; 9 `NoRetryingHandlerTests` (`Unit`), `ResilienceCompositionTests` (`Integration`). | | `XCUT-11` | MUST | ✅ | Assertion `invariant:xcut-11.shared-no-crosstalk` (kit `InvariantSuite`, both faces unless noted). Controls: a step that holds the request in a field of the shared step (`InvariantResilienceControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). 64 concurrent distinct requests through one shared pipeline, each response echoing its own id. Existing: 4c `ConcurrencyTests.Concurrent_calls_share_no_per_call_state`; `transport:transport-29.concurrent-no-crosstalk`. | -| `XCUT-12` | SHOULD | ✅ | Assertion `invariant:xcut-12.single-flight-scoped` (kit `InvariantSuite`, both faces unless noted). Controls: a cache that fetches once per caller; one that serialises every cache behind one lock (`InvariantResilienceControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Sixteen readers racing one expiring token cost one fetch; a fetch parked in one credential's cache does not hold another's. Existing: 6c `AccessTokenCacheTests.N_concurrent_requests_at_expiry_cost_one_fetch`, `Different_keys_do_not_block_each_other`. | -| `XCUT-13` | MUST | ✅ split | Assertion `invariant:xcut-13.dispose-latched` (kit `InvariantSuite`, both faces unless noted). Controls: a closer that releases the resource on every call; `xcut-13.dispose-non-blocking`: a closer that waits for the read parked on the body (`InvariantLifecycleControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Two assertions over the hook `CreateDisposables` (default: a core `Response`, a `TypedResponse` and a `ServerSentEventStream` over a release-counting body, counted at the resource): closing twice, then from eight threads at once, releases once; closing with a read parked on the body returns without waiting for it. 🚫 "preserves the ambient interrupt flag" is vacuous on .NET (design §10 entry 8, §3.7). By reference: `transport:transport-16.close-idempotent-nonblocking` cites `TRANSPORT-16` and `ASYNC-15`, not `XCUT-13`. Existing: `ResponseDisposeLatchTests`, `SystemNetHttpClientDisposeTests`. | +| `XCUT-12` | SHOULD | ✅ | Assertion `invariant:xcut-12.single-flight-scoped` (kit `InvariantSuite`, both faces unless noted). Controls: a cache that fetches once per caller; one that serialises every cache behind one lock (`InvariantResilienceControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantResilienceControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Sixteen readers racing one expiring token cost one fetch; a fetch parked in one credential's cache does not hold another's. **By reference, not asserted by the suite:** the clause "cached reads take no lock" rests on 6c `AccessTokenCacheTests.The_hot_path_takes_no_lock` (`Unit`, `tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs`: a fresh read answers synchronously while another key's fetch is parked); the hook is a read over a credential, and a per-scope gate for a portable assertion is not built, so a third-party cache is not judged on it. Existing: 6c `AccessTokenCacheTests.N_concurrent_requests_at_expiry_cost_one_fetch`, `Different_keys_do_not_block_each_other`, `The_hot_path_takes_no_lock`. | +| `XCUT-13` | MUST | ✅ split | Assertion `invariant:xcut-13.dispose-latched` (kit `InvariantSuite`, both faces unless noted). Controls: a closer that releases the resource on every call; `xcut-13.dispose-non-blocking`: a closer that waits for the read parked on the body (`InvariantLifecycleControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Two assertions over the hook `CreateDisposables` (default: a core `Response`, a `TypedResponse` and a `ServerSentEventStream` over a release-counting body, counted at the resource): closing twice, then from eight threads at once, releases once; closing with a read parked on the body returns without waiting for it. A hook that yields nothing fails **both** assertions ("no disposable component"; the non-blocking one passed vacuously before the review), and every component built for a round is closed once that round's counts are read (`InvariantLifecycleControlTests`). 🚫 "preserves the ambient interrupt flag" is vacuous on .NET (design §10 entry 8, §3.7). By reference: `transport:transport-16.close-idempotent-nonblocking` cites `TRANSPORT-16` and `ASYNC-15`, not `XCUT-13`. Existing: `ResponseDisposeLatchTests`, `SystemNetHttpClientDisposeTests`. | | `XCUT-14` | MUST | ✅ | Assertions `invariant:xcut-14.bounded-under-burst` and `invariant:xcut-14.drains-after-insert` (face `none`; hook `CreateBoundedMap`, no default: the core driver supplies a `BoundedMap`-backed probe, the DI driver leaves both `NotExercised`). A single thread inserts eight times the cap (32) and never sees more than the cap after an insert; eight threads each inserting four times the cap end at or under it; starting one short of the cap, eight threads inserting at once drain back under it. Controls: a map that never evicts (both assertions); a map that checks above the cap instead of at it (`bounded-under-burst`); a map that checks once before it inserts (`drains-after-insert`) (`InvariantLifecycleControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs`; a conforming map passes both). The driver can fail: `BoundedMap`'s capacity multiplied by 100 turned both red (never committed). The gate: a `ConcurrentDictionary` constructor outside `BoundedMap` is banned in `src/` (seven `RS0030` entries in `BannedSymbols.txt` and one scoped pragma at the single construction site; `BannedSymbolsSiteTests.ConcurrentDictionary_is_constructed_only_in_BoundedMap`, `Unit`). Existing: 4a `BoundedMapTests`; 6c `AccessTokenCacheTests`; 8b `HeaderDropLogTests`. `HeaderDropLog` is the one lock-guarded capped set (128). | | `XCUT-15` | MUST | ✅ | Assertion `invariant:xcut-15.no-external-alias` (kit `InvariantSuite`, both faces unless noted). Controls: factories that keep the caller's media-type parameter map, challenge parameter map or byte array; headers that show a builder's later fields; a header "setter" and a request "setter" that each return the same instance (`InvariantLifecycleControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantLifecycleControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; hook `Models` (a `ModelFactories` of delegates). Mutating the map or array a media type, a challenge or a bytes body was built from changes nothing; `With`-style helpers return a new instance. Existing: 2a `ModelImmutabilityArchitectureTests`; `HeadersTests`. | | `XCUT-16` | MUST | ✅ | Assertion `invariant:xcut-16.no-credential-over-http` (kit `InvariantSuite`, both faces unless noted). Controls: an auth step that stamps its credential before it looks at the scheme; one that fetches the token before the https guard (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A Basic and a bearer step over `http://` fail before any fetch and before the request reaches the transport; over `https://` they stamp; a permitted https-to-http hop is re-issued credential-free and proceeds. Existing: phase 1 `AuthHttpsGuardTests` (`Security`, unedited); 6c `HttpsRequiredExceptionTests`; 6b `RedirectCredentialLeakTests`. | -| `XCUT-17` | MUST | ✅ | Assertion `invariant:xcut-17.redirect-hygiene` (kit `InvariantSuite`, both faces unless noted). Controls: a loop that re-issues with the caller's Authorization; one that keeps Cookie and Proxy-Authorization across origins; one that judges the origin against the previous hop (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Same-origin: Authorization stripped, Cookie and Proxy-Authorization kept; cross-origin and a second hop to the foreign origin: all three stripped, judged against the seed; userinfo in a `Location` dropped; an https-to-http hop refused by default. Existing: phase 1 `RedirectWireTests` and 9 `NamedClientRedirectWireTests` (`Security`, unedited); 6b `RedirectCredentialHygieneTests`. | -| `XCUT-18` | MUST | ✅ | Assertion `invariant:xcut-18.model-validation` (kit `InvariantSuite`, both faces unless noted). Controls: a header model that accepts HTAB inside a name; one that drops a CR from an outbound value and accepts it; one that accepts a CR or LF in an inbound value (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; hook `Models`. Every C0 control, DEL and non-ASCII character is refused inside a name (outbound and inbound) and in an outbound value, every control other than HTAB in an inbound value; HTAB inside an outbound or inbound value and obs-text in an inbound value are accepted. Reading (not a departure, proposed as a design §11 item): the spec states two MUSTs that meet at the edge of a name. `XCUT-18` says a name with HTAB is rejected; `HTTP-17` says surrounding whitespace is trimmed before validation. Core follows both: HTAB inside a name is rejected, HTAB (or space) at either end is trimmed with the trimmed name stored, and the suite asserts the stored name is `X`. By reference: the transport suite's outbound header assertions cite `TRANSPORT-1x`, not `XCUT-18`; the dispatch-time half stays the transport suite's (P10-6). Existing: phase 1 `HeaderInjectionValidationTests` (`Security`, unedited); 8b `HeaderPartitionTests`. | +| `XCUT-17` | MUST | ✅ | Assertion `invariant:xcut-17.redirect-hygiene` (kit `InvariantSuite`, both faces unless noted). Controls: a loop that re-issues with the caller's Authorization; one that keeps Cookie and Proxy-Authorization across origins; one that judges the origin against the previous hop; an auth step that stamps its credential on a foreign origin (Basic and bearer); one that stamps only the seed's own request; one that re-attaches its credential when a foreign hop is retried (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Two passes. With the credentials hand-seeded and no auth step: same-origin, Authorization stripped, Cookie and Proxy-Authorization kept; cross-origin and a second hop to the foreign origin, all three stripped, judged against the seed; userinfo in a `Location` dropped; an https-to-http hop refused by default. With the subject's `Auth.Basic` and `Auth.Bearer` composed under the redirect loop (a hand-seeded Authorization would be overwritten by the first stamp, so the seed carries only Cookie and Proxy-Authorization): the seed request is stamped; a same-origin re-issue is stamped again by the auth step; a cross-origin hop carries none of the three (no credential re-stamped); a hop to a foreign origin and back is bare, then stamped again with Cookie and Proxy-Authorization still gone; a permitted https-to-http hop, then a 503 and its retry, goes out bare both times and is not refused by the https guard. The first cut did the first pass only, so the auth half of (b) was not asserted (review, fixed). Existing: phase 1 `RedirectWireTests` and 9 `NamedClientRedirectWireTests` (`Security`, unedited); 6b `RedirectCredentialHygieneTests`. | +| `XCUT-18` | MUST | ✅ split | Assertion `invariant:xcut-18.model-validation` (kit `InvariantSuite`, both faces unless noted). Controls: a header model that accepts HTAB inside a name; one that drops a CR from an outbound value and accepts it; one that accepts a CR or LF in an inbound value; one that stores a name with HTAB at one end under another name (outbound and inbound); a model that refuses such a name passes (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; hook `Models`. Every C0 control, DEL and non-ASCII character is refused inside a name (outbound and inbound) and in an outbound value, every control other than HTAB in an inbound value; HTAB inside an outbound or inbound value and obs-text in an inbound value are accepted. **Split: the HTAB edge of a name** (a clause on which the specification disagrees with itself; proposed as a design §11 item, no §10 entry). Outbound, `XCUT-18` says a name with HTAB is rejected and `HTTP-17` says surrounding whitespace is trimmed before validation, so the suite accepts a refusal (`ArgumentException`) or a name stored as exactly the trimmed token, and never a name stored with its tab (core trims). Inbound, `HTTP-19` says inbound names remain strictly validated and no row supports a trim there, yet `Headers.Builder.AddInbound` trims SP and HTAB at either end of a name and stores it (`WriteKey`, the same path as the outbound writer): the suite accepts a refusal or a trim and **does not pin the trim**. That trim is a possible unmet clause of `HTTP-19` and `XCUT-18` that this phase does not count (P10-2: no core change) and routes to phase 11 (*Open items* 3); the first cut required the trim on both paths, which would have errored a third-party model that refuses `\tX` as `XCUT-18` says (review, fixed). By reference: the transport suite's outbound header assertions cite `TRANSPORT-1x`, not `XCUT-18`; the dispatch-time half stays the transport suite's (P10-6). Existing: phase 1 `HeaderInjectionValidationTests` (`Security`, unedited); 8b `HeaderPartitionTests`. | | `XCUT-19` | MUST | ✅ | Assertion `invariant:xcut-19.redaction-default-deny` (kit `InvariantSuite`, both faces unless noted). Controls: a redactor that redacts nothing; a credential that renders its secret; a diagnostics step that logs request headers verbatim; one that logs the response body (`InvariantSecurityControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantSecurityControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A URL with userinfo, secret query values and a fragment token, and a `Location`-style header value, are redacted; each default credential's string form hides its secret; at `None` and `Headers` log levels no secret header value, query value or body reaches a log line. Existing: phase 1 `UrlRedactionDefaultDenyTests` (`Security`, unedited); 5b `HttpLoggingDefaultsTests`; 6c `CredentialRedactionTests`. | | `XCUT-20` | MUST | ✅ split | Assertion `invariant:xcut-20.observability-never-throws` (kit `InvariantSuite`, both faces unless noted). Controls: a renderer that throws on a malformed URL; a step that writes to the sink without a guard (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Six malformed URLs render a placeholder that echoes no secret; a request logged at `Body` level to a sink that throws on every touch completes. Reading (design §11 item 37, not a departure): the SDK's own recording code is total; a listener's callbacks are `OBS-30`'s party. Existing: 5b `EmissionGuardTests`, `UrlRedactorHeaderValueTests.The_method_is_total`. | -| `XCUT-21` | MUST | ✅ | Assertion `invariant:xcut-21.cnonce-unpredictable` (kit `InvariantSuite`, both faces unless noted). Controls: a draw of 64 bits; a draw cycling through eight values (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; optional hook `DrawCnonce` (null draws through the public `DigestChallengeHandler.Authorize`, so the assertion is never `NotExercised`): 32 lower-hex characters (128 bits) and no repeat in 10 000 draws. The operating-system CSPRNG source clause is **by reference** to 6c `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` (an output test cannot tell a CSPRNG from `System.Random`, so a seeded `System.Random` draw of 128 bits would pass: the stated limit). | +| `XCUT-21` | MUST | ✅ | Assertion `invariant:xcut-21.cnonce-unpredictable` (kit `InvariantSuite`, both faces unless noted). Controls: a draw of 64 bits; 120 bits as base64url; a string the suite cannot count; a draw cycling through eight values (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). Face `none`; optional hook `DrawCnonce` (null draws through the public `DigestChallengeHandler.Authorize`, so the assertion is never `NotExercised`): at least 128 bits, written as hex of either case, base64 or base64url (core draws 32 lower-hex characters), and no repeat in 10 000 draws; the first cut demanded exactly 32 lower-hex characters, which would fail a conforming draw of more bits or another encoding (review, fixed: deviation 11). The operating-system CSPRNG source clause is **by reference** to 6c `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` (an output test cannot tell a CSPRNG from `System.Random`, so a seeded `System.Random` draw of 128 bits would pass: the stated limit). | | `XCUT-22` | MUST | ✅ | Assertion `invariant:xcut-22.core-borrows` (kit `InvariantSuite`, both faces unless noted). Controls: a bridge that disposes the client it wraps (either direction); a pipeline layer that closes the caller's transport after a call (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). An `HttpPipeline`, `AsAsync` and `AsBlocking` over a counting transport dispose nothing of it, and the transport still works afterwards (hooks `AsAsync`, `AsBlocking`). Passes over the DI-built pipeline too. Existing: `transport:transport-15.borrowed-survives`; 8b `SystemNetHttpClientOwnershipTests`; 9 `PrimaryHandlerLeaseTests`. | | `XCUT-23` | MUST | ✅ split | Assertion `invariant:xcut-23.resolution-deterministic` (face `none`; hook `SeamResolution`, no default: only the DI driver supplies it, over `AddDexpaceClient`; the core driver leaves it `NotExercised`). Resolving a seam with no candidate fails with a message, resolving with two and no choice fails with a message, and an explicit install wins over a discoverable candidate; the DI subject maps these to the missing-codec start failure with the install hint, the different second install failing the call naming both, and an explicit `UseTransport` winning. Controls: a resolver that picks the last of two; one that picks nothing silently; one where a discoverable candidate beats the explicit one (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). The driver can fail: the single-registration check in `ServiceSlot.Install` made a no-op turned the DI driver red (never committed). 🚫 the discovery half is vacuous: a seam is a non-nullable parameter and nothing is discovered (design §10 entry 9, §11 item 8). Existing: 9 `SingleRegistrationTests`, `ProviderValidationTests`. | -| `XCUT-24` | SHOULD | ✅ split | Assertion `invariant:xcut-24.preview-capped-non-consuming` (kit `InvariantSuite`, both faces unless noted). Controls: a step that buffers the whole body to preview it; one that buffers the whole error body for its snapshot (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A 10 MiB body logged at `Body` level with a 1 KiB preview: no more than the preview cap plus one 64 KiB read-ahead is pulled before the reader asks, every byte reaches the reader unchanged, and no log line exceeds a bounded length; a 10 MiB error body pulls no more than `Response.MaxBufferedErrorBytes` (1 MiB) plus a read-ahead and its snapshot holds at most that. 🚫 **declined** (P10-23, `docs/first-release.md` *SHOULD declined*): above `Response.MaxBufferedErrorBytes` the error snapshot is drained, truncated without a marker and replaces the original, so the non-consuming clause holds for body-log previews and not for that snapshot; a repair candidate for phase 11. Existing: 5b `BodyPreviewTests`; 3b `ErrorBodyPreviewTests`. | +| `XCUT-24` | SHOULD | ✅ split | Assertion `invariant:xcut-24.preview-capped-non-consuming` (kit `InvariantSuite`, both faces unless noted). Controls: a step that buffers the whole body to preview it; one that buffers the whole error body for its snapshot; one that hands back an error with an empty body; one whose error body is not the server's (`InvariantHostControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/InvariantHostControlTests.cs`). Driven by the core driver `InvariantConformanceTests` (`Conformance`, `Dexpace.Sdk.Core.Tests/Conformance/`) and the DI driver `InvariantResolutionConformanceTests` (`Conformance`, `Dexpace.Sdk.Extensions.DependencyInjection.Tests/Conformance/`). A 10 MiB body logged at `Body` level with a 1 KiB preview: no more than the preview cap plus one 64 KiB read-ahead is pulled before the reader asks, every byte reaches the reader unchanged, and no log line exceeds a bounded length; a log line carries the start of the body (a pipeline that logs no body preview is `Vacuous`, never passed); a 10 MiB error body pulls no more than `Response.MaxBufferedErrorBytes` (1 MiB) plus a read-ahead and its snapshot holds at most that, is not empty and is a prefix of the body. 🚫 **declined** (P10-23, `docs/first-release.md` *SHOULD declined*): above `Response.MaxBufferedErrorBytes` the error snapshot is drained, truncated without a marker and replaces the original, so the non-consuming clause holds for body-log previews and not for that snapshot; a repair candidate for phase 11. Existing: 5b `BodyPreviewTests`; 3b `ErrorBodyPreviewTests`. | | `NFR-1` | MUST | ✅ split | Assertion `packaging:nfr-1.core-dependencies` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: an extra concrete dependency; a second dependency group; the facade missing; a group for another framework (`PackagingDependencyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs`, synthetic packages with emitted assemblies). The core nuspec has one `net10.0` group listing exactly `Microsoft.Extensions.Logging.Abstractions`. 🚫 "compile-time-only" facade: the facade is a runtime dependency (design §10 entry 1, sanctioned in part by `SEAM-1`). Existing: `scripts/ci/dependency-audit.cs` (CI "Dependency audit"), `Seam1ArchitectureTests`. | | `NFR-2` | SHOULD | ✅ | Assertion `packaging:nfr-2.adapter-budget` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: a second third-party root; a first-party package the policy does not name; core required at another version (`PackagingDependencyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingDependencyControlTests.cs`, synthetic packages with emitted assemblies). Each adapter depends on core, the first-party packages its `PackagePolicy` names and at most one third-party root, every first-party dependency at the lockstep version (design §10 entry 2: the hosting family counts as one root). `PackagingPolicyParityTests` (`Unit`) fails if `scripts/ci/packaging-conformance.cs`'s policies disagree with the audit's. | | `NFR-3` | SHOULD | ✅ | Assertion `packaging:nfr-3.no-internals-granted` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: an assembly granting its internals to another assembly; to a sibling's test project (`PackagingAssemblyControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingAssemblyControlTests.cs`, synthetic packages with emitted assemblies). Every `InternalsVisibleTo` in a packed assembly names `.Tests`. Existing: `GenerateDocumentationFile` with `CS1591` as an error; `PublicAPI` files on all five libraries. | @@ -69,11 +69,11 @@ A row's assertion is `invariant:`, `packaging:` or `transport:` as written; the | `NFR-14` | SHOULD | ✅ | Assertion `packaging:nfr-14.lockstep-version` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: a package at another version; a sibling required at another version (`PackagingMetadataControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs`, synthetic packages with emitted assemblies). Every package ships at one version and every dependency between them requires it. Existing: `Directory.Packages.props` (central versions, transitive pinning), `VersionPrefix`/`VersionSuffix` once, `global.json`, `DexpaceToolchainPackVersion`. | | `NFR-15` | SHOULD | ✅ | Assertion `packaging:nfr-15.version-resolvable` (kit `PackagingSuite`, face `none`) over the packed `.nupkg` files, run in CI as `scripts/ci/packaging-conformance.cs` after Pack (all eight green over the five real packages). Controls: an informational version of `unknown`; of `0.0.0`; one that differs from the nuspec version; none (`PackagingMetadataControlTests`, `Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Controls/PackagingMetadataControlTests.cs`, synthetic packages with emitted assemblies). The assembly's informational version without its `+` metadata equals the nuspec version and is never a placeholder; the NativeAOT smoke asserts `BuildInfo.SdkVersion` equals the library's own informational version at run time. Existing: `BuildInfoTests`. | | `NFR-16` | SHOULD | ⏳ | No release path exists yet, so the rule has no subject: `docs/first-release.md` *Blockers before first publish*, "Signing decided" (phase 12; design §9.2: repository signature on the release job, author signing optional). Nothing in this phase signs or strong-names anything. | -| `NFR-17` | MUST | ⏳ | The first half is built: `CiWorkflowTests` (`Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs`) pins that every gate of the gate table below is a named step of `.github/workflows/ci.yml` with its command, that no step or job sets `continue-on-error`, and that the only conditional steps are the Linux-only structural checks and the result upload that must run after a failure (it can fail over edited copies of the workflow). So every gate fails its CI job. ⏳ the second half is a repository setting outside the file and **was not changed**: design V9 found the `main` ruleset has no `required_status_checks` rule, so a red job does not block a merge. The request for the lead is under *Open items*; if the setting is not made before the first publish it is a `docs/first-release.md` blocker. | +| `NFR-17` | MUST | ⏳ | The first half is built: `CiWorkflowTests` (`Unit`, `tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs`) pins that every gate of the gate table below is a named step of `.github/workflows/ci.yml` with its command, that no step or job sets `continue-on-error`, that the only conditional steps are the Linux-only structural checks and the result upload that must run after a failure, that the workflow triggers on `push` and `pull_request` to `main` with no `paths`, `branches-ignore` or other filter, and that no job carries a job-level `if:`, `continue-on-error` or matrix `exclude:` (it parses each job's settings and its steps separately, and can fail over edited copies of the workflow). So every gate fails its CI job. ⏳ the second half is a repository setting outside the file and **was not changed**: design V9 found the `main` ruleset has no `required_status_checks` rule, so a red job does not block a merge. The request for the lead is under *Open items*; if the setting is not made before the first publish it is a `docs/first-release.md` blocker. | -**Totals.** 41 rows, 26 MUST and 15 SHOULD. 39 ✅ (of which 10 split: `XCUT-1`, `-4`, `-8`, `-13`, `-20`, `-23`, `-24`, `NFR-1`, `-4`, `-12`), 0 🚫, 2 ⏳ (`NFR-16` phase 12; `NFR-17` the ruleset), 0 N/A: 39 + 2 = 41. By level: `XCUT` 22 MUST + 2 SHOULD, `NFR` 4 MUST + 13 SHOULD. **The unmet-MUST count is unchanged by this phase** -(the 8b status note's seven over the parity target of five): no row adds a clause to it. One count question is raised for phase 11 and not decided here: `XCUT-4`'s "catchable as a generic I/O error" is unmet in letter on .NET, and §10 entry 7 classes it as a judged mechanism change (outside the count), as entry 25 does for `CFG-2`'s fall-through; the auditor decides -whether either belongs in the count (P10-25). `XCUT-24`'s declined clause is a SHOULD clause and goes to `docs/first-release.md` *SHOULD declined*, not to the MUST count. +**Totals.** 41 rows, 26 MUST and 15 SHOULD. 39 ✅ (of which 11 split: `XCUT-1`, `-4`, `-8`, `-13`, `-18`, `-20`, `-23`, `-24`, `NFR-1`, `-4`, `-12`), 0 🚫, 2 ⏳ (`NFR-16` phase 12; `NFR-17` the ruleset), 0 N/A: 39 + 2 = 41. By level: `XCUT` 22 MUST + 2 SHOULD, `NFR` 4 MUST + 13 SHOULD. **The unmet-MUST count is unchanged by this phase** +(the 8b status note's seven over the parity target of five): no row adds a clause to it. Two count questions are raised for phase 11 and not decided here. The first: `XCUT-4`'s "catchable as a generic I/O error" is unmet in letter on .NET, and §10 entry 7 classes it as a judged mechanism change (outside the count), as entry 25 does for `CFG-2`'s fall-through; the auditor decides +whether either belongs in the count (P10-25). The second is the inbound HTAB edge: core's lenient `AddInbound` trims SP and HTAB at either end of a name where `HTTP-19` says inbound names stay strictly validated and `XCUT-18` says a name with HTAB is rejected; the suite pins neither a refusal nor the trim there, and the auditor decides whether the trim is an unmet MUST clause (it would be one for `HTTP-19`, owned by 2a) or a reading to confirm as a §11 item (deviation 5). `XCUT-24`'s declined clause is a SHOULD clause and goes to `docs/first-release.md` *SHOULD declined*, not to the MUST count. ## Rows owned elsewhere and non-row hand-offs @@ -101,7 +101,7 @@ whether either belongs in the count (P10-25). `XCUT-24`'s declined clause is a S ## The gate table Phase 0 left no gate checklist, so this is the first disposition of design §9's table and the roadmap's Maturity Definition gate list, with each gate's state as measured at close-out. "Blocking" means it fails its CI job; merge blocking is `NFR-17`'s second half (open item 1). `CiWorkflowTests` (`Unit`) pins that each gate with a command below is a named step of -`.github/workflows/ci.yml`, that no step or job sets `continue-on-error`, and that only the documented steps carry an `if:`. +`.github/workflows/ci.yml`, that no step or job sets `continue-on-error`, that only the documented steps carry an `if:`, that the triggers are `push` and `pull_request` to `main` unfiltered, and that no job has a job-level `if:` or matrix `exclude:`. | Gate | Requirement | Mechanism | State after phase 10 | Phase 10 action | |---|---|---|---|---| @@ -148,12 +148,30 @@ Microsoft.Testing.Platform (V5, stryker-net#3094), recorded as a `Conflicts` not 3. **Plan tasks combined into fewer commits** than the plan's task count: 2.1 to 2.4 are one kit commit, 3.1 to 3.4 one packaging commit, 3.6 and 3.7 one commit, and the 3.11 script commit was amended from a work-in-progress commit; the task order is the plan's. 4. **Hooks beyond the design's list.** `InvariantSubject` gained `CreateDisposables` (with `DisposableUnderTest`; default: a core `Response`, a `TypedResponse` and a `ServerSentEventStream` over a release-counting body), `AsAsync` and `AsBlocking` (both default to core's bridges), because `xcut-13` and `xcut-22` cannot carry a negative control without a hook over what is closed and what is bridged. `ModelFactories` gained `HeadersAfterBuilderMutation`, `WithHeader` and `RequestWithHeader`, and the subject `AuthPolicyFactories` and `CredentialSample`, as R3's delegate-form hooks over sealed core types. A plan-level reading beyond R2 and R3, flagged here for the lead. -5. **`xcut-18` and HTAB.** The spec's `XCUT-18` conformance line says a name with HTAB is rejected; `HTTP-17` says surrounding whitespace is trimmed before validation. Core trims SP and HTAB at either end of a name and rejects HTAB inside it. The suite asserts exactly that ("HTAB by position"), instead of the design's "HTAB rejected in a name". Proposed as a design §11 item. +5. **`xcut-18` and HTAB.** The spec's `XCUT-18` conformance line says a name with HTAB is rejected; `HTTP-17` says surrounding whitespace is trimmed before validation (outbound names only); `HTTP-19` says inbound names remain strictly validated. Core trims SP and HTAB at either end of a name on **both** paths and rejects HTAB inside it. The suite asserts neither reading: HTAB at an edge may be refused or stored trimmed, outbound and inbound alike, and the inbound trim is reported as a split clause and a possible `HTTP-19`/`XCUT-18` gap for phase 11, not as a pass condition (the first cut pinned the trim on both paths: review, fixed). Proposed as a design §11 item. 6. **Codec suite.** A factory crash is `Failed` or `Errored` (a crash is not always an error), and `ProbeUnencodable` must be registered in a driver's context for the cause to chain: an unregistered System.Text.Json type throws a `SerializationException` with no inner cause, which is the codec's behaviour, noted; the STJ driver registers it. 7. **Runner.** `SuiteRunner`'s transport behaviour is unchanged except that `TransportFace.None` is rejected there; `Check` gained a synchronous `Guard`. The packaging suite is synchronous (`RunSync`/`RunAllSync`) and `AssertionTimeout` does not apply to it. 8. **Design and plan facts corrected against the tree.** R6 (already in the plan): seven `severity = none` lines and three `dotnet_code_quality.*` lines, no `CA2000` severity, 43 pragma sites in 31 files. The plan's check "`rg Task.Delay` over `src/Dexpace.Sdk.Conformance` is empty" required rewording two existing doc comments that named the method. 9. **V9 was not repeated.** The design read the `main` ruleset with `gh api` (no `required_status_checks`, no classic protection); this run may not use `gh` at all, so the state was not re-read and `NFR-17`'s second half is unverified here (open item 1). 10. **Gates were run with limited parallelism** (`-m:3`, one test module at a time, the `dotnet` commands serialized under one lock) so the machine stayed usable; no result depends on it. +11. **`xcut-21` judges at least 128 bits, not exactly 32 lower-hex characters.** The design (section C) and plan (1.10, R17) write the width as 32 lower-hex characters; `XCUT-21` says each nonce is at least 128 bits. The kit judges other implementations (P10-4), so a longer draw, upper-case hex, base64 or base64url passes and an encoding the suite cannot count fails. Core still draws 32 lower-hex characters and the controls still fail 64 bits (review, 2026-10-14). +12. **`xcut-24` is stricter than the design's upper bounds.** The design asserts caps only; the suite now also requires the preview to appear (a pipeline that logs no body preview is `Vacuous`) and the error snapshot to be a non-empty prefix of the body, because the specification's conformance text says the surfaced log and error "carry the truncated preview" (review, 2026-10-14). The test payload opens with a printable marker so a preview is recognisable. +13. **`xcut-12`'s cached-reads-take-no-lock clause is by reference.** The suite asserts single-flight and the per-cache lock scope; the lock-free read is evidenced by 6c's `The_hot_path_takes_no_lock` and not built as a portable assertion (a gated per-scope credential would be needed; review, 2026-10-14). + +## Review resolution (2026-10-14) + +An independent review of the finished phase raised eight verified findings; each was fixed on this branch (test first, a negative control shown to fail before the fix) and the rows above were rewritten to what is now built. + +| Finding | Resolution | +|---|---| +| `xcut-17` never built an auth step (major) | The assertion composes `Auth.Basic` and `Auth.Bearer` under the redirect loop (re-stamp on the seed's origin, none on a foreign host, none on a retried foreign hop); three new controls (`BrokenRestamp`: across origins, only the seed's request, on retry), four rows on two faces. Red before: all eight control rows reported `Passed`. | +| `xcut-18` pinned the HTAB-edge trim, inbound too (major) | Refusal or trim accepted outbound and inbound; the row is now ✅ split; the inbound trim is a possible `HTTP-19` gap routed to phase 11 (deviation 5, *Open items* 3, `APPENDIX_B.md` B.8.6). Red before: a model that refuses `\tX` was `Errored`. | +| `docs/first-release.md` named the wrong 16 MUST rows (minor) | Names `BODY-4`, `BODY-5`, `BODY-10` and the 12 `RECOV` rows, and says `HTTP-44` and `HTTP-45` are ✅ in 7a's checklist (the list was recomputed from the script's own owning-row logic). | +| `xcut-13.dispose-non-blocking` passed with nothing to close; unclosed components (minor) | Fails like its sibling (`no disposable component`); every component built for a round is closed after the round's counts are read; the CA2000 why-comment is now true. Red before: two vacuous passes and four never-closed components. | +| `xcut-24` checked upper bounds only (minor) | A log line must carry the start of the body (`Vacuous` when none does) and the error snapshot must be non-empty and a prefix of the body; two new controls and a `Vacuous` test. Red before: a pipeline with no preview, and an empty or foreign error body, passed. | +| `XCUT-12`'s lock-free clause had no evidence named (minor) | The row cites `AccessTokenCacheTests.The_hot_path_takes_no_lock` and says the clause is by reference, not asserted (deviation 13). | +| `CiWorkflowTests` ignored the trigger and job settings (minor) | Pins the triggers and rejects a job-level `if:`, `continue-on-error` and matrix `exclude:`; steps are now parsed per job. Red before: with `pull_request:` removed and the build job gated on `push`, all five tests stayed green. | +| `xcut-21` demanded exactly 32 lower-hex characters (minor) | Accepts at least 128 bits in hex of either case, base64 or base64url (deviation 11). Red before: four conforming draws failed. | ## Rulings @@ -167,8 +185,8 @@ and hard-codes widened), R6 (the measured `.editorconfig` and pragma counts), R7 - **P10-23, `XCUT-24`'s error-body snapshot.** `ErrorBodyBuffer.Capture` drains at most `Response.MaxBufferedErrorBytes` (1 MiB), truncates without a marker and replaces the original body. The byte cap holds; the non-consuming clause does not above 1 MiB. A SHOULD clause: `docs/first-release.md` *SHOULD declined*; a repair candidate for phase 11 (retain the stream, or mark the truncation). - **P10-24, `XCUT-8` and the public constructor.** Every core mapping path goes through the guarded internal factory, and `HttpResponseException(Response)` accepts a non-error status (a caller can build one around a 200). Read as the SDK's mapping path; proposed as a design §11 item; whether the constructor should guard is a phase 12 public-surface choice. - **P10-25, `ServiceResponseException`.** Public, documented in the taxonomy as "always retryable (XCUT-4, RETRY-4)" and constructed nowhere in `src/` (design V15): dead public surface beside the two branches `XCUT-4` asks for. Routed to phase 11 (delete, or argue it into §3.2) and to phase 12's one-time public-surface choices. Related count question: *Totals*. -- **The HTAB edge** (deviation 5): two MUSTs of the specification meet at the edge of a name; core follows `HTTP-17`. -- **No MUST was found broken.** Measured at the start (design V2): every one of the 24 `XCUT` rows already had a mechanism and a passing test; what was missing was the portable suite form, a citing test for nine rows and two mechanical gates (`XCUT-9`, `XCUT-14`), all built here. +- **The HTAB edge** (deviation 5): `XCUT-18` (reject HTAB in a name) and `HTTP-17` (trim surrounding whitespace) meet at the edge of an outbound name and are both satisfied by a refusal or a trim; on the inbound path `HTTP-19` keeps names strictly validated, and core's `AddInbound` trims anyway. A possible gap, found by the review of this phase and routed to phase 11. +- **No MUST was found broken, bar one possible gap.** Measured at the start (design V2): every one of the 24 `XCUT` rows already had a mechanism and a passing test (the possible gap is the inbound HTAB edge above, found on review and not counted); what was missing was the portable suite form, a citing test for nine rows and two mechanical gates (`XCUT-9`, `XCUT-14`), all built here. ## Open items for the lead @@ -177,14 +195,14 @@ and hard-codes widened), R6 (the measured `.editorconfig` and pragma counts), R7 it back (`gh api repos/dexpace/dotnet-sdk/rulesets`) and record the result in this row; until then `NFR-17` is ⏳ and `docs/first-release.md` carries it as a blocker before the first publish. This phase did not apply it (no remote action was taken) and could not read it (deviation 9). 2. **Frozen-tree corrections are proposed, not made.** `docs/sdk-design-dotnet/` and `docs/product-spec/` are frozen to routine work, so the dated corrections the design lists (§9, §9.1 to §9.3, §10 entry 7's note on `ServiceResponseException`, a §11 item for `XCUT-8`'s reading (P10-24), a §11 item for the HTAB edge, the §12 notes) are listed under *Proposed corrections* below for a human's deliberate edit. -3. **The phase 11 inbound list** (also in the roadmap's dated status note): `ServiceResponseException` (P10-25); `XCUT-24`'s error-snapshot repair candidate (P10-23); the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25; `XCUT-8`'s reading as a §11 item to confirm (P10-24); the HTAB edge as a §11 item; F-A2 (`ASYNC-2`) unchanged; the 16 MUST rows the coverage script reads as ⏳ in the row that owns them (`NFR-17`, and hand-offs closed in a later checklist's table that the script does not read as owning, such as the `RECOV` rows in 6a's), which need dated corrections before `docs/first-release.md`'s *Every MUST dispositioned* line can be ticked +3. **The phase 11 inbound list** (also in the roadmap's dated status note): `ServiceResponseException` (P10-25); `XCUT-24`'s error-snapshot repair candidate (P10-23); the unmet-clause count question for `XCUT-4`'s "catchable as a generic I/O error" beside `CFG-2`'s entry 25; `XCUT-8`'s reading as a §11 item to confirm (P10-24); the HTAB edge as a §11 item, with the inbound trim (`HTTP-19`) ruled a gap or a reading; F-A2 (`ASYNC-2`) unchanged; the 16 MUST rows the coverage script reads as ⏳ in the row that owns them (`NFR-17`; the 12 `RECOV` rows `RECOV-17` to `-24`, `-27`, `-28`, `-29` and `-34`, which 4b's table leaves ⏳ and 6a's table closes; and `BODY-4`, `BODY-5` and `BODY-10`, 3b's hand-offs, of which `BODY-4` and `BODY-5` carry a 2026-10-08 correction under 3b's table and not in the row), which need dated corrections before `docs/first-release.md`'s *Every MUST dispositioned* line can be ticked (phase 10 left it open and built the gate). Phase 12's one-time public-surface choices gain the `HttpResponseException` constructor guard (P10-24) and `ServiceResponseException` (P10-25); `NFR-4`'s baseline, `NFR-12`'s release half and `NFR-16` stay *After the first publish* and *Blockers*. ## Proposed corrections (frozen trees) Each is dated 2026-10-14 and append-only when a human makes it. **Design §9**: the gate table is now evidenced (this checklist's gate table); §9.1 `RS0030`: the `GetBaseException` and `ConcurrentDictionary` constructor bans exist; §9.2: `NFR-8`'s keep configuration is the declaration plus annotations, asserted on the packed assembly; §9.3: Stryker.NET unsupported on Microsoft.Testing.Platform (V5), benchmarks not built -(post-release triggers). **§10 entry 7**: add that `ServiceResponseException` is never raised in the tree (P10-25). **§11, new items**: (a) `XCUT-8` is read as the SDK's mapping path, not the public constructor (P10-24); (b) a header name with HTAB at either end is trimmed under `HTTP-17` and a name with HTAB inside is rejected under `XCUT-18` (deviation 5); +(post-release triggers). **§10 entry 7**: add that `ServiceResponseException` is never raised in the tree (P10-25). **§11, new items**: (a) `XCUT-8` is read as the SDK's mapping path, not the public constructor (P10-24); (b) the HTAB edge of a header name: outbound, `XCUT-18` (reject) and `HTTP-17` (trim) are both read as satisfied by a refusal or a trim, and HTAB inside a name is rejected; inbound, `HTTP-19`'s strict validation against the lenient path's trim, ruled a reading or a gap (deviation 5); (c) the `NFR-11` scan's subject is the compiled public surface of the assembly, not `PublicAPI.Shipped.txt` (P10-11). **§12**: the `XCUT` and `NFR` rows' notes (24 and 17 rows dispositioned by phase 10). No new §10 entry is opened (P10-26). ## Gates run diff --git a/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md b/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md index 2f2f11c9..63a81db2 100644 --- a/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md +++ b/tests/Dexpace.Sdk.Conformance.Tests/APPENDIX_B.md @@ -73,9 +73,9 @@ row never reads better than its worst clause. | B.8 | B.8.1 | XCUT-1, XCUT-2, XCUT-3 | invariant:xcut-1.cancel-is-terminal; invariant:xcut-2.timeout-is-retryable; invariant:xcut-3.wait-promptly-cancellable | suite | `XCUT-1`'s interrupt-flag clause is cooperative (design §10 entry 8); the transport-level clauses are `transport:transport-3.*` and `transport-4.*` (P10-6) | | B.8 | B.8.2 | XCUT-4, XCUT-5, XCUT-6, XCUT-7, XCUT-8, XCUT-9 | invariant:xcut-4.two-branches; invariant:xcut-5.one-classifier; invariant:xcut-6.capability-queried; invariant:xcut-7.configured-set-authoritative; invariant:xcut-8.strict-factory-rejects; invariant:xcut-8.convenience-returns-response; invariant:xcut-9.cycle-safe-walk | suite | `XCUT-4`'s "catchable as a generic I/O error" is a judged mechanism (design §10 entry 7, §11 item 23); `XCUT-8` reads as the SDK's mapping path, not the public constructor (P10-24) | | B.8 | B.8.3 | XCUT-10 | invariant:xcut-10.safety-gate-uniform | suite | | -| B.8 | B.8.4 | XCUT-11, XCUT-12, XCUT-13, XCUT-22, XCUT-23 | invariant:xcut-11.shared-no-crosstalk; invariant:xcut-12.single-flight-scoped; invariant:xcut-13.dispose-latched; invariant:xcut-13.dispose-non-blocking; invariant:xcut-22.core-borrows; invariant:xcut-23.resolution-deterministic | suite | `XCUT-13`'s interrupt-flag clause is vacuous (design §10 entry 8, §3.7); `XCUT-23`'s discovery half is vacuous (design §10 entry 9, §11 item 8) | +| B.8 | B.8.4 | XCUT-11, XCUT-12, XCUT-13, XCUT-22, XCUT-23 | invariant:xcut-11.shared-no-crosstalk; invariant:xcut-12.single-flight-scoped; invariant:xcut-13.dispose-latched; invariant:xcut-13.dispose-non-blocking; invariant:xcut-22.core-borrows; invariant:xcut-23.resolution-deterministic | suite | `XCUT-13`'s interrupt-flag clause is vacuous (design §10 entry 8, §3.7); `XCUT-23`'s discovery half is vacuous (design §10 entry 9, §11 item 8); `XCUT-12`'s "cached reads take no lock" clause is cited by reference to `AccessTokenCacheTests.The_hot_path_takes_no_lock` and is not asserted by the suite | | B.8 | B.8.5 | XCUT-14, XCUT-15 | invariant:xcut-14.bounded-under-burst; invariant:xcut-14.drains-after-insert; invariant:xcut-15.no-external-alias | suite | | -| B.8 | B.8.6 | XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-24 | invariant:xcut-16.no-credential-over-http; invariant:xcut-17.redirect-hygiene; invariant:xcut-18.model-validation; invariant:xcut-19.redaction-default-deny; invariant:xcut-20.observability-never-throws; invariant:xcut-21.cnonce-unpredictable; invariant:xcut-24.preview-capped-non-consuming | suite | `XCUT-24`'s non-consuming clause is declined for an error-body snapshot above 1 MiB (P10-23); `XCUT-21`'s CSPRNG source clause is cited by reference to `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` | +| B.8 | B.8.6 | XCUT-16, XCUT-17, XCUT-18, XCUT-19, XCUT-20, XCUT-21, XCUT-24 | invariant:xcut-16.no-credential-over-http; invariant:xcut-17.redirect-hygiene; invariant:xcut-18.model-validation; invariant:xcut-19.redaction-default-deny; invariant:xcut-20.observability-never-throws; invariant:xcut-21.cnonce-unpredictable; invariant:xcut-24.preview-capped-non-consuming | suite | `XCUT-17` composes the subject's Basic and bearer steps under the redirect loop for the re-stamp and no-re-stamp clauses; `XCUT-18`'s HTAB-at-the-edge-of-a-name clause is split: `XCUT-18` rejects and `HTTP-17` trims, so a refusal or a trim passes outbound, and inbound the trim of core's `AddInbound` is not pinned and is a possible `HTTP-19` gap routed to phase 11 (proposed design §11 item); `XCUT-24`'s non-consuming clause is declined for an error-body snapshot above 1 MiB (P10-23); `XCUT-21`'s CSPRNG source clause is cited by reference to `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` | | B.9 | B.9.1 | NFR-1, NFR-2 | packaging:nfr-1.core-dependencies; packaging:nfr-2.adapter-budget | suite | design §10 entries 1 and 2 | | B.9 | B.9.2 | NFR-3, NFR-4 | packaging:nfr-3.no-internals-granted; Directory.Build.props; tests/Dexpace.Sdk.Core.Tests/Architecture/NFR4ArchitectureTests.cs | by reference | `NFR-4`'s package-validation baseline waits for the first publish (phase 12) | | B.9 | B.9.3 | NFR-5, NFR-6, NFR-7, NFR-17 | scripts/ci/coverage-gate.cs; Directory.Build.props; .editorconfig; tests/Dexpace.Sdk.Conformance.Tests/Gates/CiWorkflowTests.cs; tests/Dexpace.Sdk.Conformance.Tests/Gates/AnalyzerWaiverTests.cs | by reference | `NFR-17`'s second half, that a merge requires the checks, is a repository setting recorded in the phase checklist | From da9448e41e2f50cbe3a6aac180b34caaa4bfedff Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 15 Oct 2026 09:16:36 +0300 Subject: [PATCH 41/45] docs: record the gate re-run after the review resolution in the checklist (phase 10, #99) --- .../2026-10-13-phase10-cross-cutting-invariants-checklist.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md index 0b623729..5e0e9c28 100644 --- a/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md +++ b/docs/work/mvp/phase10/2026-10-13-phase10-cross-cutting-invariants-checklist.md @@ -212,3 +212,8 @@ with coverage: **7 323 tests, 0 failed, 27 skipped** (the 22 transport-driver ro `dotnet pack` (5 packages) and the dependency audit (`5 libraries conform`); `scripts/ci/packaging-conformance.cs` (all eight packaging assertions and the appendix-B items it covers passed over the five packages); `scripts/ci/requirement-coverage.cs --check` (645 of 645 owned, no finding in a MUST row) and its self-test (14 cases ok); the tools solution build, format and tests (438 tests, 0 failed); `scripts/knowledge verify-structure` (57 notes, every cited key live); `scripts/ci/reproducible-pack.sh` (from a clean tree: five packages byte-identical across two checkouts at different paths); the NativeAOT publish (no trim or AOT warning) and its binary (`aot-smoke: all checks passed`, exit 0); the housekeeping probe (`no drift found`). `git diff --stat main...HEAD` over the three `Security` folders is empty, over `Directory.Packages.props` is empty, over `src/` is the kit and `Internal/BoundedMap.cs` (two lines) only, over the `PublicAPI.Unshipped.txt` files is the kit's only, and over the lock files is `Dexpace.Sdk.Core.Tests` and `Dexpace.Sdk.Serialization.SystemTextJson.Tests` (six lines each, the new kit reference). The group gates before it (invariant suite and drivers: 7 149 tests, coverage 97.53%; codec suite: 7 231, 97.49%; packaging suite and gates: 7 309, 97.47%) were green likewise. + +**Re-run after the review resolution** (2026-10-15, at the last code commit `7947c0b`, tree clean, same limits). `dotnet restore --locked-mode` and the tools solution; `dotnet build Dexpace.Sdk.sln -c Release --no-incremental` (0 warnings, 0 errors); `dotnet format --verify-no-changes` (clean); `dotnet test --solution Dexpace.Sdk.sln -c Release` with coverage: **7 356 tests, 0 failed, 27 skipped** (the same 22 + 5 rows; 33 more tests than before the review: the new controls and +pins); the coverage gate **97.46% aggregate against 80%** (`Dexpace.Sdk.Conformance` 97.20%, `Dexpace.Sdk.Core` 97.77%, `Dexpace.Sdk.Extensions.DependencyInjection` 97.67%, `Dexpace.Sdk.Http.SystemNet` 95.17%, `Dexpace.Sdk.Serialization.SystemTextJson` 99.04%) and its self-test (ok); `dotnet pack` (5 packages) and the dependency audit (`5 libraries conform`); `scripts/ci/packaging-conformance.cs` (all eight assertions passed); +`scripts/ci/requirement-coverage.cs --check` (645 of 645 owned, no finding in a MUST row; the tally still reads 16 MUST rows as ⏳, the rows named in `docs/first-release.md`) and its self-test (14 cases ok); the tools solution (438 tests, 0 failed); `scripts/knowledge verify-structure` (60 notes); `scripts/ci/reproducible-pack.sh` (five packages byte-identical across two checkouts at different paths); the NativeAOT publish (no warning) and its binary (`aot-smoke: all checks passed`, exit 0); the housekeeping probe (`no drift found`). +`git diff --stat main...HEAD` over the three `Security` folders is empty; over `Directory.Packages.props` is empty; over the `PublicAPI.Unshipped.txt` files is the kit's only; over the lock files is the two test projects' kit reference (unchanged by the review). From f781ba9cf4316720e930fcc84a7ad142b25d5440 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 15 Oct 2026 09:23:00 +0300 Subject: [PATCH 42/45] fix: reproducible-pack.sh anchors a relative output directory before packing inside each checkout (phase 10, #99) --- scripts/ci/reproducible-pack.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ci/reproducible-pack.sh b/scripts/ci/reproducible-pack.sh index 3f60b8cf..a22cf8a2 100755 --- a/scripts/ci/reproducible-pack.sh +++ b/scripts/ci/reproducible-pack.sh @@ -15,6 +15,8 @@ set -euo pipefail shopt -s nullglob repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" out="${1:-$repo/artifacts/reproducible}" +# Each pack runs from inside its checkout, so a relative output directory is anchored to the caller's directory first. +[[ "$out" = /* ]] || out="$PWD/$out" dotnet="${DOTNET:-dotnet}" export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$repo" log -1 --format=%ct)}" echo "reproducible-pack: SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" From 16c2fe6b0043432d5d68a0683f577f5e91d02e3a Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 15 Oct 2026 09:33:40 +0300 Subject: [PATCH 43/45] test: the sync half of Both_waits_use_TimeProviderWaits advances the clock only once its timer is armed (phase 10, #99) --- .../Dexpace.Sdk.Core.Tests/Resilience/RetryEngineTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Dexpace.Sdk.Core.Tests/Resilience/RetryEngineTests.cs b/tests/Dexpace.Sdk.Core.Tests/Resilience/RetryEngineTests.cs index 68f2fd52..184757af 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Resilience/RetryEngineTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Resilience/RetryEngineTests.cs @@ -383,9 +383,12 @@ public async Task Both_waits_use_TimeProviderWaits() var syncTask = Task.Run( () => SyncResult(Engine(syncClock).RunAsync(syncRun, async: false, TestContext.Current.CancellationToken)), TestContext.Current.CancellationToken); - await DriveAsync(syncTask, syncClock, TimeSpan.FromSeconds(10)); + // The sync run blocks a pool thread: advance only once its timer exists, or a slow runner sees the clock move past a wait not yet armed. + Assert.True(SpinWait.SpinUntil(() => syncClock.Timers.Count > 0, TimeSpan.FromSeconds(30)), "The sync wait never armed its timer."); + syncClock.Advance(TimeSpan.FromSeconds(30)); + var syncOutcome = await syncTask.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); Assert.Equal([TimeSpan.FromSeconds(30)], syncClock.Timers); - Assert.IsType(await syncTask); + Assert.IsType(syncOutcome); } private static Outcome SyncResult(ValueTask task) From 0ae87bd3798e51986c8e70ec411d59ad82b28d93 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 15 Oct 2026 09:46:49 +0300 Subject: [PATCH 44/45] ci: reproducible-pack.sh names the differing package entries and their unique strings on a mismatch (phase 10, #99) --- scripts/ci/reproducible-pack.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/ci/reproducible-pack.sh b/scripts/ci/reproducible-pack.sh index a22cf8a2..5e1731ca 100755 --- a/scripts/ci/reproducible-pack.sh +++ b/scripts/ci/reproducible-pack.sh @@ -51,6 +51,27 @@ for checkout in "${checkouts[@]}"; do ) done +# On a mismatch, name the package entries that differ and, for each, the printable strings found in only one of the two (a leaked path shows here). +explain() { + local a="$out/explain/a" b="$out/explain/b" entry + rm -rf "$out/explain" + mkdir -p "$a" "$b" + unzip -q -o "$1" -d "$a" + unzip -q -o "$2" -d "$b" + (cd "$a" && find . -type f | sort) | while read -r entry; do + if ! cmp -s "$a/$entry" "$b/$entry"; then + echo "reproducible-pack: entry $entry differs" + diff <(strings -n 8 "$a/$entry" | sort -u) <(strings -n 8 "$b/$entry" | sort -u) | grep '^[<>]' | head -n 10 | sed 's/^/reproducible-pack: /' || true + fi + done + if [[ "$(unzip -Z1 "$1" | sort)" != "$(unzip -Z1 "$2" | sort)" ]]; then + echo "reproducible-pack: the entry lists differ" + fi + if [[ "$(unzip -Zv "$1" | grep -E 'file last modified|minimum software' | sort | uniq -c)" != "$(unzip -Zv "$2" | grep -E 'file last modified|minimum software' | sort | uniq -c)" ]]; then + echo "reproducible-pack: the zip metadata (timestamps or versions) differ" + fi +} + status=0 count=0 for first in "$out"/1/*.nupkg "$out"/1/*.snupkg; do @@ -62,6 +83,7 @@ for first in "$out"/1/*.nupkg "$out"/1/*.snupkg; do status=1 elif ! cmp -s "$first" "$second"; then echo "reproducible-pack: $name differs between the two packs" + explain "$first" "$second" status=1 else echo "reproducible-pack: $name identical ($(sha256sum "$first" | cut -d' ' -f1))" From f1db46f1709a2d62ae8eda7b31192e63bd877c55 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 15 Oct 2026 10:06:55 +0300 Subject: [PATCH 45/45] ci: reproducible-pack.sh prints the zip metadata lines that differ (phase 10, #99) --- scripts/ci/reproducible-pack.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/reproducible-pack.sh b/scripts/ci/reproducible-pack.sh index 5e1731ca..68c5bcea 100755 --- a/scripts/ci/reproducible-pack.sh +++ b/scripts/ci/reproducible-pack.sh @@ -69,6 +69,7 @@ explain() { fi if [[ "$(unzip -Zv "$1" | grep -E 'file last modified|minimum software' | sort | uniq -c)" != "$(unzip -Zv "$2" | grep -E 'file last modified|minimum software' | sort | uniq -c)" ]]; then echo "reproducible-pack: the zip metadata (timestamps or versions) differ" + diff <(unzip -Zv "$1") <(unzip -Zv "$2") | grep '^[<>]' | head -n 12 | sed 's/^/reproducible-pack: /' || true fi }