From 64a1d3c3d1e0320d703cd2e3ae5b192afe4a8bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:17:21 +0000 Subject: [PATCH 1/6] chore: add the dummies commit scope to the convention The Dummies library joins the repository as its own component; its commits need a scope of their own in the closed list enforced by the shared linter and documented in CONTRIBUTING.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- CONTRIBUTING.md | 1 + tools/commit-lint/lint-commit-message.sh | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4506431..608a5202 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -257,6 +257,7 @@ The scope MAY be provided. When present it MUST be lowercase and MUST be one of: | `analyzers` | `FirstClassErrors.Analyzers` — the Roslyn analyzers and their `FCExxx` diagnostics | | `binder` | `FirstClassErrors.RequestBinder` — the request binder for the primary-adapter boundary | | `cli` | `FirstClassErrors.Cli` — the command-line tool | +| `dummies` | `Dummies` — the standalone arbitrary-test-value generator | | `gendoc` | `FirstClassErrors.GenDoc` and its worker — the documentation generator | | `testing` | `FirstClassErrors.Testing` — the test-support package | diff --git a/tools/commit-lint/lint-commit-message.sh b/tools/commit-lint/lint-commit-message.sh index f7e35e4c..6ff309e8 100755 --- a/tools/commit-lint/lint-commit-message.sh +++ b/tools/commit-lint/lint-commit-message.sh @@ -24,9 +24,9 @@ set -u TYPES='feat|fix|build|chore|ci|docs|perf|refactor|revert|style|test' -SCOPES='core|analyzers|binder|cli|gendoc|testing' +SCOPES='core|analyzers|binder|cli|dummies|gendoc|testing' TYPES_HUMAN='feat, fix, build, chore, ci, docs, perf, refactor, revert, style, test' -SCOPES_HUMAN='core, analyzers, binder, cli, gendoc, testing' +SCOPES_HUMAN='core, analyzers, binder, cli, dummies, gendoc, testing' MAX=72 # --- options ------------------------------------------------------------------ From cdb71ba350961bf983c287f69be88dad2ed6896b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:17:30 +0000 Subject: [PATCH 2/6] feat(dummies): introduce the standalone arbitrary-test-value library Dummies generates arbitrary yet valid test values through a fluent DSL of typed, immutable generators (IAny): constraints express the invariants a value must satisfy, values are built to satisfy them in one draw (no generate-then-retry), and contradictory constraints fail at declaration time with a ConflictingAnyConstraintException naming both sides. This first increment ships the Int32 and String verticals, composition toward domain types (As, Combine), implicit conversions, and a seedable ambient random context (Reproducibly, WithSeed). The project is deliberately standalone: it references no FirstClassErrors project, and an architecture test guards that boundary. Not yet wired to any release train. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- Dummies.UnitTests/AnyInt32Tests.cs | 206 ++++++++++ Dummies.UnitTests/AnyStringTests.cs | 260 +++++++++++++ Dummies.UnitTests/ArchitectureTests.cs | 37 ++ Dummies.UnitTests/CompositionTests.cs | 136 +++++++ Dummies.UnitTests/Dummies.UnitTests.csproj | 35 ++ Dummies.UnitTests/ImplicitConversionTests.cs | 52 +++ Dummies.UnitTests/SeedReproducibilityTests.cs | 169 ++++++++ Dummies.UnitTests/TestValueObjects.cs | 61 +++ Dummies/Any.cs | 234 +++++++++++ Dummies/AnyContext.cs | 55 +++ Dummies/AnyDerivation.cs | 77 ++++ Dummies/AnyException.cs | 24 ++ Dummies/AnyExtensions.cs | 49 +++ Dummies/AnyGenerationException.cs | 42 ++ Dummies/AnyInt32.cs | 185 +++++++++ Dummies/AnyString.cs | 220 +++++++++++ Dummies/ConflictingAnyConstraintException.cs | 19 + Dummies/Dummies.csproj | 67 ++++ Dummies/IAny.cs | 38 ++ Dummies/Int32Spec.cs | 158 ++++++++ Dummies/README.nuget.md | 57 +++ Dummies/RandomSource.cs | 156 ++++++++ Dummies/StringSpec.cs | 365 ++++++++++++++++++ FirstClassErrors.sln | 28 ++ 24 files changed, 2730 insertions(+) create mode 100644 Dummies.UnitTests/AnyInt32Tests.cs create mode 100644 Dummies.UnitTests/AnyStringTests.cs create mode 100644 Dummies.UnitTests/ArchitectureTests.cs create mode 100644 Dummies.UnitTests/CompositionTests.cs create mode 100644 Dummies.UnitTests/Dummies.UnitTests.csproj create mode 100644 Dummies.UnitTests/ImplicitConversionTests.cs create mode 100644 Dummies.UnitTests/SeedReproducibilityTests.cs create mode 100644 Dummies.UnitTests/TestValueObjects.cs create mode 100644 Dummies/Any.cs create mode 100644 Dummies/AnyContext.cs create mode 100644 Dummies/AnyDerivation.cs create mode 100644 Dummies/AnyException.cs create mode 100644 Dummies/AnyExtensions.cs create mode 100644 Dummies/AnyGenerationException.cs create mode 100644 Dummies/AnyInt32.cs create mode 100644 Dummies/AnyString.cs create mode 100644 Dummies/ConflictingAnyConstraintException.cs create mode 100644 Dummies/Dummies.csproj create mode 100644 Dummies/IAny.cs create mode 100644 Dummies/Int32Spec.cs create mode 100644 Dummies/README.nuget.md create mode 100644 Dummies/RandomSource.cs create mode 100644 Dummies/StringSpec.cs diff --git a/Dummies.UnitTests/AnyInt32Tests.cs b/Dummies.UnitTests/AnyInt32Tests.cs new file mode 100644 index 00000000..17bc42b3 --- /dev/null +++ b/Dummies.UnitTests/AnyInt32Tests.cs @@ -0,0 +1,206 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +[TestSubject(typeof(AnyInt32))] +public sealed class AnyInt32Tests { + + private const int SampleCount = 200; + + #region Statics members declarations + + private static IEnumerable Samples(IAny generator) { + for (int i = 0; i < SampleCount; i++) { + yield return generator.Generate(); + } + } + + #endregion + + [Fact(DisplayName = "An unconstrained Int32 generates without failing.")] + public void UnconstrainedGenerates() { + Check.ThatCode(() => Any.Int32().Generate()).DoesNotThrow(); + } + + [Fact(DisplayName = "Positive yields values strictly greater than zero.")] + public void PositiveIsStrictlyPositive() { + foreach (int value in Samples(Any.Int32().Positive())) { + Check.That(value).IsStrictlyGreaterThan(0); + } + } + + [Fact(DisplayName = "Negative yields values strictly less than zero.")] + public void NegativeIsStrictlyNegative() { + foreach (int value in Samples(Any.Int32().Negative())) { + Check.That(value).IsStrictlyLessThan(0); + } + } + + [Fact(DisplayName = "Zero yields exactly zero.")] + public void ZeroIsZero() { + Check.That(Any.Int32().Zero().Generate()).IsEqualTo(0); + } + + [Fact(DisplayName = "NonZero never yields zero.")] + public void NonZeroIsNeverZero() { + foreach (int value in Samples(Any.Int32().NonZero().Between(-2, 2))) { + Check.That(value).IsNotEqualTo(0); + } + } + + [Fact(DisplayName = "Between yields values within the inclusive bounds.")] + public void BetweenStaysWithinBounds() { + foreach (int value in Samples(Any.Int32().Between(10, 20))) { + Check.That(value).IsGreaterOrEqualThan(10); + Check.That(value).IsLessOrEqualThan(20); + } + } + + [Fact(DisplayName = "Between with equal bounds pins the value.")] + public void BetweenWithEqualBoundsPins() { + Check.That(Any.Int32().Between(5, 5).Generate()).IsEqualTo(5); + } + + [Fact(DisplayName = "Between eventually reaches both inclusive bounds.")] + public void BetweenReachesItsBounds() { + HashSet seen = new(Samples(Any.Int32().Between(1, 3))); + + Check.That(seen.Contains(1)).IsTrue(); + Check.That(seen.Contains(3)).IsTrue(); + } + + [Fact(DisplayName = "GreaterThan is exclusive, GreaterThanOrEqualTo is inclusive.")] + public void LowerBoundsAreExactlyExclusiveOrInclusive() { + foreach (int value in Samples(Any.Int32().GreaterThan(10).LessThanOrEqualTo(12))) { + Check.That(value).IsGreaterOrEqualThan(11); + } + + HashSet seen = new(Samples(Any.Int32().GreaterThanOrEqualTo(10).LessThanOrEqualTo(11))); + Check.That(seen.Contains(10)).IsTrue(); + } + + [Fact(DisplayName = "LessThan is exclusive, LessThanOrEqualTo is inclusive.")] + public void UpperBoundsAreExactlyExclusiveOrInclusive() { + foreach (int value in Samples(Any.Int32().LessThan(10).GreaterThanOrEqualTo(8))) { + Check.That(value).IsLessOrEqualThan(9); + } + + HashSet seen = new(Samples(Any.Int32().LessThanOrEqualTo(10).GreaterThanOrEqualTo(9))); + Check.That(seen.Contains(10)).IsTrue(); + } + + [Fact(DisplayName = "The extreme bounds of the Int32 range are generable.")] + public void ExtremeBoundsAreGenerable() { + Check.That(Any.Int32().LessThanOrEqualTo(int.MinValue).Generate()).IsEqualTo(int.MinValue); + Check.That(Any.Int32().GreaterThanOrEqualTo(int.MaxValue).Generate()).IsEqualTo(int.MaxValue); + } + + [Fact(DisplayName = "OneOf yields only the supplied values.")] + public void OneOfStaysWithinTheSuppliedValues() { + int[] allowed = [1, 5, 9]; + foreach (int value in Samples(Any.Int32().OneOf(allowed))) { + Check.That(allowed.Contains(value)).IsTrue(); + } + } + + [Fact(DisplayName = "Except never yields an excluded value.")] + public void ExceptNeverYieldsAnExcludedValue() { + foreach (int value in Samples(Any.Int32().Between(1, 3).Except(2))) { + Check.That(value).IsNotEqualTo(2); + } + } + + [Fact(DisplayName = "DifferentFrom never yields the excluded value.")] + public void DifferentFromNeverYieldsTheValue() { + foreach (int value in Samples(Any.Int32().Between(7, 8).DifferentFrom(7))) { + Check.That(value).IsEqualTo(8); + } + } + + [Fact(DisplayName = "Positive then Negative conflicts, naming both constraints.")] + public void PositiveThenNegativeConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Int32().Positive().Negative()); + + Check.That(conflict.Message).Contains("Negative()"); + Check.That(conflict.Message).Contains("Positive()"); + } + + [Fact(DisplayName = "GreaterThan then an impossible LessThan conflicts, naming both constraints.")] + public void CrossedBoundsConflict() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Int32().GreaterThan(100).LessThan(10)); + + Check.That(conflict.Message).Contains("LessThan(10)"); + Check.That(conflict.Message).Contains("GreaterThan(100)"); + } + + [Fact(DisplayName = "Zero then NonZero conflicts: the pinned value is excluded.")] + public void ZeroThenNonZeroConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Int32().Zero().NonZero()); + + Check.That(conflict.Message).Contains("NonZero()"); + Check.That(conflict.Message).Contains("Zero()"); + } + + [Fact(DisplayName = "GreaterThan int.MaxValue conflicts: no Int32 satisfies it.")] + public void GreaterThanMaxValueConflicts() { + Check.ThatCode(() => Any.Int32().GreaterThan(int.MaxValue)).Throws(); + } + + [Fact(DisplayName = "OneOf then a bound excluding every allowed value conflicts.")] + public void OneOfEmptiedByABoundConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Int32().OneOf(1, 2).GreaterThan(5)); + + Check.That(conflict.Message).Contains("GreaterThan(5)"); + Check.That(conflict.Message).Contains("OneOf(1, 2)"); + } + + [Fact(DisplayName = "A second OneOf conflicts: the allow-list is declared once.")] + public void SecondOneOfConflicts() { + Check.ThatCode(() => Any.Int32().OneOf(1, 2).OneOf(3, 4)).Throws(); + } + + [Fact(DisplayName = "Except exhausting the whole interval conflicts.")] + public void ExceptExhaustingTheIntervalConflicts() { + Check.ThatCode(() => Any.Int32().Between(1, 2).Except(1, 2)).Throws(); + } + + [Fact(DisplayName = "Except exhausting the allow-list conflicts.")] + public void ExceptExhaustingTheAllowListConflicts() { + Check.ThatCode(() => Any.Int32().OneOf(1, 2).Except(1).Except(2)).Throws(); + } + + [Fact(DisplayName = "Between with crossed arguments is an argument error, not a conflict.")] + public void BetweenWithCrossedArgumentsIsAnArgumentError() { + Check.ThatCode(() => Any.Int32().Between(10, 1)).Throws(); + } + + [Fact(DisplayName = "OneOf and Except reject null or empty value lists.")] + public void OneOfAndExceptRejectNullOrEmpty() { + Check.ThatCode(() => Any.Int32().OneOf()).Throws(); + Check.ThatCode(() => Any.Int32().OneOf(null!)).Throws(); + Check.ThatCode(() => Any.Int32().Except()).Throws(); + Check.ThatCode(() => Any.Int32().Except(null!)).Throws(); + } + + [Fact(DisplayName = "A constrained generator is a new instance: the original is unchanged.")] + public void ConstrainingReturnsANewGenerator() { + AnyInt32 original = Any.Int32().Between(1, 10); + AnyInt32 constrained = original.GreaterThanOrEqualTo(10); + + Check.That(ReferenceEquals(constrained, original)).IsFalse(); + // The original still generates over its own, wider domain. + HashSet seen = new(Samples(original)); + Check.That(seen.Count).IsStrictlyGreaterThan(1); + } + +} diff --git a/Dummies.UnitTests/AnyStringTests.cs b/Dummies.UnitTests/AnyStringTests.cs new file mode 100644 index 00000000..2b987ff8 --- /dev/null +++ b/Dummies.UnitTests/AnyStringTests.cs @@ -0,0 +1,260 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +[TestSubject(typeof(AnyString))] +public sealed class AnyStringTests { + + private const int SampleCount = 200; + + #region Statics members declarations + + private static IEnumerable Samples(IAny generator) { + for (int i = 0; i < SampleCount; i++) { + yield return generator.Generate(); + } + } + + #endregion + + [Fact(DisplayName = "An unconstrained String yields 0 to 16 ASCII letters and digits.")] + public void UnconstrainedYieldsShortAlphanumeric() { + foreach (string value in Samples(Any.String())) { + Check.That(value.Length).IsLessOrEqualThan(16); + Check.That(value.All(char.IsLetterOrDigit)).IsTrue(); + } + } + + [Fact(DisplayName = "NonEmpty yields at least one character.")] + public void NonEmptyHasAtLeastOneCharacter() { + foreach (string value in Samples(Any.String().NonEmpty())) { + Check.That(value.Length).IsStrictlyGreaterThan(0); + } + } + + [Fact(DisplayName = "WithLength yields exactly that many characters.")] + public void WithLengthIsExact() { + foreach (string value in Samples(Any.String().WithLength(10))) { + Check.That(value.Length).IsEqualTo(10); + } + } + + [Fact(DisplayName = "WithLength(0) yields the empty string.")] + public void WithLengthZeroIsEmpty() { + Check.That(Any.String().WithLength(0).Generate()).IsEqualTo(string.Empty); + } + + [Fact(DisplayName = "WithMinLength and WithMaxLength bound the length inclusively.")] + public void MinAndMaxLengthAreInclusiveBounds() { + foreach (string value in Samples(Any.String().WithMinLength(3).WithMaxLength(5))) { + Check.That(value.Length).IsGreaterOrEqualThan(3); + Check.That(value.Length).IsLessOrEqualThan(5); + } + } + + [Fact(DisplayName = "WithLengthBetween bounds the length inclusively and reaches its bounds.")] + public void WithLengthBetweenIsInclusive() { + HashSet lengths = new(); + foreach (string value in Samples(Any.String().WithLengthBetween(2, 4))) { + lengths.Add(value.Length); + Check.That(value.Length).IsGreaterOrEqualThan(2); + Check.That(value.Length).IsLessOrEqualThan(4); + } + + Check.That(lengths.Contains(2)).IsTrue(); + Check.That(lengths.Contains(4)).IsTrue(); + } + + [Fact(DisplayName = "StartingWith anchors the prefix.")] + public void StartingWithAnchorsThePrefix() { + foreach (string value in Samples(Any.String().StartingWith("ORD-"))) { + Check.That(value).StartsWith("ORD-"); + } + } + + [Fact(DisplayName = "EndingWith anchors the suffix.")] + public void EndingWithAnchorsTheSuffix() { + foreach (string value in Samples(Any.String().EndingWith("-FR"))) { + Check.That(value).EndsWith("-FR"); + } + } + + [Fact(DisplayName = "Containing embeds the value.")] + public void ContainingEmbedsTheValue() { + foreach (string value in Samples(Any.String().Containing("ABC"))) { + Check.That(value).Contains("ABC"); + } + } + + [Fact(DisplayName = "Prefix, contained value, suffix and exact length hold together.")] + public void FragmentsAndExactLengthHoldTogether() { + foreach (string value in Samples(Any.String().StartingWith("ORD-").Containing("X").EndingWith("-FR").WithLength(12))) { + Check.That(value.Length).IsEqualTo(12); + Check.That(value).StartsWith("ORD-"); + Check.That(value).Contains("X"); + Check.That(value).EndsWith("-FR"); + } + } + + [Fact(DisplayName = "A fragment-only budget is generable: length equals the fragment sum.")] + public void FragmentsExactlyFillingTheLengthAreGenerable() { + Check.That(Any.String().StartingWith("AB").EndingWith("CD").WithLength(4).Generate()).IsEqualTo("ABCD"); + } + + [Fact(DisplayName = "Alpha yields ASCII letters only.")] + public void AlphaYieldsLettersOnly() { + foreach (string value in Samples(Any.String().Alpha().NonEmpty())) { + Check.That(value.All(character => character is >= 'A' and <= 'Z' or >= 'a' and <= 'z')).IsTrue(); + } + } + + [Fact(DisplayName = "Numeric yields ASCII digits only.")] + public void NumericYieldsDigitsOnly() { + foreach (string value in Samples(Any.String().Numeric().NonEmpty())) { + Check.That(value.All(character => character is >= '0' and <= '9')).IsTrue(); + } + } + + [Fact(DisplayName = "AlphaNumeric yields ASCII letters and digits only.")] + public void AlphaNumericYieldsLettersAndDigitsOnly() { + foreach (string value in Samples(Any.String().AlphaNumeric().NonEmpty())) { + Check.That(value.All(character => character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9')).IsTrue(); + } + } + + [Fact(DisplayName = "LowerCase yields no uppercase letter; digits stay allowed.")] + public void LowerCaseForbidsUppercaseLetters() { + foreach (string value in Samples(Any.String().LowerCase().NonEmpty())) { + Check.That(value.Any(character => character is >= 'A' and <= 'Z')).IsFalse(); + } + } + + [Fact(DisplayName = "UpperCase yields no lowercase letter; fragments keep their own characters.")] + public void UpperCaseForbidsLowercaseLetters() { + foreach (string value in Samples(Any.String().UpperCase().StartingWith("ORD-").NonEmpty())) { + Check.That(value.Any(character => character is >= 'a' and <= 'z')).IsFalse(); + Check.That(value).StartsWith("ORD-"); + } + } + + [Fact(DisplayName = "A second WithLength conflicts: the exact length is declared once.")] + public void SecondWithLengthConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().WithLength(3).WithLength(5)); + + Check.That(conflict.Message).Contains("WithLength(5)"); + Check.That(conflict.Message).Contains("WithLength(3)"); + } + + [Fact(DisplayName = "A prefix longer than the exact length conflicts, naming both sides.")] + public void PrefixLongerThanExactLengthConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().WithLength(3).StartingWith("ORD-")); + + Check.That(conflict.Message).Contains("StartingWith(\"ORD-\")"); + Check.That(conflict.Message).Contains("WithLength(3)"); + Check.That(conflict.Message).Contains("4"); + } + + [Fact(DisplayName = "An exact length shorter than an already declared prefix conflicts, naming both sides.")] + public void ExactLengthShorterThanPrefixConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().StartingWith("ORD-").WithLength(3)); + + Check.That(conflict.Message).Contains("WithLength(3)"); + Check.That(conflict.Message).Contains("ORD-"); + Check.That(conflict.Message).Contains("4"); + } + + [Fact(DisplayName = "A numeric-only string cannot start with a non-numeric prefix.")] + public void NumericPrefixMismatchConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().Numeric().StartingWith("ORD-")); + + Check.That(conflict.Message).Contains("StartingWith(\"ORD-\")"); + Check.That(conflict.Message).Contains("Numeric()"); + } + + [Fact(DisplayName = "Declaring the charset after an incompatible prefix conflicts too: order does not matter.")] + public void CharsetAfterIncompatiblePrefixConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().StartingWith("ORD-").Numeric()); + + Check.That(conflict.Message).Contains("Numeric()"); + Check.That(conflict.Message).Contains("ORD-"); + } + + [Fact(DisplayName = "A minimum length above the maximum conflicts.")] + public void MinAboveMaxConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().WithMinLength(10).WithMaxLength(3)); + + Check.That(conflict.Message).Contains("WithMaxLength(3)"); + Check.That(conflict.Message).Contains("WithMinLength(10)"); + } + + [Fact(DisplayName = "An exact length above an already declared maximum conflicts.")] + public void ExactAboveMaxConflicts() { + Check.ThatCode(() => Any.String().WithMaxLength(3).WithLength(5)).Throws(); + } + + [Fact(DisplayName = "LowerCase then UpperCase conflicts: one casing per generator.")] + public void LowerThenUpperCaseConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().LowerCase().UpperCase()); + + Check.That(conflict.Message).Contains("UpperCase()"); + Check.That(conflict.Message).Contains("LowerCase()"); + } + + [Fact(DisplayName = "Alpha then Numeric conflicts: one character family per generator.")] + public void AlphaThenNumericConflicts() { + Check.ThatCode(() => Any.String().Alpha().Numeric()).Throws(); + } + + [Fact(DisplayName = "A lowercase-only string cannot anchor an uppercase prefix.")] + public void LowerCaseUppercasePrefixConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().LowerCase().StartingWith("ORD-")); + + Check.That(conflict.Message).Contains("StartingWith(\"ORD-\")"); + Check.That(conflict.Message).Contains("LowerCase()"); + } + + [Fact(DisplayName = "A second StartingWith conflicts: the prefix is declared once.")] + public void SecondStartingWithConflicts() { + Check.ThatCode(() => Any.String().StartingWith("A").StartingWith("B")).Throws(); + } + + [Fact(DisplayName = "Fragments exceeding the maximum length conflict.")] + public void FragmentsExceedingMaxLengthConflict() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.String().WithMaxLength(5).StartingWith("ORD-").EndingWith("-FR")); + + Check.That(conflict.Message).Contains("EndingWith(\"-FR\")"); + Check.That(conflict.Message).Contains("7"); + } + + [Fact(DisplayName = "Length arguments are validated as arguments, not as conflicts.")] + public void LengthArgumentsAreValidated() { + Check.ThatCode(() => Any.String().WithLength(-1)).Throws(); + Check.ThatCode(() => Any.String().WithMinLength(-1)).Throws(); + Check.ThatCode(() => Any.String().WithMaxLength(-1)).Throws(); + Check.ThatCode(() => Any.String().WithLengthBetween(5, 3)).Throws(); + } + + [Fact(DisplayName = "Fragment arguments are validated as arguments, not as conflicts.")] + public void FragmentArgumentsAreValidated() { + Check.ThatCode(() => Any.String().StartingWith(null!)).Throws(); + Check.ThatCode(() => Any.String().StartingWith("")).Throws(); + Check.ThatCode(() => Any.String().EndingWith(null!)).Throws(); + Check.ThatCode(() => Any.String().Containing("")).Throws(); + } + +} diff --git a/Dummies.UnitTests/ArchitectureTests.cs b/Dummies.UnitTests/ArchitectureTests.cs new file mode 100644 index 00000000..68fb3f57 --- /dev/null +++ b/Dummies.UnitTests/ArchitectureTests.cs @@ -0,0 +1,37 @@ +#region Usings declarations + +using System.Reflection; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +/// +/// Guards the standalone boundary of the library: Dummies is error-agnostic and must never gain a dependency on +/// FirstClassErrors (or any other project of this repository). If this test fails, the boundary was crossed — +/// see the ADR that records the decision before touching it. +/// +public sealed class ArchitectureTests { + + [Fact(DisplayName = "Dummies references no FirstClassErrors assembly.")] + public void DummiesReferencesNoFirstClassErrorsAssembly() { + AssemblyName[] references = typeof(Any).Assembly.GetReferencedAssemblies(); + + foreach (AssemblyName reference in references) { + Check.That(reference.Name!.StartsWith("FirstClassErrors", StringComparison.Ordinal)).IsFalse(); + } + } + + [Fact(DisplayName = "Dummies depends on nothing beyond the standard library.")] + public void DummiesDependsOnNothingBeyondTheStandardLibrary() { + AssemblyName[] references = typeof(Any).Assembly.GetReferencedAssemblies(); + + foreach (AssemblyName reference in references) { + bool standard = reference.Name is "netstandard" or "System.Runtime" or "System.Linq" or "System.Threading" or "System.Runtime.Extensions" or "System.Collections"; + Check.WithCustomMessage($"Unexpected assembly reference: {reference.Name}").That(standard).IsTrue(); + } + } + +} diff --git a/Dummies.UnitTests/CompositionTests.cs b/Dummies.UnitTests/CompositionTests.cs new file mode 100644 index 00000000..e651ea51 --- /dev/null +++ b/Dummies.UnitTests/CompositionTests.cs @@ -0,0 +1,136 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +[TestSubject(typeof(AnyExtensions))] +public sealed class CompositionTests { + + #region Statics members declarations + + private static T Materialize(IAny generator) { + return generator.Generate(); + } + + #endregion + + [Fact(DisplayName = "As bridges a constrained string to a value object through its own factory.")] + public void AsBuildsAStringValueObject() { + IAny generator = Any.String() + .StartingWith("ORD-") + .WithLength(12) + .As(OrderReference.Create); + + OrderReference reference = generator.Generate(); + + Check.That(reference.Value).StartsWith("ORD-"); + Check.That(reference.Value.Length).IsEqualTo(12); + } + + [Fact(DisplayName = "As bridges a constrained integer to a value object through its own factory.")] + public void AsBuildsANumericValueObject() { + IAny generator = Any.Int32().Between(0, 100).As(Percentage.Create); + + Percentage percentage = generator.Generate(); + + Check.That(percentage.Value).IsGreaterOrEqualThan(0); + Check.That(percentage.Value).IsLessOrEqualThan(100); + } + + [Fact(DisplayName = "A factory rejecting the generated value surfaces as AnyGenerationException naming the value and the seed.")] + public void AsWrapsFactoryFailures() { + IAny tooWeaklyConstrained = Any.Int32().Between(200, 300).As(Percentage.Create); + + AnyGenerationException? caught = null; + Assert.Throws( + () => Any.Reproducibly(9876, () => { + try { + tooWeaklyConstrained.Generate(); + } catch (AnyGenerationException exception) { + caught = exception; + + throw; + } + }, _ => { })); + + Check.That(caught).IsNotNull(); + Check.That(caught!.Seed).IsEqualTo(9876); + Check.That(caught.Message).Contains("As(...)"); + Check.That(caught.Message).Contains("9876"); + Check.That(caught.InnerException).IsInstanceOf(); + } + + [Fact(DisplayName = "Combine assembles two constrained parts through a constructor lambda.")] + public void CombineAssemblesTwoParts() { + IAny generator = Any.Combine( + Any.String().NonEmpty().WithMaxLength(50), + Any.String().StartingWith("ORD-").WithLength(12), + (name, reference) => new Customer(name, OrderReference.Create(reference))); + + Customer customer = generator.Generate(); + + Check.That(customer.Name).IsNotEmpty(); + Check.That(customer.LastOrder.Value).StartsWith("ORD-"); + } + + [Fact(DisplayName = "Combine assembles three parts through a constructor lambda.")] + public void CombineAssemblesThreeParts() { + IAny generator = Any.Combine( + Any.String().WithLength(2).UpperCase(), + Any.Int32().Between(10, 99), + Any.String().WithLength(2).LowerCase(), + (head, middle, tail) => $"{head}{middle}{tail}"); + + string value = generator.Generate(); + + Check.That(value.Length).IsEqualTo(6); + } + + [Fact(DisplayName = "A composer failure surfaces as AnyGenerationException naming the generated values.")] + public void CombineWrapsComposerFailures() { + IAny generator = Any.Combine( + Any.Int32().Between(1, 3), + Any.Int32().Between(4, 6), + (first, second) => throw new InvalidOperationException($"rejected {first}/{second}")); + + AnyGenerationException caught = Assert.Throws(() => generator.Generate()); + + Check.That(caught.Message).Contains("Combine(...)"); + Check.That(caught.InnerException).IsInstanceOf(); + } + + [Fact(DisplayName = "Generic inference flows through IAny without relying on implicit conversions.")] + public void GenericInferenceFlowsThroughIAny() { + string text = Materialize(Any.String().NonEmpty().WithMaxLength(50)); + int value = Materialize(Any.Int32().Positive()); + + Check.That(text).IsNotEmpty(); + Check.That(value).IsStrictlyGreaterThan(0); + } + + [Fact(DisplayName = "As and Combine validate their arguments.")] + public void CompositionValidatesArguments() { + Check.ThatCode(() => Any.String().As(null!)).Throws(); + Check.ThatCode(() => AnyExtensions.As(null!, (string value) => value)).Throws(); + Check.ThatCode(() => Any.Combine(null!, Any.Int32(), (int a, int b) => a + b)).Throws(); + Check.ThatCode(() => Any.Combine(Any.Int32(), Any.Int32(), (Func)null!)).Throws(); + } + + [Fact(DisplayName = "A derived generator draws fresh values on every generation.")] + public void DerivedGeneratorsDrawFreshValues() { + IAny generator = Any.Int32().Between(0, 100).As(Percentage.Create); + + HashSet seen = new(); + for (int i = 0; i < 100; i++) { + seen.Add(generator.Generate().Value); + } + + Check.That(seen.Count).IsStrictlyGreaterThan(1); + } + +} diff --git a/Dummies.UnitTests/Dummies.UnitTests.csproj b/Dummies.UnitTests/Dummies.UnitTests.csproj new file mode 100644 index 00000000..20a369fb --- /dev/null +++ b/Dummies.UnitTests/Dummies.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/Dummies.UnitTests/ImplicitConversionTests.cs b/Dummies.UnitTests/ImplicitConversionTests.cs new file mode 100644 index 00000000..b5a2def5 --- /dev/null +++ b/Dummies.UnitTests/ImplicitConversionTests.cs @@ -0,0 +1,52 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +[TestSubject(typeof(Any))] +public sealed class ImplicitConversionTests { + + [Fact(DisplayName = "An AnyString flows implicitly into a string variable.")] + public void AnyStringConvertsImplicitly() { + string value = Any.String().NonEmpty(); + + Check.That(value).IsNotEmpty(); + } + + [Fact(DisplayName = "An AnyInt32 flows implicitly into an int variable.")] + public void AnyInt32ConvertsImplicitly() { + int value = Any.Int32().Positive(); + + Check.That(value).IsStrictlyGreaterThan(0); + } + + [Fact(DisplayName = "An AnyString flows implicitly into a method expecting a string.")] + public void AnyStringConvertsImplicitlyAtACallSite() { + static int Measure(string text) { + return text.Length; + } + + int length = Measure(Any.String().WithLength(9)); + + Check.That(length).IsEqualTo(9); + } + + [Fact(DisplayName = "Each implicit conversion draws a fresh value.")] + public void EachConversionDrawsAFreshValue() { + AnyInt32 generator = Any.Int32().Between(0, int.MaxValue); + + HashSet seen = new(); + for (int i = 0; i < 20; i++) { + int value = generator; + seen.Add(value); + } + + Check.That(seen.Count).IsStrictlyGreaterThan(1); + } + +} diff --git a/Dummies.UnitTests/SeedReproducibilityTests.cs b/Dummies.UnitTests/SeedReproducibilityTests.cs new file mode 100644 index 00000000..c7a4e4ec --- /dev/null +++ b/Dummies.UnitTests/SeedReproducibilityTests.cs @@ -0,0 +1,169 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +[TestSubject(typeof(Any))] +public sealed class SeedReproducibilityTests { + + #region Statics members declarations + + private static string Batch() { + // Explicitly typed locals: string.Join(params object[]) would otherwise box the generators and + // call their ToString() instead of triggering the implicit conversions. + int full = Any.Int32(); + int bounded = Any.Int32().Between(1, 1000); + string free = Any.String(); + string capped = Any.String().NonEmpty().WithMaxLength(50); + string shaped = Any.String().StartingWith("ORD-").WithLength(12); + + return string.Join("|", full, bounded, free, capped, shaped); + } + + #endregion + + [Fact(DisplayName = "Two contexts created with the same seed yield the same values.")] + public void SameSeedContextsAgree() { + AnyContext any1 = Any.WithSeed(12345); + AnyContext any2 = Any.WithSeed(12345); + + string value1 = any1.String().NonEmpty(); + string value2 = any2.String().NonEmpty(); + + Check.That(value2).IsEqualTo(value1); + } + + [Fact(DisplayName = "Two contexts with the same seed agree across a mixed sequence of draws.")] + public void SameSeedContextsAgreeAcrossASequence() { + AnyContext any1 = Any.WithSeed(777); + AnyContext any2 = Any.WithSeed(777); + + string sequence1 = $"{any1.Int32().Positive().Generate()}|{any1.String().WithLength(8).Generate()}|{any1.Int32().Between(0, 9).Generate()}"; + string sequence2 = $"{any2.Int32().Positive().Generate()}|{any2.String().WithLength(8).Generate()}|{any2.Int32().Between(0, 9).Generate()}"; + + Check.That(sequence2).IsEqualTo(sequence1); + } + + [Fact(DisplayName = "Contexts with different seeds diverge.")] + public void DifferentSeedContextsDiverge() { + string sequence1 = string.Join("|", Enumerable.Range(0, 8).Select(_ => Any.WithSeed(1).String().WithLength(12).Generate())); + string sequence2 = string.Join("|", Enumerable.Range(0, 8).Select(_ => Any.WithSeed(2).String().WithLength(12).Generate())); + + Check.That(sequence2).IsNotEqualTo(sequence1); + } + + [Fact(DisplayName = "A context is isolated from the ambient source: interleaved ambient draws do not shift it.")] + public void ContextIsIsolatedFromAmbientDraws() { + AnyContext quiet = Any.WithSeed(31415); + string undisturbed = quiet.String().WithLength(10); + + AnyContext interleaved = Any.WithSeed(31415); + Any.String().Generate(); + Any.Int32().Generate(); + string disturbed = interleaved.String().WithLength(10); + + Check.That(disturbed).IsEqualTo(undisturbed); + } + + [Fact(DisplayName = "Reproducibly with a given seed replays the same sequence of values.")] + public void ReproduciblyWithASeedIsDeterministic() { + string first = string.Empty; + string second = string.Empty; + + Any.Reproducibly(1234, () => { first = Batch(); }); + Any.Reproducibly(1234, () => { second = Batch(); }); + + Check.That(second).IsEqualTo(first); + } + + [Fact(DisplayName = "Reproducibly with different seeds produces different sequences.")] + public void DifferentSeedsDiffer() { + string fromOne = string.Empty; + string fromTwo = string.Empty; + + Any.Reproducibly(1, () => { fromOne = Batch(); }); + Any.Reproducibly(2, () => { fromTwo = Batch(); }); + + Check.That(fromTwo).IsNotEqualTo(fromOne); + } + + [Fact(DisplayName = "Reproducibly reports the seed and rethrows the original exception on failure.")] + public void ReproduciblyReportsTheSeedAndRethrows() { + string? reported = null; + InvalidOperationException boom = new("boom"); + Action failing = () => throw boom; + + InvalidOperationException thrown = Assert.Throws( + () => Any.Reproducibly(4242, failing, message => reported = message)); + + Check.That(ReferenceEquals(thrown, boom)).IsTrue(); + Check.That(reported).IsNotNull(); + Check.That(reported!).Contains("4242"); + Check.That(reported!).Contains("Any.Reproducibly("); + } + + [Fact(DisplayName = "Reproducibly does not report when the body succeeds.")] + public void ReproduciblyIsSilentOnSuccess() { + bool reported = false; + + Any.Reproducibly(() => { Any.String().NonEmpty().Generate(); }, _ => reported = true); + + Check.That(reported).IsFalse(); + } + + [Fact(DisplayName = "Reproducibly without a seed reports a replayable seed on failure.")] + public void ReproduciblyWithoutSeedStillReportsAReplayableSeed() { + string? reported = null; + Action failing = () => throw new InvalidOperationException("x"); + + Assert.Throws( + () => Any.Reproducibly(failing, message => reported = message)); + + Check.That(reported).IsNotNull(); + Check.That(reported!).Contains("Any.Reproducibly("); + } + + [Fact(DisplayName = "The async Reproducibly reports the seed and rethrows on failure.")] + public async Task AsyncReproduciblyReportsTheSeedAndRethrows() { + string? reported = null; + + await Assert.ThrowsAsync( + () => Any.Reproducibly(7, async () => { + await Task.Yield(); + + throw new InvalidOperationException("boom"); + }, message => reported = message)); + + Check.That(reported).IsNotNull(); + Check.That(reported!).Contains("7"); + } + + [Fact(DisplayName = "The async Reproducibly with a given seed replays the same sequence of values.")] + public async Task AsyncReproduciblyWithASeedIsDeterministic() { + string first = string.Empty; + string second = string.Empty; + + await Any.Reproducibly(4321, async () => { + await Task.Yield(); + first = Batch(); + }); + await Any.Reproducibly(4321, async () => { + await Task.Yield(); + second = Batch(); + }); + + Check.That(second).IsEqualTo(first); + } + + [Fact(DisplayName = "Reproducibly requires a body.")] + public void ReproduciblyRequiresABody() { + Check.ThatCode(() => Any.Reproducibly((Action)null!)).Throws(); + Check.ThatCode(() => Any.Reproducibly((Func)null!)).Throws(); + } + +} diff --git a/Dummies.UnitTests/TestValueObjects.cs b/Dummies.UnitTests/TestValueObjects.cs new file mode 100644 index 00000000..613e8bd3 --- /dev/null +++ b/Dummies.UnitTests/TestValueObjects.cs @@ -0,0 +1,61 @@ +namespace Dummies.UnitTests; + +/// +/// A DDD-style value object with a format invariant, used to exercise the primitive-to-value-object bridge +/// (As): its factory is the single gatekeeper, exactly as in production code. +/// +public sealed class OrderReference { + + #region Statics members declarations + + public static OrderReference Create(string value) { + if (value is null) { throw new ArgumentNullException(nameof(value)); } + if (!value.StartsWith("ORD-", StringComparison.Ordinal)) { throw new ArgumentException("An order reference starts with 'ORD-'.", nameof(value)); } + if (value.Length != 12) { throw new ArgumentException("An order reference is exactly 12 characters long.", nameof(value)); } + + return new OrderReference(value); + } + + #endregion + + private OrderReference(string value) { + Value = value; + } + + public string Value { get; } + +} + +/// A numeric value object with a range invariant, for the numeric side of the bridge. +public sealed class Percentage { + + #region Statics members declarations + + public static Percentage Create(int value) { + if (value is < 0 or > 100) { throw new ArgumentOutOfRangeException(nameof(value), value, "A percentage lies between 0 and 100."); } + + return new Percentage(value); + } + + #endregion + + private Percentage(int value) { + Value = value; + } + + public int Value { get; } + +} + +/// A small aggregate assembled from constrained parts, for Any.Combine. +public sealed class Customer { + + public Customer(string name, OrderReference lastOrder) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + LastOrder = lastOrder ?? throw new ArgumentNullException(nameof(lastOrder)); + } + + public string Name { get; } + public OrderReference LastOrder { get; } + +} diff --git a/Dummies/Any.cs b/Dummies/Any.cs new file mode 100644 index 00000000..e817a6ec --- /dev/null +++ b/Dummies/Any.cs @@ -0,0 +1,234 @@ +namespace Dummies; + +/// +/// The entry point of the library: supplies arbitrary, valid values for the parts of a test that are not +/// under assertion — the dummies a test needs so its Arrange stops advertising values it never +/// checks. The constraints chained on a generator express what the surrounding code requires of the value (a +/// value object's invariant, a contract precondition), never what the test asserts: an explicit +/// call reads as "this is arbitrary" where a hand-picked literal reads as "this matters". +/// +/// +/// +/// Values are built to satisfy the declared constraints — the library never generates candidates and +/// filters them afterwards. Constraints that contradict each other fail at declaration time with a +/// naming both sides. +/// +/// +/// Every value is drawn from a pseudo-random source. By default that source is unseeded, so each run produces +/// fresh values — which surfaces a test that secretly depends on one. Wrap a value-sensitive test in +/// to make a failing run replayable: the source flows with +/// the current execution context, so it never leaks across tests running in parallel. For an explicit, +/// isolated deterministic context — for example outside a test body — use . +/// +/// +/// +/// // The reference format is the invariant; the exact value is irrelevant — so it is Any. +/// string reference = Any.String().StartingWith("ORD-").WithLength(12); +/// +/// // Turn a constrained primitive into a value object, without reflection: +/// OrderReference order = Any.String().StartingWith("ORD-").WithLength(12) +/// .As(OrderReference.Create) +/// .Generate(); +/// +/// // Make a value-sensitive test replayable: the seed is reported on failure... +/// Any.Reproducibly(() => { /* arrange with Any, act, assert */ }); +/// // ...and replayed by passing it back: +/// Any.Reproducibly(1234, () => { /* ... */ }); +/// +/// +/// +public static class Any { + + /// + /// Starts an arbitrary generator drawing from the ambient random context. Unconstrained, + /// it yields a string of 0 to 16 ASCII letters and digits; chain constraints to express what the surrounding + /// code requires (NonEmpty(), WithLength(...), StartingWith(...), ...). + /// + /// A string generator to constrain fluently. + public static AnyString String() { + return new AnyString(AmbientRandomSource.Instance, StringSpec.Unconstrained); + } + + /// + /// Starts an arbitrary generator drawing from the ambient random context. Unconstrained, it + /// draws from the full range; chain constraints to express what the surrounding code + /// requires (Positive(), Between(...), ...). + /// + /// An integer generator to constrain fluently. + public static AnyInt32 Int32() { + return new AnyInt32(AmbientRandomSource.Instance, Int32Spec.Unconstrained); + } + + /// + /// Creates an isolated, deterministic generation context: every generator created from it draws from a + /// dedicated source seeded with , independent of the ambient context. Two contexts + /// created with the same seed yield the same sequence of values. Prefer + /// inside tests — it keeps the arbitrary-by-default + /// behavior and reports the seed only when the test fails; reach for when you need an + /// explicit generator object, for example outside a test body. + /// + /// The seed pinning the context's value sequence. + /// A deterministic generation context. + public static AnyContext WithSeed(int seed) { + return new AnyContext(seed); + } + + /// + /// Runs with the ambient random context pinned to a fresh seed and, if the body + /// throws, reports that seed before letting the exception propagate. This is how a test that draws on + /// stays reproducible: the values still vary between runs (which surfaces accidental + /// dependencies), yet a failure names the exact seed to replay. + /// + /// + /// + /// On failure the seed is written to (by default ), + /// with a message naming the Any.Reproducibly(seed, ...) call that reproduces the run. Pass your + /// test framework's output writer (for example xUnit's ITestOutputHelper.WriteLine) to route it + /// there instead. The original exception is rethrown unchanged, so the test still fails with its real + /// message. + /// + /// + /// Reproducing a run needs the same sequence of draws, so a body whose generation order depends on + /// non-deterministic external state is not fully replayable from the seed alone. + /// + /// + /// The test body to run under a reproducible random context. + /// The sink the seed is written to on failure. Defaults to when null. + /// Thrown when is null. + public static void Reproducibly(Action body, Action? report = null) { + Reproducibly(AmbientRandomSource.NewSeed(), body, report); + } + + /// + /// Replays with the ambient random context pinned to , so a + /// run first seen through the parameterless overload can + /// be reproduced exactly. If the body throws, the seed is reported before the exception propagates. + /// + /// The seed to replay — typically the one a previous failure reported. + /// The test body to run under the seeded random context. + /// The sink the seed is written to on failure. Defaults to when null. + /// Thrown when is null. + public static void Reproducibly(int seed, Action body, Action? report = null) { + if (body is null) { throw new ArgumentNullException(nameof(body)); } + + using (AmbientRandomSource.UseSeed(seed)) { + try { + body(); + } catch { + Report(report, seed); + + throw; + } + } + } + + /// + /// Asynchronous counterpart of : awaits + /// under a fresh seed and reports it if the body faults. + /// + /// The asynchronous test body to run under a reproducible random context. + /// The sink the seed is written to on failure. Defaults to when null. + /// A task that completes when completes. + /// Thrown when is null. + public static Task Reproducibly(Func body, Action? report = null) { + return Reproducibly(AmbientRandomSource.NewSeed(), body, report); + } + + /// + /// Asynchronous counterpart of : awaits + /// under and reports it if the body faults. + /// + /// The seed to replay — typically the one a previous failure reported. + /// The asynchronous test body to run under the seeded random context. + /// The sink the seed is written to on failure. Defaults to when null. + /// A task that completes when completes. + /// Thrown when is null. + public static async Task Reproducibly(int seed, Func body, Action? report = null) { + if (body is null) { throw new ArgumentNullException(nameof(body)); } + + using (AmbientRandomSource.UseSeed(seed)) { + try { + await body().ConfigureAwait(false); + } catch { + Report(report, seed); + + throw; + } + } + } + + /// + /// Composes two generators into one through a constructor lambda — the reflection-free way to assemble an + /// object from constrained parts. Each part draws from its own random context when the composed generator + /// generates. + /// + /// + /// + /// + /// IAny<Customer> customer = Any.Combine( + /// Any.String().NonEmpty().WithMaxLength(50), + /// Any.String().StartingWith("ORD-").WithLength(12), + /// (name, reference) => new Customer(name, OrderReference.Create(reference))); + /// + /// + /// + /// The generator of the first part. + /// The generator of the second part. + /// The constructor lambda assembling the parts. + /// The type of the first part. + /// The type of the second part. + /// The type of the composed value. + /// A generator of the composed value. + /// Thrown when any argument is null. + public static IAny Combine(IAny first, IAny second, Func compose) { + if (first is null) { throw new ArgumentNullException(nameof(first)); } + if (second is null) { throw new ArgumentNullException(nameof(second)); } + if (compose is null) { throw new ArgumentNullException(nameof(compose)); } + + RandomSource? source = AnyDerivation.SourceOf(first) ?? AnyDerivation.SourceOf(second); + + return new DerivedAny(source, () => { + T1 firstValue = first.Generate(); + T2 secondValue = second.Generate(); + + return AnyDerivation.Invoke(() => compose(firstValue, secondValue), source, $"the composer passed to Combine(...) threw for the generated values ({AnyDerivation.Display(firstValue)}, {AnyDerivation.Display(secondValue)})"); + }); + } + + /// + /// Composes three generators into one through a constructor lambda — see + /// . + /// + /// The generator of the first part. + /// The generator of the second part. + /// The generator of the third part. + /// The constructor lambda assembling the parts. + /// The type of the first part. + /// The type of the second part. + /// The type of the third part. + /// The type of the composed value. + /// A generator of the composed value. + /// Thrown when any argument is null. + public static IAny Combine(IAny first, IAny second, IAny third, Func compose) { + if (first is null) { throw new ArgumentNullException(nameof(first)); } + if (second is null) { throw new ArgumentNullException(nameof(second)); } + if (third is null) { throw new ArgumentNullException(nameof(third)); } + if (compose is null) { throw new ArgumentNullException(nameof(compose)); } + + RandomSource? source = AnyDerivation.SourceOf(first) ?? AnyDerivation.SourceOf(second) ?? AnyDerivation.SourceOf(third); + + return new DerivedAny(source, () => { + T1 firstValue = first.Generate(); + T2 secondValue = second.Generate(); + T3 thirdValue = third.Generate(); + + return AnyDerivation.Invoke(() => compose(firstValue, secondValue, thirdValue), source, $"the composer passed to Combine(...) threw for the generated values ({AnyDerivation.Display(firstValue)}, {AnyDerivation.Display(secondValue)}, {AnyDerivation.Display(thirdValue)})"); + }); + } + + private static void Report(Action? report, int seed) { + (report ?? Console.Error.WriteLine)( + $"[Dummies] These arbitrary values were seeded with {seed}. Reproduce this run with Any.Reproducibly({seed}, ...)."); + } + +} diff --git a/Dummies/AnyContext.cs b/Dummies/AnyContext.cs new file mode 100644 index 00000000..38e88bc4 --- /dev/null +++ b/Dummies/AnyContext.cs @@ -0,0 +1,55 @@ +namespace Dummies; + +/// +/// An isolated, deterministic generation context created by : every generator created +/// from it draws from a dedicated source seeded with , independent of the ambient context the +/// static entry points use. Two contexts created with the same seed yield the same sequence +/// of values. +/// +/// +/// +/// Inside a test, prefer wrapping the body in Any.Reproducibly(...): it keeps values arbitrary by +/// default and reports a replayable seed only when the test fails. A context is the explicit-object +/// alternative for when that scope does not fit — generating a deterministic dataset outside a test body, +/// for example. +/// +/// +/// A context owns a single pseudo-random generator and is not thread-safe: use one context per test +/// or per generation sequence, not a shared one. +/// +/// +public sealed class AnyContext { + + #region Fields declarations + + private readonly FixedRandomSource _source; + + #endregion + + internal AnyContext(int seed) { + Seed = seed; + _source = new FixedRandomSource(seed); + } + + /// The seed pinning this context's value sequence. + public int Seed { get; } + + /// + /// Starts an arbitrary generator drawing from this context — same fluent surface as + /// , deterministic under this context's seed. + /// + /// A string generator to constrain fluently. + public AnyString String() { + return new AnyString(_source, StringSpec.Unconstrained); + } + + /// + /// Starts an arbitrary generator drawing from this context — same fluent surface as + /// , deterministic under this context's seed. + /// + /// An integer generator to constrain fluently. + public AnyInt32 Int32() { + return new AnyInt32(_source, Int32Spec.Unconstrained); + } + +} diff --git a/Dummies/AnyDerivation.cs b/Dummies/AnyDerivation.cs new file mode 100644 index 00000000..e7428433 --- /dev/null +++ b/Dummies/AnyDerivation.cs @@ -0,0 +1,77 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// A generator derived from other generators (As, Combine): it delegates generation to a closure +/// and carries, when known, the random context of the generators it derives from — so a failure inside the +/// derivation can still name the seed that replays the run. +/// +/// The type of the generated values. +internal sealed class DerivedAny : IAny, IHasRandomSource { + + #region Fields declarations + + private readonly Func _generate; + private readonly RandomSource? _source; + + #endregion + + internal DerivedAny(RandomSource? source, Func generate) { + _source = source; + _generate = generate; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// + public T Generate() { + return _generate(); + } + +} + +/// Shared plumbing of the derived generators. +internal static class AnyDerivation { + + /// The random context of , when it is one of the library's own. + internal static RandomSource? SourceOf(IAny generator) { + return (generator as IHasRandomSource)?.Source; + } + + /// + /// Runs a user-supplied factory or composer and converts its failure into an + /// that names the generated value(s) and, when the random context is + /// known, the seed that replays the run. The library's own exceptions pass through untouched. + /// + internal static T Invoke(Func invoke, RandomSource? source, string failure) { + try { + return invoke(); + } catch (AnyException) { + throw; + } catch (Exception exception) { + int? seed = source?.Current.Seed; + string message = $"Generation failed: {failure} ({exception.GetType().Name}: {exception.Message})."; + if (seed is not null) { + message += $" The arbitrary values were seeded with {seed}; reproduce this run with Any.Reproducibly({seed}, ...)."; + } + + throw new AnyGenerationException(message, seed, exception); + } + } + + /// Renders a generated value for an exception message. + internal static string Display(object? value) { + switch (value) { + case null: return "null"; + case string text: return "\"" + text + "\""; + case IFormattable formattable: return formattable.ToString(null, CultureInfo.InvariantCulture); + default: return value.ToString() ?? value.GetType().Name; + } + } + +} diff --git a/Dummies/AnyException.cs b/Dummies/AnyException.cs new file mode 100644 index 00000000..bbfd4eb4 --- /dev/null +++ b/Dummies/AnyException.cs @@ -0,0 +1,24 @@ +namespace Dummies; + +/// +/// Base class of every exception the library throws on its own behalf, so a caller can catch "anything Dummies +/// rejected" with a single clause. Concrete cases: +/// when two declared constraints cannot be satisfied together, +/// and when a generation fails even though the constraints were accepted. +/// +public abstract class AnyException : Exception { + + /// + /// Initializes a new instance of the class. + /// + /// A description of the failure. + protected AnyException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class wrapping an underlying failure. + /// + /// A description of the failure. + /// The underlying failure. + protected AnyException(string message, Exception innerException) : base(message, innerException) { } + +} diff --git a/Dummies/AnyExtensions.cs b/Dummies/AnyExtensions.cs new file mode 100644 index 00000000..e656e543 --- /dev/null +++ b/Dummies/AnyExtensions.cs @@ -0,0 +1,49 @@ +namespace Dummies; + +/// +/// Composition over . These extensions are the bridge between constrained primitives and +/// domain types: a generator of raw values becomes a generator of value objects by going through the type's own +/// factory — no reflection, and the domain's validation stays the single gatekeeper. +/// +public static class AnyExtensions { + + /// + /// Derives a generator of by passing each generated + /// through — typically a value object's own + /// factory method, so the constraints declared upstream express the invariant that factory enforces. + /// + /// + /// + /// If the factory throws, the failure is wrapped in an naming the + /// generated value and, when known, the seed that replays the run — the usual cause is constraints weaker + /// than the invariant the factory enforces, and the fix is to tighten them. + /// + /// + /// + /// IAny<OrderReference> reference = Any.String() + /// .StartingWith("ORD-") + /// .WithLength(12) + /// .As(OrderReference.Create); + /// + /// + /// + /// The generator of the raw values. + /// The factory turning a raw value into a . + /// The type of the raw generated values. + /// The type the factory produces. + /// A generator of . + /// Thrown when or is null. + public static IAny As(this IAny generator, Func factory) { + if (generator is null) { throw new ArgumentNullException(nameof(generator)); } + if (factory is null) { throw new ArgumentNullException(nameof(factory)); } + + RandomSource? source = AnyDerivation.SourceOf(generator); + + return new DerivedAny(source, () => { + TSource value = generator.Generate(); + + return AnyDerivation.Invoke(() => factory(value), source, $"the factory passed to As(...) threw for the generated value {AnyDerivation.Display(value)}"); + }); + } + +} diff --git a/Dummies/AnyGenerationException.cs b/Dummies/AnyGenerationException.cs new file mode 100644 index 00000000..4b4d7e52 --- /dev/null +++ b/Dummies/AnyGenerationException.cs @@ -0,0 +1,42 @@ +namespace Dummies; + +/// +/// Thrown when a generation cannot be completed even though every declared constraint was accepted — most +/// commonly when a factory passed to or a composer passed to +/// rejects a generated value. Whenever the failing generator draws from +/// one of the library's random contexts, the message names the seed that replays the run and +/// carries it. +/// +/// +/// The library prefers detecting contradictions before generation — those throw +/// at declaration time. Reaching this exception therefore +/// usually means the constraints declared on the generator were weaker than the invariant the factory enforces; +/// the fix is to tighten the constraints so they express that invariant. +/// +public sealed class AnyGenerationException : AnyException { + + /// + /// Initializes a new instance of the class. + /// + /// A description of the failed generation. + public AnyGenerationException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class wrapping an underlying failure. + /// + /// A description of the failed generation. + /// The underlying failure. + public AnyGenerationException(string message, Exception innerException) : base(message, innerException) { } + + internal AnyGenerationException(string message, int? seed, Exception innerException) : base(message, innerException) { + Seed = seed; + } + + /// + /// The seed of the random context the failing generation drew from, when it is known — pass it to + /// Any.Reproducibly(seed, ...) to replay the run. null when the failing generator does not draw + /// from one of the library's random contexts. + /// + public int? Seed { get; } + +} diff --git a/Dummies/AnyInt32.cs b/Dummies/AnyInt32.cs new file mode 100644 index 00000000..26c79827 --- /dev/null +++ b/Dummies/AnyInt32.cs @@ -0,0 +1,185 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// A fluent generator of arbitrary values. Each constraint narrows the domain the value is +/// drawn from — the constraints express what the surrounding code requires of the value (an invariant, a +/// precondition), never what the test asserts. Constraints that contradict each other fail immediately with a +/// naming both sides, so an impossible Arrange reads as +/// the test defect it is. +/// +/// +/// +/// Instances are immutable recipes: every method returns a new generator, and the value is drawn only when +/// runs (or when the generator is implicitly converted to ), from +/// the random context the generator was created with. Values are built to satisfy the constraints in +/// one draw — the library never generates candidates and retries. +/// +/// +/// +/// int quantity = Any.Int32().Positive(); +/// int percentage = Any.Int32().Between(0, 100); +/// Any.Int32().GreaterThan(100).LessThan(10); // throws ConflictingAnyConstraintException +/// +/// +/// +public sealed class AnyInt32 : IAny, IHasRandomSource { + + #region Statics members declarations + + /// + /// Generates the value — an can be used wherever an is expected. + /// Each conversion draws a fresh value. + /// + /// The generator to draw from. + /// An arbitrary value satisfying the generator's constraints. + public static implicit operator int(AnyInt32 generator) { + return generator.Generate(); + } + + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + private static string Join(int[] values) { + return string.Join(", ", values.Select(value => value.ToString(CultureInfo.InvariantCulture))); + } + + #endregion + + #region Fields declarations + + private readonly RandomSource _source; + private readonly Int32Spec _spec; + + #endregion + + internal AnyInt32(RandomSource source, Int32Spec spec) { + _source = source; + _spec = spec; + } + + RandomSource IHasRandomSource.Source => _source; + + /// Requires a value strictly greater than zero. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 Positive() { + return new AnyInt32(_source, _spec.WithMinimum(1, "Positive()")); + } + + /// Requires a value strictly less than zero. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 Negative() { + return new AnyInt32(_source, _spec.WithMaximum(-1, "Negative()")); + } + + /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 Zero() { + return new AnyInt32(_source, _spec.WithMinimum(0, "Zero()").WithMaximum(0, "Zero()")); + } + + /// Requires a value different from zero. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 NonZero() { + return new AnyInt32(_source, _spec.WithExcluded([0], "NonZero()")); + } + + /// Requires a value strictly greater than . + /// The exclusive lower bound. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 GreaterThan(int value) { + return new AnyInt32(_source, _spec.WithMinimum((long)value + 1, $"GreaterThan({V(value)})")); + } + + /// Requires a value greater than or equal to . + /// The inclusive lower bound. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 GreaterThanOrEqualTo(int value) { + return new AnyInt32(_source, _spec.WithMinimum(value, $"GreaterThanOrEqualTo({V(value)})")); + } + + /// Requires a value strictly less than . + /// The exclusive upper bound. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 LessThan(int value) { + return new AnyInt32(_source, _spec.WithMaximum((long)value - 1, $"LessThan({V(value)})")); + } + + /// Requires a value less than or equal to . + /// The inclusive upper bound. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 LessThanOrEqualTo(int value) { + return new AnyInt32(_source, _spec.WithMaximum(value, $"LessThanOrEqualTo({V(value)})")); + } + + /// Requires a value within the inclusive range [, ]. + /// The inclusive lower bound. + /// The inclusive upper bound. + /// A new generator carrying the added constraint. + /// Thrown when is greater than . + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 Between(int minimum, int maximum) { + if (minimum > maximum) { throw new ArgumentException($"The minimum ({V(minimum)}) must be less than or equal to the maximum ({V(maximum)}).", nameof(minimum)); } + + string constraint = $"Between({V(minimum)}, {V(maximum)})"; + + return new AnyInt32(_source, _spec.WithMinimum(minimum, constraint).WithMaximum(maximum, constraint)); + } + + /// Requires the value to be one of the supplied values. Declared once per generator. + /// The allowed values; duplicates are ignored. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when is empty. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 OneOf(params int[] values) { + if (values is null) { throw new ArgumentNullException(nameof(values)); } + if (values.Length == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } + + return new AnyInt32(_source, _spec.WithAllowed(values, $"OneOf({Join(values)})")); + } + + /// Requires the value to be none of the supplied values. + /// The forbidden values. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when is empty. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 Except(params int[] values) { + if (values is null) { throw new ArgumentNullException(nameof(values)); } + if (values.Length == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } + + return new AnyInt32(_source, _spec.WithExcluded(values, $"Except({Join(values)})")); + } + + /// + /// Requires the value to differ from — typically an existing value the test already + /// holds. Semantically equivalent to ; the name carries the intent at the call site. + /// + /// The value the generated value must differ from. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 DifferentFrom(int value) { + return new AnyInt32(_source, _spec.WithExcluded([value], $"DifferentFrom({V(value)})")); + } + + /// + public int Generate() { + return _spec.Generate(_source.Current.Random); + } + +} diff --git a/Dummies/AnyString.cs b/Dummies/AnyString.cs new file mode 100644 index 00000000..05e1eb4e --- /dev/null +++ b/Dummies/AnyString.cs @@ -0,0 +1,220 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// A fluent generator of arbitrary values. Each constraint narrows the shape of the +/// generated string — the constraints express what the surrounding code requires of the value (a value +/// object's format invariant, a contract precondition), never what the test asserts. Constraints that contradict +/// each other fail immediately with a naming both sides, so an +/// impossible Arrange reads as the test defect it is. +/// +/// +/// +/// Instances are immutable recipes: every method returns a new generator, and the value is drawn only when +/// runs (or when the generator is implicitly converted to ), +/// from the random context the generator was created with. Strings are built to satisfy the +/// constraints — laid out as prefix + filler + contained values + filler + suffix — never generated +/// and filtered. That layout means fragments never overlap: the length budget they require is the plain sum +/// of their lengths. +/// +/// +/// Unconstrained, the generator yields 0 to 16 ASCII letters and digits; an unconstrained draw can therefore +/// be empty — chain when the surrounding code requires content. +/// +/// +/// +/// string code = Any.String().NonEmpty().WithMaxLength(50).StartingWith("ORD-"); +/// Any.String().WithLength(3).StartingWith("ORD-"); // throws ConflictingAnyConstraintException +/// Any.String().Numeric().StartingWith("ORD-"); // throws ConflictingAnyConstraintException +/// +/// +/// +public sealed class AnyString : IAny, IHasRandomSource { + + #region Statics members declarations + + /// + /// Generates the value — an can be used wherever a is expected. + /// Each conversion draws a fresh value. + /// + /// The generator to draw from. + /// An arbitrary string satisfying the generator's constraints. + public static implicit operator string(AnyString generator) { + return generator.Generate(); + } + + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + private static string RequireText(string value, string parameterName) { + if (value is null) { throw new ArgumentNullException(parameterName); } + if (value.Length == 0) { throw new ArgumentException("The value must not be empty.", parameterName); } + + return value; + } + + private static int RequireNonNegative(int length, string parameterName) { + if (length < 0) { throw new ArgumentOutOfRangeException(parameterName, length, "The length must not be negative."); } + + return length; + } + + #endregion + + #region Fields declarations + + private readonly RandomSource _source; + private readonly StringSpec _spec; + + #endregion + + internal AnyString(RandomSource source, StringSpec spec) { + _source = source; + _spec = spec; + } + + RandomSource? IHasRandomSource.Source => _source; + + /// Requires at least one character. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString NonEmpty() { + return new AnyString(_source, _spec.WithMinLength(1, "NonEmpty()")); + } + + /// Fixes the exact length. Declared once per generator. + /// The exact number of characters. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString WithLength(int length) { + RequireNonNegative(length, nameof(length)); + + return new AnyString(_source, _spec.WithExactLength(length, $"WithLength({V(length)})")); + } + + /// Requires at least characters. + /// The inclusive minimum number of characters. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString WithMinLength(int length) { + RequireNonNegative(length, nameof(length)); + + return new AnyString(_source, _spec.WithMinLength(length, $"WithMinLength({V(length)})")); + } + + /// Requires at most characters. + /// The inclusive maximum number of characters. + /// A new generator carrying the added constraint. + /// Thrown when is negative. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString WithMaxLength(int length) { + RequireNonNegative(length, nameof(length)); + + return new AnyString(_source, _spec.WithMaxLength(length, $"WithMaxLength({V(length)})")); + } + + /// Requires a length within the inclusive range [, ]. + /// The inclusive minimum number of characters. + /// The inclusive maximum number of characters. + /// A new generator carrying the added constraint. + /// Thrown when a bound is negative. + /// Thrown when is greater than . + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString WithLengthBetween(int minimum, int maximum) { + RequireNonNegative(minimum, nameof(minimum)); + RequireNonNegative(maximum, nameof(maximum)); + if (minimum > maximum) { throw new ArgumentException($"The minimum ({V(minimum)}) must be less than or equal to the maximum ({V(maximum)}).", nameof(minimum)); } + + string constraint = $"WithLengthBetween({V(minimum)}, {V(maximum)})"; + + return new AnyString(_source, _spec.WithMinLength(minimum, constraint).WithMaxLength(maximum, constraint)); + } + + /// Requires the string to start with . Declared once per generator. + /// The required prefix. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when is empty. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString StartingWith(string prefix) { + RequireText(prefix, nameof(prefix)); + + return new AnyString(_source, _spec.WithPrefix(prefix, $"StartingWith(\"{prefix}\")")); + } + + /// Requires the string to end with . Declared once per generator. + /// The required suffix. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when is empty. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString EndingWith(string suffix) { + RequireText(suffix, nameof(suffix)); + + return new AnyString(_source, _spec.WithSuffix(suffix, $"EndingWith(\"{suffix}\")")); + } + + /// + /// Requires the string to contain . May be declared several times; the contained + /// values are laid out side by side, without overlap, between the prefix and the suffix. + /// + /// The value the generated string must contain. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when is empty. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString Containing(string value) { + RequireText(value, nameof(value)); + + return new AnyString(_source, _spec.WithFragment(value, $"Containing(\"{value}\")")); + } + + /// Restricts the string to ASCII letters only. Declared once per generator. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString Alpha() { + return new AnyString(_source, _spec.WithCharset(CharacterSet.Alpha, "Alpha()")); + } + + /// Restricts the string to ASCII digits only. Declared once per generator. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString Numeric() { + return new AnyString(_source, _spec.WithCharset(CharacterSet.Numeric, "Numeric()")); + } + + /// Restricts the string to ASCII letters and digits only. Declared once per generator. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString AlphaNumeric() { + return new AnyString(_source, _spec.WithCharset(CharacterSet.AlphaNumeric, "AlphaNumeric()")); + } + + /// Requires every alphabetic character to be lowercase. Declared once per generator. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString LowerCase() { + return new AnyString(_source, _spec.WithCasing(LetterCasing.Lower, "LowerCase()")); + } + + /// Requires every alphabetic character to be uppercase. Declared once per generator. + /// A new generator carrying the added constraint. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyString UpperCase() { + return new AnyString(_source, _spec.WithCasing(LetterCasing.Upper, "UpperCase()")); + } + + /// + public string Generate() { + return _spec.Generate(_source.Current.Random); + } + +} diff --git a/Dummies/ConflictingAnyConstraintException.cs b/Dummies/ConflictingAnyConstraintException.cs new file mode 100644 index 00000000..e9d09c92 --- /dev/null +++ b/Dummies/ConflictingAnyConstraintException.cs @@ -0,0 +1,19 @@ +namespace Dummies; + +/// +/// Thrown at the moment a constraint is declared when it cannot be satisfied together with the constraints +/// already declared on the same generator — for example +/// Any.String().WithLength(3).StartingWith("ORD-"), where the prefix alone already requires 4 +/// characters. Failing at declaration time, with a message that names both constraints, is a deliberate part of +/// the library's contract: a contradiction in a test's Arrange is a defect of the test, and it should +/// read as one — not surface later as a puzzling generation failure. +/// +public sealed class ConflictingAnyConstraintException : AnyException { + + /// + /// Initializes a new instance of the class. + /// + /// A description naming the newly declared constraint and the declared constraint it conflicts with. + public ConflictingAnyConstraintException(string message) : base(message) { } + +} diff --git a/Dummies/Dummies.csproj b/Dummies/Dummies.csproj new file mode 100644 index 00000000..61137a0a --- /dev/null +++ b/Dummies/Dummies.csproj @@ -0,0 +1,67 @@ + + + + + netstandard2.0 + enable + enable + latest + + + true + + + 0.1.0-dev + + + Dummies + Sylvain AURAT + Reefact + + + + A fluent DSL for generating arbitrary yet valid test values: dummies. Constraints express the invariants a value must satisfy — never what the test asserts. Conflicting constraints fail fast with clear, actionable exceptions, and any run is reproducible from a reported seed. + + + + testing;test-data;dummies;arbitrary;anonymous-values;fluent;deterministic;seed;value-objects;constraints + Apache-2.0 + false + + + https://github.com/Reefact/first-class-errors + https://github.com/Reefact/first-class-errors.git + git + + © Reefact 2026 + + + icon.png + readme.md + + + + + + + + + + + + + + + + + + + diff --git a/Dummies/IAny.cs b/Dummies/IAny.cs new file mode 100644 index 00000000..115cb982 --- /dev/null +++ b/Dummies/IAny.cs @@ -0,0 +1,38 @@ +namespace Dummies; + +/// +/// A recipe for an arbitrary value of type that satisfies the constraints declared on +/// it. This is the composition seam of the library: every generator — built-in or derived through +/// and — implements it, +/// so a constrained primitive, a value object built from one, and an object assembled from several all flow +/// through the same contract. +/// +/// +/// +/// A generator is an immutable recipe, not a value: each fluent constraint returns a new generator, and +/// randomness is drawn only when runs, from the random context the generator was +/// created with — the ambient context for the static entry points (see +/// ), or the isolated context of +/// . The same recipe can therefore be generated from several times, yielding a +/// fresh value each time. +/// +/// +/// Generic inference flows through this interface — Materialize(Any.String().NonEmpty()) infers +/// T = string — whereas the implicit conversions the concrete generators offer are an ergonomic +/// convenience only. +/// +/// +/// The type of the generated values. +public interface IAny { + + /// + /// Produces one arbitrary value satisfying every constraint declared on this generator. + /// + /// A value that satisfies the declared constraints. + /// + /// Thrown when the value cannot be produced even though the declared constraints were accepted — for example + /// when a factory passed to rejects a generated value. + /// + T Generate(); + +} diff --git a/Dummies/Int32Spec.cs b/Dummies/Int32Spec.cs new file mode 100644 index 00000000..b6d2fbb8 --- /dev/null +++ b/Dummies/Int32Spec.cs @@ -0,0 +1,158 @@ +#region Usings declarations + +using System.Globalization; + +#endregion + +namespace Dummies; + +/// +/// The immutable specification behind : an inclusive interval, an optional allow-list +/// (OneOf), and an exclusion list — each bound remembering the constraint that set it, so a conflict +/// message can name both sides. Every mutation returns a new specification and validates satisfiability eagerly: +/// an that exists can always generate. +/// +internal sealed class Int32Spec { + + #region Statics members declarations + + internal static readonly Int32Spec Unconstrained = new(int.MinValue, null, int.MaxValue, null, null, null, []); + + private static string V(long value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + #endregion + + #region Fields declarations + + private readonly IReadOnlyList? _allowed; + private readonly string? _allowedConstraint; + private readonly IReadOnlyList _excluded; + private readonly long _max; + private readonly string? _maxConstraint; + private readonly long _min; + private readonly string? _minConstraint; + + #endregion + + private Int32Spec(long min, string? minConstraint, long max, string? maxConstraint, + IReadOnlyList? allowed, string? allowedConstraint, IReadOnlyList excluded) { + _min = min; + _minConstraint = minConstraint; + _max = max; + _maxConstraint = maxConstraint; + _allowed = allowed; + _allowedConstraint = allowedConstraint; + _excluded = excluded; + } + + /// Tightens the lower bound; a looser bound than the current one is a no-op. + internal Int32Spec WithMinimum(long minimum, string applying) { + if (minimum > int.MaxValue) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because no Int32 value satisfies it."); } + if (minimum <= _min) { return this; } + + if (minimum > _max) { + if (_maxConstraint is null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because no Int32 value satisfies it."); } + + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_maxConstraint} already requires values less than or equal to {V(_max)}."); + } + + return Validated(new Int32Spec(minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded), applying); + } + + /// Tightens the upper bound; a looser bound than the current one is a no-op. + internal Int32Spec WithMaximum(long maximum, string applying) { + if (maximum < int.MinValue) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because no Int32 value satisfies it."); } + if (maximum >= _max) { return this; } + + if (maximum < _min) { + if (_minConstraint is null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because no Int32 value satisfies it."); } + + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_minConstraint} already requires values greater than or equal to {V(_min)}."); + } + + return Validated(new Int32Spec(_min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded), applying); + } + + /// Restricts the domain to an explicit allow-list; declared once per generator. + internal Int32Spec WithAllowed(int[] values, string applying) { + if (_allowedConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_allowedConstraint} is already defined."); } + + int[] distinct = values.Distinct().ToArray(); + + return Validated(new Int32Spec(_min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded), applying); + } + + /// Adds values the generator must never produce. + internal Int32Spec WithExcluded(int[] values, string applying) { + List excluded = new(_excluded); + excluded.AddRange(values); + + return Validated(new Int32Spec(_min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); + } + + /// Draws one value satisfying the whole specification — built directly, never generate-then-retry. + internal int Generate(Random random) { + if (_allowed is not null) { + List pool = EffectiveAllowed(); + + return pool[random.Next(pool.Count)]; + } + + List excluded = ExcludedInRangeSorted(); + long validCount = (_max - _min + 1) - excluded.Count; + long candidate = _min + random.NextInt64(0, validCount - 1); + // Map the drawn index onto the k-th non-excluded value of the interval: every excluded value at or + // below the candidate shifts it up by one. Sorted ascending, so a single pass suffices. + foreach (int value in excluded) { + if (candidate >= value) { candidate++; } + } + + return (int)candidate; + } + + private static Int32Spec Validated(Int32Spec candidate, string applying) { + if (candidate.CountCandidates() > 0) { return candidate; } + + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion()}."); + } + + private long CountCandidates() { + if (_allowed is not null) { return EffectiveAllowed().Count; } + + return (_max - _min + 1) - ExcludedInRangeSorted().Count; + } + + private List EffectiveAllowed() { + HashSet excluded = new(_excluded); + + return _allowed!.Where(value => value >= _min && value <= _max && !excluded.Contains(value)).ToList(); + } + + private List ExcludedInRangeSorted() { + List excluded = _excluded.Where(value => value >= _min && value <= _max).Distinct().ToList(); + excluded.Sort(); + + return excluded; + } + + private string DescribeExhaustion() { + if (_allowed is not null) { + if (_excluded.Count > 0 && _allowed.All(value => _excluded.Contains(value) || value < _min || value > _max)) { + return $"no value {_allowedConstraint} allows remains available"; + } + + return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; + } + + if (_min == _max) { + string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; + + return $"{pinning} already pins the value to {V(_min)}"; + } + + return $"no value remains between {V(_min)} and {V(_max)} once the excluded values are removed"; + } + +} diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md new file mode 100644 index 00000000..5dea7073 --- /dev/null +++ b/Dummies/README.nuget.md @@ -0,0 +1,57 @@ +# Dummies + +A fluent DSL for generating arbitrary yet **valid** test values — *dummies*: values a +test needs but never asserts on. + +## The idea + +A test's `Arrange` is full of values the test does not check: an order reference, a +quantity, a label. A hand-picked literal reads as significant even when it is not. +`Dummies` makes the incidental legible as incidental — and, when the value must cross +an invariant (a value object, a contract precondition), the constraints express *that +invariant*, never what the test asserts: + + string code = Any.String() + .NonEmpty() + .WithMaxLength(50) + .StartingWith("ORD-"); + +Read it as: *any* string that satisfies these constraints. The exact value does not +matter — and that is the point. + +## What's inside + +- **Fluent, typed generators** (`Any.String()`, `Any.Int32()`, ...) implementing + `IAny`, with implicit conversion to the generated type. +- **Values built to satisfy the constraints** — never generate-then-filter, no retry + loops. +- **Conflicting constraints fail fast** with a clear, actionable + `ConflictingAnyConstraintException` at the moment the conflicting constraint is + declared — for example `Any.String().WithLength(3).StartingWith("ORD-")`. +- **Composition without reflection**: `.As(factory)` turns a constrained primitive + into a domain value object; `Any.Combine(...)` assembles larger objects through + constructor lambdas. +- **Reproducible runs**: wrap a test in `Any.Reproducibly(...)` and a failing run + reports the seed to replay; `Any.WithSeed(seed)` gives an isolated, deterministic + context. + +## Example + + using Dummies; + + OrderReference reference = Any.String() + .StartingWith("ORD-") + .WithLength(12) + .As(OrderReference.Create) + .Generate(); + +## What it is not + +No realistic fake data (names, emails, addresses), no object-graph auto-filling, no +reflection. Small, deterministic, explicit. + +## Documentation + +Full documentation on GitHub: + +https://github.com/Reefact/first-class-errors diff --git a/Dummies/RandomSource.cs b/Dummies/RandomSource.cs new file mode 100644 index 00000000..6b02e012 --- /dev/null +++ b/Dummies/RandomSource.cs @@ -0,0 +1,156 @@ +namespace Dummies; + +/// +/// The random context a generator draws from when it generates: a pseudo-random generator paired with the seed +/// that created it, so any failure can name the seed that replays the run. Generators hold a +/// and resolve it at time — never at construction +/// time — which is what lets a recipe built outside an Any.Reproducibly(...) scope generate +/// deterministically inside one. +/// +internal abstract class RandomSource { + + /// The seeded pseudo-random generator to draw from right now. + internal abstract SeededRandom Current { get; } + +} + +/// A pseudo-random generator that remembers the seed it was created from. +internal sealed class SeededRandom { + + internal SeededRandom(int seed) { + Seed = seed; + Random = new Random(seed); + } + + internal int Seed { get; } + internal Random Random { get; } + +} + +/// +/// The default random context behind the static entry points. The state is stored in an +/// , so it flows with the current execution context and never leaks across tests +/// running in parallel. Outside an scope it lazily seeds itself with a fresh seed — every +/// run differs, which surfaces a test that secretly depends on a value — and that seed is remembered, so a +/// generation failure can still report it. Inside a scope (how Any.Reproducibly(...) pins a run) it is +/// deterministic. +/// +internal sealed class AmbientRandomSource : RandomSource { + + #region Statics members declarations + + internal static readonly AmbientRandomSource Instance = new(); + + private static readonly AsyncLocal State = new(); + + internal static int NewSeed() { + return Guid.NewGuid().GetHashCode(); + } + + internal static IDisposable UseSeed(int seed) { + SeededRandom? previous = State.Value; + State.Value = new SeededRandom(seed); + + return new SeedScope(previous); + } + + #endregion + + private AmbientRandomSource() { } + + internal override SeededRandom Current { + get { + SeededRandom? current = State.Value; + if (current is null) { + current = new SeededRandom(NewSeed()); + State.Value = current; + } + + return current; + } + } + + #region Nested types + + private sealed class SeedScope : IDisposable { + + private readonly SeededRandom? _previous; + private bool _disposed; + + internal SeedScope(SeededRandom? previous) { + _previous = previous; + } + + public void Dispose() { + if (_disposed) { return; } + + _disposed = true; + State.Value = _previous; + } + + } + + #endregion + +} + +/// +/// The isolated random context behind : one fixed, seeded generator owned by a single +/// . Unlike the ambient source it does not flow with the execution context — it is +/// deterministic by construction and belongs to whoever holds the context. +/// +internal sealed class FixedRandomSource : RandomSource { + + private readonly SeededRandom _random; + + internal FixedRandomSource(int seed) { + _random = new SeededRandom(seed); + } + + internal override SeededRandom Current => _random; + +} + +/// +/// Implemented by the library's own generators so that derived generators (As, Combine) can +/// propagate the random context of their operands, and so that a generation failure can resolve the seed to +/// report. Foreign implementations simply do not carry one, and a derived generator +/// built over a foreign one carries null. +/// +internal interface IHasRandomSource { + + RandomSource? Source { get; } + +} + +/// Uniform sampling helpers shared by the generators. +internal static class RandomSampling { + + /// + /// Draws a uniform value in the inclusive range [, + /// ]. Unlike the upper bound is reachable, + /// which matters for full-range and boundary draws. The draw maps 8 random bytes onto the range size; the + /// modulo bias is at most 2^-32 for the ranges an can express — irrelevant for arbitrary + /// test values. + /// + internal static long NextInt64(this Random random, long minInclusive, long maxInclusive) { + if (minInclusive > maxInclusive) { throw new ArgumentOutOfRangeException(nameof(maxInclusive), "The maximum must be greater than or equal to the minimum."); } + + ulong rangeSize = (ulong)(maxInclusive - minInclusive) + 1UL; + byte[] bytes = new byte[8]; + random.NextBytes(bytes); + ulong draw = BitConverter.ToUInt64(bytes, 0); + + // rangeSize is 0 only when the range spans the full ulong width, which int-derived bounds never do; + // guard anyway so the helper stays correct if reused with wider bounds. + if (rangeSize == 0UL) { return unchecked((long)draw); } + + return minInclusive + (long)(draw % rangeSize); + } + + /// Draws a uniform in the inclusive range — see . + internal static int NextInt32(this Random random, int minInclusive, int maxInclusive) { + return (int)random.NextInt64(minInclusive, maxInclusive); + } + +} diff --git a/Dummies/StringSpec.cs b/Dummies/StringSpec.cs new file mode 100644 index 00000000..9846c033 --- /dev/null +++ b/Dummies/StringSpec.cs @@ -0,0 +1,365 @@ +#region Usings declarations + +using System.Globalization; +using System.Text; + +#endregion + +namespace Dummies; + +/// The character families a string generator can be restricted to. +internal enum CharacterSet { + + Alpha, + Numeric, + AlphaNumeric + +} + +/// The casing a string generator can impose on alphabetic characters. +internal enum LetterCasing { + + Lower, + Upper + +} + +/// +/// The immutable specification behind : length bounds, anchored fragments (prefix, +/// suffix, contained values), a character set and a letter casing — each remembering the constraint that set it, +/// so a conflict message can name both sides. Every mutation returns a new specification and cross-validates the +/// whole eagerly: an that exists can always generate. +/// +/// +/// Fragments are laid out without overlap analysis: a generated string is +/// prefix + filler + contained values + filler + suffix, so the length budget the fragments require is +/// the plain sum of their lengths. A combination that only a cleverer overlapping layout could satisfy is +/// reported as a conflict — a deliberate V1 simplification, kept explicit in the conflict messages. +/// +internal sealed class StringSpec { + + private const int DefaultLengthSpread = 16; + + private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz"; + private const string Digits = "0123456789"; + + #region Statics members declarations + + internal static readonly StringSpec Unconstrained = new(null, null, 0, null, null, null, + null, null, null, null, [], + null, null, null, null); + + private static string V(int value) { + return value.ToString(CultureInfo.InvariantCulture); + } + + private static string Characters(int count) { + return count == 1 ? "1 character" : $"{V(count)} characters"; + } + + #endregion + + #region Fields declarations + + private readonly LetterCasing? _casing; + private readonly string? _casingConstraint; + private readonly CharacterSet? _charset; + private readonly string? _charsetConstraint; + private readonly int? _exactLength; + private readonly string? _exactConstraint; + private readonly IReadOnlyList _fragments; + private readonly int? _maxLength; + private readonly string? _maxConstraint; + private readonly int _minLength; + private readonly string? _minConstraint; + private readonly string? _prefix; + private readonly string? _prefixConstraint; + private readonly string? _suffix; + private readonly string? _suffixConstraint; + + #endregion + + private StringSpec(int? exactLength, string? exactConstraint, + int minLength, string? minConstraint, + int? maxLength, string? maxConstraint, + string? prefix, string? prefixConstraint, + string? suffix, string? suffixConstraint, + IReadOnlyList fragments, + CharacterSet? charset, string? charsetConstraint, + LetterCasing? casing, string? casingConstraint) { + _exactLength = exactLength; + _exactConstraint = exactConstraint; + _minLength = minLength; + _minConstraint = minConstraint; + _maxLength = maxLength; + _maxConstraint = maxConstraint; + _prefix = prefix; + _prefixConstraint = prefixConstraint; + _suffix = suffix; + _suffixConstraint = suffixConstraint; + _fragments = fragments; + _charset = charset; + _charsetConstraint = charsetConstraint; + _casing = casing; + _casingConstraint = casingConstraint; + } + + /// Fixes the exact length; declared once per generator. + internal StringSpec WithExactLength(int length, string applying) { + if (_exactConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_exactConstraint} is already defined."); } + + StringSpec candidate = new(length, applying, _minLength, _minConstraint, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, + _charset, _charsetConstraint, _casing, _casingConstraint); + + return candidate.Validated(applying); + } + + /// Tightens the minimum length; a looser bound than the current one is a no-op. + internal StringSpec WithMinLength(int length, string applying) { + if (length <= _minLength) { return this; } + + StringSpec candidate = new(_exactLength, _exactConstraint, length, applying, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, + _charset, _charsetConstraint, _casing, _casingConstraint); + + return candidate.Validated(applying); + } + + /// Tightens the maximum length; a looser bound than the current one is a no-op. + internal StringSpec WithMaxLength(int length, string applying) { + if (_maxLength is not null && length >= _maxLength) { return this; } + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, length, applying, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, + _charset, _charsetConstraint, _casing, _casingConstraint); + + return candidate.Validated(applying); + } + + /// Anchors a prefix; declared once per generator. + internal StringSpec WithPrefix(string prefix, string applying) { + if (_prefixConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_prefixConstraint} is already defined."); } + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, + prefix, applying, _suffix, _suffixConstraint, _fragments, + _charset, _charsetConstraint, _casing, _casingConstraint); + + return candidate.Validated(applying); + } + + /// Anchors a suffix; declared once per generator. + internal StringSpec WithSuffix(string suffix, string applying) { + if (_suffixConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_suffixConstraint} is already defined."); } + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, suffix, applying, _fragments, + _charset, _charsetConstraint, _casing, _casingConstraint); + + return candidate.Validated(applying); + } + + /// Adds a value the generated string must contain. + internal StringSpec WithFragment(string fragment, string applying) { + List fragments = new(_fragments) { fragment }; + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, fragments, + _charset, _charsetConstraint, _casing, _casingConstraint); + + return candidate.Validated(applying); + } + + /// Restricts the character family; declared once per generator. + internal StringSpec WithCharset(CharacterSet charset, string applying) { + if (_charsetConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_charsetConstraint} is already defined."); } + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, + charset, applying, _casing, _casingConstraint); + + return candidate.Validated(applying); + } + + /// Imposes a letter casing; declared once per generator. + internal StringSpec WithCasing(LetterCasing casing, string applying) { + if (_casingConstraint is not null) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_casingConstraint} is already defined."); } + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, + _charset, _charsetConstraint, casing, applying); + + return candidate.Validated(applying); + } + + /// Builds one string satisfying the whole specification — laid out directly, never generate-then-retry. + internal string Generate(Random random) { + int required = RequiredLength(); + int effectiveMin = Math.Max(_minLength, required); + // Long arithmetic: a huge declared minimum must saturate instead of overflowing past int.MaxValue. + int effectiveMax = _maxLength ?? (int)Math.Min((long)effectiveMin + DefaultLengthSpread, int.MaxValue); + int length = _exactLength ?? random.NextInt32(effectiveMin, effectiveMax); + + string pool = FillerPool(); + int fillerLength = length - required; + int before = random.Next(fillerLength + 1); + int after = fillerLength - before; + + StringBuilder builder = new(length); + if (_prefix is not null) { builder.Append(_prefix); } + AppendFiller(builder, random, pool, before); + foreach (string fragment in _fragments) { builder.Append(fragment); } + AppendFiller(builder, random, pool, after); + if (_suffix is not null) { builder.Append(_suffix); } + + return builder.ToString(); + } + + private static void AppendFiller(StringBuilder builder, Random random, string pool, int count) { + for (int i = 0; i < count; i++) { + builder.Append(pool[random.Next(pool.Length)]); + } + } + + private StringSpec Validated(string applying) { + ValidateLengthBounds(applying); + ValidateFragmentBudget(applying); + ValidateFragmentCharacters(applying); + + return this; + } + + private void ValidateLengthBounds(string applying) { + if (_exactLength is int exact) { + if (exact < _minLength) { + throw new ConflictingAnyConstraintException(applying == _exactConstraint + ? $"Cannot apply {applying} because {_minConstraint} already requires at least {Characters(_minLength)}." + : $"Cannot apply {applying} because {_exactConstraint} already fixes the length at {V(exact)}."); + } + + if (_maxLength is int cappedAt && exact > cappedAt) { + throw new ConflictingAnyConstraintException(applying == _exactConstraint + ? $"Cannot apply {applying} because {_maxConstraint} already caps the length at {V(cappedAt)}." + : $"Cannot apply {applying} because {_exactConstraint} already fixes the length at {V(exact)}."); + } + } + + if (_maxLength is int max && _minLength > max) { + throw new ConflictingAnyConstraintException(applying == _maxConstraint + ? $"Cannot apply {applying} because {_minConstraint} already requires at least {Characters(_minLength)}." + : $"Cannot apply {applying} because {_maxConstraint} already caps the length at {V(max)}."); + } + } + + private void ValidateFragmentBudget(string applying) { + int required = RequiredLength(); + if (required == 0) { return; } + + (string description, bool several) = DescribeFragments(); + string requires = several ? "require" : "requires"; + + if (_exactLength is int exact && required > exact) { + throw new ConflictingAnyConstraintException(applying == _exactConstraint + ? $"Cannot apply {applying} because {description} already {requires} {Characters(required)}." + : $"Cannot apply {applying} because {_exactConstraint} allows only {Characters(exact)} while {description} {requires} {V(required)}."); + } + + if (_maxLength is int max && required > max) { + throw new ConflictingAnyConstraintException(applying == _maxConstraint + ? $"Cannot apply {applying} because {description} already {requires} {Characters(required)}." + : $"Cannot apply {applying} because {_maxConstraint} allows at most {Characters(max)} while {description} {requires} {V(required)}."); + } + } + + private void ValidateFragmentCharacters(string applying) { + foreach ((string kind, string fragment) in Fragments()) { + if (_charset is CharacterSet charset) { + char? offending = FirstOutsideCharset(fragment, charset); + if (offending is char outside) { + throw new ConflictingAnyConstraintException(applying == _charsetConstraint + ? $"Cannot apply {applying} because the {kind} \"{fragment}\" contains '{outside}', which it does not allow." + : $"Cannot apply {applying} because {_charsetConstraint} does not allow its character '{outside}'."); + } + } + + if (_casing is LetterCasing casing) { + char? offending = FirstAgainstCasing(fragment, casing); + if (offending is char against) { + string caseName = casing == LetterCasing.Lower ? "uppercase" : "lowercase"; + throw new ConflictingAnyConstraintException(applying == _casingConstraint + ? $"Cannot apply {applying} because the {kind} \"{fragment}\" contains the {caseName} letter '{against}'." + : $"Cannot apply {applying} because {_casingConstraint} forbids its {caseName} letter '{against}'."); + } + } + } + } + + private IEnumerable<(string Kind, string Fragment)> Fragments() { + if (_prefix is not null) { yield return ("prefix", _prefix); } + foreach (string fragment in _fragments) { yield return ("contained value", fragment); } + if (_suffix is not null) { yield return ("suffix", _suffix); } + } + + private (string Description, bool Several) DescribeFragments() { + List parts = new(); + if (_prefix is not null) { parts.Add($"the prefix \"{_prefix}\""); } + foreach (string fragment in _fragments) { parts.Add($"the contained value \"{fragment}\""); } + if (_suffix is not null) { parts.Add($"the suffix \"{_suffix}\""); } + + return (string.Join(" and ", parts), parts.Count > 1); + } + + private int RequiredLength() { + int required = (_prefix?.Length ?? 0) + (_suffix?.Length ?? 0); + foreach (string fragment in _fragments) { required += fragment.Length; } + + return required; + } + + private static char? FirstOutsideCharset(string fragment, CharacterSet charset) { + foreach (char character in fragment) { + bool allowed = charset switch { + CharacterSet.Alpha => IsAsciiLetter(character), + CharacterSet.Numeric => IsAsciiDigit(character), + CharacterSet.AlphaNumeric => IsAsciiLetter(character) || IsAsciiDigit(character), + _ => true + }; + if (!allowed) { return character; } + } + + return null; + } + + private static char? FirstAgainstCasing(string fragment, LetterCasing casing) { + foreach (char character in fragment) { + if (casing == LetterCasing.Lower && character is >= 'A' and <= 'Z') { return character; } + if (casing == LetterCasing.Upper && character is >= 'a' and <= 'z') { return character; } + } + + return null; + } + + private static bool IsAsciiLetter(char character) { + return character is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + } + + private static bool IsAsciiDigit(char character) { + return character is >= '0' and <= '9'; + } + + private string FillerPool() { + string letters = _casing switch { + LetterCasing.Lower => LowerLetters, + LetterCasing.Upper => UpperLetters, + _ => UpperLetters + LowerLetters + }; + + return _charset switch { + CharacterSet.Alpha => letters, + CharacterSet.Numeric => Digits, + _ => letters + Digits + }; + } + +} diff --git a/FirstClassErrors.sln b/FirstClassErrors.sln index ee67ca71..457f2083 100644 --- a/FirstClassErrors.sln +++ b/FirstClassErrors.sln @@ -45,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.RequestBin EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.RequestBinder.UnitTests", "FirstClassErrors.RequestBinder.UnitTests\FirstClassErrors.RequestBinder.UnitTests.csproj", "{75EA57DD-0128-4EB9-ADBE-567BFF602A93}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dummies", "Dummies\Dummies.csproj", "{F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dummies.UnitTests", "Dummies.UnitTests\Dummies.UnitTests.csproj", "{BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -233,6 +237,30 @@ Global {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x64.Build.0 = Release|Any CPU {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x86.ActiveCfg = Release|Any CPU {75EA57DD-0128-4EB9-ADBE-567BFF602A93}.Release|x86.Build.0 = Release|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Debug|x64.Build.0 = Debug|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Debug|x86.Build.0 = Debug|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Release|Any CPU.Build.0 = Release|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Release|x64.ActiveCfg = Release|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Release|x64.Build.0 = Release|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Release|x86.ActiveCfg = Release|Any CPU + {F2C739E7-6F2E-4256-9C87-9D6F1168DF4A}.Release|x86.Build.0 = Release|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Debug|x64.Build.0 = Debug|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Debug|x86.Build.0 = Debug|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|Any CPU.Build.0 = Release|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|x64.ActiveCfg = Release|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|x64.Build.0 = Release|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|x86.ActiveCfg = Release|Any CPU + {BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 431f17cba2dfa652bfaacd3f6c639805aaac17fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:17:42 +0000 Subject: [PATCH 3/6] docs: draft ADR-0010 hosting Dummies as a standalone package Records the decision (Proposed) to ship Dummies as its own NuGet package hosted in this repository, with a hard no-reference boundary toward FirstClassErrors guarded by an architecture test, and lists the triggers that would justify extracting it to a dedicated repository. Realizes the extraction follow-up anticipated by ADR-0006. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- ...host-dummies-as-a-standalone-package.fr.md | 141 ++++++++++++++++++ ...10-host-dummies-as-a-standalone-package.md | 136 +++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 278 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.md diff --git a/doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.fr.md b/doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.fr.md new file mode 100644 index 00000000..ce4049f7 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.fr.md @@ -0,0 +1,141 @@ +# ADR-0010 | Héberger Dummies comme package autonome dans ce dépôt + +🌍 🇬🇧 [English](0010-host-dummies-as-a-standalone-package.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-17 +**Décideurs :** Reefact + +## Contexte + +`FirstClassErrors.Testing` fournit des valeurs de test arbitraires via une façade +`Any` orientée erreurs, adossée à une source unique à graine (ADR-0006). Cet ADR +listait, en action de suivi, l'extraction du moteur générique de valeurs vers un +utilitaire autonome et agnostique des erreurs, et le moteur avait été gardé +séparable en interne à cette fin. + +Une nouvelle bibliothèque, `Dummies`, fournit désormais une DSL fluide de +générateurs typés porteurs de contraintes (`IAny`) pour des valeurs de test +arbitraires mais valides. Ses contraintes expriment les invariants qu'une valeur +doit satisfaire — le format d'un value object, une précondition de contrat — ce +qui vise les tests orientés domaine en général, pas la gestion d'erreurs : la +bibliothèque ne connaît rien de FirstClassErrors, cible `netstandard2.0` et n'a +aucune dépendance. Son audience visée dépasse les utilisateurs de +FirstClassErrors. + +Deux faits contraignent où et sous quel nom elle est publiée : + +* Un identifiant de package NuGet est de fait permanent : le renommer après + adoption signifie publier un nouveau package et imposer une migration aux + consommateurs. +* Ce dépôt porte déjà l'appareillage de publication qu'un package publié + requiert — CI avec cliquet zéro warning, SBOM embarqué, SourceLink, trains de + release pilotés par tag et sélectionnés par liste explicite de projets, + conventions de commit, et cette base d'ADR. Un dépôt séparé devrait tout + dupliquer. + +L'API de la bibliothèque évoluera le plus vite dans ses premières itérations, +alors que ses premiers consommateurs probables (les projets de test de ce dépôt, +et peut-être `FirstClassErrors.Testing` plus tard) vivent ici. + +## Décision + +La bibliothèque `Dummies` est publiée comme package NuGet propre, nommé +`Dummies`, hébergé dans ce dépôt comme projet autonome ne référençant aucun +projet FirstClassErrors — frontière gardée par un test d'architecture. + +## Justification + +* **Le nom ne doit pas restreindre l'audience.** La bibliothèque est un + générateur générique de valeurs de test ; un nom `FirstClassErrors.Testing.*` + la décrirait comme un outillage de gestion d'erreurs, plafonnerait son + audience aux utilisateurs de FirstClassErrors et suggérerait une dépendance + qui n'existe pas. Un identifiant de package étant permanent, ce choix devait + être tranché avant la première publication, pas après. +* **L'identité tient à la frontière du package, pas à celle du dépôt.** Un + identifiant autonome, son propre namespace et une règle de zéro référence + livrent l'identité indépendante ; héberger les sources ici réutilise + l'appareillage existant et garde la friction d'itération basse précisément + quand l'API bouge le plus. +* **La frontière est vérifiée, pas espérée.** Un test d'architecture fait + échouer tout build où `Dummies` gagnerait une référence FirstClassErrors : la + promesse d'autonomie ne peut pas s'éroder silencieusement, et une extraction + ultérieure vers son propre dépôt reste une opération mécanique. +* **La décision réalise le suivi d'ADR-0006 tel qu'anticipé.** L'utilitaire + autonome et agnostique des erreurs que cet ADR envisageait existe désormais + comme package de premier rang plutôt que comme moteur interne. + +## Alternatives considérées + +### Le nommer `FirstClassErrors.Testing.Dummies` + +Considéré parce que la bibliothèque est née en scindant le moteur générique de +`FirstClassErrors.Testing`, et qu'un nom de famille hérite de l'audience de ce +package. Rejeté parce que le nom décrit mal le contenu (la bibliothèque ne +parle pas d'erreurs), plafonne l'audience visée et suggère un couplage que le +code interdit délibérément. + +### Créer un dépôt séparé dès maintenant + +Considéré parce qu'un produit autonome dans son propre dépôt est l'identité la +plus propre à long terme. Rejeté pour l'instant parce que cela duplique tout +l'appareillage de publication sans gain d'identité que la frontière du package +ne livre déjà, et ajoute une friction inter-dépôts au moment où l'API évolue le +plus vite. L'extraction reste peu coûteuse tant que la frontière de zéro +référence tient ; les déclencheurs de réexamen sont listés en actions de suivi. + +### Étendre la façade `Any` de `FirstClassErrors.Testing` sur place + +Considéré parce que cette façade existe et est publiée. Rejeté parce que cela +soude le moteur générique à la surface spécifique aux erreurs — l'inverse de +l'ambition d'autonomie — et que faire grandir une DSL de contraintes complète +dans un package de support de test dédié aux erreurs en déplacerait le centre +de gravité. `FirstClassErrors.Testing` garde sa façade inchangée. + +## Conséquences + +### Positives + +* La bibliothèque porte une identité et une audience propres, indépendantes de + FirstClassErrors, dès sa première release. +* Aucune infrastructure de publication n'est dupliquée ; le package bénéficie + de la CI, du durcissement de packaging et des conventions existants. +* La frontière de zéro référence est vérifiée par la machine, et l'extraction + vers un dépôt dédié reste une option mécanique et peu coûteuse. + +### Négatives + +* Un package publié de plus à maintenir depuis ce dépôt : son propre train de + release, sa documentation, sa cadence de versions. +* Le nom du dépôt ne met pas le package en avant ; sa découvrabilité repose sur + le package lui-même et sa documentation. +* La liste des scopes de commit grandit d'un élément (`dummies`), et les + contributeurs doivent savoir qu'un projet de ce dépôt ne fait délibérément + pas partie du graphe de dépendances FirstClassErrors. + +### Risques + +* **Érosion de la frontière** — un raccourci commode ajoute une référence + FirstClassErrors. Atténué par le test d'architecture et par cet ADR qui + consigne la règle. +* **Conflit de cadence** — le rythme de release de Dummies peut finir par se + heurter aux trains du dépôt. Cette pression est un déclencheur d'extraction, + pas une raison de coupler le package davantage. + +## Actions de suivi + +* Donner à `Dummies` son propre train de release dans l'outillage de packaging + avant sa première publication ; d'ici là, aucune release ne le publie. +* Extraire vers un dépôt dédié (en conservant l'identifiant du package) quand + un déclencheur se présente : arrivée de contributeurs externes, cadence de + release divergente, ou flux d'issues propre au package. +* Écrire la documentation utilisateur (anglais et français) une fois la surface + V1 stabilisée. +* Décider séparément si `FirstClassErrors.Testing` rebase plus tard son moteur + interne de valeurs sur `Dummies` ; rien dans cette décision ne l'impose. + +## Références + +* ADR-0006 — Fournir les valeurs de test arbitraires depuis une source unique à + graine (le suivi que cette décision réalise). +* Le test d'architecture gardant la frontière, dans `Dummies.UnitTests`. diff --git a/doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.md b/doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.md new file mode 100644 index 00000000..aa5c0b3c --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0010-host-dummies-as-a-standalone-package.md @@ -0,0 +1,136 @@ +# ADR-0010 | Host Dummies as a standalone package in this repository + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0010-host-dummies-as-a-standalone-package.fr.md) + +**Status:** Proposed +**Date:** 2026-07-17 +**Decision Makers:** Reefact + +## Context + +`FirstClassErrors.Testing` supplies arbitrary test values through an error-aware +`Any` facade backed by a single seedable source (ADR-0006). That ADR listed, as a +follow-up, extracting the generic value engine into a standalone, error-agnostic +utility, and the engine was deliberately kept internally separable to that end. + +A new library, `Dummies`, now provides a fluent DSL of typed, constraint-carrying +generators (`IAny`) for arbitrary yet valid test values. Its constraints +express the invariants a value must satisfy — a value object's format, a contract +precondition — which targets domain-driven tests in general, not error handling: +the library carries no knowledge of FirstClassErrors, targets `netstandard2.0`, +and has zero dependencies. Its intended audience extends beyond FirstClassErrors +users. + +Two facts constrain where and under what name it ships: + +* A NuGet package ID is effectively permanent: renaming after adoption means + publishing a new package and forcing a consumer migration. +* This repository already carries the shipping apparatus a published package + needs — CI with a zero-warning ratchet, SBOM embedding, SourceLink, tag-driven + release trains selected by an explicit project list, commit conventions, and + this ADR base. A separate repository would have to duplicate all of it. + +The library's API is expected to evolve fastest in its first iterations, while +its most likely early consumers (this repository's own test projects, and +possibly `FirstClassErrors.Testing` later) live here. + +## Decision + +The `Dummies` library ships as its own NuGet package, named `Dummies`, hosted in +this repository as a standalone project that references no FirstClassErrors +project — a boundary guarded by an architecture test. + +## Rationale + +* **The name must not narrow the audience.** The library is a generic + test-value generator; a `FirstClassErrors.Testing.*` name would describe it as + error-handling tooling, cap its audience to FirstClassErrors users, and imply + a dependency that does not exist. Because a package ID is permanent, this had + to be decided before first publication, not after. +* **The identity lives in the package boundary, not the repository boundary.** + A standalone package ID, its own namespace, and a zero-reference rule deliver + the independent identity; hosting the sources here reuses the existing + shipping apparatus and keeps iteration friction low precisely while the API + churns most. +* **The boundary is enforced, not hoped for.** An architecture test fails any + build in which `Dummies` gains a FirstClassErrors reference, so the standalone + promise cannot erode silently, and a later extraction to its own repository + stays a mechanical operation. +* **It realizes ADR-0006's follow-up as intended.** The standalone, + error-agnostic utility that ADR anticipated now exists as a first-class + package rather than an internal engine. + +## Alternatives Considered + +### Name it `FirstClassErrors.Testing.Dummies` + +Considered because the library was conceived while splitting the generic value +engine out of `FirstClassErrors.Testing`, and a family name inherits that +package's audience. Rejected because the name misdescribes the content (the +library is not about errors), caps the audience the library is built for, and +suggests a coupling the code deliberately forbids. + +### Create a separate repository now + +Considered because a standalone product in its own repository is the cleanest +long-term identity. Rejected for now because it duplicates the entire shipping +apparatus for no identity gain the package boundary does not already deliver, +and it adds cross-repository friction at the moment the API evolves fastest. +The extraction stays cheap as long as the no-reference boundary holds; the +triggers for revisiting are listed as follow-ups. + +### Extend the `Any` facade of `FirstClassErrors.Testing` in place + +Considered because that facade exists and is shipped. Rejected because it welds +the generic engine to the error-specific surface — the opposite of the +standalone ambition — and because growing a full constraint DSL inside a +test-support package for errors would misplace its center of gravity. +`FirstClassErrors.Testing` keeps its own facade unchanged. + +## Consequences + +### Positive + +* The library carries an identity and an audience of its own, independent of + FirstClassErrors, from its first release. +* No shipping infrastructure is duplicated; the package benefits from the + repository's existing CI, packaging hardening, and conventions. +* The no-reference boundary is machine-checked, and extraction to a dedicated + repository remains a low-cost, mechanical option. + +### Negative + +* One more published package to maintain from this repository: its own release + train, documentation, and versioning cadence. +* The repository's name does not advertise the package; discoverability rests + on the package itself and its documentation. +* The commit-scope list grows by one (`dummies`), and contributors must know + that one project in this repository is deliberately not part of the + FirstClassErrors dependency graph. + +### Risks + +* **Boundary erosion** — a convenient shortcut adds a FirstClassErrors + reference. Mitigated by the architecture test and by this ADR recording the + rule. +* **Cadence conflict** — Dummies' release rhythm may start fighting the + repository's release trains. That pressure is an extraction trigger, not a + reason to couple the package tighter. + +## Follow-up Actions + +* Give `Dummies` its own release train in the packaging tooling before its + first publication; until then, no release publishes it. +* Extract to a dedicated repository (keeping the package ID) when a trigger + fires: external contributors arrive, the release cadence diverges, or the + package develops an issue flow of its own. +* Write the user documentation (English and French) once the V1 surface + stabilizes. +* Decide separately whether `FirstClassErrors.Testing` later re-bases its + internal value engine on `Dummies`; nothing in this decision requires it. + +## References + +* ADR-0006 — Supply arbitrary test values from a single seedable source (the + follow-up this decision realizes). +* The architecture test guarding the boundary, in `Dummies.UnitTests`. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 032fa55a..2d46f4a8 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -183,3 +183,4 @@ Optional supporting material: | [ADR-0007](0007-name-the-binder-terminals-new-and-create.md) | Name the binder terminals New and Create | Accepted | | [ADR-0008](0008-bind-nullable-value-type-properties-through-a-struct-constrained-overload.md) | Bind nullable value-type properties through a struct-constrained overload | Proposed | | [ADR-0009](0009-report-the-toolings-failures-as-first-class-errors.md) | Report the tooling's failures as first-class errors | Proposed | +| [ADR-0010](0010-host-dummies-as-a-standalone-package.md) | Host Dummies as a standalone package in this repository | Proposed | From af6d8ab3dbbc73f5f6824267e83d5412c9f51719 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:29:36 +0000 Subject: [PATCH 4/6] ci(dummies): add the dum release train Give Dummies its own independently-versioned release train (tag prefix dum-v*), following the AddingAReleaseTrain runbook: one data row in tools/trains.sh, the static edits in release.yml (tag trigger, dispatch choice, version resolution), a dum branch in pack.sh with a standalone guard asserting the packed nuspec declares no FirstClassErrors dependency, dry-run rehearsal of the new train's pack and notes, the changelog dispatch option, the maintainer-doc train tables, and a pre-created Dummies/CHANGELOG.md. Verified locally: release-notes partition lists only dummies-scoped commits on the dum train, and pack.sh 0.0.0-dry.1 dum packs with the SBOM embedded and the standalone guard passing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- .github/workflows/changelog.yml | 1 + .github/workflows/release-dryrun.yml | 10 ++++--- .github/workflows/release.yml | 16 +++++++----- Dummies/CHANGELOG.md | 12 +++++++++ Dummies/Dummies.csproj | 4 +-- .../for-maintainers/AddingAReleaseTrain.en.md | 5 ++-- .../for-maintainers/AddingAReleaseTrain.fr.md | 5 ++-- .../for-maintainers/ReleaseDryRun.en.md | 2 +- .../for-maintainers/ReleaseDryRun.fr.md | 2 +- tools/packaging/pack.sh | 26 ++++++++++++++++--- tools/trains.sh | 3 ++- 11 files changed, 64 insertions(+), 22 deletions(-) create mode 100644 Dummies/CHANGELOG.md diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 27f85454..54892fe2 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -16,6 +16,7 @@ on: options: - lib - cli + - dum required: true from_ref: description: "Previous tag to diff from. Leave blank to auto-detect the train's latest tag; if the train has no tag yet, the whole history is used." diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index c07d2b69..6de6f528 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -1,7 +1,7 @@ name: release-dryrun # Continuously rehearses the SIDE-EFFECT-FREE portion of the release pipeline — build, pack, and embed the -# SBOM for the two published projects — on every push to main and every pull request. release.yml itself +# SBOM for the published trains — on every push to main and every pull request. release.yml itself # runs only on a tag or a manual dispatch, so its packaging path is otherwise exercised for the first time # in production, on a tag, once; this catches packaging/SBOM regressions in ordinary CI instead. # @@ -63,17 +63,19 @@ jobs: # The SAME script release.yml packs with — it packs each train's projects with their SBOM and then # asserts the SBOM is actually embedded. Sharing it is the point: this rehearsal cannot drift from the - # real release, because there is only one definition of "pack the release artifacts". Both trains are - # rehearsed so neither packaging path first runs in production. + # real release, because there is only one definition of "pack the release artifacts". All trains are + # rehearsed so no packaging path first runs in production. # (The unit/integration tests run in ci.yml; this job's unique contribution is the packaging.) - name: Pack with SBOM (build artifacts, no publish) run: | tools/packaging/pack.sh "$DRYRUN_VERSION" lib tools/packaging/pack.sh "$DRYRUN_VERSION" cli + tools/packaging/pack.sh "$DRYRUN_VERSION" dum # Rehearse the train-scoped release-notes generation too (release.yml's other production-only path), # so a bug in it surfaces here instead of during a real release. Prints to the log; publishes nothing. - - name: Rehearse release notes (both trains, no publish) + - name: Rehearse release notes (all trains, no publish) run: | echo "----- lib notes -----"; tools/packaging/release-notes.sh lib HEAD echo "----- cli notes -----"; tools/packaging/release-notes.sh cli HEAD + echo "----- dum notes -----"; tools/packaging/release-notes.sh dum HEAD diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0923f300..2838bf8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,12 +2,14 @@ name: release on: push: - # Publish on a train-prefixed semantic-version tag. The two trains version independently: + # Publish on a train-prefixed semantic-version tag. The trains version independently: # lib-v1.2.3 -> FirstClassErrors + FirstClassErrors.Testing # cli-v1.2.3 -> FirstClassErrors.Cli (the fce .NET tool) + # dum-v1.2.3 -> Dummies (the standalone arbitrary-test-value library) tags: - 'lib-v*.*.*' - 'cli-v*.*.*' + - 'dum-v*.*.*' workflow_dispatch: inputs: component: @@ -16,6 +18,7 @@ on: options: - lib - cli + - dum required: true version: description: 'Package version, without any prefix (e.g. 1.2.3; a dry run can use any SemVer, e.g. 0.0.0-dry.1)' @@ -99,12 +102,13 @@ jobs: case "$REF_NAME" in lib-v*) COMPONENT="lib"; VERSION="${REF_NAME#lib-v}" ;; cli-v*) COMPONENT="cli"; VERSION="${REF_NAME#cli-v}" ;; - *) echo "::error::Tag '$REF_NAME' is not a release tag; expected lib-v*.*.* or cli-v*.*.*."; exit 1 ;; + dum-v*) COMPONENT="dum"; VERSION="${REF_NAME#dum-v}" ;; + *) echo "::error::Tag '$REF_NAME' is not a release tag; expected lib-v*.*.*, cli-v*.*.* or dum-v*.*.*."; exit 1 ;; esac fi case "$COMPONENT" in - lib|cli) ;; - *) echo "::error::Invalid component '$COMPONENT'; expected 'lib' or 'cli'."; exit 1 ;; + lib|cli|dum) ;; + *) echo "::error::Invalid component '$COMPONENT'; expected 'lib', 'cli' or 'dum'."; exit 1 ;; esac # Build metadata (+...) is deliberately REJECTED even though SemVer allows it: NuGet strips it # from the package identity, so a tag like lib-v1.2.3+build5 packs as FirstClassErrors.1.2.3.nupkg. @@ -129,8 +133,8 @@ jobs: - name: Test run: dotnet test FirstClassErrors.sln -c Release --no-build --logger "console;verbosity=normal" - # Pack only the train this release targets (lib -> FirstClassErrors + .Testing; cli -> the fce tool), - # so a lib release never republishes the CLI and vice versa. The analyzer is bundled inside the main + # Pack only the train this release targets (lib -> FirstClassErrors + .Testing; cli -> the fce + # tool; dum -> Dummies), so a lib release never republishes the CLI and vice versa. The analyzer is bundled inside the main # package and the GenDoc worker inside the CLI tool; the samples are not published. # GenerateSBOM activates Microsoft.Sbom.Targets in each packable project: each package embeds its SPDX # inventory at _manifest/spdx_2.2/manifest.spdx.json. tools/packaging/pack.sh is the single source of diff --git a/Dummies/CHANGELOG.md b/Dummies/CHANGELOG.md new file mode 100644 index 00000000..dbe8851c --- /dev/null +++ b/Dummies/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable, user-facing changes to **Dummies** are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Releases are cut from the `dum` train (see [CONTRIBUTING.md](../CONTRIBUTING.md)). + +## [Unreleased] + +_No unreleased changes recorded yet. This section is drafted automatically from +merged pull requests — see [`.github/workflows/changelog.yml`](../.github/workflows/changelog.yml)._ diff --git a/Dummies/Dummies.csproj b/Dummies/Dummies.csproj index 61137a0a..08521aec 100644 --- a/Dummies/Dummies.csproj +++ b/Dummies/Dummies.csproj @@ -10,8 +10,8 @@ true - + 0.1.0-dev diff --git a/doc/handwritten/for-maintainers/AddingAReleaseTrain.en.md b/doc/handwritten/for-maintainers/AddingAReleaseTrain.en.md index d1119ee6..09747e18 100644 --- a/doc/handwritten/for-maintainers/AddingAReleaseTrain.en.md +++ b/doc/handwritten/for-maintainers/AddingAReleaseTrain.en.md @@ -9,12 +9,13 @@ ## What a train is A **release train** is a package (or lockstep group of packages) that versions and -publishes on its own tag prefix. Today there are two: +publishes on its own tag prefix. Today there are three: | Train | Tag prefix | Scopes | Package(s) | Changelog | | --- | --- | --- | --- | --- | -| `lib` | `lib-v*` | `core`, `analyzers`, `testing` | FirstClassErrors + FirstClassErrors.Testing | `CHANGELOG.md` | +| `lib` | `lib-v*` | `core`, `analyzers`, `testing`, `binder` | FirstClassErrors + FirstClassErrors.Testing + FirstClassErrors.RequestBinder | `CHANGELOG.md` | | `cli` | `cli-v*` | `cli`, `gendoc` | FirstClassErrors.Cli (the `fce` tool) | `FirstClassErrors.Cli/CHANGELOG.md` | +| `dum` | `dum-v*` | `dummies` | Dummies (the standalone test-value library) | `Dummies/CHANGELOG.md` | The train → (prefix, scopes, package, changelog file) mapping lives in **one place**, [`tools/trains.sh`](../../../tools/trains.sh), which the release-notes generator, the diff --git a/doc/handwritten/for-maintainers/AddingAReleaseTrain.fr.md b/doc/handwritten/for-maintainers/AddingAReleaseTrain.fr.md index 5a3df6a5..9bfc03bd 100644 --- a/doc/handwritten/for-maintainers/AddingAReleaseTrain.fr.md +++ b/doc/handwritten/for-maintainers/AddingAReleaseTrain.fr.md @@ -9,13 +9,14 @@ ## Ce qu'est un train Un **train de release** est un paquet (ou un groupe de paquets versionnés en -lockstep) qui se version et se publie sur son propre préfixe de tag. Il y en a deux +lockstep) qui se version et se publie sur son propre préfixe de tag. Il y en a trois aujourd'hui : | Train | Préfixe de tag | Scopes | Paquet(s) | Changelog | | --- | --- | --- | --- | --- | -| `lib` | `lib-v*` | `core`, `analyzers`, `testing` | FirstClassErrors + FirstClassErrors.Testing | `CHANGELOG.md` | +| `lib` | `lib-v*` | `core`, `analyzers`, `testing`, `binder` | FirstClassErrors + FirstClassErrors.Testing + FirstClassErrors.RequestBinder | `CHANGELOG.md` | | `cli` | `cli-v*` | `cli`, `gendoc` | FirstClassErrors.Cli (l'outil `fce`) | `FirstClassErrors.Cli/CHANGELOG.md` | +| `dum` | `dum-v*` | `dummies` | Dummies (la bibliothèque autonome de valeurs de test) | `Dummies/CHANGELOG.md` | Le mapping train → (préfixe, scopes, paquet, fichier changelog) vit à **un seul endroit**, [`tools/trains.sh`](../../../tools/trains.sh), que le générateur de notes de diff --git a/doc/handwritten/for-maintainers/ReleaseDryRun.en.md b/doc/handwritten/for-maintainers/ReleaseDryRun.en.md index 23dc7ff6..fd1159da 100644 --- a/doc/handwritten/for-maintainers/ReleaseDryRun.en.md +++ b/doc/handwritten/for-maintainers/ReleaseDryRun.en.md @@ -28,7 +28,7 @@ before it matters. | --- | --- | --- | | Resolve & validate version | ✅ | ✅ | | Restore, build, test | ✅ | ✅ | -| Pack the two published projects | ✅ | ✅ | +| Pack the published trains | ✅ | ✅ | | Embed the SPDX SBOM | ✅ | ✅ | | Upload packages as workflow artifacts | ✅ | ✅ | | **Sign the provenance attestation** | ✅ | ✅ (see *Impacts*) | diff --git a/doc/handwritten/for-maintainers/ReleaseDryRun.fr.md b/doc/handwritten/for-maintainers/ReleaseDryRun.fr.md index 9e983d65..49a203d6 100644 --- a/doc/handwritten/for-maintainers/ReleaseDryRun.fr.md +++ b/doc/handwritten/for-maintainers/ReleaseDryRun.fr.md @@ -29,7 +29,7 @@ saine avant que ça ne compte. | --- | --- | --- | | Résoudre & valider la version | ✅ | ✅ | | Restore, build, tests | ✅ | ✅ | -| Packer les deux projets publiés | ✅ | ✅ | +| Packer les trains publiés | ✅ | ✅ | | Embarquer le SBOM SPDX | ✅ | ✅ | | Uploader les packages en artefacts de run | ✅ | ✅ | | **Signer l'attestation de provenance** | ✅ | ✅ (voir *Impacts*) | diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh index 2f0c6467..33f6c707 100755 --- a/tools/packaging/pack.sh +++ b/tools/packaging/pack.sh @@ -10,18 +10,19 @@ # It assumes the solution has already been built in Release (it packs with # --no-build). It writes the .nupkg / .snupkg into ./artifacts. # -# Usage: tools/packaging/pack.sh +# Usage: tools/packaging/pack.sh # is any valid SemVer (a real release passes the tag version; the # dry run passes a throwaway like 0.0.0-dryrun). # selects which release train to pack, since the trains are versioned # and released independently: # lib -> FirstClassErrors + FirstClassErrors.Testing + FirstClassErrors.RequestBinder (lockstep) # cli -> FirstClassErrors.Cli (the `fce` .NET tool) +# dum -> Dummies (the standalone arbitrary-test-value library) set -eu if [ "$#" -ne 2 ] || [ -z "$1" ] || [ -z "$2" ]; then - echo "usage: tools/packaging/pack.sh " >&2 + echo "usage: tools/packaging/pack.sh " >&2 exit 2 fi version="$1" @@ -45,8 +46,14 @@ case "$scope" in # package -- asserted after the pack, below). Released on its own cadence and version. projects='FirstClassErrors.Cli/FirstClassErrors.Cli.csproj' ;; + dum) + # Dummies, the standalone arbitrary-test-value library. Deliberately independent of everything else in + # this repository (ADR-0010): it references no FirstClassErrors project, so it releases on its own + # train and its package must declare no FirstClassErrors dependency -- asserted below. + projects='Dummies/Dummies.csproj' + ;; *) - echo "error: unknown scope '$scope' (expected 'lib' or 'cli')" >&2 + echo "error: unknown scope '$scope' (expected 'lib', 'cli' or 'dum')" >&2 exit 2 ;; esac @@ -89,6 +96,19 @@ EOF echo "ok: every lib-train package pins its FirstClassErrors dependency to the co-published $version" fi +# Standalone guard for the dum train. Dummies' whole identity is that it depends on nothing (ADR-0010): +# an architecture test asserts it at build time, and this asserts it on the shipped artifact itself -- a +# FirstClassErrors dependency sneaking into the nuspec must fail the pack, not surface on nuget.org. +if [ "$scope" = "dum" ]; then + for package in artifacts/Dummies.*.nupkg; do + if unzip -p "$package" '*.nuspec' | grep -q ']*id="FirstClassErrors'; then + echo "error: $package declares a FirstClassErrors dependency; Dummies is standalone (ADR-0010)" >&2 + exit 1 + fi + echo "ok: $package is standalone (no FirstClassErrors dependency)" + done +fi + # Positive proof that the fce tool ships its GenDoc worker. `fce generate` does not do the whole job # in-process: it spawns FirstClassErrors.GenDoc.Worker in a child process (dotnet exec) and resolves it # next to the installed executable (ResolveWorkerAssemblyPath -> AppContext.BaseDirectory). PackAsTool packs diff --git a/tools/trains.sh b/tools/trains.sh index 9902b8a7..144e033a 100644 --- a/tools/trains.sh +++ b/tools/trains.sh @@ -1,7 +1,7 @@ #!/bin/sh # Single source of truth for the release trains. # -# The two published trains version independently and each owns a tag prefix, a set +# The published trains version independently and each owns a tag prefix, a set # of Conventional Commit scopes, a NuGet package label, and a changelog file. That # mapping used to be copied verbatim into tools/packaging/release-notes.sh and # tools/changelog/collect-prs.sh; it now lives here, once. The scripts and the @@ -24,6 +24,7 @@ trains_rows() { cat <<'ROWS' lib|lib-v|core,analyzers,testing,binder|CHANGELOG.md|FirstClassErrors, FirstClassErrors.Testing and FirstClassErrors.RequestBinder cli|cli-v|cli,gendoc|FirstClassErrors.Cli/CHANGELOG.md|FirstClassErrors.Cli (the fce .NET tool) +dum|dum-v|dummies|Dummies/CHANGELOG.md|Dummies ROWS } From af3ed0d3b01807cd34ed55fa670c1479f1b8c3cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:38:16 +0000 Subject: [PATCH 5/6] ci(dummies): harden the dum guard and refresh train enumerations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups surfaced by a completeness sweep of the new train: - pack.sh: make the dum standalone guard fail closed — an unmatched glob or unreadable nuspec now errors instead of passing as ok, matching the cli guard's shape. - changelog.yml, release-notes.sh, collect-prs.sh: refresh the train enumerations in comments and usage strings for the third train (and heal the lib rows that predated the binder joining that train). - workflows/changelog docs (en/fr): add the dum row to the train tables. - CLAUDE.md: add the dummies scope to the closed scope list. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- .github/workflows/changelog.yml | 5 +++-- CLAUDE.md | 2 +- .../for-maintainers/workflows/changelog.en.md | 5 +++-- .../for-maintainers/workflows/changelog.fr.md | 5 +++-- tools/changelog/collect-prs.sh | 11 ++++++----- tools/packaging/pack.sh | 6 +++++- tools/packaging/release-notes.sh | 13 +++++++------ 7 files changed, 28 insertions(+), 19 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 54892fe2..afa2f639 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -4,9 +4,10 @@ name: Draft changelog # drafts the "[Unreleased]" section of ONE release train's changelog from the pull # requests merged since that train's last tag, then opens a pull request so a human # can review it (and delete any benefit the model inferred rather than found) -# before merging. The two trains version independently and keep separate changelogs: +# before merging. The trains version independently and keep separate changelogs: # lib -> CHANGELOG.md (FirstClassErrors + FirstClassErrors.Testing) # cli -> FirstClassErrors.Cli/CHANGELOG.md (the fce .NET tool) +# dum -> Dummies/CHANGELOG.md (the standalone Dummies library) on: workflow_dispatch: inputs: @@ -91,7 +92,7 @@ jobs: tools/changelog/collect-prs.sh "$COMPONENT" > prs.json echo "count=$(jq 'length' prs.json)" >> "$GITHUB_OUTPUT" echo "Collected $(jq 'length' prs.json) ${COMPONENT}-facing pull request(s) for this changelog." - # COMPONENT is a choice input (lib|cli only), safe to pass straight through. + # COMPONENT is a choice input (lib|cli|dum only), safe to pass straight through. # from_ref is free text, so it travels via the environment (never inlined # into the script) and is only ever handed to `git log`. diff --git a/CLAUDE.md b/CLAUDE.md index c8560f45..a84bc7f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ The essentials, inlined so they hold even if `AGENTS.md` is not read: * Follow `.github/pull_request_template.md` for every pull request. * Do not open a pull request unless I explicitly ask for one. * PR titles, descriptions, commits, and branch names must be written in English. -* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit). +* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, dummies, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit). * Write every pull request title per [`CONTRIBUTING.md`](CONTRIBUTING.md): name the whole change in English; a single-intention PR mirrors its commit header (`type(scope): description`), a multi-intention PR uses a short descriptive title, and issue references stay in the description, not the title. * Enable the local commit-message hook once per clone with `git config core.hooksPath .githooks`; the same check runs in CI on every pull request. * In PR descriptions, do not invent testing results. Only check items that were actually run. diff --git a/doc/handwritten/for-maintainers/workflows/changelog.en.md b/doc/handwritten/for-maintainers/workflows/changelog.en.md index b9c8999f..8c9a64c5 100644 --- a/doc/handwritten/for-maintainers/workflows/changelog.en.md +++ b/doc/handwritten/for-maintainers/workflows/changelog.en.md @@ -23,12 +23,13 @@ produces the complementary artifact: a **narrative, user-facing changelog** in (Breaking / Added / Changed / Fixed / Deprecated), written for the developer who consumes the package from NuGet. -The two trains version independently and keep **separate** changelog files: +The trains version independently and keep **separate** changelog files: | Train | Scopes | Changelog file | | --- | --- | --- | -| `lib` | `core`, `analyzers`, `testing` | [`CHANGELOG.md`](../../../../CHANGELOG.md) | +| `lib` | `core`, `analyzers`, `testing`, `binder` | [`CHANGELOG.md`](../../../../CHANGELOG.md) | | `cli` | `cli`, `gendoc` | [`FirstClassErrors.Cli/CHANGELOG.md`](../../../../FirstClassErrors.Cli/CHANGELOG.md) | +| `dum` | `dummies` | [`Dummies/CHANGELOG.md`](../../../../Dummies/CHANGELOG.md) | ## When it runs diff --git a/doc/handwritten/for-maintainers/workflows/changelog.fr.md b/doc/handwritten/for-maintainers/workflows/changelog.fr.md index 589835d3..9ff1a3d1 100644 --- a/doc/handwritten/for-maintainers/workflows/changelog.fr.md +++ b/doc/handwritten/for-maintainers/workflows/changelog.fr.md @@ -23,13 +23,14 @@ au format [Keep a Changelog](https://keepachangelog.com/), groupé par nature de changement (Breaking / Added / Changed / Fixed / Deprecated), écrit pour le développeur qui consomme le package depuis NuGet. -Les deux trains sont versionnés indépendamment et gardent des fichiers de +Les trains sont versionnés indépendamment et gardent des fichiers de changelog **distincts** : | Train | Scopes | Fichier de changelog | | --- | --- | --- | -| `lib` | `core`, `analyzers`, `testing` | [`CHANGELOG.md`](../../../../CHANGELOG.md) | +| `lib` | `core`, `analyzers`, `testing`, `binder` | [`CHANGELOG.md`](../../../../CHANGELOG.md) | | `cli` | `cli`, `gendoc` | [`FirstClassErrors.Cli/CHANGELOG.md`](../../../../FirstClassErrors.Cli/CHANGELOG.md) | +| `dum` | `dummies` | [`Dummies/CHANGELOG.md`](../../../../Dummies/CHANGELOG.md) | ## Quand il s'exécute diff --git a/tools/changelog/collect-prs.sh b/tools/changelog/collect-prs.sh index 21f75b0b..293f2e6a 100755 --- a/tools/changelog/collect-prs.sh +++ b/tools/changelog/collect-prs.sh @@ -1,19 +1,20 @@ #!/bin/sh -# Collect the merged pull requests that belong to ONE release train (lib or cli) -# and emit them as slim JSON on stdout, for the changelog drafter to summarise. +# Collect the merged pull requests that belong to ONE release train (lib, cli or +# dum) and emit them as slim JSON on stdout, for the changelog drafter to summarise. # # The train partition mirrors tools/packaging/release-notes.sh EXACTLY — by the # Conventional Commit scope carried by a pull request's own commits, not by # labels or by the pull request title: -# lib -> scopes core, analyzers, testing (FirstClassErrors + FirstClassErrors.Testing) -# cli -> scopes cli, gendoc (the fce tool: CLI + GenDoc + worker) +# lib -> scopes core, analyzers, testing, binder (FirstClassErrors + .Testing + .RequestBinder) +# cli -> scopes cli, gendoc (the fce tool: CLI + GenDoc + worker) +# dum -> scope dummies (the standalone Dummies library) # A pull request is kept when at least one of its commits carries a scope in the # train's set. Pull requests whose commits are all scopeless infrastructure # (bare `ci:` / `chore:` / `docs:` ...) belong to neither train and are dropped — # the same rule release-notes.sh applies to commits, so the human-facing changelog # and the generated GitHub Release notes describe the same set of changes. # -# Usage: tools/changelog/collect-prs.sh +# Usage: tools/changelog/collect-prs.sh # Reads the optional environment variable FROM_REF: the previous tag whose merge # time bounds the range. When empty, the train's latest tag is used; when there # is none either (the train's first release), the whole history is taken. diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh index 07491b57..5db987de 100755 --- a/tools/packaging/pack.sh +++ b/tools/packaging/pack.sh @@ -101,7 +101,11 @@ fi # FirstClassErrors dependency sneaking into the nuspec must fail the pack, not surface on nuget.org. if [ "$scope" = "dum" ]; then for package in artifacts/Dummies.*.nupkg; do - if unzip -p "$package" '*.nuspec' | grep -q ']*id="FirstClassErrors'; then + # Fail CLOSED, like the cli guard: an unmatched glob or an unreadable nuspec must not pass as + # "standalone" -- read the nuspec first (unzip fails loudly on both), then reject any + # FirstClassErrors dependency found in it. + nuspec="$(unzip -p "$package" '*.nuspec')" || { echo "error: cannot read the nuspec from $package" >&2; exit 1; } + if printf '%s\n' "$nuspec" | grep -q ']*id="FirstClassErrors'; then echo "error: $package declares a FirstClassErrors dependency; Dummies is standalone (ADR-0011)" >&2 exit 1 fi diff --git a/tools/packaging/release-notes.sh b/tools/packaging/release-notes.sh index 4c879715..1b6b0b57 100755 --- a/tools/packaging/release-notes.sh +++ b/tools/packaging/release-notes.sh @@ -1,14 +1,15 @@ #!/bin/sh -# Generate GitHub Release notes for ONE release train (lib or cli), containing only the commits -# that belong to that train — so a lib release never lists cli work, and vice versa. +# Generate GitHub Release notes for ONE release train (lib, cli or dum), containing only the +# commits that belong to that train — so a lib release never lists cli work, and vice versa. # # The partition is by Conventional Commit scope (enforced by tools/commit-lint): -# lib -> scopes core, analyzers, testing (FirstClassErrors + FirstClassErrors.Testing) -# cli -> scopes cli, gendoc (the fce tool: CLI + GenDoc + worker) +# lib -> scopes core, analyzers, testing, binder (FirstClassErrors + .Testing + .RequestBinder) +# cli -> scopes cli, gendoc (the fce tool: CLI + GenDoc + worker) +# dum -> scope dummies (the standalone Dummies library) # Commits with no scope (bare `ci:`, `build:`, `chore:` ...) are infrastructure and are left out # of both trains: these notes describe what changed for the consumer of the package, nothing else. # -# Usage: tools/packaging/release-notes.sh [] +# Usage: tools/packaging/release-notes.sh [] # Emits Markdown on stdout. Needs full history + tags in the checkout (actions/checkout with # fetch-depth: 0) so the previous same-train tag — the lower bound of the range — resolves. # is the upper bound and defaults to ; pass the release commit when the tag does not exist @@ -17,7 +18,7 @@ set -eu if [ "$#" -lt 2 ] || [ "$#" -gt 3 ] || [ -z "$1" ] || [ -z "$2" ]; then - echo "usage: tools/packaging/release-notes.sh []" >&2 + echo "usage: tools/packaging/release-notes.sh []" >&2 exit 2 fi scope="$1" From 893744485c2d9601c7a0548874f42281735d1e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:41:01 +0000 Subject: [PATCH 6/6] fix(dummies): make the standard-library guard facade-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The architecture test enumerated the exact BCL facades the Debug build happened to reference; the CI Release build also references System.Threading.Thread, failing the allowlist. The facade split varies with the SDK and build configuration and carries no boundary meaning: assert the intent instead — every reference must be netstandard, mscorlib, or System.* — which still rejects any FirstClassErrors or third-party dependency. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhPrBqngpiSwdsF4DWvNPw --- Dummies.UnitTests/ArchitectureTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dummies.UnitTests/ArchitectureTests.cs b/Dummies.UnitTests/ArchitectureTests.cs index 68fb3f57..2296c85b 100644 --- a/Dummies.UnitTests/ArchitectureTests.cs +++ b/Dummies.UnitTests/ArchitectureTests.cs @@ -29,7 +29,9 @@ public void DummiesDependsOnNothingBeyondTheStandardLibrary() { AssemblyName[] references = typeof(Any).Assembly.GetReferencedAssemblies(); foreach (AssemblyName reference in references) { - bool standard = reference.Name is "netstandard" or "System.Runtime" or "System.Linq" or "System.Threading" or "System.Runtime.Extensions" or "System.Collections"; + // The exact facade split (System.Runtime, System.Threading, ...) varies with the SDK and build + // configuration, so the guard checks the intent — standard library only — not a fixed list. + bool standard = reference.Name is "netstandard" or "mscorlib" || reference.Name!.StartsWith("System.", StringComparison.Ordinal); Check.WithCustomMessage($"Unexpected assembly reference: {reference.Name}").That(standard).IsTrue(); } }