From c94e60e6676824f6ae68f4325ecc8a696b51ae55 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:35:46 +0000 Subject: [PATCH 1/3] test(dummies): assert cross-TFM seed equality across packaged assets The Dummies packaged-asset guard (tools/dummies-check) proved asset SELECTION and per-asset reproducibility, but never compared the two shipped assets against each other: each leg replayed its own seed in its own process, so a seed-sequence divergence between the net8.0 and netstandard2.0 assets would leave both legs green and reach NuGet unseen. Emit the seeded common-surface batch as a one-line SEEDBATCH banner from each consumer leg, then compare the net8.0 and net6.0 legs byte-for-byte in dummies.yml. new Random(seed) keeps the legacy algorithm on modern .NET, so the two assets should agree seed-for-seed; the check makes that silent assumption a contract and fails closed on a missing banner. Broaden SeedBatch and the smoke to cover the common generators the guard never touched -- OrNull, ArrayOf/SequenceOf, PairOf/TripleOf, StringMatching and enum draws -- so they ride the cross-asset comparison too. Every part renders to printable ASCII, keeping the banner grep-safe. Refs: #215 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KjV7imhqhLeUr6vZscTSHh --- .github/workflows/dummies.yml | 23 ++++++++++++ tools/dummies-check/Program.cs | 64 ++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dummies.yml b/.github/workflows/dummies.yml index 9bfa33aa..be02d3f2 100644 --- a/.github/workflows/dummies.yml +++ b/.github/workflows/dummies.yml @@ -90,3 +90,26 @@ jobs: cat netstandard.log grep -q 'ASSET=.NETStandard,Version=v2.0' netstandard.log grep -q 'RESULT=PASS' netstandard.log + + - name: Cross-TFM seed equality (net8.0 asset vs netstandard2.0 asset) + # Each leg above printed a SEEDBATCH= line: the SAME fixed seed drawn from the COMMON surface, each on its + # OWN runtime from the asset that consumer TFM forced. The two packaged assets must produce an identical + # seeded sequence — new Random(seed) keeps the legacy algorithm on modern .NET so they SHOULD agree, but + # nothing else asserts it and Random reserves the right to differ across framework versions (issue #215). + # Compare byte-for-byte; the empty-guard fails closed so a missing/renamed banner can never pass silently. + working-directory: tools/dummies-check + run: | + set -euo pipefail + net8_seq="$(sed -n 's/^SEEDBATCH=//p' net8.log)" + netstd_seq="$(sed -n 's/^SEEDBATCH=//p' netstandard.log)" + echo "net8.0 asset : ${net8_seq}" + echo "netstandard2.0 asset: ${netstd_seq}" + if [ -z "$net8_seq" ] || [ -z "$netstd_seq" ]; then + echo "FAIL: a SEEDBATCH banner was missing (net8.0='${net8_seq}', netstandard2.0='${netstd_seq}')" + exit 1 + fi + if [ "$net8_seq" != "$netstd_seq" ]; then + echo "FAIL: cross-TFM seed divergence — the two packaged assets produced different seeded sequences" + exit 1 + fi + echo "PASS: both assets produced an identical seeded sequence" diff --git a/tools/dummies-check/Program.cs b/tools/dummies-check/Program.cs index 7dc4cbe7..ce5b5064 100644 --- a/tools/dummies-check/Program.cs +++ b/tools/dummies-check/Program.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Text.RegularExpressions; using Dummies; @@ -37,6 +38,13 @@ internal static class Program { // asset, absent on the netstandard2.0 asset — the exact conditional surface the acceptance criteria name. private static readonly string[] ModernEntryPoints = { "DateOnly", "TimeOnly", "Int128", "UInt128", "Half" }; + // Fixed seed for the cross-TFM golden sequence. SeedBatch draws from the COMMON surface under this seed, and + // Main prints the result as the SEEDBATCH banner. dummies.yml compares that banner byte-for-byte between the + // net8.0 and netstandard2.0 legs: new Random(seed) keeps the legacy algorithm on modern .NET, so the two + // packaged assets SHOULD agree seed-for-seed — but nothing else asserts it, and Random reserves the right to + // differ across framework versions. This turns that silent assumption into a checked contract (issue #215). + private const int CrossTfmSeed = 20260719; + private static int Main() { Assembly dummies = typeof(Any).Assembly; string assetMoniker = dummies.GetCustomAttribute()?.FrameworkName ?? "(none)"; @@ -47,6 +55,11 @@ private static int Main() { Console.WriteLine($"ASSET={assetMoniker}"); Console.WriteLine($"RUNTIME={RuntimeInformation.FrameworkDescription}"); + // The seeded common-surface batch for THIS asset, on one grep-safe line (every draw renders to printable + // ASCII). dummies.yml diffs it against the other leg's banner to prove cross-asset seed equality. Emitted + // here with the other identifying banners, before the checks run; a leg that no-oped would omit it. + Console.WriteLine($"SEEDBATCH={SeedBatch(Any.WithSeed(CrossTfmSeed))}"); + List failures = new(); // 1. The restored asset is the one this consumer TFM is meant to force. @@ -111,10 +124,34 @@ private static void RunSmoke(List failures) { HashSet set = Any.SetOf(Any.Int32().Between(0, 99)).WithCount(3).Generate(); Require(failures, set.Count == 3, $"SetOf(...).WithCount(3) produced {set.Count} elements"); + // issue #215: exercise the common generators the packaged-asset guard never touched, so a break on either + // asset (a packaging or conditional-compilation regression) surfaces here — OrNull, array/sequence, + // pair/triple, StringMatching and enum draws. These also ride the SEEDBATCH cross-asset comparison below. + int? maybeDiscount = Any.Int32().Between(0, 100).OrNull().Generate(); + Require(failures, maybeDiscount is null or (>= 0 and <= 100), $"Int32().Between(0,100).OrNull() produced {maybeDiscount}"); + + int[] trio = Any.ArrayOf(Any.Int32().Between(0, 9)).WithCount(3).Generate(); + Require(failures, trio.Length == 3 && trio.All(value => value is >= 0 and <= 9), $"ArrayOf(...).WithCount(3) produced [{string.Join(",", trio)}]"); + + List couple = Any.SequenceOf(Any.Int32().Between(0, 9)).WithCount(2).Generate().ToList(); + Require(failures, couple.Count == 2 && couple.All(value => value is >= 0 and <= 9), $"SequenceOf(...).WithCount(2) produced {couple.Count} elements"); + + (int, string) pair = Any.PairOf(Any.Int32().Between(1, 9), Any.String().NonEmpty().WithMaxLength(4)).Generate(); + Require(failures, pair.Item1 is >= 1 and <= 9 && pair.Item2.Length is >= 1 and <= 4, $"PairOf(...) produced ({pair.Item1},{pair.Item2})"); + + (bool, int, char) triple = Any.TripleOf(Any.Boolean(), Any.Int32().Between(0, 9), Any.Char()).Generate(); + Require(failures, triple.Item2 is >= 0 and <= 9, $"TripleOf(...) produced ({triple.Item1},{triple.Item2},{triple.Item3})"); + + string code = Any.StringMatching("[A-Z]{3}-[0-9]{4}").Generate(); + Require(failures, Regex.IsMatch(code, "^[A-Z]{3}-[0-9]{4}$"), $"StringMatching('[A-Z]{{3}}-[0-9]{{4}}') produced '{code}'"); + + Suit suit = Any.Enum().Generate(); + Require(failures, System.Enum.IsDefined(typeof(Suit), suit), $"Enum() produced {suit}"); + // Seeded reproducibility: two contexts with the same seed replay an identical mixed sequence, and a // different seed diverges. This is the library's crown-jewel guarantee — verified here on each asset. - string first = SeedBatch(Any.WithSeed(20260719)); - string second = SeedBatch(Any.WithSeed(20260719)); + string first = SeedBatch(Any.WithSeed(CrossTfmSeed)); + string second = SeedBatch(Any.WithSeed(CrossTfmSeed)); Require(failures, first == second, "same-seed contexts diverged"); string other = SeedBatch(Any.WithSeed(987654321)); @@ -123,6 +160,8 @@ private static void RunSmoke(List failures) { // Draws a fixed mixed sequence from the COMMON surface only (no modern types), so it compiles and runs // on both assets. Rendered with InvariantCulture to match the library's own culture-invariant rendering. + // Every part renders to printable ASCII (unconstrained Char/String draw ASCII letters and digits; the + // pattern and enum are ASCII by construction), so the joined line is safe to emit as a one-line banner. private static string SeedBatch(AnyContext any) { List parts = new() { any.Int32().Generate().ToString(CultureInfo.InvariantCulture), @@ -139,6 +178,23 @@ private static string SeedBatch(AnyContext any) { any.DateTime().Generate().Ticks.ToString(CultureInfo.InvariantCulture) }; + // issue #215: broaden the compared batch beyond scalars — OrNull, array/sequence, pair/triple, + // StringMatching and enum draws. Collections and tuples inherit THIS seeded context through their + // operand (which carries the source), so every added draw is still reproducible and cross-asset stable. + parts.Add(any.Int32().Between(0, 100).OrNull().Generate() is int discount ? discount.ToString(CultureInfo.InvariantCulture) : "null"); + parts.Add(any.String().NonEmpty().WithMaxLength(8).OrNull().Generate() ?? "null"); + parts.Add(string.Join(",", Any.ArrayOf(any.Int32().Between(0, 9)).WithCount(3).Generate().Select(value => value.ToString(CultureInfo.InvariantCulture)))); + parts.Add(string.Join(",", Any.SequenceOf(any.Int32().Between(0, 9)).WithCount(2).Generate().Select(value => value.ToString(CultureInfo.InvariantCulture)))); + + (int, char) pair = Any.PairOf(any.Int32().Between(1, 9), any.Char()).Generate(); + parts.Add($"({pair.Item1.ToString(CultureInfo.InvariantCulture)},{pair.Item2})"); + + (bool, int, char) triple = Any.TripleOf(any.Boolean(), any.Int32().Between(0, 9), any.Char()).Generate(); + parts.Add($"({triple.Item1},{triple.Item2.ToString(CultureInfo.InvariantCulture)},{triple.Item3})"); + + parts.Add(any.StringMatching("[A-Z]{3}-[0-9]{4}").Generate()); + parts.Add(any.Enum().Generate().ToString()); + return string.Join("|", parts); } @@ -146,4 +202,8 @@ private static void Require(List failures, bool condition, string messag if (!condition) { failures.Add(message); } } + // A small closed enum for the enum-draw coverage (issue #215). Members render to stable, culture-independent + // names, keeping the enum part of the SEEDBATCH banner comparable across the two asset legs. + private enum Suit { Clubs, Diamonds, Hearts, Spades } + } From c345968de2e1a9f99accb7c0e27f1b4035889f42 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:35:47 +0000 Subject: [PATCH 2/3] test(dummies): run the contract suite on the net472 floor Dummies.UnitTests targeted net10.0 only, so Dummies' own contract suite (conflict detection, regex oracle, distinctness gating, seed reproducibility) never ran on the netstandard2.0 asset that .NET Framework consumers load -- it was exercised there only transitively, through FirstClassErrors' floor job. ADR-0022 asks for a floor that is continuously verified, not merely asserted. Import build/Net472TestFloor.props (as the FirstClassErrors library test projects do) so the suite gains a net472 leg under EnableNet472Floor, and add Dummies.UnitTests to the framework-floor loop in ci.yml. Condition out the .NET 8+ surface the netstandard2.0 asset does not carry. AnyModernTypeTests, CrossEngineReachabilityTests and ContinuousExclusionNudgeTests are excluded wholesale: they are saturated with the DateOnly/TimeOnly/Int128/UInt128/Half generators and with Math.BitIncrement/MathF/BitConverter.Half (netstandard2.1+ helpers absent from net472) used to build adjacent-value fixtures, and they guard modern-.NET reachability and nudge behavior that is runtime-invariant. The isolated modern draws in SeedReproducibilityTests and AnyCollectionTests are guarded with #if NET8_0_OR_GREATER. net10 keeps all tests green; net472 compiles clean and the framework-floor job runs it on Windows. Refs: #215 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KjV7imhqhLeUr6vZscTSHh --- .github/workflows/ci.yml | 13 ++++++++----- Dummies.UnitTests/AnyCollectionTests.cs | 2 ++ Dummies.UnitTests/Dummies.UnitTests.csproj | 19 ++++++++++++++++++- Dummies.UnitTests/SeedReproducibilityTests.cs | 7 ++++++- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12fc2263..bec83446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: framework-floor: name: Library on the .NET Framework 4.7.2 floor runs-on: windows-latest - # Restore + build + test of three small projects; cap a hung run like the other jobs. + # Restore + build + test of four small projects; cap a hung run like the other jobs. timeout-minutes: 15 steps: - name: Checkout @@ -95,11 +95,13 @@ jobs: with: dotnet-version: '10.0.x' - # Only the three library test projects carry the net472 leg (EnableNet472Floor adds it): the tooling test + # Only these library test projects carry the net472 leg (EnableNet472Floor adds it): the tooling test # projects (Roslyn / GenDoc / Cli) stay net10-only by design, and RequestBinder.UnitTests stays net10-only # because its fixtures bind DateOnly, a .NET 6+ type absent from net472. RequestBinder is still floored here - # through its property tests. A per-project loop (not a solution-wide -f net472, which would force the TFM - # onto the net10-only projects and fail) keeps the net472 scope exactly these three. + # through its property tests, and Dummies through its own contract suite (Dummies.UnitTests), running on the + # netstandard2.0 asset .NET Framework consumers load — its net8-only tests are conditioned out (issue #215). + # A per-project loop (not a solution-wide -f net472, which would force the TFM onto the net10-only projects + # and fail) keeps the net472 scope exactly these four. - name: Test the netstandard2.0 libraries on .NET Framework 4.7.2 shell: bash run: | @@ -107,7 +109,8 @@ jobs: for proj in \ FirstClassErrors.UnitTests \ FirstClassErrors.PropertyTests \ - FirstClassErrors.RequestBinder.PropertyTests ; do + FirstClassErrors.RequestBinder.PropertyTests \ + Dummies.UnitTests ; do echo "::group::$proj (net472)" dotnet test "$proj/$proj.csproj" -c Release -f net472 -p:EnableNet472Floor=true \ --logger "console;verbosity=normal" diff --git a/Dummies.UnitTests/AnyCollectionTests.cs b/Dummies.UnitTests/AnyCollectionTests.cs index 1899b3a0..6b1e2de8 100644 --- a/Dummies.UnitTests/AnyCollectionTests.cs +++ b/Dummies.UnitTests/AnyCollectionTests.cs @@ -257,7 +257,9 @@ public void FiniteScalarGeneratorsGateEagerly() { Check.ThatCode(() => Any.SetOf(Any.Decimal().OneOf(1m, 2m)).WithCount(3)).Throws(); Check.ThatCode(() => Any.SetOf(Any.Double().OneOf(1d, 2d)).WithCount(3)).Throws(); Check.ThatCode(() => Any.SetOf(Any.Single().OneOf(1f, 2f)).WithCount(3)).Throws(); +#if NET8_0_OR_GREATER Check.ThatCode(() => Any.SetOf(Any.Int128().Between(1, 3)).WithCount(5)).Throws(); +#endif // Membership travels with cardinality: an out-of-domain contained value extends the effective domain... for (int i = 0; i < SampleCount; i++) { diff --git a/Dummies.UnitTests/Dummies.UnitTests.csproj b/Dummies.UnitTests/Dummies.UnitTests.csproj index 20a369fb..b5f65bc9 100644 --- a/Dummies.UnitTests/Dummies.UnitTests.csproj +++ b/Dummies.UnitTests/Dummies.UnitTests.csproj @@ -1,7 +1,12 @@ + + + - net10.0 enable enable false @@ -32,4 +37,16 @@ + + + + + + + diff --git a/Dummies.UnitTests/SeedReproducibilityTests.cs b/Dummies.UnitTests/SeedReproducibilityTests.cs index 3eae09a3..189dafa7 100644 --- a/Dummies.UnitTests/SeedReproducibilityTests.cs +++ b/Dummies.UnitTests/SeedReproducibilityTests.cs @@ -30,8 +30,10 @@ private static string Batch() { char letter = Any.Char().Generate(); TimeSpan span = Any.TimeSpan().Generate(); DateTime instant = Any.DateTime().Generate(); +#if NET8_0_OR_GREATER Int128 huge = Any.Int128().Generate(); Half tiny = Any.Half().Generate(); +#endif List list = Any.ListOf(Any.Int32().Between(0, 9)).WithCount(4).Generate(); HashSet set = Any.SetOf(Any.Int32().Between(0, 99)).WithCount(3).Generate(); int? maybe = Any.Int32().Between(0, 9).OrNull().Generate(); @@ -39,7 +41,10 @@ private static string Batch() { return string.Join("|", full, bounded, free, capped, shaped, wide, unsigned, real, exact, flag, id, letter, - span.Ticks, instant.Ticks, huge, tiny, + span.Ticks, instant.Ticks, +#if NET8_0_OR_GREATER + huge, tiny, +#endif string.Join("-", list), string.Join("-", set.OrderBy(value => value)), maybe?.ToString() ?? "null", coded); } From 67f6795a241a6af49e988d297730bd852b979a82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:35:47 +0000 Subject: [PATCH 3/3] docs(dummies): document the .NET Framework floor in the package README The FirstClassErrors package README states its .NET Framework 4.7.2+ support; the Dummies README named only netstandard2.0 and net8.0, leaving the floor to inference. State it explicitly now that Dummies' own suite verifies it on the net472 floor in CI. Refs: #215 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KjV7imhqhLeUr6vZscTSHh --- Dummies/README.nuget.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index a26b2907..a7530422 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -29,7 +29,9 @@ matter — and that is the point. `Boolean`, `Guid`, `Enum` (declared members only), `TimeSpan`, `DateTime` (UTC) and `DateTimeOffset`. On modern targets (`net8.0`) the surface extends to `DateOnly`, `TimeOnly`, `Int128`, `UInt128` and `Half`; the package also targets - `netstandard2.0` for the widest reach. + `netstandard2.0` and runs on **.NET Framework 4.7.2+**, .NET Core 2.0+ and .NET 5+ + for the widest reach — with the .NET Framework 4.7.2 floor exercised in CI, not + merely advertised. - **Strings from a regex**: `Any.StringMatching(pattern)` generates arbitrary strings that match a regular expression — the dummy for a format-validated value object. Home-grown (zero dependencies) over the regular subset of the pattern language; a