diff --git a/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs b/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs index fd5d8e38..c3a9b733 100644 --- a/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs +++ b/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -35,6 +35,14 @@ public static async Task> GetDiagnosticsAsync(Diagnos references: References, options: options); + // A snippet that does not compile binds no operations, so every rule stands down and an "expects nothing" + // assertion passes for the wrong reason. That is not hypothetical: a JD027 test omitted the type arguments a + // throw-only lambda cannot infer, went green, and hid a live false positive. Fail loudly instead. + ImmutableArray compilerErrors = [.. compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)]; + if (compilerErrors.Length > 0) { + throw new InvalidOperationException($"The test snippet does not compile, so no rule could have run:{Environment.NewLine}{string.Join(Environment.NewLine, compilerErrors)}"); + } + CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); return await withAnalyzers.GetAnalyzerDiagnosticsAsync(); diff --git a/JustDummies.Analyzers.UnitTests/Jd006DiscardedGeneratorResultTests.cs b/JustDummies.Analyzers.UnitTests/Jd006DiscardedGeneratorResultTests.cs index 4006440c..f5fc785a 100644 --- a/JustDummies.Analyzers.UnitTests/Jd006DiscardedGeneratorResultTests.cs +++ b/JustDummies.Analyzers.UnitTests/Jd006DiscardedGeneratorResultTests.cs @@ -165,7 +165,7 @@ public static void M() { [Fact] public async Task Does_not_report_when_JustDummies_is_absent_from_the_compilation() { const string source = """ - public static class Other { + public sealed class Other { public static Other NonEmpty() => new(); } diff --git a/JustDummies.Analyzers.UnitTests/Jd025Jd026PoolAndUriTests.cs b/JustDummies.Analyzers.UnitTests/Jd025Jd026PoolAndUriTests.cs new file mode 100644 index 00000000..68b866f7 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd025Jd026PoolAndUriTests.cs @@ -0,0 +1,213 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd025DuplicatePoolValueTests { + + [Theory] + [InlineData("Any.OneOf(1, 2, 1)")] + [InlineData("Any.OneOf(\"EUR\", \"USD\", \"EUR\")")] + [InlineData("Any.Int32().OneOf(3, 3)")] + [InlineData("Any.OneOf(true, false, true)")] + public async Task Reports_a_value_listed_twice(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicatePoolValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD025"); + } + + [Theory] + [InlineData("Any.OneOf(1, 2, 3)")] + [InlineData("Any.OneOf(\"a\", \"A\")")] + [InlineData("Any.Int32().OneOf(3, 4)")] + public async Task Does_not_report_a_pool_of_distinct_values(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicatePoolValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Reports_an_enum_member_listed_twice() { + const string source = """ + using JustDummies; + + public enum Status { Active, Pending, Closed } + + public static class Sample { + public static void M() { + _ = Any.OneOf(Status.Active, Status.Pending, Status.Active); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicatePoolValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD025"); + } + + [Fact] + public async Task Stands_down_when_one_element_does_not_fold() { + // The unfoldable element could itself be the duplicate of a later one, so a partial walk would claim a + // completeness it does not have. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M(int supplied) { + _ = Any.OneOf(1, supplied, 1); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicatePoolValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_pool_held_in_a_variable() { + const string source = """ + using System.Collections.Generic; + using JustDummies; + + public static class Sample { + public static void M(IReadOnlyList pool) { + _ = Any.ElementOf(pool); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicatePoolValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_negative_test() { + const string source = """ + using System; + using JustDummies; + + public static class Sample { + public static void M() { + Run(() => Any.OneOf(1, 1)); + } + + private static void Run(Func body) { } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new DuplicatePoolValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} + +public class Jd026EmptyRelativeUriTests { + + [Fact] + public async Task Reports_a_relative_uri_that_can_only_be_empty() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Uri().Relative().WithPathSegments(0); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyRelativeUriAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD026"); + } + + [Theory] + [InlineData("Any.Uri().Relative().WithPathSegments(0).WithQuery()")] + [InlineData("Any.Uri().Relative().WithPathSegments(0).WithFragment()")] + [InlineData("Any.Uri().Relative().Rooted().WithPathSegments(0)")] + [InlineData("Any.Uri().Relative().WithPathSegments(1)")] + [InlineData("Any.Uri().Relative()")] + [InlineData("Any.Uri().Web().WithPathSegments(0)")] + [InlineData("Any.Uri().Ftp().WithPathSegments(0)")] + public async Task Does_not_report_a_reference_that_can_render(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyRelativeUriAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_non_constant_segment_count() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M(int segments) { + _ = Any.Uri().Relative().WithPathSegments(segments); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyRelativeUriAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_negative_test() { + const string source = """ + using System; + using JustDummies; + + public static class Sample { + public static void M() { + Run(() => Any.Uri().Relative().WithPathSegments(0)); + } + + private static void Run(Func body) { } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EmptyRelativeUriAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/Jd027Jd028CompositionTests.cs b/JustDummies.Analyzers.UnitTests/Jd027Jd028CompositionTests.cs new file mode 100644 index 00000000..c5c5d533 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd027Jd028CompositionTests.cs @@ -0,0 +1,371 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd027UnusedCombineOperandTests { + + [Fact] + public async Task Reports_an_operand_the_composer_never_reads() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Combine(Any.Int32(), Any.String(), (number, text) => number); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedCombineOperandAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD027"); + Check.That(diagnostics[0].GetMessage()).Contains("'text'"); + } + + [Fact] + public async Task Reports_every_ignored_operand() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Combine(Any.Int32(), Any.String(), Any.Boolean(), (number, text, flag) => number); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedCombineOperandAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(2); + } + + [Fact] + public async Task Does_not_report_a_composer_that_reads_every_operand() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Combine(Any.Int32(), Any.String(), (number, text) => text + number); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedCombineOperandAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_discarded_parameter() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Combine(Any.Int32(), Any.String(), (number, _) => number); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedCombineOperandAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Sees_a_parameter_read_inside_a_nested_lambda() { + const string source = """ + using System; + using System.Linq; + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Combine(Any.Int32(), Any.String(), (number, text) => Enumerable.Range(0, 1).Select(_ => text).First() + number); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedCombineOperandAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_composer_whose_whole_body_is_a_throw() { + // Dogfooding found this on the library's own arity-8 test: a composer that throws reads no parameter by + // construction, and is exercising the failure path Combine wraps rather than ignoring an operand. + // + // The type arguments are explicit because they have to be — a throw-only lambda gives inference nothing to + // infer TResult from. The first version of this test omitted them, so the snippet did not compile, no lambda + // was ever bound, and the rule stood down for the wrong reason: it passed while the real site still fired. + const string source = """ + using System; + using JustDummies; + + public static class Sample { + public static void M() { + IAny failing = Any.Combine(Any.Int32(), Any.String(), (number, text) => throw new InvalidOperationException("rejected")); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedCombineOperandAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_composer_passed_as_a_method_group() { + // The method's body is not necessarily this compilation's to read, so which operands it uses is not knowable. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Combine(Any.Int32(), Any.String(), Pair); + } + + private static string Pair(int number, string text) { return text; } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new UnusedCombineOperandAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} + +public class Jd028InertDistinctnessTests { + + private const string ReferenceEqualityType = """ + public sealed class Box { + public Box(int value) { Value = value; } + public int Value { get; } + } + """; + + [Theory] + [InlineData("Any.ListOf(Any.Int32().As(v => new Box(v))).Distinct()")] + [InlineData("Any.ArrayOf(Any.Int32().As(v => new Box(v))).Distinct()")] + [InlineData("Any.SequenceOf(Any.Int32().As(v => new Box(v))).Distinct()")] + [InlineData("Any.SetOf(Any.Int32().As(v => new Box(v)))")] + public async Task Reports_distinctness_over_reference_equality(string expression) { + string source = $$""" + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD028"); + Check.That(diagnostics[0].GetMessage()).Contains("'Box'"); + } + + [Theory] + [InlineData("public sealed record Box(int Value);")] + [InlineData("public sealed class Box : System.IEquatable { public bool Equals(Box other) => true; public override bool Equals(object o) => true; public override int GetHashCode() => 0; }")] + [InlineData("public sealed class Box { public override bool Equals(object o) => true; public override int GetHashCode() => 0; }")] + [InlineData("public class Box { }")] + public async Task Does_not_report_a_type_whose_equality_can_bind(string declaration) { + string source = $$""" + using JustDummies; + + {{declaration}} + + public static class Sample { + public static void M() { + _ = Any.ListOf(Any.Int32().As(v => (Box)null)).Distinct(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Theory] + [InlineData("Any.ListOf(Any.Int32()).Distinct()")] + [InlineData("Any.ListOf(Any.String()).Distinct()")] + [InlineData("Any.SetOf(Any.Guid())")] + public async Task Does_not_report_a_built_in_element_type(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Theory] + [InlineData("Any.SetOf(Any.OneOf(first, second))")] + [InlineData("Any.ListOf(Any.OneOf(first, second)).Distinct()")] + [InlineData("Any.ListOf(held).Distinct()")] + public async Task Does_not_report_a_generator_that_hands_back_existing_instances(string expression) { + // The narrowing dogfooding forced. A pool returns the very references it was handed, so drawing the same + // member twice yields the same reference and distinctness binds exactly as asked — the library's own suite + // asserts precisely that. + string source = $$""" + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + public static void M(Box first, Box second, IAny held) { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_collection_that_never_asked_for_distinctness() { + string source = $$""" + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + public static void M() { + _ = Any.ListOf(Any.Int32().As(v => new Box(v))).WithCount(3); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_when_an_explicit_comparer_answers_the_question() { + string source = $$""" + using System.Collections.Generic; + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + public static void M(IEqualityComparer comparer) { + _ = Any.ListOf(Any.Int32().As(v => new Box(v))).Distinct(comparer); + _ = Any.SetOf(Any.Int32().As(v => new Box(v)), comparer); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Reports_an_element_composed_by_Combine() { + string source = $$""" + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + public static void M() { + _ = Any.SetOf(Any.Combine(Any.Int32(), Any.Int32(), (left, right) => new Box(left + right))); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD028"); + } + + [Fact] + public async Task Does_not_report_a_projection_that_may_return_a_shared_instance() { + string source = $$""" + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + private static Box Lookup(int value) { return null; } + + public static void M() { + _ = Any.SetOf(Any.Int32().As(v => Lookup(v))); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Reports_a_dictionary_on_its_key_type() { + string source = $$""" + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + public static void M() { + _ = Any.DictionaryOf(Any.Int32().As(v => new Box(v)), Any.String()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD028"); + } + + [Fact] + public async Task Does_not_report_a_negative_test() { + string source = $$""" + using System; + using JustDummies; + + {{ReferenceEqualityType}} + + public static class Sample { + public static void M() { + Run(() => Any.SetOf(Any.Int32().As(v => new Box(v)))); + } + + private static void Run(Func body) { } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new InertDistinctnessAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md index 8e96c350..3f91912f 100644 --- a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md +++ b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md @@ -29,3 +29,7 @@ JD021 | JustDummies.Reproducibility | Warning | BlankReplaySnippetAnalyzer JD022 | JustDummies.Reproducibility | Info | ParallelDrawWithoutPerItemSeedAnalyzer JD023 | JustDummies.Constraints | Warning | ScalarChainAdmitsNoValueAnalyzer JD024 | JustDummies.Constraints | Info | ScalarChainAdmitsNoValueAnalyzer +JD025 | JustDummies.Constraints | Warning | DuplicatePoolValueAnalyzer +JD026 | JustDummies.Constraints | Warning | EmptyRelativeUriAnalyzer +JD027 | JustDummies.Composition | Warning | UnusedCombineOperandAnalyzer +JD028 | JustDummies.Composition | Warning | InertDistinctnessAnalyzer diff --git a/JustDummies.Analyzers/Descriptors.cs b/JustDummies.Analyzers/Descriptors.cs index a467b301..f5146556 100644 --- a/JustDummies.Analyzers/Descriptors.cs +++ b/JustDummies.Analyzers/Descriptors.cs @@ -254,4 +254,44 @@ internal static class Descriptors { description: "The constraint is legal and inert: the domain it produces is the one that already existed. This is the only member of the constraint family the run time NEVER reports — every other contradiction throws eventually and loudly, while an inert constraint leaves the test green and exercising a domain the author did not write. The dangerous case is an exclusion of a sentinel the generator could never draw: it silently misses, and starts mattering the day someone widens the range.", helpLinkUri: HelpLinks.For(DiagnosticIds.ConstraintWithNoEffect)); + public static readonly DiagnosticDescriptor DuplicatePoolValue = new( + id: DiagnosticIds.DuplicatePoolValue, + title: "The same value is listed twice in a pool", + messageFormat: "This value is already in the pool; a duplicate neither weights it nor widens the domain", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A pool is deduplicated under the default equality when it is built, so a value listed twice contributes exactly once. The library declines to weight a pool on purpose — writing a value twice therefore cannot mean 'draw this more often', and the pool is one value smaller than it reads. That gap surfaces far from here, when a distinct collection over the pool gates against the real distinct count and reports a number the author cannot find in their source.", + helpLinkUri: HelpLinks.For(DiagnosticIds.DuplicatePoolValue)); + + public static readonly DiagnosticDescriptor EmptyRelativeUri = new( + id: DiagnosticIds.EmptyRelativeUri, + title: "The declared relative URI is empty", + messageFormat: "A relative URI with exactly 0 path segments and no query, fragment or root is empty, which is not a valid URI reference", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The chain describes the empty reference, which no URI can be. The library reports it, but only at Generate() — this is the one constraint family member whose failure lands at act time rather than at the arrange line, so the stack points at the code under test instead of at the declaration that is wrong. Add WithQuery(), WithFragment(), Rooted(), or a positive segment count.", + helpLinkUri: HelpLinks.For(DiagnosticIds.EmptyRelativeUri)); + + public static readonly DiagnosticDescriptor UnusedCombineOperand = new( + id: DiagnosticIds.UnusedCombineOperand, + title: "A Combine operand never reaches the composed value", + messageFormat: "This generator is drawn and thrown away: the composer never reads its parameter '{0}'", + category: DiagnosticCategories.Composition, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Combine draws every operand before calling the composer, so an operand the composer ignores is still generated — constraints, conflict checks and all — and then dropped. Nothing fails: the composed value is well-formed, and simply does not carry the part the call site says it carries. The usual causes are a constructor argument forgotten during a refactor and a composer whose parameters no longer line up with its operands. Rename the parameter to '_' to say the draw is deliberate.", + helpLinkUri: HelpLinks.For(DiagnosticIds.UnusedCombineOperand)); + + public static readonly DiagnosticDescriptor InertDistinctness = new( + id: DiagnosticIds.InertDistinctness, + title: "Distinctness is declared over an element type that has no value equality", + messageFormat: "Distinctness cannot bind here: '{0}' inherits reference equality, so every freshly generated element already counts as distinct", + category: DiagnosticCategories.Composition, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The element type neither overrides Equals nor implements IEquatable, so the default comparer falls back to reference equality — and every element the generator produces is a new instance. Distinctness is therefore satisfied by construction and constrains nothing: the collection can hold the same value several times, which is precisely what the declaration asks it not to. The library cannot report this, because from its side the requirement is met. Give the type value equality, or pass an explicit comparer.", + helpLinkUri: HelpLinks.For(DiagnosticIds.InertDistinctness)); + } diff --git a/JustDummies.Analyzers/DiagnosticCategories.cs b/JustDummies.Analyzers/DiagnosticCategories.cs index fbdc9856..d4e819ee 100644 --- a/JustDummies.Analyzers/DiagnosticCategories.cs +++ b/JustDummies.Analyzers/DiagnosticCategories.cs @@ -19,4 +19,11 @@ internal static class DiagnosticCategories { /// public const string Constraints = "JustDummies.Constraints"; + /// + /// Rules about assembling generators into bigger ones — Combine's operands, and the element contract a + /// collection generator relies on. What they share is that nothing goes wrong: the composed generator builds, + /// draws and returns a value. It is simply not the value the call site describes. + /// + public const string Composition = "JustDummies.Composition"; + } diff --git a/JustDummies.Analyzers/DiagnosticIds.cs b/JustDummies.Analyzers/DiagnosticIds.cs index e0e8f7af..eee40c94 100644 --- a/JustDummies.Analyzers/DiagnosticIds.cs +++ b/JustDummies.Analyzers/DiagnosticIds.cs @@ -43,4 +43,11 @@ internal static class DiagnosticIds { public const string ScalarChainAdmitsNoValue = "JD023"; public const string ConstraintWithNoEffect = "JD024"; + public const string DuplicatePoolValue = "JD025"; + public const string EmptyRelativeUri = "JD026"; + + // Category: Composition — a part that reaches no result, a constraint that cannot bind + public const string UnusedCombineOperand = "JD027"; + public const string InertDistinctness = "JD028"; + } diff --git a/JustDummies.Analyzers/DuplicatePoolValueAnalyzer.cs b/JustDummies.Analyzers/DuplicatePoolValueAnalyzer.cs new file mode 100644 index 00000000..abe6d03e --- /dev/null +++ b/JustDummies.Analyzers/DuplicatePoolValueAnalyzer.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD025 — reports a constant listed twice in the same pool. Any.OneOf(a, b, a) is deduplicated when the +/// generator is built, so the pool is one value smaller than it reads, and nothing anywhere says so. +/// +/// +/// Weighting is the reading this rule exists to refuse: listing a value twice looks like "draw this one more +/// often", and the library declines to weight a pool on purpose. The consequence surfaces somewhere else +/// entirely — a distinct collection over the pool gates against the real distinct count and names a number the +/// author cannot find in their source. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DuplicatePoolValueAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.DuplicatePoolValue); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Any is null || symbols.IAny is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (invocation.TargetMethod.Name is not ("OneOf" or "ElementOf")) { return; } + if (!AnyChainFacts.TryGetChain(invocation, symbols, out _, out IInvocationOperation? factory) || factory is null) { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + HashSet seen = []; + + foreach (IOperation element in PoolElements(invocation)) { + // The element's own constant, not the unwrapped operand's: the conversion to the pool's element type is + // what decides whether two literals written differently are the same pooled value. + Optional constant = element.ConstantValue; + + // One unfoldable element and the pool stops being knowable: a later duplicate of THAT value would go + // unseen, and reporting the ones this walk can see would claim a completeness the walk does not have. + if (!constant.HasValue) { return; } + if (seen.Add(constant.Value)) { continue; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.DuplicatePoolValue, element.Syntax.GetLocation())); + + return; + } + } + + private static IEnumerable PoolElements(IInvocationOperation invocation) { + foreach (IArgumentOperation argument in invocation.Arguments) { + if (argument.ArgumentKind == ArgumentKind.ParamArray) { + if (argument.Value is IArrayCreationOperation { Initializer: { } initializer }) { + foreach (IOperation element in initializer.ElementValues) { yield return element; } + } + + continue; + } + + // ElementOf takes a materialized collection; only an inline collection expression or array creation is + // knowable here — anything held in a variable is not this rule's business. + if (GeneratorFacts.Unwrap(argument.Value) is IArrayCreationOperation { Initializer: { } inline }) { + foreach (IOperation element in inline.ElementValues) { yield return element; } + } + } + } + +} diff --git a/JustDummies.Analyzers/EmptyRelativeUriAnalyzer.cs b/JustDummies.Analyzers/EmptyRelativeUriAnalyzer.cs new file mode 100644 index 00000000..6b1be037 --- /dev/null +++ b/JustDummies.Analyzers/EmptyRelativeUriAnalyzer.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD026 — reports a relative-URI chain that describes the empty reference: +/// Any.Uri().Relative().WithPathSegments(0) with no query, no fragment and no root. +/// +/// +/// The whole point is when the library reports it. Every other unsatisfiable chain throws at the arrange +/// line; this one cannot, because emptiness is only settled once the components have been drawn — so it throws +/// inside Generate(), at act time, with a stack pointing at the code under test rather than at the +/// declaration that is wrong. Moving it to build time is the entire value of the rule. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EmptyRelativeUriAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.EmptyRelativeUri); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Any is null || symbols.IAny is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (invocation.Parent is IInvocationOperation) { return; } + if (!AnyChainFacts.TryGetChain(invocation, symbols, out IReadOnlyList constraints, out IInvocationOperation? factory)) { return; } + if (factory is null || factory.TargetMethod.Name != "Uri") { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + // Only the relative family can render empty. Web, WebSocket and FTP carry an authority, so a path of zero + // segments still renders as "/" and the reference stays valid. + bool relative = false; + IInvocationOperation? zeroSegments = null; + + foreach (IInvocationOperation constraint in constraints) { + switch (constraint.TargetMethod.Name) { + case "Relative": relative = true; break; + + // Any one of these three saves the reference from being empty. + case "WithQuery" or "WithFragment" or "Rooted": return; + + case "WithPathSegments" when constraint.Arguments.Length == 1 && ConstantFacts.TryGetInt32(constraint.Arguments[0].Value, out int count): + if (count != 0) { return; } + + zeroSegments = constraint; + + break; + } + } + + if (!relative || zeroSegments is null) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.EmptyRelativeUri, zeroSegments.Syntax.GetLocation())); + } + +} diff --git a/JustDummies.Analyzers/EqualityFacts.cs b/JustDummies.Analyzers/EqualityFacts.cs new file mode 100644 index 00000000..7d640959 --- /dev/null +++ b/JustDummies.Analyzers/EqualityFacts.cs @@ -0,0 +1,51 @@ +using Microsoft.CodeAnalysis; + +namespace JustDummies.Analyzers; + +/// +/// Whether a type carries value equality, decided the way EqualityComparer<T>.Default decides it: an +/// IEquatable<T> implementation, or an Equals(object) override somewhere below +/// object. A type with neither falls back to reference equality. +/// +/// +/// The answer is only ever used to claim that equality cannot distinguish two values, which is a claim that +/// has to be certain — so every uncertainty resolves to "it has value equality" and the caller stands down. The +/// sealed requirement is the largest of those: an open class says nothing about the instance a generator actually +/// produces, since a derived type is free to add the equality the base lacks. +/// +internal static class EqualityFacts { + + /// + /// Whether provably compares by reference under the default comparer. + /// + public static bool UsesReferenceEquality(ITypeSymbol? type) { + // A value type never compares by reference; a type parameter, an interface, an array or a delegate is either + // substitutable or already carries its own equality. Only a sealed class settles the question here. + if (type is not INamedTypeSymbol { TypeKind: TypeKind.Class, IsSealed: true, IsRecord: false } named) { return false; } + if (named.SpecialType == SpecialType.System_Object) { return false; } + if (ImplementsIEquatable(named)) { return false; } + + for (INamedTypeSymbol? current = named; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) { + if (OverridesEquals(current)) { return false; } + } + + return true; + } + + private static bool ImplementsIEquatable(INamedTypeSymbol type) { + foreach (INamedTypeSymbol implemented in type.AllInterfaces) { + if (implemented is { IsGenericType: true, Name: "IEquatable" } && implemented.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }) { return true; } + } + + return false; + } + + private static bool OverridesEquals(INamedTypeSymbol type) { + foreach (ISymbol member in type.GetMembers("Equals")) { + if (member is IMethodSymbol { IsOverride: true, Parameters.Length: 1, ReturnType.SpecialType: SpecialType.System_Boolean }) { return true; } + } + + return false; + } + +} diff --git a/JustDummies.Analyzers/InertDistinctnessAnalyzer.cs b/JustDummies.Analyzers/InertDistinctnessAnalyzer.cs new file mode 100644 index 00000000..d99829ce --- /dev/null +++ b/JustDummies.Analyzers/InertDistinctnessAnalyzer.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD028 — reports distinctness declared over an element type that has no value equality. The default comparer +/// falls back to reference equality, every generated element is a new instance, and the requirement is therefore +/// satisfied by construction: the collection can hold the same value several times, which is exactly what the +/// declaration asks it not to. +/// +/// +/// The library cannot report this, and that is the point: from its side the requirement is met, the draws are +/// pairwise unequal, and there is nothing to complain about. Only the element type's equality tells the two apart, +/// and it is visible here. Measured on the built library: six "distinct" elements over a two-value domain came back +/// as [1, 1, 1, 2, 1, 2], green. Give the type value equality — a record, an IEquatable +/// implementation, an Equals override — or pass an explicit comparer. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class InertDistinctnessAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.InertDistinctness); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Any is null || symbols.IAny is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (invocation.Parent is IInvocationOperation) { return; } + if (!AnyChainFacts.TryGetChain(invocation, symbols, out IReadOnlyList constraints, out IInvocationOperation? factory)) { return; } + if (factory is null) { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + string factoryName = factory.TargetMethod.Name; + if (factoryName is not ("ListOf" or "ArrayOf" or "SequenceOf" or "SetOf" or "DictionaryOf")) { return; } + + // A comparer supplied to the factory answers the equality question itself, whatever the element type does. + bool impliedByFactory = factoryName is "SetOf" or "DictionaryOf"; + if (impliedByFactory && CarriesComparer(factory)) { return; } + + IInvocationOperation? declaration = impliedByFactory ? factory : null; + + foreach (IInvocationOperation constraint in constraints) { + if (constraint.TargetMethod.Name != "Distinct") { continue; } + if (CarriesComparer(constraint)) { return; } + + declaration ??= constraint; + } + + if (declaration is null) { return; } + if (factory.TargetMethod.TypeArguments.Length == 0 || factory.Arguments.Length == 0) { return; } + + // A dictionary is distinct on its KEYS, which is its first type argument — the same position the collection + // generators use for their element. + ITypeSymbol element = factory.TargetMethod.TypeArguments[0]; + if (!EqualityFacts.UsesReferenceEquality(element)) { return; } + if (!ProducesFreshInstances(factory.Arguments[0].Value)) { return; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.InertDistinctness, declaration.Syntax.GetLocation(), element.Name)); + } + + /// + /// Whether the element generator provably hands back a new instance on every draw, which is what makes + /// reference equality unable to bind. + /// + /// + /// The narrowing dogfooding forced. A pool generator returns the very references it was handed, so + /// Any.SetOf(Any.OneOf(first, second)) is a legal and meaningful declaration: drawing first twice + /// yields the same reference and the set rejects it exactly as asked. The rule's premise — every element is a + /// new instance — holds only where the chain builds the value here, so that is the only shape it claims. + /// + private static bool ProducesFreshInstances(IOperation element) { + if (GeneratorFacts.Unwrap(element) is not IInvocationOperation invocation) { return false; } + if (invocation.TargetMethod.Name is not ("As" or "Combine")) { return false; } + if (invocation.Arguments.Length == 0) { return false; } + + IArgumentOperation last = invocation.Arguments[invocation.Arguments.Length - 1]; + if (GeneratorFacts.Unwrap(last.Value) is not IDelegateCreationOperation { Target: IAnonymousFunctionOperation builder }) { return false; } + + foreach (IOperation statement in builder.Body.Operations) { + if (statement is IReturnOperation { ReturnedValue: { } value } && GeneratorFacts.Unwrap(value) is IObjectCreationOperation) { return true; } + } + + return false; + } + + private static bool CarriesComparer(IInvocationOperation invocation) { + foreach (IParameterSymbol parameter in invocation.TargetMethod.Parameters) { + if (parameter.Type is INamedTypeSymbol { Name: "IEqualityComparer" }) { return true; } + } + + return false; + } + +} diff --git a/JustDummies.Analyzers/UnusedCombineOperandAnalyzer.cs b/JustDummies.Analyzers/UnusedCombineOperandAnalyzer.cs new file mode 100644 index 00000000..a41eeb40 --- /dev/null +++ b/JustDummies.Analyzers/UnusedCombineOperandAnalyzer.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD027 — reports a Combine operand whose value never reaches the composed result, because the composer +/// lambda never reads the parameter it is bound to. +/// +/// +/// The operand is still drawn: Combine generates every part before calling the composer, so the constraints +/// are built, the conflict checks run, and the value is dropped on the floor. Nothing fails — the composed value is +/// well-formed and simply does not carry the part the call site says it carries. Naming the parameter _ is +/// the acknowledgement that switches the rule off, the same escape hatch C# already gives for a discard. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnusedCombineOperandAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.UnusedCombineOperand); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Any is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (invocation.TargetMethod.Name != "Combine") { return; } + if (!SymbolEqualityComparer.Default.Equals(invocation.TargetMethod.ContainingType, symbols.Any)) { return; } + if (invocation.Arguments.Length < 3) { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + // The composer is the last argument, and it has to be a lambda written here: a method group's body is not + // this compilation's to read, so an operand it ignores is not knowable. + IArgumentOperation composerArgument = invocation.Arguments[invocation.Arguments.Length - 1]; + if (GeneratorFacts.Unwrap(composerArgument.Value) is not IDelegateCreationOperation { Target: IAnonymousFunctionOperation composer }) { return; } + + int operandCount = invocation.Arguments.Length - 1; + if (composer.Symbol.Parameters.Length != operandCount) { return; } + if (ComposesNothing(composer)) { return; } + + HashSet read = ReadParameters(composer); + + for (int index = 0; index < operandCount; index++) { + IParameterSymbol parameter = composer.Symbol.Parameters[index]; + + // '_' is how C# spells "I know, and I mean it" — for a discard parameter and for one merely named like + // one. Either way the author has said the draw is deliberate. + if (parameter.Name is "_" or "") { continue; } + if (read.Contains(parameter)) { continue; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.UnusedCombineOperand, invocation.Arguments[index].Value.Syntax.GetLocation(), parameter.Name)); + } + } + + // A composer whose whole body is a throw reads no parameter, and that is not an ignored operand: it composes + // nothing at all, on purpose, which is how a test exercises the failure path Combine wraps. Found by dogfooding + // this rule on the library's own suite, where the arity-8 case is written exactly that way. + // + // The spellings are one shape seen from several places in the tree. An expression-bodied '=> throw ...' is a + // Return CARRYING the throw — and carrying it through a conversion to the composer's result type, so the throw is + // not the returned operation but the operand under it. Both facts had to be measured; guessing either one wrong + // let the very site that motivated this guard through. + private static bool ComposesNothing(IAnonymousFunctionOperation composer) { + foreach (IOperation statement in composer.Body.Operations) { + if (statement is IThrowOperation or IExpressionStatementOperation { Operation: IThrowOperation }) { return true; } + if (statement is IReturnOperation { ReturnedValue: { } returned } && GeneratorFacts.Unwrap(returned) is IThrowOperation) { return true; } + } + + return false; + } + + // Every parameter the composer's body reads, including through a nested lambda that captures it. + private static HashSet ReadParameters(IAnonymousFunctionOperation composer) { + HashSet read = new(SymbolEqualityComparer.Default); + + foreach (IOperation descendant in composer.Body.Descendants()) { + if (descendant is IParameterReferenceOperation reference) { read.Add(reference.Parameter); } + } + + return read; + } + +} diff --git a/JustDummies.UnitTests/AnyCollectionTests.cs b/JustDummies.UnitTests/AnyCollectionTests.cs index 05ef2acd..956e4eff 100644 --- a/JustDummies.UnitTests/AnyCollectionTests.cs +++ b/JustDummies.UnitTests/AnyCollectionTests.cs @@ -127,6 +127,21 @@ public void DistinctFallbackThrowsAtGeneration() { Check.ThatCode(() => Any.SetOf(opaque).WithCount(5).Generate()).Throws(); } + [Fact(DisplayName = "Distinct: over an element type without value equality the requirement is inert, and the collection holds repeats.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("JustDummies.Composition", "JD028:Distinctness is declared over an element type that has no value equality", + Justification = + "The inert distinctness IS the subject. This pins the silent behaviour JD028 reports, which the library cannot report itself: from " + + "its side the requirement is met, because the draws really are pairwise unequal under the comparer it was given.")] + public void DistinctOverReferenceEqualityIsInert() { + // Percentage has no value equality, and '.As' builds a NEW instance per draw, so the default comparer can + // never call two of them equal. Six 'distinct' elements over a two-value domain therefore succeed — and hold + // repeats. The count is not statistical: six draws from a domain of two cannot show more than two values. + List percentages = Any.ListOf(Any.Int32().Between(1, 2).As(Percentage.Create)).Distinct().WithCount(6).Generate(); + + Check.That(percentages.Count).IsEqualTo(6); + Check.That(percentages.Select(percentage => percentage.Value).Distinct().Count()).IsStrictlyLessThan(3); + } + [Fact(DisplayName = "SetOf: elements are always distinct and drawn from the item generator.")] public void SetOfIsDistinct() { for (int i = 0; i < SampleCount; i++) { diff --git a/JustDummies.UnitTests/AnyOneOfTests.cs b/JustDummies.UnitTests/AnyOneOfTests.cs index 12070afc..7512b82d 100644 --- a/JustDummies.UnitTests/AnyOneOfTests.cs +++ b/JustDummies.UnitTests/AnyOneOfTests.cs @@ -204,6 +204,22 @@ public void ExcludingAnAbsentValueRemovesNothing() { Check.That(seen).Contains(1, 2); } + [Fact(DisplayName = "A held collection passed to OneOf is one pool member: the draw is the collection itself, not a value from it.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("JustDummies.Usage", "JD013:A held collection is passed to Any.OneOf, making a pool of one", + Justification = + "The one-member pool IS the subject. This pins the behaviour JD013 reports: inference binds T to the collection, so the call is " + + "legal, the draw succeeds, and what comes back is the whole list.")] + public void AHeldCollectionPassedToOneOfIsOnePoolMember() { + IReadOnlyList held = new[] { 1, 2, 3 }; + + IReadOnlyList drawn = Any.OneOf(held).Generate(); + + Check.That(ReferenceEquals(drawn, held)).IsTrue(); + + // ElementOf is the overload that draws FROM the collection — the same argument, a different pool. + Check.That(held.Contains(Any.ElementOf(held).Generate())).IsTrue(); + } + [Fact(DisplayName = "An exclusion that empties the pool conflicts at declaration, naming both sides.")] public void AnExclusionEmptyingThePoolConflicts() { ConflictingAnyConstraintException conflict = Assert.Throws( diff --git a/JustDummies.UnitTests/CompositionTests.cs b/JustDummies.UnitTests/CompositionTests.cs index b0366b93..a5540771 100644 --- a/JustDummies.UnitTests/CompositionTests.cs +++ b/JustDummies.UnitTests/CompositionTests.cs @@ -196,6 +196,32 @@ public void CombineSupportsHigherArities() { } } + [Fact(DisplayName = "Combine draws every operand before composing, including one the composer never reads.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("JustDummies.Composition", "JD027:A Combine operand never reaches the composed value", + Justification = + "The ignored operand IS the subject. This pins the behaviour JD027 reports: the operand is generated in full — constraints built, " + + "conflict checks run — and then dropped, with nothing failing.")] + public void CombineDrawsAnOperandTheComposerIgnores() { + int drawnFirst = 0; + int drawnSecond = 0; + + IAny first = Any.Int32().As(value => { + drawnFirst++; + + return value; + }); + IAny second = Any.Int32().As(value => { + drawnSecond++; + + return value; + }); + + _ = Any.Combine(first, second, (a, b) => a).Generate(); + + Check.That(drawnFirst).IsEqualTo(1); + Check.That(drawnSecond).IsEqualTo(1); + } + [Fact(DisplayName = "A higher-arity Combine validates its arguments and wraps composer failures.")] public void HigherArityCombineValidatesAndWraps() { Check.ThatCode(() => Any.Combine(Any.Int32(), Any.Int32(), Any.Int32(), Any.Int32(), (Func)null!)).Throws(); diff --git a/JustDummies.UnitTests/MaterializationTests.cs b/JustDummies.UnitTests/MaterializationTests.cs index cef94063..d5fcc700 100644 --- a/JustDummies.UnitTests/MaterializationTests.cs +++ b/JustDummies.UnitTests/MaterializationTests.cs @@ -65,4 +65,33 @@ public void GenericInferenceMaterializesThroughIAny() { Check.That(value).IsStrictlyGreaterThan(0); } + [Fact(DisplayName = "Building a generator draws nothing at all, which is why a chain left unmaterialized is silent.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("JustDummies.Usage", "JD006:The generator returned by a constraint is discarded", + Justification = + "The discarded generator IS the subject. This pins the behaviour JD006 reports: the arrange line reads like it did something, " + + "and drew nothing.")] + public void BuildingAGeneratorDrawsNothing() { + int draws = 0; + + Any.Int32().As(value => { + draws++; + + return value; + }); + + Check.That(draws).IsEqualTo(0); + } + + [Fact(DisplayName = "A generator interpolated into text renders its type name, never a value it could draw.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("JustDummies.Usage", "JD005:A generator is rendered as text instead of the value it would draw", + Justification = + "The rendered generator IS the subject. This pins the behaviour JD005 reports, and the deliberate absence of a ToString override " + + "that would mask it — an override returning a drawn value would make this test red, which is the point.")] + public void AGeneratorRendersAsItsTypeName() { + string rendered = $"{Any.Int32()}"; + + Check.That(rendered).IsEqualTo(typeof(AnyInt32).ToString()); + Check.That(int.TryParse(rendered, out int _)).IsFalse(); + } + } diff --git a/doc/handwritten/for-maintainers/adr/0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.fr.md b/doc/handwritten/for-maintainers/adr/0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.fr.md new file mode 100644 index 00000000..9715c03a --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.fr.md @@ -0,0 +1,212 @@ +# ADR-0061 | Faire tourner les analyseurs JustDummies sur le code du dépôt lui-même + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.md) + +**Statut :** Proposé +**Proposé :** 2026-07-29 +**Décideurs :** Reefact + +## Contexte + +Ce dépôt livre deux paquets d'analyseurs. `FirstClassErrors.Analyzers` porte +`FCE001`–`FCE022` ; `JustDummies.Analyzers`, créé sous +[ADR-0044](0044-ship-justdummies-analyzers.md), porte `JD001`–`JD028`. + +Les deux sont vérifiés de façon très différente. + +Les analyseurs FirstClassErrors sont chargés par `FirstClassErrors.Usage`, que le flux +`analyzers` construit sur chaque pull request. Les règles tournent donc, à chaque +changement, sur du code écrit pour utiliser la bibliothèque et non pour exercer les +règles. + +Les analyseurs JustDummies ne sont chargés par aucun projet de ce dépôt. Leur seule +vérification permanente est leur propre suite unitaire — 246 tests, chacun compilant une +snippet écrite par l'auteur de la règle. Au-delà, un agent injecte l'analyseur construit +dans les suites du dépôt à la main, via une propriété MSBuild, et lit les avertissements. + +Ce balayage manuel n'est pas une formalité. Il a contredit le modèle de l'auteur au moins +une fois à chacune des quatre dernières vagues de règles, et chaque contradiction portait +sur une règle qui serait partie fausse : + +* `JD015` modélisait la casse des lettres comme un vivier de caractères, ce qui aurait + condamné le légal `UpperCase().StartingWith("ORD-")`. +* `JD016` comptait les membres déclarés d'un enum alors qu'`AllowingCombinations()` + élargit l'univers à leur clôture par OU. +* `JD023` tenait `LessThanOrEqualTo(long.MinValue)` pour insatisfiable, alors que la + suite de la bibliothèque asserte que cette chaîne est légale. +* `JD028` supposait que chaque tirage est une instance neuve, ce qui est faux pour un + réservoir : `OneOf` rend les références mêmes qu'on lui a données. +* Le retrait de `JD027` devant un composeur qui lève ne se déclenchait pas, parce qu'un + `=> throw` en corps d'expression est un retour qui *porte* le throw, sous une + conversion. + +Aucune de ces erreurs n'a été trouvée par la suite unitaire, et aucune ne pouvait l'être : +l'auteur écrit à la fois la règle et la snippet qui la teste, donc une idée fausse +partagée passe des deux côtés. Toutes ont été trouvées sur le code de la bibliothèque +elle-même, écrit pour d'autres raisons et qui ne partage donc pas l'idée fausse. + +Les règles couvrent quatre sévérités. `JD001`–`JD005` sont `Error` ; la plupart sont +`Warning` ; `JD020`, `JD022` et `JD024` sont `Info` ; `JD011` et `JD019` sont livrées +désactivées, sur adhésion explicite. Roslyn ne fait pas remonter les diagnostics `Info` à +la verbosité de compilation par défaut — un balayage précoce a paru propre exactement +pour cette raison, et n'a rien signalé alors que deux règles étaient actives et +silencieuses. + +Les suites de tests de la bibliothèque écrivent délibérément les formes que les règles +signalent. Ce n'est pas accessoire : un test d'un comportement doit l'exercer. Sept sites +de ce genre existent aujourd'hui. Cinq portent un `SuppressMessage` qui nomme leur règle +et dit pourquoi la forme est délibérée ; les deux tests d'écrasement des doublons n'en +portent pas. + +Le chargement des analyseurs dans `JustDummies.UnitTests` a été mesuré avant l'écriture +de ce document. La compilation réussit. Exactement deux diagnostics sont signalés — les +deux tests d'écrasement des doublons. Les cinq suppressions existantes font taire leur +règle : le mécanisme fonctionne donc sur un vrai test. Deux attributs de plus ramènent la +surface à zéro. Les autres projets consommant JustDummies ne signalent rien du tout. Une +compilation à froid, non incrémentale, de ce projet est passée d'environ six secondes et +demie à environ neuf. + +## Décision + +Charger `JustDummies.Analyzers` dans chaque projet de ce dépôt qui consomme JustDummies, +de sorte que les règles tournent à la compilation et dans l'IDE, et consigner chaque +violation délibérée par une suppression nommant la règle à laquelle elle répond. + +## Justification + +Une règle de ce catalogue est une affirmation sur le comportement de JustDummies. Sa +suite unitaire prouve quelque chose de plus faible : que la règle se déclenche sur une +snippet que son auteur a écrite pour elle. Quand le modèle que l'auteur se fait de la +bibliothèque est faux, les deux côtés de ce test sont faux ensemble et il passe. Chacune +des erreurs de modèle listées dans le contexte a passé sa suite unitaire. + +Ce qui les a attrapées, c'est du code réaliste — les suites de la bibliothèque, écrites +pour exercer JustDummies et non pour exercer les règles. Ce corpus est le seul de ce genre +que le dépôt maîtrise, et y faire tourner les règles est la seule vérification qui puisse +échouer pour une raison à laquelle l'auteur de la règle n'a pas pensé. Rendre cela continu +plutôt que manuel est toute la décision. + +L'arrangement actuel a le signal sans la garantie. Il dépend de la mémoire de qui +travaille, pour un balayage que rien dans le dépôt ne réclame — et l'histoire montre ce +que cela vaut : le balayage n'a pas été fait du tout avant la quatrième vague, et les +règles parties avant lui sont celles qu'il a fallu corriger ensuite. Un contrôle qui a +attrapé cinq règles fausses et qui repose sur la mémoire est un contrôle qui finira par ne +pas être fait. + +La surface de suppression mesurée — deux sites, sur un mécanisme déjà éprouvé sur cinq +autres — est assez petite pour que la décision soit adoptable aujourd'hui plutôt qu'après +un nettoyage. Plus important : ces suppressions ne sont pas un coût toléré. Un test qui +écrit une forme signalée est le test *de* cette forme, et l'attribut est l'endroit où un +lecteur futur apprend que la forme est le sujet et non une erreur. Le dépôt allait déjà +dans ce sens : les cinq suppressions existantes ont été écrites avant ce document, +précisément parce que l'annotation se lit mieux que la forme nue. + +Que les règles de sévérité `Error` cassent la compilation si elles se déclenchent un jour +sur le code du dépôt est le comportement correct, non un inconvénient à contourner. Une +règle `Error` qui se déclenche sur du code réaliste signifie soit un vrai défaut, soit une +règle fausse, et les deux doivent être tranchés avant la fusion. + +Le coût de compilation est connu, borné, et payé sur un projet de test plutôt que sur quoi +que ce soit qui est livré. + +## Alternatives envisagées + +### Continuer le dogfooding à la main + +C'est ce qui a trouvé toutes les erreurs de modèle : ce n'est donc pas inefficace — mais +son efficacité n'est pas la question. Rien dans le dépôt n'énonce que le balayage existe, +quand il doit être fait, ni ce que signifie un résultat propre ; le contrôle ne survit donc +qu'aussi longtemps que celui qui travaille s'en souvient. L'histoire montre déjà la +défaillance : trois vagues de règles sont parties avant que le balayage ne devienne une +habitude, et ce sont les vagues dont les règles ont eu besoin d'être corrigées. + +Rejetée parce qu'une vérification qui dépend de la mémoire n'est pas une vérification. + +### Ajouter un projet d'exemple JustDummies, en miroir de FirstClassErrors.Usage + +Symétrique de l'arrangement qui fonctionne déjà pour les analyseurs FirstClassErrors, et +il ne porterait aucune pression de suppression, puisqu'un exemple écrit pour démontrer la +bibliothèque n'a aucune raison d'écrire une forme signalée exprès. + +Rejetée comme *la* réponse, parce qu'un exemple n'exerce que ce que quelqu'un a pensé à y +mettre. Aucune des erreurs de modèle ne vient d'un exemple ; elles viennent de suites +écrites bien avant l'existence des règles, pour des raisons étrangères à elles, ce qui est +exactement pourquoi elles ne partageaient pas l'idée fausse de l'auteur. Un exemple n'en +aurait trouvé aucune. + +Elle reste un ajout raisonnable pour ses propres mérites — de la documentation qui +compile — et ce document ne plaide pas contre. + +### Faire du balayage un job CI consultatif + +La mécanique existe déjà ; formaliser l'injection en job de flux mettrait le résultat sur +chaque pull request sans toucher au moindre fichier projet, et le dépôt a un précédent de +contrôle consultatif avec le score de mutation par pull request +([ADR-0046](0046-make-the-per-pull-request-mutation-gate-advisory.md)). + +Rejetée pour deux raisons. Cela garde le résultat hors de l'IDE, là où l'auteur se trouve +au moment où l'erreur est commise et où une règle sur une erreur silencieuse vaut le plus ; +et un signal consultatif est un signal que personne n'est tenu de traiter, ce qui +reproduit le mode de défaillance que ce document existe pour fermer. Le précédent de la +mutation ne se transpose pas : un score de mutation est une mesure continue dont le seuil +relève du jugement, alors qu'un diagnostic est une affirmation binaire que quelque chose de +précis ne va pas. + +## Conséquences + +### Positives + +* Les règles sont vérifiées en continu contre du code qui n'a pas été écrit pour leur + plaire, ce qui est la seule vérification capable de révéler un modèle faux. +* Les cinq erreurs de modèle que cette pratique a déjà attrapées deviennent impossibles à + réintroduire en silence. +* Les attributs `SuppressMessage` deviennent vivants : une règle qui cesse de se + déclencher sur un site qui prétend l'exercer est le signe que la règle ou la + bibliothèque a bougé. +* Le taux de faux positifs d'une nouvelle règle se mesure pendant qu'on l'écrit, dans + l'IDE, au lieu d'à la fin d'une vague. + +### Négatives + +* Chaque projet qui consomme JustDummies paie le coût de l'analyseur à chaque + compilation. +* Une nouvelle règle peut exiger de nouvelles suppressions dans les suites de la + bibliothèque avant de pouvoir être fusionnée. +* Qui écrit désormais un test JustDummies rencontre les règles, et doit savoir pourquoi + une suppression est la bonne réponse plutôt qu'un contournement. + +### Risques + +* **Les règles `Info` restent invisibles.** `JD020`, `JD022` et `JD024` ne remontent pas + à la verbosité par défaut : cette décision ne les vérifie donc pas — elle vérifie les + règles bruyantes. Une compilation propre se lira comme une couverture complète alors que + trois règles ne sont pas exercées, ce qui est précisément le piège qui a fait paraître + propre un balayage précoce. +* **Les règles sur adhésion restent éteintes.** `JD011` et `JD019` sont livrées + désactivées et ne tourneraient pas, laissant deux règles sans aucune vérification + permanente. +* **Une règle `Error` peut casser la compilation sur une forme délibérée.** La réponse est + une suppression, mais pour une règle introduite dans le même changement l'échec arrive à + la fusion plutôt qu'à l'écriture. +* Le projet de test de l'analyseur ne doit pas charger l'analyseur qu'il teste. + +## Actions de suivi + +* Décider si les règles `Info` doivent être escaladées dans la configuration du dépôt + lui-même. Sans cela, les trois règles dont toute la valeur est que l'exécution ne dit + rien sont les trois que cette décision n'exerce pas. +* Décider si `JD011` et `JD019` doivent être exercées quelque part, étant livrées + désactivées par choix. +* Réexaminer si les analyseurs FirstClassErrors, vérifiés aujourd'hui uniquement par + `FirstClassErrors.Usage`, devraient atteindre les suites de cette bibliothèque au titre + du même argument. + +## Références + +* [ADR-0044](0044-ship-justdummies-analyzers.md) — la décision de livrer des analyseurs + JustDummies de première partie. +* [ADR-0046](0046-make-the-per-pull-request-mutation-gate-advisory.md) — le contrôle + consultatif par pull request que ce document refuse d'imiter. +* [ADR-0059](0059-guard-the-recipe-versus-value-boundary-with-analyzers.md) — les règles + recette-contre-valeur, dont le dogfooding a produit une partie des preuves ci-dessus. +* [Les règles d'analyse JustDummies](../../for-users/analyzers/README.md). diff --git a/doc/handwritten/for-maintainers/adr/0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.md b/doc/handwritten/for-maintainers/adr/0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.md new file mode 100644 index 00000000..b602c96f --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.md @@ -0,0 +1,197 @@ +# ADR-0061 | Run the JustDummies analyzers on the repository's own code + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.fr.md) + +**Status:** Proposed +**Proposed:** 2026-07-29 +**Decision Makers:** Reefact + +## Context + +This repository ships two analyzer packages. `FirstClassErrors.Analyzers` carries +`FCE001`–`FCE022`; `JustDummies.Analyzers`, created under +[ADR-0044](0044-ship-justdummies-analyzers.md), carries `JD001`–`JD028`. + +The two are verified very differently. + +The FirstClassErrors analyzers are loaded by `FirstClassErrors.Usage`, which the +`analyzers` workflow builds on every pull request. The rules therefore run, on every +change, over code written to use the library rather than to exercise the rules. + +The JustDummies analyzers are loaded by no project in this repository. Their only +standing verification is their own unit suite — 246 tests, each compiling a snippet +written by the rule's author. Beyond that, an agent injects the built analyzer into the +repository's suites by hand, through an MSBuild property, and reads the warnings. + +That hand sweep is not a formality. It has contradicted the author's model at least once +in each of the last four rule waves, and each contradiction was a rule that would have +shipped wrong: + +* `JD015` modelled letter casing as a character pool, which would have condemned the + legal `UpperCase().StartingWith("ORD-")`. +* `JD016` counted an enum's declared members while `AllowingCombinations()` widens the + universe to their OR-closure. +* `JD023` treated `LessThanOrEqualTo(long.MinValue)` as unsatisfiable, a chain the + library's own suite asserts is legal. +* `JD028` assumed every draw is a fresh instance, which is false for a pool: `OneOf` + returns the very references it was given. +* `JD027`'s stand-down for a throwing composer did not fire, because an + expression-bodied `=> throw` is a return *carrying* the throw, under a conversion. + +None of these were found by the unit suite, and none could have been: the author writes +both the rule and the snippet it is tested against, so a shared misconception passes +both. All were found on the library's own code, which was written for other reasons and +therefore does not share the misconception. + +The rules span four severities. `JD001`–`JD005` are `Error`; most are `Warning`; +`JD020`, `JD022` and `JD024` are `Info`; `JD011` and `JD019` ship disabled, opt-in. +Roslyn does not surface `Info` diagnostics at default build verbosity — an early sweep +looked clean for exactly that reason, and reported nothing while two rules were live and +silent. + +The library's own test suites deliberately write the shapes the rules report. That is +not incidental: a test for a behaviour has to exercise it. Seven such sites exist today. +Five carry a `SuppressMessage` naming their rule and stating why the shape is deliberate; +the two duplicate-collapsing tests do not. + +Loading the analyzers into `JustDummies.UnitTests` was measured before this record was +written. The build succeeds. Exactly two diagnostics are reported — the two +duplicate-collapsing tests. The five existing suppressions silence their rules, so the +mechanism works on a real test. Adding two more attributes brings the surface to zero. +The other JustDummies-consuming projects report nothing at all. A cold, non-incremental +build of that project moved from roughly six and a half seconds to roughly nine. + +## Decision + +Load `JustDummies.Analyzers` into every project in this repository that consumes +JustDummies, so the rules run at build and in the IDE, and record each deliberate +violation as a suppression naming the rule it answers. + +## Rationale + +A rule in this catalogue is a claim about how JustDummies behaves. Its unit suite proves +something weaker: that the rule fires on a snippet its author wrote for it. When the +author's model of the library is wrong, both sides of that test are wrong together and it +passes. Every model error listed in the Context passed its unit suite. + +What caught them was realistic code — the library's own suites, written to exercise +JustDummies rather than to exercise the rules. That body of code is the only such corpus +the repository controls, and running the rules over it is the only verification that can +fail for a reason the rule's author did not think of. Making it continuous rather than +manual is the whole decision. + +The current arrangement has the signal without the guarantee. It depends on whoever is +working remembering to run a sweep that nothing in the repository asks for, and the +record shows what that is worth: the sweep was not run at all before the fourth wave, and +the rules that shipped before it were the ones that needed correcting after. A check that +has caught five wrong rules and rests on memory is a check that will eventually not be +run. + +The measured suppression surface — two sites, on a mechanism already proven to work on +five others — is small enough that the decision is adoptable today rather than after a +cleanup. More importantly, those suppressions are not a cost being tolerated. A test that +writes a flagged shape is the test *for* that shape, and the attribute is where a future +reader learns that the shape is the subject rather than a mistake. The repository was +already moving that way: the five existing suppressions were written before this record, +precisely because the annotation reads better than the bare shape. + +That `Error`-severity rules will break the build if they ever fire on the repository's +own code is the correct behaviour, not a drawback to be worked around. An `Error` rule +firing on realistic code means either a real defect or a wrong rule, and both have to be +settled before the change merges. + +The build cost is known, bounded, and paid on a test project rather than on anything +shipped. + +## Alternatives Considered + +### Keep dogfooding by hand + +It is what found every model error, so it is not ineffective — but its effectiveness is +not the question. Nothing in the repository states that the sweep exists, when it must be +run, or what a clean result means, so the check survives only as long as whoever is +working remembers it. The record already shows it lapsing: three waves of rules shipped +before the sweep became routine, and those are the waves whose rules needed correcting. + +Rejected because a verification that depends on memory is not a verification. + +### Add a dedicated JustDummies sample, mirroring FirstClassErrors.Usage + +Symmetrical with the arrangement that already works for the FirstClassErrors analyzers, +and it would carry no suppression pressure at all, since a sample written to demonstrate +the library has no reason to write a flagged shape on purpose. + +Rejected as the answer, because a sample only exercises what someone thought to put in +it. None of the model errors came from a sample; they came from suites written well +before the rules existed, for reasons unrelated to them, which is exactly why they did +not share the author's misconception. A sample would have found none of them. + +It remains a reasonable addition on its own merits — as documentation that compiles — and +this record does not argue against one. + +### Run the sweep as an advisory CI job + +The mechanics already exist; formalizing the injection as a workflow job would put the +result on every pull request without touching any project file, and the repository has +precedent for an advisory check in the per-pull-request mutation score +([ADR-0046](0046-make-the-per-pull-request-mutation-gate-advisory.md)). + +Rejected on two grounds. It keeps the result out of the IDE, where the author is at the +moment the mistake is made and where a rule about a silent mistake is worth most; and an +advisory signal is one nobody is obliged to act on, which reproduces the failure mode +this record exists to close. The mutation precedent does not transfer: a mutation score +is a continuous measure whose threshold is a judgement call, while a diagnostic is a +binary claim that something specific is wrong. + +## Consequences + +### Positive + +* The rules are verified continuously against code that was not written to please them, + which is the only verification that can surface a wrong model. +* The five model errors this practice has already caught become impossible to reintroduce + silently. +* The `SuppressMessage` attributes become live: a rule that stops firing on a site that + claims to exercise it is a signal the rule or the library moved. +* A new rule's false-positive rate is measured while it is being written, in the IDE, + instead of at the end of a wave. + +### Negative + +* Every project that consumes JustDummies pays the analyzer cost on each build. +* A new rule may require new suppressions in the library's suites before it can merge. +* Whoever writes a JustDummies test now meets the rules, and has to know why a suppression + is the right answer rather than a workaround. + +### Risks + +* **The `Info` rules stay invisible.** `JD020`, `JD022` and `JD024` do not surface at + default verbosity, so this decision does not verify them — it verifies the rules that + are loud. A clean build will read as full coverage while three rules are not being + exercised, which is precisely the trap that made an early sweep look clean. +* **The opt-in rules stay off.** `JD011` and `JD019` ship disabled and would not run, + leaving two rules with no standing verification at all. +* **An `Error` rule can break the build on a deliberate shape.** The answer is a + suppression, but for a rule introduced in the same change the failure arrives at merge + rather than at authoring. +* The analyzer's own test project must not load the analyzer it tests. + +## Follow-up Actions + +* Decide whether the `Info` rules should be escalated in the repository's own + configuration. Without that, the three rules whose entire value is that the run time + says nothing are the three this decision does not exercise. +* Decide whether `JD011` and `JD019` should be exercised anywhere, given they ship + disabled by design. +* Reconsider whether the FirstClassErrors analyzers, verified today only through + `FirstClassErrors.Usage`, should reach that library's own suites on the same argument. + +## References + +* [ADR-0044](0044-ship-justdummies-analyzers.md) — the decision to ship + first-party JustDummies analyzers. +* [ADR-0046](0046-make-the-per-pull-request-mutation-gate-advisory.md) — the advisory + per-pull-request check this record declines to imitate. +* [ADR-0059](0059-guard-the-recipe-versus-value-boundary-with-analyzers.md) — the + recipe-versus-value rules, whose dogfooding produced part of the evidence above. +* [The JustDummies analyzer rules](../../for-users/analyzers/README.md). diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index e311b71d..8e6a8495 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -262,3 +262,4 @@ Optional supporting material: | [ADR-0058](0058-suppress-ca1510-while-the-netstandard-floor-stands.md) | Suppress CA1510 while the pre-.NET-6 floor stands | Accepted | | [ADR-0059](0059-guard-the-recipe-versus-value-boundary-with-analyzers.md) | Guard the recipe-versus-value boundary with analyzers where the type system cannot reach it | Proposed | | [ADR-0060](0060-let-stated-intent-outrank-generic-analyzer-advice.md) | Let stated intent outrank generic analyzer advice, and record the refusal beside the rule | Proposed | +| [ADR-0061](0061-run-the-justdummies-analyzers-on-the-repository-s-own-code.md) | Run the JustDummies analyzers on the repository's own code, so the rules are verified against code not written to please them | Proposed | diff --git a/doc/handwritten/for-users/analyzers/JD025.en.md b/doc/handwritten/for-users/analyzers/JD025.en.md new file mode 100644 index 00000000..91cbf2ad --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD025.en.md @@ -0,0 +1,51 @@ +# JD025: DuplicatePoolValue + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD025.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🟡 Warning | +| **Enabled by default** | Yes | + +The same value is listed twice in a pool. A pool is deduplicated under the default equality when the generator is built, so a value written twice contributes exactly once. + +The reading this rule exists to refuse is **weighting**. Listing a value twice looks like "draw this one more often", and the library declines to weight a pool on purpose — so the duplicate does nothing at all, and the pool is one value smaller than it reads. + +## Noncompliant + +```csharp +Any.OneOf(1, 2, 1) // JD025: the pool holds two values, not three +Any.OneOf("EUR", "USD", "EUR") // JD025 +Any.Int32().OneOf(3, 3) // JD025 +``` + +## Compliant + +```csharp +Any.OneOf(1, 2) // say what the pool is +Any.OneOf("EUR", "USD") +``` + +## Where the gap surfaces + +Nothing fails here, which is the problem: the consequence lands somewhere else entirely. A distinct collection over the pool gates against the **real** distinct count, and reports a number the author cannot find in their source: + +```csharp +Any.SetOf(Any.OneOf(1, 2, 1)).WithCount(3) +// ConflictingAnyConstraintException: 3 elements required to be distinct +// exceed the 2 distinct value(s) the element generator can produce. +``` + +Three values are written on the line; the message says two. This rule points at the line that is actually wrong. + +## What it does not flag + +* A pool whose elements are not all compile-time constants — one unfoldable element and the pool stops being knowable, so the rule stands down rather than report a partial walk. +* A pool held in a variable or built by a query (`Any.ElementOf(orders)`); only the values written at the call site are visible. +* Values that merely look alike: `Any.OneOf("a", "A")` is a pool of two. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD025.fr.md b/doc/handwritten/for-users/analyzers/JD025.fr.md new file mode 100644 index 00000000..f25f088c --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD025.fr.md @@ -0,0 +1,51 @@ +# JD025 : DuplicatePoolValue + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD025.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🟡 Avertissement | +| **Activée par défaut** | Oui | + +La même valeur figure deux fois dans un réservoir. Un réservoir est dédoublonné sous l'égalité par défaut à la construction du générateur : une valeur écrite deux fois n'y contribue qu'une seule fois. + +La lecture que cette règle existe pour refuser est la **pondération**. Lister une valeur deux fois ressemble à « tirer celle-ci plus souvent », et la bibliothèque refuse délibérément de pondérer un réservoir — le doublon ne fait donc rien du tout, et le réservoir est plus petit d'une valeur que ce qu'il paraît. + +## Non conforme + +```csharp +Any.OneOf(1, 2, 1) // JD025 : le réservoir contient deux valeurs, pas trois +Any.OneOf("EUR", "USD", "EUR") // JD025 +Any.Int32().OneOf(3, 3) // JD025 +``` + +## Conforme + +```csharp +Any.OneOf(1, 2) // dire ce qu'est le réservoir +Any.OneOf("EUR", "USD") +``` + +## Où l'écart se manifeste + +Rien n'échoue ici, et c'est bien le problème : la conséquence tombe complètement ailleurs. Une collection distincte au-dessus du réservoir vérifie le **vrai** nombre de valeurs distinctes, et annonce un nombre que l'auteur ne retrouve pas dans son source : + +```csharp +Any.SetOf(Any.OneOf(1, 2, 1)).WithCount(3) +// ConflictingAnyConstraintException : 3 elements required to be distinct +// exceed the 2 distinct value(s) the element generator can produce. +``` + +Trois valeurs sont écrites sur la ligne ; le message en annonce deux. Cette règle désigne la ligne réellement fautive. + +## Ce qui n'est pas signalé + +* Un réservoir dont les éléments ne sont pas tous des constantes de compilation — un seul élément non repliable et le réservoir cesse d'être connaissable, donc la règle se retire plutôt que de signaler un parcours partiel. +* Un réservoir tenu dans une variable ou construit par une requête (`Any.ElementOf(orders)`) ; seules les valeurs écrites sur le site d'appel sont visibles. +* Des valeurs qui se ressemblent seulement : `Any.OneOf("a", "A")` est un réservoir de deux. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD026.en.md b/doc/handwritten/for-users/analyzers/JD026.en.md new file mode 100644 index 00000000..a20eb4f5 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD026.en.md @@ -0,0 +1,50 @@ +# JD026: EmptyRelativeUri + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD026.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🟡 Warning | +| **Enabled by default** | Yes | + +The chain describes the **empty reference**, which no URI can be: a relative URI with exactly zero path segments, no query, no fragment and no root renders as the empty string. + +## Noncompliant + +```csharp +Any.Uri().Relative().WithPathSegments(0) // JD026: nothing left to render +``` + +## Compliant + +```csharp +Any.Uri().Relative().WithPathSegments(0).WithQuery() // "?page=2" +Any.Uri().Relative().WithPathSegments(0).WithFragment() // "#top" +Any.Uri().Relative().Rooted().WithPathSegments(0) // "/" +Any.Uri().Relative().WithPathSegments(1) // "orders" +``` + +Any one of the four is enough: the reference only needs *something* to render. + +## Why this one is worth a rule + +The library already reports it — but only at `Generate()`. This is the one member of the constraint family whose failure lands at **act** time rather than at the arrange line, because emptiness is only settled once the components have been drawn: + +``` +AnyGenerationException: A relative URI with exactly 0 path segments and no query, +fragment or root is empty, which is not a valid URI reference. Add a query, a +fragment, Rooted(), or a positive segment count. +``` + +The message is clear; the stack is not. It points at the code under test, several frames from the declaration that is wrong — and, when the chain lives in a shared fixture, in a test that never mentions URIs. Moving the report to build time is the whole value of the rule. + +## What it does not flag + +* A non-relative family. `Web()`, `WebSocket()` and `Ftp()` carry an authority, so a path of zero segments still renders as `/` and the reference stays valid. +* A segment count that is not a compile-time constant. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD026.fr.md b/doc/handwritten/for-users/analyzers/JD026.fr.md new file mode 100644 index 00000000..b8d627fa --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD026.fr.md @@ -0,0 +1,50 @@ +# JD026 : EmptyRelativeUri + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD026.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🟡 Avertissement | +| **Activée par défaut** | Oui | + +La chaîne décrit la **référence vide**, ce qu'aucune URI ne peut être : une URI relative à exactement zéro segment de chemin, sans requête, sans fragment et sans racine se rend comme la chaîne vide. + +## Non conforme + +```csharp +Any.Uri().Relative().WithPathSegments(0) // JD026 : plus rien à rendre +``` + +## Conforme + +```csharp +Any.Uri().Relative().WithPathSegments(0).WithQuery() // "?page=2" +Any.Uri().Relative().WithPathSegments(0).WithFragment() // "#top" +Any.Uri().Relative().Rooted().WithPathSegments(0) // "/" +Any.Uri().Relative().WithPathSegments(1) // "orders" +``` + +N'importe laquelle des quatre suffit : la référence a seulement besoin de *quelque chose* à rendre. + +## Pourquoi celle-ci mérite une règle + +La bibliothèque le signale déjà — mais seulement à `Generate()`. C'est le seul membre de la famille des contraintes dont l'échec atterrit au moment de l'**act** plutôt que sur la ligne d'arrange, parce que la vacuité ne se décide qu'une fois les composants tirés : + +``` +AnyGenerationException: A relative URI with exactly 0 path segments and no query, +fragment or root is empty, which is not a valid URI reference. Add a query, a +fragment, Rooted(), or a positive segment count. +``` + +Le message est clair ; la pile ne l'est pas. Elle désigne le code sous test, à plusieurs cadres de la déclaration fautive — et, quand la chaîne vit dans une fixture partagée, dans un test qui ne parle jamais d'URI. Ramener le signalement au moment de la compilation est tout l'intérêt de la règle. + +## Ce qui n'est pas signalé + +* Une famille non relative. `Web()`, `WebSocket()` et `Ftp()` portent une autorité : un chemin de zéro segment se rend quand même en `/` et la référence reste valide. +* Un nombre de segments qui n'est pas une constante de compilation. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD027.en.md b/doc/handwritten/for-users/analyzers/JD027.en.md new file mode 100644 index 00000000..82e5d1b8 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD027.en.md @@ -0,0 +1,61 @@ +# JD027: UnusedCombineOperand + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD027.fr.md) + +| | | +|---|---| +| **Category** | Composition (`JustDummies.Composition`) | +| **Severity** | 🟡 Warning | +| **Enabled by default** | Yes | + +The composer never reads the parameter one of the operands is bound to, so that operand is **drawn and thrown away**. + +`Combine` generates every part before calling the composer — constraints built, conflict checks run, value produced — and then drops it. Nothing fails: the composed value is well-formed, and simply does not carry the part the call site says it carries. + +## Noncompliant + +```csharp +IAny customer = Any.Combine( + Any.String().NonEmpty().WithMaxLength(50), + Any.String().StartingWith("ORD-").WithLength(12), + (name, reference) => new Customer(name)); // JD027: 'reference' never reaches the Customer +``` + +## Compliant + +```csharp +IAny customer = Any.Combine( + Any.String().NonEmpty().WithMaxLength(50), + Any.String().StartingWith("ORD-").WithLength(12), + (name, reference) => new Customer(name, OrderReference.Create(reference))); +``` + +Or drop the operand entirely, if it is genuinely not part of the value: + +```csharp +IAny customer = Any.String().NonEmpty().WithMaxLength(50).As(name => new Customer(name)); +``` + +## Saying the draw is deliberate + +Name the parameter `_`, the same way C# spells "I know, and I mean it" anywhere else: + +```csharp +Any.Combine(first, second, (value, _) => new Wrapper(value)) // no diagnostic +``` + +## Why it happens + +The two shapes seen most often are a constructor argument forgotten during a refactor, and a composer whose parameters no longer line up with its operands after one was inserted in the middle. Both leave a generator whose carefully written constraints have no effect on anything the test observes. + +## What it does not flag + +* A parameter named `_`. +* A composer passed as a method group — its body is not necessarily this compilation's to read, so which operands it uses is not knowable. +* A composer whose whole body is a `throw`. It reads no parameter by construction, and is exercising the failure path `Combine` wraps rather than ignoring an operand. +* A parameter read only inside a nested lambda: that still counts as read. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD027.fr.md b/doc/handwritten/for-users/analyzers/JD027.fr.md new file mode 100644 index 00000000..3b188e73 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD027.fr.md @@ -0,0 +1,61 @@ +# JD027 : UnusedCombineOperand + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD027.en.md) + +| | | +|---|---| +| **Catégorie** | Composition (`JustDummies.Composition`) | +| **Sévérité** | 🟡 Avertissement | +| **Activée par défaut** | Oui | + +Le composeur ne lit jamais le paramètre auquel l'un des opérandes est lié : cet opérande est donc **tiré puis jeté**. + +`Combine` génère chaque partie avant d'appeler le composeur — contraintes construites, vérifications de conflit exécutées, valeur produite — puis la laisse tomber. Rien n'échoue : la valeur composée est bien formée, et ne porte simplement pas la partie que le site d'appel dit qu'elle porte. + +## Non conforme + +```csharp +IAny customer = Any.Combine( + Any.String().NonEmpty().WithMaxLength(50), + Any.String().StartingWith("ORD-").WithLength(12), + (name, reference) => new Customer(name)); // JD027 : 'reference' n'atteint jamais le Customer +``` + +## Conforme + +```csharp +IAny customer = Any.Combine( + Any.String().NonEmpty().WithMaxLength(50), + Any.String().StartingWith("ORD-").WithLength(12), + (name, reference) => new Customer(name, OrderReference.Create(reference))); +``` + +Ou supprimer l'opérande, s'il ne fait réellement pas partie de la valeur : + +```csharp +IAny customer = Any.String().NonEmpty().WithMaxLength(50).As(name => new Customer(name)); +``` + +## Dire que le tirage est délibéré + +Nommer le paramètre `_`, comme C# l'écrit partout ailleurs pour dire « je sais, et c'est voulu » : + +```csharp +Any.Combine(first, second, (value, _) => new Wrapper(value)) // aucun diagnostic +``` + +## Pourquoi cela arrive + +Les deux formes les plus fréquentes sont un argument de constructeur oublié pendant un remaniement, et un composeur dont les paramètres ne correspondent plus à ses opérandes après l'insertion d'un opérande au milieu. Les deux laissent un générateur dont les contraintes soigneusement écrites n'ont d'effet sur rien de ce que le test observe. + +## Ce qui n'est pas signalé + +* Un paramètre nommé `_`. +* Un composeur passé comme groupe de méthodes — son corps n'appartient pas nécessairement à cette compilation, donc les opérandes qu'il utilise ne sont pas connaissables. +* Un composeur dont le corps entier est un `throw`. Il ne lit aucun paramètre par construction, et exerce le chemin d'échec que `Combine` enveloppe plutôt qu'il n'ignore un opérande. +* Un paramètre lu seulement dans un lambda imbriqué : cela compte comme lu. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD028.en.md b/doc/handwritten/for-users/analyzers/JD028.en.md new file mode 100644 index 00000000..2be3597e --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD028.en.md @@ -0,0 +1,63 @@ +# JD028: InertDistinctness + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD028.fr.md) + +| | | +|---|---| +| **Category** | Composition (`JustDummies.Composition`) | +| **Severity** | 🟡 Warning | +| **Enabled by default** | Yes | + +Distinctness is declared over an element type that has no value equality. The default comparer falls back to reference equality, and every element the generator builds is a new instance — so the requirement is satisfied by construction and constrains nothing. + +The collection can hold the same value several times, which is precisely what the declaration asks it not to. + +## Noncompliant + +```csharp +public sealed class Box { // neither Equals nor IEquatable + public Box(int value) { Value = value; } + public int Value { get; } +} + +Any.ListOf(Any.Int32().Between(1, 2).As(v => new Box(v))).Distinct().WithCount(6) // JD028 +``` + +Measured on the library, that declaration returns six "distinct" boxes holding `[1, 1, 1, 2, 1, 2]` — green, every time. + +## Compliant + +Give the element type value equality: + +```csharp +public sealed record Box(int Value); + +Any.ListOf(Any.Int32().Between(1, 2).As(v => new Box(v))).Distinct().WithCount(6) +// AnyGenerationException: the element generator produced only 2 distinct value(s) +``` + +Now the declaration means something, and the impossible request is reported. + +Or answer the equality question explicitly: + +```csharp +Any.ListOf(generator).Distinct(new BoxByValue()) +Any.SetOf(generator, new BoxByValue()) +``` + +## Why the library cannot report this + +From its side the requirement is met: the draws really are pairwise unequal under the comparer it was given, and there is nothing to complain about. Only the element type's equality tells the inert case from the real one, and that is visible at the call site. + +## What it does not flag + +* A generator that hands back **existing** instances. `Any.SetOf(Any.OneOf(first, second))` returns the very references it was given, so drawing the same member twice yields the same reference and distinctness binds exactly as asked. +* An element type that is a value type, a record, an `IEquatable` implementer, or that overrides `Equals` — anywhere in its base chain. +* A non-sealed element type. A derived instance is free to add the equality the base lacks, and the rule only claims what it can prove. +* A projection that may return a shared instance (`As(v => Lookup(v))`); only a chain that provably builds a new value here qualifies. +* Any collection given an explicit comparer. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD028.fr.md b/doc/handwritten/for-users/analyzers/JD028.fr.md new file mode 100644 index 00000000..a7bd0278 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD028.fr.md @@ -0,0 +1,63 @@ +# JD028 : InertDistinctness + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD028.en.md) + +| | | +|---|---| +| **Catégorie** | Composition (`JustDummies.Composition`) | +| **Sévérité** | 🟡 Avertissement | +| **Activée par défaut** | Oui | + +La distinction est déclarée sur un type d'élément dépourvu d'égalité de valeur. Le comparateur par défaut retombe sur l'égalité de référence, et chaque élément que le générateur construit est une nouvelle instance — l'exigence est donc satisfaite par construction et ne contraint rien. + +La collection peut contenir plusieurs fois la même valeur, ce que la déclaration lui demande précisément de ne pas faire. + +## Non conforme + +```csharp +public sealed class Box { // ni Equals ni IEquatable + public Box(int value) { Value = value; } + public int Value { get; } +} + +Any.ListOf(Any.Int32().Between(1, 2).As(v => new Box(v))).Distinct().WithCount(6) // JD028 +``` + +Mesuré sur la bibliothèque, cette déclaration rend six boîtes « distinctes » portant `[1, 1, 1, 2, 1, 2]` — au vert, à chaque fois. + +## Conforme + +Donner une égalité de valeur au type d'élément : + +```csharp +public sealed record Box(int Value); + +Any.ListOf(Any.Int32().Between(1, 2).As(v => new Box(v))).Distinct().WithCount(6) +// AnyGenerationException : the element generator produced only 2 distinct value(s) +``` + +La déclaration signifie maintenant quelque chose, et la demande impossible est signalée. + +Ou répondre explicitement à la question de l'égalité : + +```csharp +Any.ListOf(generator).Distinct(new BoxByValue()) +Any.SetOf(generator, new BoxByValue()) +``` + +## Pourquoi la bibliothèque ne peut pas le signaler + +De son côté, l'exigence est tenue : les tirages sont réellement deux à deux différents sous le comparateur qu'on lui a donné, et il n'y a rien à redire. Seule l'égalité du type d'élément distingue le cas inerte du cas réel, et elle est visible sur le site d'appel. + +## Ce qui n'est pas signalé + +* Un générateur qui restitue des instances **existantes**. `Any.SetOf(Any.OneOf(first, second))` rend les références mêmes qu'on lui a confiées : tirer deux fois le même membre donne la même référence, et la distinction s'applique exactement comme demandé. +* Un type d'élément qui est un type valeur, un record, une implémentation d'`IEquatable`, ou qui redéfinit `Equals` — n'importe où dans sa chaîne de bases. +* Un type d'élément non scellé. Une instance dérivée est libre d'ajouter l'égalité qui manque à la base, et la règle ne prétend que ce qu'elle peut prouver. +* Une projection susceptible de rendre une instance partagée (`As(v => Lookup(v))`) ; seule une chaîne qui construit prouvablement une valeur neuve ici est concernée. +* Toute collection à laquelle un comparateur explicite est fourni. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/README.fr.md b/doc/handwritten/for-users/analyzers/README.fr.md index f8a9d051..fa284488 100644 --- a/doc/handwritten/for-users/analyzers/README.fr.md +++ b/doc/handwritten/for-users/analyzers/README.fr.md @@ -93,6 +93,17 @@ Ces règles anticipent, à la compilation, le sous-ensemble des vérifications d | [JD017 EnumUniverseViolation](JD017.fr.md) | 🟠 Avertissement | on | Une contrainte d'enum sort des membres déclarés — une combinaison de drapeaux sans `AllowingCombinations()`, ou une exclusion qui vide l'univers. | | [JD023 ScalarChainAdmitsNoValue](JD023.fr.md) | 🟠 Avertissement | on | Les contraintes constantes d'une chaîne entière réduisent le domaine à rien — bornes, treillis ou liste d'autorisation. | | [JD024 ConstraintWithNoEffect](JD024.fr.md) | 🔵 Info | on | Une contrainte ne rétrécit rien : exclusion d'une valeur que le domaine ne pouvait pas produire, ou borne déjà impliquée. La seule famille de contraintes que l'exécution ne signale jamais. | +| [JD025 DuplicatePoolValue](JD025.fr.md) | 🟠 Avertissement | on | La même constante figure deux fois dans un réservoir ; les doublons sont écrasés, donc le réservoir est plus petit d'une valeur qu'il n'y paraît et le doublon ne pondère rien. | +| [JD026 EmptyRelativeUri](JD026.fr.md) | 🟠 Avertissement | on | Une URI relative à zéro segment, sans requête, fragment ni racine est la référence vide — la seule chaîne dont l'échec atterrit au moment de l'act plutôt que sur la ligne d'arrange. | + +## JustDummies — Composition + +Ces règles concernent l'assemblage de générateurs en générateurs plus gros — les opérandes de `Combine`, et le contrat d'élément sur lequel s'appuie un générateur de collection. Leur point commun : rien ne va de travers. Le générateur composé se construit, tire et rend une valeur. Ce n'est simplement pas la valeur que le site d'appel décrit. + +| Règle | Sévérité | Défaut | Description | +|-------|----------|--------|-------------| +| [JD027 UnusedCombineOperand](JD027.fr.md) | 🟠 Avertissement | on | Un opérande de `Combine` est tiré puis jeté parce que le composeur ne lit jamais son paramètre. Nommer le paramètre `_` pour dire que le tirage est délibéré. | +| [JD028 InertDistinctness](JD028.fr.md) | 🟠 Avertissement | on | La distinction est déclarée sur un type d'élément sans égalité de valeur : elle est satisfaite par construction et la collection peut quand même contenir deux fois la même valeur. | ## Configuration diff --git a/doc/handwritten/for-users/analyzers/README.md b/doc/handwritten/for-users/analyzers/README.md index 2d486b86..e713fe33 100644 --- a/doc/handwritten/for-users/analyzers/README.md +++ b/doc/handwritten/for-users/analyzers/README.md @@ -93,6 +93,17 @@ These rules front-load, to build time, the subset of the library's run-time cons | [JD017 EnumUniverseViolation](JD017.en.md) | 🟠 Warning | on | An enum constraint steps outside the declared members — a flag combination without AllowingCombinations(), or an exclusion that empties the universe. | | [JD023 ScalarChainAdmitsNoValue](JD023.en.md) | 🟠 Warning | on | An integer chain's constant constraints narrow the domain to nothing — bounds, lattice or allow-list. | | [JD024 ConstraintWithNoEffect](JD024.en.md) | 🔵 Info | on | A constraint narrows nothing: an exclusion of a value the domain could never produce, or a bound already implied. The only constraint family the run time never reports. | +| [JD025 DuplicatePoolValue](JD025.en.md) | 🟠 Warning | on | The same constant is listed twice in a pool; duplicates collapse, so the pool is one value smaller than it reads and the duplicate weights nothing. | +| [JD026 EmptyRelativeUri](JD026.en.md) | 🟠 Warning | on | A relative URI with zero path segments and no query, fragment or root is the empty reference — the one chain whose failure lands at act time rather than at the arrange line. | + +## JustDummies — Composition + +These rules are about assembling generators into bigger ones — `Combine`'s operands, and the element contract a collection generator relies on. What they share is that nothing goes wrong: the composed generator builds, draws and returns a value. It is simply not the value the call site describes. + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [JD027 UnusedCombineOperand](JD027.en.md) | 🟠 Warning | on | A Combine operand is drawn and thrown away because the composer never reads its parameter. Name the parameter `_` to say the draw is deliberate. | +| [JD028 InertDistinctness](JD028.en.md) | 🟠 Warning | on | Distinctness is declared over an element type with no value equality, so it is satisfied by construction and the collection can still hold the same value twice. | ## Configuring