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