From 24166b673e983faefe30a37d82e5b8aa869349d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:10:45 +0000 Subject: [PATCH] feat(justdummies): add JD011-JD013 for recipes reaching value positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the JustDummies.Usage category with the three remaining ways a generator stands in for the value it would draw. JD011 covers the residue ADR-0020 could not close. Generators are reference types, so an object, dynamic or params object[] position takes one with no conversion — there was nothing to remove there. The recipe then survives as an opaque object: Assert.NotNull(Any.String()) is green for ever, a theory row built as object[] feeds the recipe to the code under test, and gen.Equals(value) is false for every run and seed. JD012 and JD013 are the two Any.OneOf traps. Pooling generators infers the builder type as the element type, so drawing yields a recipe; and a held collection binds T to the collection itself, making a pool of one whose every draw returns the same list. ADR-0032 recorded the second as an accepted risk "mitigated by ElementOf being the documented path" — this turns that mitigation into a check. JD011 ships opt-in (isEnabledByDefault: false), which is a measurement and not a preference. ADR-0059's follow-up required its default to be decided on dogfooding evidence; across this repository's suites it found no true positive and two false ones, both in a convention test that collects generators into a List on purpose. That shape cannot be told apart from the theory-row mistake the rule targets, so it cannot be narrowed away — and the rule earns its keep in a consumer suite instead, where object-typed assertion helpers are common and reflection over generators is not. Two exclusions came out of that same pass and are covered by tests citing the real sites: a comparison between two generators is never reported, because ReferenceEquals(original, narrowed) is how the property suite proves a constraint returned a new recipe, and Generate() there would destroy the property under test. The test harness gains the opt-in mechanism FirstClassErrors' harness already had, so a disabled-by-default rule can be forced on for its own tests exactly as an .editorconfig would. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GKqyhG9Y4AfdxEYekP2Evq --- .../AnalyzerTestHarness.cs | 12 +- .../Jd011GeneratorWhereValueExpectedTests.cs | 188 ++++++++++++++++++ .../Jd012GeneratorPooledAsValueTests.cs | 105 ++++++++++ .../Jd013HeldCollectionPassedToOneOfTests.cs | 121 +++++++++++ .../AnalyzerReleases.Unshipped.md | 3 + JustDummies.Analyzers/Descriptors.cs | 35 ++++ JustDummies.Analyzers/DiagnosticIds.cs | 5 + .../GeneratorPooledAsValueAnalyzer.cs | 59 ++++++ .../GeneratorWhereValueExpectedAnalyzer.cs | 112 +++++++++++ .../HeldCollectionPassedToOneOfAnalyzer.cs | 90 +++++++++ .../for-users/analyzers/JD011.en.md | 56 ++++++ .../for-users/analyzers/JD011.fr.md | 56 ++++++ .../for-users/analyzers/JD012.en.md | 40 ++++ .../for-users/analyzers/JD012.fr.md | 40 ++++ .../for-users/analyzers/JD013.en.md | 41 ++++ .../for-users/analyzers/JD013.fr.md | 41 ++++ .../for-users/analyzers/README.fr.md | 3 + doc/handwritten/for-users/analyzers/README.md | 3 + 18 files changed, 1008 insertions(+), 2 deletions(-) create mode 100644 JustDummies.Analyzers.UnitTests/Jd011GeneratorWhereValueExpectedTests.cs create mode 100644 JustDummies.Analyzers.UnitTests/Jd012GeneratorPooledAsValueTests.cs create mode 100644 JustDummies.Analyzers.UnitTests/Jd013HeldCollectionPassedToOneOfTests.cs create mode 100644 JustDummies.Analyzers/GeneratorPooledAsValueAnalyzer.cs create mode 100644 JustDummies.Analyzers/GeneratorWhereValueExpectedAnalyzer.cs create mode 100644 JustDummies.Analyzers/HeldCollectionPassedToOneOfAnalyzer.cs create mode 100644 doc/handwritten/for-users/analyzers/JD011.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD011.fr.md create mode 100644 doc/handwritten/for-users/analyzers/JD012.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD012.fr.md create mode 100644 doc/handwritten/for-users/analyzers/JD013.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD013.fr.md diff --git a/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs b/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs index 2d142299..36a3f120 100644 --- a/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs +++ b/JustDummies.Analyzers.UnitTests/AnalyzerTestHarness.cs @@ -18,14 +18,22 @@ internal static class AnalyzerTestHarness { private static readonly ImmutableArray References = BuildReferences(); - public static async Task> GetDiagnosticsAsync(DiagnosticAnalyzer analyzer, string source) { + public static async Task> GetDiagnosticsAsync(DiagnosticAnalyzer analyzer, string source, params string[] enabledDiagnosticIds) { SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + CSharpCompilationOptions options = new(OutputKind.DynamicallyLinkedLibrary); + if (enabledDiagnosticIds.Length > 0) { + // Force otherwise opt-in (isEnabledByDefault: false) rules on for the test, as an .editorconfig would. + ImmutableDictionary.Builder specific = ImmutableDictionary.CreateBuilder(); + foreach (string id in enabledDiagnosticIds) { specific[id] = ReportDiagnostic.Warn; } + options = options.WithSpecificDiagnosticOptions(specific.ToImmutable()); + } + CSharpCompilation compilation = CSharpCompilation.Create( assemblyName: "JustDummies.Analyzers.TestSnippet", syntaxTrees: new[] { syntaxTree }, references: References, - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + options: options); CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); diff --git a/JustDummies.Analyzers.UnitTests/Jd011GeneratorWhereValueExpectedTests.cs b/JustDummies.Analyzers.UnitTests/Jd011GeneratorWhereValueExpectedTests.cs new file mode 100644 index 00000000..d0479ead --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd011GeneratorWhereValueExpectedTests.cs @@ -0,0 +1,188 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd011GeneratorWhereValueExpectedTests { + + [Fact] + public async Task Reports_a_generator_bound_to_an_object_parameter() { + // The shape that matters: an assertion helper taking object inspects the recipe, not the value. + const string source = """ + using JustDummies; + + public static class Assert { + public static void NotNull(object value) { } + } + + public static class Sample { + public static void M() { + Assert.NotNull(Any.String().NonEmpty()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD011"); + Check.That(diagnostics[0].GetMessage()).Contains("Generate()"); + } + + [Fact] + public async Task Reports_a_generator_in_an_object_array_row() { + const string source = """ + using JustDummies; + + public static class Sample { + public static object[] Row() { + return new object[] { Any.Int32().Positive(), 1 }; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD011"); + } + + [Fact] + public async Task Reports_a_generator_assigned_to_an_object_local() { + const string source = """ + using JustDummies; + + public static class Sample { + public static object M() { + object boxed = Any.Int32().Positive(); + + return boxed; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD011"); + } + + [Fact] + public async Task Reports_Equals_against_a_value() { + const string source = """ + using JustDummies; + + public static class Sample { + public static bool M(string expected) { + return Any.String().NonEmpty().Equals(expected); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD011"); + } + + [Fact] + public async Task Does_not_report_ReferenceEquals_between_two_generators() { + // How an immutability test proves a constraint returned a new generator. Generate() would destroy it. + // This shape is live in JustDummies.PropertyTests/ScalarIntervalProperties.cs. + const string source = """ + using JustDummies; + + public static class Sample { + public static bool M() { + AnyInt32 original = Any.Int32().Between(1, 10); + AnyInt32 narrowed = original.GreaterThanOrEqualTo(10); + + return !ReferenceEquals(original, narrowed); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_Equals_between_two_generators() { + const string source = """ + using JustDummies; + + public static class Sample { + public static bool M() { + AnyInt32 first = Any.Int32(); + AnyInt32 second = Any.Int32(); + + return first.Equals(second); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_throws_assertion_binding_to_Func_of_object() { + // Assert.Throws(() => chain) binds to Func, producing a real generator-to-object conversion at + // 88+ existing sites in this repository. + const string source = """ + using System; + using JustDummies; + + public static class Assert { + public static T Throws(Func code) where T : Exception => null!; + } + + public static class Sample { + public static void M() { + Assert.Throws(() => Any.String().WithLength(3).StartingWith("ORD-")); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_generated_value() { + const string source = """ + using JustDummies; + + public static class Assert { + public static void NotNull(object value) { } + } + + public static class Sample { + public static void M() { + Assert.NotNull(Any.String().NonEmpty().Generate()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorWhereValueExpectedAnalyzer(), source, "JD011"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Is_disabled_by_default() { + // The severity choice is the finding, not an incidental: dogfooding produced no true positive and two false + // ones, so the rule ships opt-in (ADR-0059's follow-up). + DiagnosticDescriptor descriptor = new GeneratorWhereValueExpectedAnalyzer().SupportedDiagnostics[0]; + + Check.That(descriptor.IsEnabledByDefault).IsFalse(); + Check.That(descriptor.DefaultSeverity).IsEqualTo(DiagnosticSeverity.Warning); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/Jd012GeneratorPooledAsValueTests.cs b/JustDummies.Analyzers.UnitTests/Jd012GeneratorPooledAsValueTests.cs new file mode 100644 index 00000000..e71911c1 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd012GeneratorPooledAsValueTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd012GeneratorPooledAsValueTests { + + [Fact] + public async Task Reports_a_pool_of_generators() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + IAny pool = Any.OneOf(Any.Int32().Positive(), Any.Int32().Negative()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorPooledAsValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD012"); + Check.That(diagnostics[0].GetMessage()).Contains("Generate()"); + } + + [Fact] + public async Task Reports_a_pool_of_generators_on_a_seeded_context() { + // OneOf and ElementOf are mirrored on AnyContext; a rule keyed on Any alone would miss half the surface. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + AnyContext context = Any.WithSeed(1234); + IAny pool = context.OneOf(context.Int32(), context.Int32()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorPooledAsValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD012"); + } + + [Fact] + public async Task Does_not_report_a_pool_of_values() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + IAny pool = Any.OneOf(1, 2, 3); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorPooledAsValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_pool_of_generated_values() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + IAny pool = Any.OneOf(Any.Int32().Positive().Generate(), Any.Int32().Negative().Generate()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorPooledAsValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_another_types_OneOf() { + const string source = """ + using JustDummies; + + public static class Other { + public static T OneOf(params T[] values) => values[0]; + } + + public static class Sample { + public static void M() { + AnyInt32 chosen = Other.OneOf(Any.Int32(), Any.Int32()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new GeneratorPooledAsValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/Jd013HeldCollectionPassedToOneOfTests.cs b/JustDummies.Analyzers.UnitTests/Jd013HeldCollectionPassedToOneOfTests.cs new file mode 100644 index 00000000..e6dbba3c --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd013HeldCollectionPassedToOneOfTests.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd013HeldCollectionPassedToOneOfTests { + + [Fact] + public async Task Reports_a_held_list_passed_to_OneOf() { + const string source = """ + using System.Collections.Generic; + using JustDummies; + + public static class Sample { + public static void M(List references) { + IAny> pool = Any.OneOf(references); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new HeldCollectionPassedToOneOfAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD013"); + Check.That(diagnostics[0].GetMessage()).Contains("ElementOf"); + } + + [Fact] + public async Task Does_not_report_an_explicit_type_argument() { + // Any.OneOf>(references) states the intent: a pool whose single element is that collection. + const string source = """ + using System.Collections.Generic; + using JustDummies; + + public static class Sample { + public static void M(List references) { + IAny> pool = Any.OneOf>(references); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new HeldCollectionPassedToOneOfAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_single_string() { + // A string is IEnumerable; a one-string pool is ordinary and must not be flagged. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + IAny pool = Any.OneOf("EUR"); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new HeldCollectionPassedToOneOfAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_several_values() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + IAny pool = Any.OneOf("EUR", "USD"); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new HeldCollectionPassedToOneOfAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_ElementOf() { + const string source = """ + using System.Collections.Generic; + using JustDummies; + + public static class Sample { + public static void M(List references) { + IAny pool = Any.ElementOf(references); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new HeldCollectionPassedToOneOfAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_an_array_which_binds_to_the_element_type() { + // An array satisfies params directly, so T is inferred as the element type — the call is already correct. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M(string[] references) { + IAny pool = Any.OneOf(references); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new HeldCollectionPassedToOneOfAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md index 73cf7173..6c237375 100644 --- a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md +++ b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md @@ -15,3 +15,6 @@ JD007 | JustDummies.Reproducibility | Warning | DrawOutsideThePinnedScopeAnal JD008 | JustDummies.Reproducibility | Warning | ArbitraryValueInTheoryDataAnalyzer JD009 | JustDummies.Reproducibility | Warning | DrawInStaticInitializerAnalyzer JD010 | JustDummies.Reproducibility | Warning | ReproducibleOnNonTestMethodAnalyzer +JD011 | JustDummies.Usage | Disabled | GeneratorWhereValueExpectedAnalyzer +JD012 | JustDummies.Usage | Warning | GeneratorPooledAsValueAnalyzer +JD013 | JustDummies.Usage | Warning | HeldCollectionPassedToOneOfAnalyzer diff --git a/JustDummies.Analyzers/Descriptors.cs b/JustDummies.Analyzers/Descriptors.cs index 58996eab..4ba64e58 100644 --- a/JustDummies.Analyzers/Descriptors.cs +++ b/JustDummies.Analyzers/Descriptors.cs @@ -107,4 +107,39 @@ internal static class Descriptors { description: "The adapter's hooks are collected from a test method, its declaring class and the assembly. On a helper — or on a method whose [Fact] was removed during a refactor — the attribute is never read: it pins no seed and reports none. Because a working [Reproducible] is silent on a passing test by design, nothing else distinguishes the inert form from the working one.", helpLinkUri: HelpLinks.For(DiagnosticIds.ReproducibleOnNonTestMethod)); + public static readonly DiagnosticDescriptor GeneratorWhereValueExpected = new( + id: DiagnosticIds.GeneratorWhereValueExpected, + title: "A generator reaches a position that expected its value", + messageFormat: "Call Generate() on the {0}: passed where an object is expected, the recipe itself is stored, compared or asserted on, never the value it would draw", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + // Opt-in, on the evidence ADR-0059's follow-up asked for rather than on intuition: dogfooded over this + // repository's suites the rule found no true positive and two false ones, both in a convention test that + // collects generators into a List on purpose. That shape is indistinguishable from the theory-row + // mistake this rule exists to catch, so it cannot be narrowed away. The rule earns its keep in a consumer + // suite, where object-typed assertion helpers are common and reflection over generators is not. + isEnabledByDefault: false, + description: "Generators are reference types, so an object, dynamic or params object[] position accepts one with no conversion — the residue the removal of the implicit conversions could not close. An assertion helper taking object then inspects the recipe (Assert.NotNull(Any.String()) is green for ever), a theory row carries the recipe into the code under test, and Equals against a value is false for every run and every seed. Opt-in: a suite that manipulates generators as objects on purpose would see this fire on legitimate code.", + helpLinkUri: HelpLinks.For(DiagnosticIds.GeneratorWhereValueExpected)); + + public static readonly DiagnosticDescriptor GeneratorPooledAsValue = new( + id: DiagnosticIds.GeneratorPooledAsValue, + title: "A choice pool is built from generators rather than values", + messageFormat: "Call Generate() on each pooled generator: Any.{0} inferred a pool of recipes, so drawing from it yields a recipe rather than a value", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Any.OneOf(Any.Int32(), Any.Int32()) compiles and infers the builder type as the pool's element type, so the pool holds recipes. What makes this a trap rather than an obvious mistake is that the surface is inconsistent about it: pooling generators of different types fails type inference and the compiler catches it, while two of the same type bind cleanly.", + helpLinkUri: HelpLinks.For(DiagnosticIds.GeneratorPooledAsValue)); + + public static readonly DiagnosticDescriptor HeldCollectionPassedToOneOf = new( + id: DiagnosticIds.HeldCollectionPassedToOneOf, + title: "A held collection is passed to Any.OneOf, making a pool of one", + messageFormat: "Use Any.ElementOf to draw from the collection's elements: passed to OneOf it binds T to {0}, so the pool holds one item and every draw returns the same one", + category: DiagnosticCategories.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Any.OneOf takes params T[], so a single collection argument binds T to the collection type itself rather than to its elements. The call compiles, draws succeed, and every one of them returns the same collection — the arbitrary choice the test claims to make never varies. Any.ElementOf is the entry point that draws from a collection's elements; an explicit type argument states the opposite intent and is left alone.", + helpLinkUri: HelpLinks.For(DiagnosticIds.HeldCollectionPassedToOneOf)); + } diff --git a/JustDummies.Analyzers/DiagnosticIds.cs b/JustDummies.Analyzers/DiagnosticIds.cs index f04a3bf9..a04b0ebc 100644 --- a/JustDummies.Analyzers/DiagnosticIds.cs +++ b/JustDummies.Analyzers/DiagnosticIds.cs @@ -22,4 +22,9 @@ internal static class DiagnosticIds { public const string DrawInStaticInitializer = "JD009"; public const string ReproducibleOnNonTestMethod = "JD010"; + // Category: Usage — a recipe reaching a position that wanted the value + public const string GeneratorWhereValueExpected = "JD011"; + public const string GeneratorPooledAsValue = "JD012"; + public const string HeldCollectionPassedToOneOf = "JD013"; + } diff --git a/JustDummies.Analyzers/GeneratorPooledAsValueAnalyzer.cs b/JustDummies.Analyzers/GeneratorPooledAsValueAnalyzer.cs new file mode 100644 index 00000000..94658b57 --- /dev/null +++ b/JustDummies.Analyzers/GeneratorPooledAsValueAnalyzer.cs @@ -0,0 +1,59 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD012 — reports a choice pool built from generators rather than values. Any.OneOf(Any.Int32(), Any.Int32()) +/// compiles and infers T = AnyInt32, so the pool holds recipes and drawing from it yields a recipe rather +/// than a number. The surface is inconsistent about it, which is what makes it a trap: pooled generators of +/// different types fail inference and are caught by the compiler, while two of the same type sail through. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class GeneratorPooledAsValueAnalyzer : DiagnosticAnalyzer { + + private const string OneOfMethodName = "OneOf"; + private const string ElementOfMethodName = "ElementOf"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.GeneratorPooledAsValue); + + /// + 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 || symbols.AnyContext is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name is not (OneOfMethodName or ElementOfMethodName)) { return; } + if (!IsChoiceFactory(method, symbols)) { return; } + if (method.TypeArguments.Length != 1) { return; } + + if (!GeneratorFacts.IsGenerator(method.TypeArguments[0], symbols.IAny!)) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.GeneratorPooledAsValue, invocation.Syntax.GetLocation(), method.Name)); + } + + // Both entry points are mirrored on Any and AnyContext (SurfaceParityTests enforces the mirror), so the rule must + // recognise either receiver or it would fire on half the surface. + private static bool IsChoiceFactory(IMethodSymbol method, KnownSymbols symbols) { + return SymbolEqualityComparer.Default.Equals(method.ContainingType, symbols.Any) + || SymbolEqualityComparer.Default.Equals(method.ContainingType, symbols.AnyContext); + } + +} diff --git a/JustDummies.Analyzers/GeneratorWhereValueExpectedAnalyzer.cs b/JustDummies.Analyzers/GeneratorWhereValueExpectedAnalyzer.cs new file mode 100644 index 00000000..b485a37b --- /dev/null +++ b/JustDummies.Analyzers/GeneratorWhereValueExpectedAnalyzer.cs @@ -0,0 +1,112 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD011 — reports a generator reaching a position that accepts object. Generators are reference types, so +/// no conversion stands in the way and none was removed with the implicit ones: the recipe is stored, passed or +/// compared where the drawn value was meant. An assertion helper taking object then checks the recipe — +/// Assert.NotNull(Any.String()) is green for ever and asserts nothing — and a theory row built as +/// object[] feeds the generator itself to the code under test. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class GeneratorWhereValueExpectedAnalyzer : DiagnosticAnalyzer { + + private const string EqualsMethodName = "Equals"; + private const string ReferenceEqualsMethodName = "ReferenceEquals"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.GeneratorWhereValueExpected); + + /// + 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.IAny is null) { return; } + + INamedTypeSymbol iAny = symbols.IAny; + + context.RegisterOperationAction(operationContext => AnalyzeConversion(operationContext, iAny), OperationKind.Conversion); + context.RegisterOperationAction(operationContext => AnalyzeEquals(operationContext, iAny), OperationKind.Invocation); + } + + private static void AnalyzeConversion(OperationAnalysisContext context, INamedTypeSymbol iAny) { + IConversionOperation conversion = (IConversionOperation)context.Operation; + + if (!conversion.IsImplicit) { return; } + if (!IsObjectLike(conversion.Type)) { return; } + + IOperation operand = GeneratorFacts.Unwrap(conversion.Operand); + if (!GeneratorFacts.IsGenerator(operand.Type, iAny)) { return; } + + // A test asserting that the chain throws writes it as the whole body of a lambda argument, which binds to + // Func rather than Action and so produces a real generator-to-object conversion. Reporting it would + // fight every throws-assertion in the suite. + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(operand.Syntax)) { return; } + + // Comparing two recipes is a deliberate operation — it is how an immutability test proves a constraint + // returned a new generator rather than mutating the receiver. Generate() there would destroy the very + // property under test, so the identity comparisons belong to the Equals branch below, which reports only + // the mixed comparison. + if (IsOperandOfAGeneratorComparison(conversion, iAny)) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.GeneratorWhereValueExpected, operand.Syntax.GetLocation(), operand.Type!.Name)); + } + + // gen.Equals(value) resolves to object.Equals — reference equality against an unrelated object, false for ever. + private static void AnalyzeEquals(OperationAnalysisContext context, INamedTypeSymbol iAny) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != EqualsMethodName) { return; } + if (method.ContainingType?.SpecialType != SpecialType.System_Object) { return; } + + IOperation? receiver = invocation.Instance is null ? null : GeneratorFacts.Unwrap(invocation.Instance); + if (receiver is null || invocation.Arguments.Length != 1) { return; } + + IOperation argument = GeneratorFacts.Unwrap(invocation.Arguments[0].Value); + + bool receiverIsGenerator = GeneratorFacts.IsGenerator(receiver.Type, iAny); + bool argumentIsGenerator = GeneratorFacts.IsGenerator(argument.Type, iAny); + + // Comparing two generators is a deliberate identity check — this repository's own immutability tests do it. + // Only the mixed comparison is the mistake, and only the generator side needs the Generate(). + if (receiverIsGenerator == argumentIsGenerator) { return; } + + IOperation offending = receiverIsGenerator ? receiver : argument; + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.GeneratorWhereValueExpected, offending.Syntax.GetLocation(), offending.Type!.Name)); + } + + // True when the conversion feeds object.Equals or object.ReferenceEquals and some other operand of that same call + // is itself a generator — that is, when the call compares two recipes rather than a recipe against a value. + private static bool IsOperandOfAGeneratorComparison(IConversionOperation conversion, INamedTypeSymbol iAny) { + if (conversion.Parent is not IArgumentOperation { Parent: IInvocationOperation call }) { return false; } + if (call.TargetMethod.Name is not (EqualsMethodName or ReferenceEqualsMethodName)) { return false; } + if (call.TargetMethod.ContainingType?.SpecialType != SpecialType.System_Object) { return false; } + + if (call.Instance is not null && GeneratorFacts.IsGenerator(GeneratorFacts.Unwrap(call.Instance).Type, iAny)) { return true; } + + foreach (IArgumentOperation argument in call.Arguments) { + if (ReferenceEquals(argument, conversion.Parent)) { continue; } + if (GeneratorFacts.IsGenerator(GeneratorFacts.Unwrap(argument.Value).Type, iAny)) { return true; } + } + + return false; + } + + private static bool IsObjectLike(ITypeSymbol? type) { + return type is not null && (type.SpecialType == SpecialType.System_Object || type.TypeKind == TypeKind.Dynamic); + } + +} diff --git a/JustDummies.Analyzers/HeldCollectionPassedToOneOfAnalyzer.cs b/JustDummies.Analyzers/HeldCollectionPassedToOneOfAnalyzer.cs new file mode 100644 index 00000000..e7490d79 --- /dev/null +++ b/JustDummies.Analyzers/HeldCollectionPassedToOneOfAnalyzer.cs @@ -0,0 +1,90 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD013 — reports a held collection handed to Any.OneOf. The parameter is params T[], so a single +/// List<Order> argument binds T = List<Order> and yields a pool of exactly one: +/// every draw returns the same list, and the "arbitrary order" the test claims to exercise never varies. +/// Any.ElementOf is the entry point that takes a collection and draws from its elements. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class HeldCollectionPassedToOneOfAnalyzer : DiagnosticAnalyzer { + + private const string OneOfMethodName = "OneOf"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.HeldCollectionPassedToOneOf); + + /// + 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.AnyContext is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + IMethodSymbol method = invocation.TargetMethod; + + if (method.Name != OneOfMethodName) { return; } + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, symbols.Any) + && !SymbolEqualityComparer.Default.Equals(method.ContainingType, symbols.AnyContext)) { return; } + + // An explicit type argument states the intent — Any.OneOf>(orders) really does want a pool of one. + if (!IsTypeArgumentInferred(invocation)) { return; } + if (method.TypeArguments.Length != 1) { return; } + + if (!TryGetSingleExpandedArgument(invocation, out IOperation? single)) { return; } + if (!IsHeldCollection(single!.Type, method.TypeArguments[0])) { return; } + + context.ReportDiagnostic(Diagnostic.Create(Descriptors.HeldCollectionPassedToOneOf, invocation.Syntax.GetLocation(), method.TypeArguments[0].ToDisplayString())); + } + + private static bool IsTypeArgumentInferred(IInvocationOperation invocation) { + return invocation.Syntax is Microsoft.CodeAnalysis.CSharp.Syntax.InvocationExpressionSyntax { Expression: Microsoft.CodeAnalysis.CSharp.Syntax.MemberAccessExpressionSyntax { Name: not Microsoft.CodeAnalysis.CSharp.Syntax.GenericNameSyntax } }; + } + + // The params array was built by the compiler from exactly one argument — the shape that silently makes a pool of one. + private static bool TryGetSingleExpandedArgument(IInvocationOperation invocation, out IOperation? single) { + single = null; + + foreach (IArgumentOperation argument in invocation.Arguments) { + if (argument.ArgumentKind != ArgumentKind.ParamArray) { continue; } + if (argument.Value is not IArrayCreationOperation { Initializer: { } initializer }) { return false; } + if (initializer.ElementValues.Length != 1) { return false; } + + single = GeneratorFacts.Unwrap(initializer.ElementValues[0]); + + return true; + } + + return false; + } + + // A string is IEnumerable, so a single-string pool would otherwise be reported — and it is perfectly normal. + private static bool IsHeldCollection(ITypeSymbol? argumentType, ITypeSymbol inferred) { + if (argumentType is null) { return false; } + if (argumentType.SpecialType == SpecialType.System_String) { return false; } + if (inferred.TypeKind == TypeKind.TypeParameter) { return false; } + + foreach (INamedTypeSymbol implemented in argumentType.AllInterfaces) { + if (implemented.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T) { return true; } + } + + return false; + } + +} diff --git a/doc/handwritten/for-users/analyzers/JD011.en.md b/doc/handwritten/for-users/analyzers/JD011.en.md new file mode 100644 index 00000000..30efe993 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD011.en.md @@ -0,0 +1,56 @@ +# JD011: GeneratorWhereValueExpected + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD011.fr.md) + +| | | +|---|---| +| **Category** | Usage (`JustDummies.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | **No — opt-in** | + +Generators are reference types, so an `object`, `dynamic` or `params object[]` position accepts one with **no conversion at all**. This is the residue [ADR-0020](../../for-maintainers/adr/0020-materialize-dummies-only-through-generate.md) could not close by removing the implicit conversions: there was nothing to remove here. + +The recipe then survives as an opaque object: + +* an assertion helper taking `object` inspects the recipe — `Assert.NotNull(Any.String())` is green for ever and asserts nothing; +* a theory row built as `object[]` feeds the generator itself to the code under test; +* `gen.Equals(value)` resolves to `object.Equals`, which is reference equality against an unrelated object: false for every run and every seed. + +## Enabling it + +```ini +dotnet_diagnostic.JD011.severity = warning +``` + +## Noncompliant + +```csharp +Assert.NotNull(Any.String().NonEmpty()); // JD011: asserts the recipe is non-null. It always is. +object[] row = { Any.Int32().Positive(), 1 }; // JD011: the row carries a recipe +bool same = Any.String().NonEmpty().Equals(expected); // JD011: false, always +``` + +## Compliant + +```csharp +Assert.NotNull(Any.String().NonEmpty().Generate()); +object[] row = { Any.Int32().Positive().Generate(), 1 }; +bool same = Any.String().NonEmpty().Generate().Equals(expected); +``` + +## Why it ships opt-in + +[ADR-0059](../../for-maintainers/adr/0059-guard-the-recipe-versus-value-boundary-with-analyzers.md) required this rule's default to be decided on measurement rather than intuition. Dogfooded across this repository's suites it produced **no true positive and two false ones**, both in a convention test that collects generators into a `List` on purpose — a shape indistinguishable from the theory-row mistake the rule exists to catch, and therefore impossible to narrow away. + +That is weak evidence for enabling it everywhere, and good evidence that it belongs in a consumer's suite, where `object`-typed assertion helpers are common and reflection over generator instances is not. Enable it there. + +## What it does not flag + +* A comparison between **two** generators — `ReferenceEquals(original, narrowed)` and `first.Equals(second)`. Comparing recipes by identity is how an immutability test proves a constraint returned a new generator; `Generate()` there would destroy the property under test. +* `Assert.Throws(() => Any.String().WithLength(3))`, which binds to `Func` rather than `Action` and so produces a real generator-to-`object` conversion. That shape exists at 88+ sites in this repository alone. +* A generated value, which is the point. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD011.fr.md b/doc/handwritten/for-users/analyzers/JD011.fr.md new file mode 100644 index 00000000..43b8fc1c --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD011.fr.md @@ -0,0 +1,56 @@ +# JD011 : GeneratorWhereValueExpected + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD011.en.md) + +| | | +|---|---| +| **Catégorie** | Usage (`JustDummies.Usage`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | **Non — opt-in** | + +Les générateurs sont des types référence : une position `object`, `dynamic` ou `params object[]` en accepte un **sans aucune conversion**. C'est le résidu que l'[ADR-0020](../../for-maintainers/adr/0020-materialize-dummies-only-through-generate.md) ne pouvait pas fermer en supprimant les conversions implicites — il n'y avait rien à supprimer ici. + +La recette survit alors sous forme d'objet opaque : + +* un utilitaire d'assertion prenant `object` inspecte la recette — `Assert.NotNull(Any.String())` est vert pour toujours et n'assère rien ; +* une ligne de théorie construite en `object[]` livre le générateur lui-même au code sous test ; +* `gen.Equals(value)` se résout vers `object.Equals`, soit une égalité de référence contre un objet sans rapport : faux à chaque exécution et pour chaque graine. + +## Comment l'activer + +```ini +dotnet_diagnostic.JD011.severity = warning +``` + +## Non conforme + +```csharp +Assert.NotNull(Any.String().NonEmpty()); // JD011 : assère que la recette est non nulle. Elle l'est toujours. +object[] row = { Any.Int32().Positive(), 1 }; // JD011 : la ligne transporte une recette +bool same = Any.String().NonEmpty().Equals(expected); // JD011 : faux, toujours +``` + +## Conforme + +```csharp +Assert.NotNull(Any.String().NonEmpty().Generate()); +object[] row = { Any.Int32().Positive().Generate(), 1 }; +bool same = Any.String().NonEmpty().Generate().Equals(expected); +``` + +## Pourquoi elle est livrée en opt-in + +L'[ADR-0059](../../for-maintainers/adr/0059-guard-the-recipe-versus-value-boundary-with-analyzers.md) imposait que la valeur par défaut de cette règle soit tranchée sur mesure et non à l'intuition. Mise à l'épreuve sur les suites de ce dépôt, elle a produit **aucun vrai positif et deux faux**, tous deux dans un test de convention qui collecte délibérément des générateurs dans une `List` — une forme indiscernable de l'erreur de ligne de théorie que la règle vise, donc impossible à exclure. + +C'est une preuve faible en faveur d'une activation générale, et une bonne indication qu'elle a sa place dans la suite d'un consommateur, où les utilitaires d'assertion typés `object` sont courants et la réflexion sur des instances de générateurs ne l'est pas. Activez-la là. + +## Ce qui n'est pas signalé + +* Une comparaison entre **deux** générateurs — `ReferenceEquals(original, narrowed)` et `first.Equals(second)`. Comparer des recettes par identité est la façon dont un test d'immuabilité prouve qu'une contrainte a retourné un nouveau générateur ; `Generate()` y détruirait la propriété sous test. +* `Assert.Throws(() => Any.String().WithLength(3))`, qui se lie à `Func` et non à `Action`, produisant donc une véritable conversion générateur → `object`. Cette forme existe à plus de 88 endroits rien que dans ce dépôt. +* Une valeur générée, ce qui est l'objectif. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD012.en.md b/doc/handwritten/for-users/analyzers/JD012.en.md new file mode 100644 index 00000000..411b480a --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD012.en.md @@ -0,0 +1,40 @@ +# JD012: GeneratorPooledAsValue + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD012.fr.md) + +| | | +|---|---| +| **Category** | Usage (`JustDummies.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +`Any.OneOf` draws one value from a pool of values. Handed generators, it infers the *builder* type as the pool's element type: the pool holds recipes, and drawing from it yields a recipe rather than a value — which then flows on into the `object` and text positions [JD005](JD005.en.md) and [JD011](JD011.en.md) report. + +What makes this a trap rather than an obvious slip is that the surface is inconsistent about it. `Any.OneOf(Any.Int32(), Any.Int64())` fails type inference and the compiler stops you; `Any.OneOf(Any.Int32(), Any.Int32())` binds cleanly and says nothing. + +## Noncompliant + +```csharp +IAny pool = Any.OneOf(Any.Int32().Positive(), Any.Int32().Negative()); // JD012 +AnyInt32 drawn = pool.Generate(); // a recipe, not a number +``` + +## Compliant + +```csharp +IAny pool = Any.OneOf(Any.Int32().Positive().Generate(), Any.Int32().Negative().Generate()); +int drawn = pool.Generate(); +``` + +Note what the compliant form changes: the pool is built **eagerly**, one draw per element at construction, and `Generate()` then chooses among those fixed values. That is the documented semantics of a choice pool — if you wanted a fresh draw from a chosen generator on every call, that is a different intent and `Any.OneOf` is not the tool. + +## What it does not flag + +* A pool of ordinary values, or of generated values. +* A `OneOf` on any type other than `Any` / `AnyContext`. +* A pool whose generators have different types — the compiler already refuses it. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD012.fr.md b/doc/handwritten/for-users/analyzers/JD012.fr.md new file mode 100644 index 00000000..dcf3c1c5 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD012.fr.md @@ -0,0 +1,40 @@ +# JD012 : GeneratorPooledAsValue + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD012.en.md) + +| | | +|---|---| +| **Catégorie** | Usage (`JustDummies.Usage`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | Oui | + +`Any.OneOf` tire une valeur parmi un ensemble de valeurs. Alimenté avec des générateurs, il infère le type du *constructeur* comme type d'élément : l'ensemble contient des recettes, et y tirer produit une recette plutôt qu'une valeur — laquelle se propage ensuite vers les positions `object` et texte que signalent [JD005](JD005.fr.md) et [JD011](JD011.fr.md). + +Ce qui en fait un piège plutôt qu'une bévue évidente, c'est l'incohérence de la surface. `Any.OneOf(Any.Int32(), Any.Int64())` échoue à l'inférence de types et le compilateur vous arrête ; `Any.OneOf(Any.Int32(), Any.Int32())` se lie proprement et ne dit rien. + +## Non conforme + +```csharp +IAny pool = Any.OneOf(Any.Int32().Positive(), Any.Int32().Negative()); // JD012 +AnyInt32 drawn = pool.Generate(); // une recette, pas un nombre +``` + +## Conforme + +```csharp +IAny pool = Any.OneOf(Any.Int32().Positive().Generate(), Any.Int32().Negative().Generate()); +int drawn = pool.Generate(); +``` + +Notez ce que la forme conforme change : l'ensemble est construit **avec empressement**, un tirage par élément à la construction, et `Generate()` choisit ensuite parmi ces valeurs figées. C'est la sémantique documentée d'un ensemble de choix — si vous vouliez un tirage frais depuis un générateur choisi à chaque appel, c'est une autre intention et `Any.OneOf` n'est pas l'outil. + +## Ce qui n'est pas signalé + +* Un ensemble de valeurs ordinaires, ou de valeurs générées. +* Un `OneOf` porté par un autre type qu'`Any` / `AnyContext`. +* Un ensemble dont les générateurs ont des types différents — le compilateur le refuse déjà. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD013.en.md b/doc/handwritten/for-users/analyzers/JD013.en.md new file mode 100644 index 00000000..f715cf46 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD013.en.md @@ -0,0 +1,41 @@ +# JD013: HeldCollectionPassedToOneOf + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD013.fr.md) + +| | | +|---|---| +| **Category** | Usage (`JustDummies.Usage`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +`Any.OneOf` takes `params T[]`. A single `List` argument therefore binds `T = List`, not `Order`: the pool holds **one** item, every draw returns the same list, and the arbitrary choice the test claims to make never varies. Nothing fails — the call compiles, the draws succeed, and the test is simply less arbitrary than it reads. + +[ADR-0032](../../for-maintainers/adr/0032-draw-arbitrary-values-from-an-explicit-top-level-pool.md) recorded this as an accepted risk, "mitigated by `ElementOf` being the documented path". This rule turns that mitigation from a documentation hope into a check. + +`Any.ElementOf` is the entry point that takes a collection and draws from its **elements**. + +## Noncompliant + +```csharp +List orders = ...; +IAny> pool = Any.OneOf(orders); // JD013: a pool of one — every draw is the same list +``` + +## Compliant + +```csharp +List orders = ...; +IAny pool = Any.ElementOf(orders); +``` + +## What it does not flag + +* `Any.OneOf>(orders)` — an explicit type argument states the opposite intent, and is the documented way to say "a pool whose single element is that collection". +* A single `string`, which is `IEnumerable`: a one-string pool is ordinary. +* An **array**. An array satisfies `params` directly, so `T` is already inferred as the element type and the call is correct as written. +* Two or more arguments, which cannot produce the confusion. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD013.fr.md b/doc/handwritten/for-users/analyzers/JD013.fr.md new file mode 100644 index 00000000..ba97481d --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD013.fr.md @@ -0,0 +1,41 @@ +# JD013 : HeldCollectionPassedToOneOf + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD013.en.md) + +| | | +|---|---| +| **Catégorie** | Usage (`JustDummies.Usage`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | Oui | + +`Any.OneOf` prend `params T[]`. Un unique argument `List` lie donc `T = List` et non `Order` : l'ensemble contient **un** élément, chaque tirage retourne la même liste, et le choix arbitraire que le test prétend faire ne varie jamais. Rien n'échoue — l'appel compile, les tirages réussissent, et le test est simplement moins arbitraire qu'il ne se lit. + +L'[ADR-0032](../../for-maintainers/adr/0032-draw-arbitrary-values-from-an-explicit-top-level-pool.md) a consigné cela comme un risque accepté, « atténué par le fait qu'`ElementOf` est le chemin documenté ». Cette règle transforme cette atténuation d'un espoir documentaire en vérification. + +`Any.ElementOf` est le point d'entrée qui prend une collection et tire parmi ses **éléments**. + +## Non conforme + +```csharp +List orders = ...; +IAny> pool = Any.OneOf(orders); // JD013 : un ensemble d'un seul élément — chaque tirage rend la même liste +``` + +## Conforme + +```csharp +List orders = ...; +IAny pool = Any.ElementOf(orders); +``` + +## Ce qui n'est pas signalé + +* `Any.OneOf>(orders)` — un argument de type explicite énonce l'intention inverse, et c'est la façon documentée de dire « un ensemble dont l'unique élément est cette collection ». +* Une `string` seule, qui est `IEnumerable` : un ensemble d'une chaîne est ordinaire. +* Un **tableau**. Un tableau satisfait `params` directement : `T` est déjà inféré comme le type d'élément et l'appel est correct tel quel. +* Deux arguments ou plus, qui ne peuvent pas produire la confusion. + +--- + +[← 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 93aaafdb..9f7d4601 100644 --- a/doc/handwritten/for-users/analyzers/README.fr.md +++ b/doc/handwritten/for-users/analyzers/README.fr.md @@ -72,6 +72,9 @@ Un générateur est une *recette* immuable, et `Generate()` est la seule chose q |-------|----------|--------|-------------| | [JD005 GeneratorRenderedAsText](JD005.fr.md) | 🔴 Erreur | on | Un générateur est interpolé, concaténé ou passé à `ToString()` au lieu d'être généré ; aucun générateur ne surcharge `ToString()`, donc le texte obtenu est le nom de type du constructeur. | | [JD006 DiscardedGeneratorResult](JD006.fr.md) | 🟠 Avertissement | on | Le générateur retourné par une contrainte est jeté en instruction isolée ; les générateurs étant immuables, l'invariant déclaré est silencieusement perdu. | +| [JD011 GeneratorWhereValueExpected](JD011.fr.md) | 🟠 Avertissement | opt-in | Un générateur atteint une position `object`, `dynamic` ou `params object[]` : c'est la recette qui est stockée, comparée ou assérée, pas la valeur. | +| [JD012 GeneratorPooledAsValue](JD012.fr.md) | 🟠 Avertissement | on | `Any.OneOf` reçoit des générateurs et infère un ensemble de recettes ; y tirer produit une recette plutôt qu'une valeur. | +| [JD013 HeldCollectionPassedToOneOf](JD013.fr.md) | 🟠 Avertissement | on | Une collection tenue passée à `Any.OneOf` lie `T` au type de la collection, formant un ensemble d'un seul élément ; `Any.ElementOf` tire parmi ses éléments. | ## Configuration diff --git a/doc/handwritten/for-users/analyzers/README.md b/doc/handwritten/for-users/analyzers/README.md index e7f3c08a..64bbaa1d 100644 --- a/doc/handwritten/for-users/analyzers/README.md +++ b/doc/handwritten/for-users/analyzers/README.md @@ -72,6 +72,9 @@ A generator is an immutable *recipe*, and `Generate()` is the only thing that ma |------|----------|---------|-------------| | [JD005 GeneratorRenderedAsText](JD005.en.md) | 🔴 Error | on | A generator is interpolated, concatenated or ToString()'d instead of generated from; no generator overrides ToString(), so the text is the builder's type name. | | [JD006 DiscardedGeneratorResult](JD006.en.md) | 🟠 Warning | on | The generator returned by a constraint is discarded as a bare statement; generators are immutable, so the declared invariant is silently lost. | +| [JD011 GeneratorWhereValueExpected](JD011.en.md) | 🟠 Warning | opt-in | A generator reaches an object, dynamic or params object[] position, so the recipe is stored, compared or asserted on instead of the value. | +| [JD012 GeneratorPooledAsValue](JD012.en.md) | 🟠 Warning | on | Any.OneOf is given generators, inferring a pool of recipes; drawing from it yields a recipe rather than a value. | +| [JD013 HeldCollectionPassedToOneOf](JD013.en.md) | 🟠 Warning | on | A held collection passed to Any.OneOf binds T to the collection type, making a pool of one; Any.ElementOf draws from its elements. | ## Configuring