From 646915d7130a8c0b69c3a94c8a7876690cf401f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:28:12 +0000 Subject: [PATCH] feat(justdummies): add JD014-JD015 and the constraint-analysis helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens the JustDummies.Constraints category, which front-loads to build time the subset of the library's run-time constraint checks that is decidable from compile-time constants. The run-time checks stay: they cover every argument these cannot see. Three helpers carry it. ConstantFacts reads statically-known values and is deliberately wider than IOperation.ConstantValue, because a TimeSpan is never a C# constant yet TimeSpan.Zero and TimeSpan.FromSeconds(1) are as statically known as any literal — and the granularity constraints that take one are exactly the ones worth checking. AnyChainFacts walks a fluent chain back to its factory and yields the constraint calls in order, syntactically and without dataflow: a rule claiming a chain is unsatisfiable must see every constraint it carries, so a chain split across statements is not followed rather than half-understood. JD014 reports a constant argument the generator's own guard refuses, as one rule over one table mirroring SizeGuard and the per-generator guards. The library validates these in one place with one message shape, and a reader who learns "a constant the guard rejects" has learned all of them. JD015 reports an AnyString chain whose constant constraints admit no value. This is the case ADR-0035 names by hand as the one an analyzer should carry and the type system cannot. Dogfooding corrected JD015 twice, in opposite directions, and both corrections are now pinned by tests citing the sites that produced them. Casing is not a character set: LowerCase/UpperCase constrain the case of a fragment's LETTERS and say nothing about its other characters, so UpperCase().StartingWith("ORD-") is legal — the first version modelled casing as a pool and flagged a chain AnyStringTests asserts is legal. And the length budget, which the library does compute, no longer applies once OneOf is declared: the fragments are then matched against the pooled values rather than laid out side by side, which is why OneOf("aba").WithMaxLength(3).Containing("ab").Containing("ba") is legal. Both were verified against the built library before being encoded, not inferred from reading the guards. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GKqyhG9Y4AfdxEYekP2Evq --- .../Jd014RejectedConstantArgumentTests.cs | 200 +++++++++++++ ...Jd015StringConstraintsAdmitNoValueTests.cs | 252 ++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 2 + JustDummies.Analyzers/AnyChainFacts.cs | 76 +++++ JustDummies.Analyzers/ConstantFacts.cs | 80 +++++ JustDummies.Analyzers/Descriptors.cs | 20 ++ JustDummies.Analyzers/DiagnosticCategories.cs | 6 + JustDummies.Analyzers/DiagnosticIds.cs | 4 + .../RejectedConstantArgumentAnalyzer.cs | 280 ++++++++++++++++++ .../StringConstraintsAdmitNoValueAnalyzer.cs | 166 +++++++++++ .../for-users/analyzers/JD014.en.md | 53 ++++ .../for-users/analyzers/JD014.fr.md | 53 ++++ .../for-users/analyzers/JD015.en.md | 53 ++++ .../for-users/analyzers/JD015.fr.md | 53 ++++ .../for-users/analyzers/README.fr.md | 9 + doc/handwritten/for-users/analyzers/README.md | 9 + 16 files changed, 1316 insertions(+) create mode 100644 JustDummies.Analyzers.UnitTests/Jd014RejectedConstantArgumentTests.cs create mode 100644 JustDummies.Analyzers.UnitTests/Jd015StringConstraintsAdmitNoValueTests.cs create mode 100644 JustDummies.Analyzers/AnyChainFacts.cs create mode 100644 JustDummies.Analyzers/ConstantFacts.cs create mode 100644 JustDummies.Analyzers/RejectedConstantArgumentAnalyzer.cs create mode 100644 JustDummies.Analyzers/StringConstraintsAdmitNoValueAnalyzer.cs create mode 100644 doc/handwritten/for-users/analyzers/JD014.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD014.fr.md create mode 100644 doc/handwritten/for-users/analyzers/JD015.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD015.fr.md diff --git a/JustDummies.Analyzers.UnitTests/Jd014RejectedConstantArgumentTests.cs b/JustDummies.Analyzers.UnitTests/Jd014RejectedConstantArgumentTests.cs new file mode 100644 index 00000000..27dfa093 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd014RejectedConstantArgumentTests.cs @@ -0,0 +1,200 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd014RejectedConstantArgumentTests { + + [Theory] + [InlineData("Any.String().WithLengthBetween(10, 5)", "transposed")] + [InlineData("Any.Int32().Between(10, 5)", "transposed")] + [InlineData("Any.String().WithLength(-1)", "negative")] + [InlineData("Any.String().WithMaxLength(-1)", "negative")] + [InlineData("Any.String().WithLength(2000000)", "1,000,000")] + [InlineData("Any.Int32().MultipleOf(0)", "strictly positive")] + [InlineData("Any.Decimal().WithScale(29)", "[0, 28]")] + [InlineData("Any.String().StartingWith(\"\")", "must not be empty")] + [InlineData("Any.String().WithChars(\"\")", "must not be empty")] + public async Task Reports_an_argument_the_guard_rejects(string expression, string expectedFragment) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD014"); + Check.That(diagnostics[0].GetMessage()).Contains(expectedFragment); + } + + [Fact] + public async Task Reports_a_collection_count_range_that_is_transposed() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.ListOf(Any.Int32()).WithCountBetween(10, 2); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD014"); + } + + [Fact] + public async Task Reports_an_empty_choice_pool() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.String().OneOf(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("at least one value"); + } + + [Fact] + public async Task Reports_a_non_positive_granularity() { + const string source = """ + using System; + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.DateTime().WithGranularity(TimeSpan.Zero); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("strictly positive"); + } + + [Theory] + [InlineData("Any.String().WithLengthBetween(5, 10)")] + [InlineData("Any.Int32().Between(5, 10)")] + [InlineData("Any.Int32().Between(5, 5)")] + [InlineData("Any.String().WithLength(0)")] + [InlineData("Any.String().WithMaxLength(0)")] + [InlineData("Any.Int32().MultipleOf(7)")] + [InlineData("Any.Decimal().WithScale(28)")] + [InlineData("Any.String().StartingWith(\"ORD-\")")] + [InlineData("Any.String().OneOf(\"EUR\")")] + [InlineData("Any.ListOf(Any.Int32()).WithCountBetween(2, 10)")] + public async Task Does_not_report_a_legal_argument(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_non_constant_argument() { + // Only a constant is decidable; a variable is the run-time guard's business and must stay unreported. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M(int minimum, int maximum) { + _ = Any.Int32().Between(minimum, maximum); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_guard_asserting_negative_test() { + // The shape this repository writes hundreds of times; reporting it would fight the suite that documents + // the guard's behaviour. + const string source = """ + using System; + using JustDummies; + + public static class Check2 { + public static void ThatCode(Func code) { } + } + + public static class Sample { + public static void M() { + Check2.ThatCode(() => Any.Int32().Between(10, 5)); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_same_named_method_on_another_type() { + const string source = """ + public sealed class Other { + public Other Between(int minimum, int maximum) => this; + } + + public static class Sample { + public static void M() { + _ = new Other().Between(10, 5); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_Containing_on_a_collection() { + // Containing(TItem) is not the string overload; the non-empty-text check must key on the parameter type. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.ListOf(Any.Int32()).Containing(0); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new RejectedConstantArgumentAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/Jd015StringConstraintsAdmitNoValueTests.cs b/JustDummies.Analyzers.UnitTests/Jd015StringConstraintsAdmitNoValueTests.cs new file mode 100644 index 00000000..75054347 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd015StringConstraintsAdmitNoValueTests.cs @@ -0,0 +1,252 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd015StringConstraintsAdmitNoValueTests { + + [Fact] + public async Task Reports_the_case_ADR_0035_names() { + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().Numeric().StartingWith("ORD-").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD015"); + Check.That(diagnostics[0].GetMessage()).Contains("Numeric()"); + } + + [Fact] + public async Task Does_not_report_the_sibling_ADR_0035_contrasts_it_with() { + // Identical call site, identical static types — only the argument's value differs, and this one is legal. + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().Numeric().StartingWith("123").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Reports_a_letter_whose_case_the_chain_forbids() { + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().LowerCase().StartingWith("ABC").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("uppercase letter 'A'"); + } + + [Fact] + public async Task Does_not_report_a_non_letter_under_a_casing_constraint() { + // Verified against the library: casing constrains the case of a fragment's LETTERS and says nothing about its + // other characters. This exact chain is asserted legal by JustDummies.UnitTests/AnyStringTests.cs. + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().UpperCase().StartingWith("ORD-").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_length_budget_once_a_value_set_is_declared() { + // With OneOf declared, the fragments are matched against the pooled values rather than laid out side by side, + // so the budget no longer applies. Live in JustDummies.UnitTests/AnyStringValueSetTests.cs. + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().OneOf("aba").WithMaxLength(3).Containing("ab").Containing("ba").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Reports_the_constraint_in_either_declaration_order() { + // StringSpec re-validates the whole spec on every mutation, so the order does not change the verdict. + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().StartingWith("ORD-").Numeric().Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + } + + [Fact] + public async Task Reports_fragments_that_cannot_fit_a_fixed_length() { + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().WithLength(3).StartingWith("ORD-").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("at least 4 characters"); + } + + [Fact] + public async Task Reports_a_prefix_and_suffix_that_exceed_the_cap() { + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().WithMaxLength(6).StartingWith("SKU-").EndingWith("-EUR").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("WithMaxLength(6)"); + } + + [Fact] + public async Task Does_not_report_a_budget_that_fits_exactly() { + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().WithLength(8).StartingWith("SKU-").EndingWith("-EUR").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_fragment_inside_an_explicit_pool() { + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + return Any.String().WithChars("ORD-0123456789").StartingWith("ORD-").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_non_constant_fragment() { + const string source = """ + using JustDummies; + + public static class Sample { + public static string M(string prefix) { + return Any.String().Numeric().StartingWith(prefix).Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_chain_split_across_statements() { + // The chain must be one expression: following a generator through a local would need dataflow, and a rule + // claiming a chain is unsatisfiable must see every constraint it carries. + const string source = """ + using JustDummies; + + public static class Sample { + public static string M() { + AnyString generator = Any.String().Numeric(); + + return generator.StartingWith("ORD-").Generate(); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_conflict_asserting_negative_test() { + const string source = """ + using System; + using JustDummies; + + public static class Check2 { + public static void ThatCode(Func code) { } + } + + public static class Sample { + public static void M() { + Check2.ThatCode(() => Any.String().Numeric().StartingWith("ORD-")); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new StringConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md index 6c237375..ef167bcd 100644 --- a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md +++ b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md @@ -18,3 +18,5 @@ JD010 | JustDummies.Reproducibility | Warning | ReproducibleOnNonTestMethodAn JD011 | JustDummies.Usage | Disabled | GeneratorWhereValueExpectedAnalyzer JD012 | JustDummies.Usage | Warning | GeneratorPooledAsValueAnalyzer JD013 | JustDummies.Usage | Warning | HeldCollectionPassedToOneOfAnalyzer +JD014 | JustDummies.Constraints | Warning | RejectedConstantArgumentAnalyzer +JD015 | JustDummies.Constraints | Warning | StringConstraintsAdmitNoValueAnalyzer diff --git a/JustDummies.Analyzers/AnyChainFacts.cs b/JustDummies.Analyzers/AnyChainFacts.cs new file mode 100644 index 00000000..05ddfc39 --- /dev/null +++ b/JustDummies.Analyzers/AnyChainFacts.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// Walks a fluent generator chain back to the factory that started it, and yields the constraint calls in the +/// order they were written. Every rule that reasons about a combination of constraints — rather than about +/// one argument — needs this, because the library's own conflict detection is order-sensitive in its messages and +/// order-insensitive in its verdict. +/// +/// +/// Syntactic on purpose. The chain must be written as one expression; a generator passed through a local, a field +/// or a helper is not followed. That is a deliberate limit, not an oversight: following it would mean dataflow, +/// and a rule that claims a chain is unsatisfiable must be certain of every constraint the chain carries. +/// +internal static class AnyChainFacts { + + /// + /// The constraint calls of the chain belongs to, outermost call last, together + /// with the factory that rooted it. Returns false when the chain does not start at a + /// JustDummies.Any / AnyContext factory, which is the only case a rule can reason about + /// completely. + /// + public static bool TryGetChain(IInvocationOperation invocation, KnownSymbols symbols, out IReadOnlyList constraints, out IInvocationOperation? factory) { + List collected = new(); + constraints = collected; + factory = null; + + // Climb to the outermost invocation of the chain, so a rule registered on any link sees the whole of it. + IInvocationOperation outermost = invocation; + while (outermost.Parent is IInvocationOperation parent && ReferenceEquals(GeneratorFacts.Unwrap(parent.Instance ?? parent), outermost)) { + outermost = parent; + } + + for (IInvocationOperation? current = outermost; current is not null;) { + if (current.Instance is null) { + // A static call roots the chain: it is the factory when it belongs to Any, otherwise this is not a + // JustDummies chain at all. + if (!IsFactoryOwner(current.TargetMethod.ContainingType, symbols)) { return false; } + + factory = current; + collected.Reverse(); + + return true; + } + + IOperation receiver = GeneratorFacts.Unwrap(current.Instance); + + // An instance call on AnyContext roots the chain the same way Any's static factories do. + if (receiver is not IInvocationOperation next) { + if (IsFactoryOwner(current.TargetMethod.ContainingType, symbols)) { + factory = current; + collected.Reverse(); + + return true; + } + + return false; + } + + collected.Add(current); + current = next; + } + + return false; + } + + private static bool IsFactoryOwner(INamedTypeSymbol? type, KnownSymbols symbols) { + return SymbolEqualityComparer.Default.Equals(type, symbols.Any) + || SymbolEqualityComparer.Default.Equals(type, symbols.AnyContext); + } + +} diff --git a/JustDummies.Analyzers/ConstantFacts.cs b/JustDummies.Analyzers/ConstantFacts.cs new file mode 100644 index 00000000..a2b13308 --- /dev/null +++ b/JustDummies.Analyzers/ConstantFacts.cs @@ -0,0 +1,80 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// Reads the statically-known value of an argument. Deliberately wider than on +/// IOperation.ConstantValue: a is never a C# constant, yet +/// TimeSpan.Zero and TimeSpan.FromSeconds(1) are as statically known as any literal, and the +/// constraints that take one are exactly the ones worth checking. +/// +/// +/// Every reader answers "no" rather than guessing. A rule built on this can therefore only under-report, which is +/// the right failure direction for a diagnostic that claims a call is certainly wrong. +/// +internal static class ConstantFacts { + + private const string TimeSpanMetadataName = "System.TimeSpan"; + private const string ZeroFieldName = "Zero"; + + /// Reads a constant , folding named constants as the compiler does. + public static bool TryGetInt32(IOperation operation, out int value) { + value = 0; + + IOperation unwrapped = GeneratorFacts.Unwrap(operation); + if (unwrapped.ConstantValue is not { HasValue: true, Value: int constant }) { return false; } + + value = constant; + + return true; + } + + /// Reads a constant . A null literal answers false: a null argument is + /// the null-guard's business, not a constraint rule's. + public static bool TryGetString(IOperation operation, out string value) { + value = string.Empty; + + IOperation unwrapped = GeneratorFacts.Unwrap(operation); + if (unwrapped.ConstantValue is not { HasValue: true, Value: string constant }) { return false; } + + value = constant; + + return true; + } + + /// + /// Whether the operation is a statically-known non-positive — the shape + /// every granularity guard rejects. Recognises TimeSpan.Zero and the TimeSpan.FromXxx(constant) + /// factories; anything else answers false. + /// + public static bool IsNonPositiveTimeSpan(IOperation operation, Compilation compilation) { + INamedTypeSymbol? timeSpan = compilation.GetTypeByMetadataName(TimeSpanMetadataName); + if (timeSpan is null) { return false; } + + IOperation unwrapped = GeneratorFacts.Unwrap(operation); + + if (unwrapped is IFieldReferenceOperation field) { + return field.Field.Name == ZeroFieldName && SymbolEqualityComparer.Default.Equals(field.Field.ContainingType, timeSpan); + } + + // TimeSpan.FromSeconds(0), FromMinutes(-1) ... — a single constant numeric argument settles the sign. + if (unwrapped is IInvocationOperation { Arguments.Length: 1 } invocation + && SymbolEqualityComparer.Default.Equals(invocation.TargetMethod.ContainingType, timeSpan) + && invocation.TargetMethod.Name.StartsWith("From", System.StringComparison.Ordinal)) { + + IOperation argument = GeneratorFacts.Unwrap(invocation.Arguments[0].Value); + if (argument.ConstantValue is { HasValue: true, Value: { } raw }) { + return raw switch { + double d => d <= 0, + int i => i <= 0, + long l => l <= 0, + _ => false, + }; + } + } + + return false; + } + +} diff --git a/JustDummies.Analyzers/Descriptors.cs b/JustDummies.Analyzers/Descriptors.cs index 4ba64e58..8fd628db 100644 --- a/JustDummies.Analyzers/Descriptors.cs +++ b/JustDummies.Analyzers/Descriptors.cs @@ -142,4 +142,24 @@ internal static class Descriptors { 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)); + public static readonly DiagnosticDescriptor RejectedConstantArgument = new( + id: DiagnosticIds.RejectedConstantArgument, + title: "A constant argument is one the generator rejects", + messageFormat: "{0} throws for this argument: {1}", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The argument is a compile-time constant the generator's own guard refuses, so the call throws every time it runs. Nothing is decided at run time that is not already decided here, and the failure otherwise surfaces late — inside an arrange helper shared by many tests, where it reads as a library problem rather than as the transposition typo it usually is. The run-time guards stay for every argument this cannot see.", + helpLinkUri: HelpLinks.For(DiagnosticIds.RejectedConstantArgument)); + + public static readonly DiagnosticDescriptor StringConstraintsAdmitNoValue = new( + id: DiagnosticIds.StringConstraintsAdmitNoValue, + title: "The declared string constraints admit no value", + messageFormat: "No string satisfies this chain: {0}", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The constraints contradict each other for the constants written at the call site, so the chain throws a ConflictingAnyConstraintException the moment the arrange line runs. This is the case ADR-0035 names as the one an analyzer should carry: Numeric().StartingWith(\"ORD-\") conflicts while Numeric().StartingWith(\"123\") does not, from identical call sites and identical static types — only the argument value tells them apart.", + helpLinkUri: HelpLinks.For(DiagnosticIds.StringConstraintsAdmitNoValue)); + } diff --git a/JustDummies.Analyzers/DiagnosticCategories.cs b/JustDummies.Analyzers/DiagnosticCategories.cs index 5184e574..fbdc9856 100644 --- a/JustDummies.Analyzers/DiagnosticCategories.cs +++ b/JustDummies.Analyzers/DiagnosticCategories.cs @@ -13,4 +13,10 @@ internal static class DiagnosticCategories { /// public const string Usage = "JustDummies.Usage"; + /// + /// Rules that front-load, to build time, the subset of the library's run-time constraint checks that is + /// decidable from compile-time constants. The run-time checks stay: they cover every argument these cannot see. + /// + public const string Constraints = "JustDummies.Constraints"; + } diff --git a/JustDummies.Analyzers/DiagnosticIds.cs b/JustDummies.Analyzers/DiagnosticIds.cs index a04b0ebc..a94750b1 100644 --- a/JustDummies.Analyzers/DiagnosticIds.cs +++ b/JustDummies.Analyzers/DiagnosticIds.cs @@ -27,4 +27,8 @@ internal static class DiagnosticIds { public const string GeneratorPooledAsValue = "JD012"; public const string HeldCollectionPassedToOneOf = "JD013"; + // Category: Constraints — decidable from compile-time constants + public const string RejectedConstantArgument = "JD014"; + public const string StringConstraintsAdmitNoValue = "JD015"; + } diff --git a/JustDummies.Analyzers/RejectedConstantArgumentAnalyzer.cs b/JustDummies.Analyzers/RejectedConstantArgumentAnalyzer.cs new file mode 100644 index 00000000..66dceda8 --- /dev/null +++ b/JustDummies.Analyzers/RejectedConstantArgumentAnalyzer.cs @@ -0,0 +1,280 @@ +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD014 — reports a constraint argument that is a compile-time constant the generator's own guard rejects, so the +/// call throws every time it runs. The mistake is fully determined by a literal at the call site, yet it survives +/// the build and only fires when that arrange line executes — often deep inside a helper shared by many tests, +/// where the failure reads as a library problem rather than as the transposition typo it usually is. +/// +/// +/// One rule over one table rather than a rule per method: the library validates these in one place, with one +/// message shape, and a reader who learns "a constant the guard rejects" has learned all of them. The table +/// mirrors SizeGuard and the per-generator guards exactly; where it cannot be certain it stays silent. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class RejectedConstantArgumentAnalyzer : DiagnosticAnalyzer { + + // SizeGuard.MaxProducibleSize — a size the generator must actually produce is capped here. + private const int MaxProducibleSize = 1_000_000; + private const int MaxDecimalScale = 28; + private const int MinPort = 1; + private const int MaxPort = 65535; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.RejectedConstantArgument); + + /// + 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; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (!IsJustDummiesMember(invocation.TargetMethod, symbols)) { return; } + + // A test asserting that the guard rejects the argument writes the illegal call as the whole body of a lambda + // argument. This repository alone holds hundreds of them. + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + if (TryFindViolation(invocation, context.Compilation, out IOperation? offending, out string? reason)) { + context.ReportDiagnostic(Diagnostic.Create(Descriptors.RejectedConstantArgument, offending!.Syntax.GetLocation(), invocation.TargetMethod.Name, reason)); + } + } + + private static bool TryFindViolation(IInvocationOperation invocation, Compilation compilation, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + string name = invocation.TargetMethod.Name; + + switch (name) { + case "WithLength" or "WithMinLength" or "WithCount" or "WithMinCount": + return TryCheckSize(invocation, capped: true, out offending, out reason); + + case "WithMaxLength" or "WithMaxCount" or "WithPathSegments": + return TryCheckSize(invocation, capped: false, out offending, out reason); + + case "WithLengthBetween" or "WithCountBetween": + return TryCheckSizeRange(invocation, out offending, out reason); + + case "Between": + return TryCheckOrderedPair(invocation, out offending, out reason); + + case "MultipleOf": + return TryCheckStrictlyPositive(invocation, out offending, out reason); + + case "WithGranularity": + return TryCheckGranularity(invocation, compilation, out offending, out reason); + + case "WithScale": + return TryCheckRange(invocation, 0, MaxDecimalScale, "the scale must be in the inclusive range [0, 28]", out offending, out reason); + + case "WithPort": + return TryCheckRange(invocation, MinPort, MaxPort, "the port must be between 1 and 65535", out offending, out reason); + + case "StartingWith" or "EndingWith" or "Containing" or "WithChars" or "WithHost": + return TryCheckNonEmptyText(invocation, out offending, out reason); + + case "OneOf" or "Except": + return TryCheckNonEmptyPool(invocation, out offending, out reason); + + default: + return false; + } + } + + private static bool TryCheckSize(IInvocationOperation invocation, bool capped, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + foreach (IArgumentOperation argument in NumericArguments(invocation)) { + if (!ConstantFacts.TryGetInt32(argument.Value, out int value)) { continue; } + + if (value < 0) { + offending = argument.Value; + reason = "it must not be negative"; + + return true; + } + + if (capped && value > MaxProducibleSize) { + offending = argument.Value; + reason = $"it must not exceed {MaxProducibleSize:N0}, the largest size the generator will produce"; + + return true; + } + } + + return false; + } + + private static bool TryCheckSizeRange(IInvocationOperation invocation, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + IArgumentOperation[] arguments = NumericArguments(invocation).ToArray(); + if (arguments.Length != 2) { return false; } + + if (ConstantFacts.TryGetInt32(arguments[0].Value, out int minimum) && minimum < 0) { + offending = arguments[0].Value; + reason = "it must not be negative"; + + return true; + } + + if (ConstantFacts.TryGetInt32(arguments[0].Value, out int min) && min > MaxProducibleSize) { + offending = arguments[0].Value; + reason = $"it must not exceed {MaxProducibleSize:N0}, the largest size the generator will produce"; + + return true; + } + + if (ConstantFacts.TryGetInt32(arguments[1].Value, out int maximum) && maximum < 0) { + offending = arguments[1].Value; + reason = "it must not be negative"; + + return true; + } + + return TryCheckOrderedPair(invocation, out offending, out reason); + } + + private static bool TryCheckOrderedPair(IInvocationOperation invocation, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + IArgumentOperation[] arguments = NumericArguments(invocation).ToArray(); + if (arguments.Length != 2) { return false; } + + if (!ConstantFacts.TryGetInt32(arguments[0].Value, out int minimum)) { return false; } + if (!ConstantFacts.TryGetInt32(arguments[1].Value, out int maximum)) { return false; } + if (minimum <= maximum) { return false; } + + offending = arguments[0].Value; + reason = $"the minimum ({minimum}) must be less than or equal to the maximum ({maximum}) — the two arguments look transposed"; + + return true; + } + + private static bool TryCheckStrictlyPositive(IInvocationOperation invocation, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + foreach (IArgumentOperation argument in NumericArguments(invocation)) { + if (!ConstantFacts.TryGetInt32(argument.Value, out int value) || value > 0) { continue; } + + offending = argument.Value; + reason = "it must be strictly positive"; + + return true; + } + + return false; + } + + private static bool TryCheckGranularity(IInvocationOperation invocation, Compilation compilation, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + foreach (IArgumentOperation argument in invocation.Arguments) { + if (!ConstantFacts.IsNonPositiveTimeSpan(argument.Value, compilation)) { continue; } + + offending = argument.Value; + reason = "the granularity must be strictly positive"; + + return true; + } + + return false; + } + + private static bool TryCheckRange(IInvocationOperation invocation, int minimum, int maximum, string requirement, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + foreach (IArgumentOperation argument in NumericArguments(invocation)) { + if (!ConstantFacts.TryGetInt32(argument.Value, out int value)) { continue; } + if (value >= minimum && value <= maximum) { continue; } + + offending = argument.Value; + reason = requirement; + + return true; + } + + return false; + } + + private static bool TryCheckNonEmptyText(IInvocationOperation invocation, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + foreach (IArgumentOperation argument in invocation.Arguments) { + if (argument.Parameter?.Type.SpecialType != SpecialType.System_String) { continue; } + if (!ConstantFacts.TryGetString(argument.Value, out string text)) { continue; } + + if (text.Length == 0) { + offending = argument.Value; + reason = "it must not be empty"; + + return true; + } + + if (invocation.TargetMethod.Name == "WithChars" && text.Any(char.IsSurrogate)) { + offending = argument.Value; + reason = "a character pool must not contain a surrogate: an astral code point spans two UTF-16 units, which the draw would split. Use OneOf(...) to draw such values as whole strings"; + + return true; + } + } + + return false; + } + + private static bool TryCheckNonEmptyPool(IInvocationOperation invocation, out IOperation? offending, out string? reason) { + offending = null; + reason = null; + + foreach (IArgumentOperation argument in invocation.Arguments) { + if (argument.ArgumentKind != ArgumentKind.ParamArray) { continue; } + if (argument.Value is not IArrayCreationOperation { Initializer: { } initializer }) { continue; } + if (!initializer.ElementValues.IsEmpty) { continue; } + + offending = invocation; + reason = "at least one value is required"; + + return true; + } + + return false; + } + + // Only the arguments a size or bound guard inspects: an int parameter. This keeps Containing(TItem) on a + // collection, or Between(DateTime, DateTime), out of the integer checks rather than misreading them. + private static System.Collections.Generic.IEnumerable NumericArguments(IInvocationOperation invocation) { + return invocation.Arguments.Where(argument => argument.Parameter?.Type.SpecialType == SpecialType.System_Int32); + } + + private static bool IsJustDummiesMember(IMethodSymbol method, KnownSymbols symbols) { + return SymbolEqualityComparer.Default.Equals(method.ContainingType?.ContainingAssembly, symbols.IAny!.ContainingAssembly); + } + +} diff --git a/JustDummies.Analyzers/StringConstraintsAdmitNoValueAnalyzer.cs b/JustDummies.Analyzers/StringConstraintsAdmitNoValueAnalyzer.cs new file mode 100644 index 00000000..90697b34 --- /dev/null +++ b/JustDummies.Analyzers/StringConstraintsAdmitNoValueAnalyzer.cs @@ -0,0 +1,166 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD015 — reports an AnyString chain whose constant constraints admit no value: an anchored fragment +/// holding a character the declared character family forbids, or fragments that cannot fit the declared length. +/// +/// +/// This is the case ADR-0035 names by hand as the one an analyzer should carry and the type system cannot: +/// Numeric().StartingWith("ORD-") conflicts while Numeric().StartingWith("123") does not, from +/// identical call sites and identical static types. Only the argument's value tells them apart, which is exactly +/// what makes it value-dependent — and what puts it on the analyzer's side of the ADR's line. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class StringConstraintsAdmitNoValueAnalyzer : DiagnosticAnalyzer { + + private const string UpperLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string LowerLetters = "abcdefghijklmnopqrstuvwxyz"; + private const string Digits = "0123456789"; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.StringConstraintsAdmitNoValue); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Any is null || symbols.IAny is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + // Analyse each chain once, from its outermost call. + if (invocation.Parent is IInvocationOperation) { return; } + if (!AnyChainFacts.TryGetChain(invocation, symbols, out IReadOnlyList constraints, out IInvocationOperation? factory)) { return; } + if (factory is null || factory.TargetMethod.Name != "String") { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + string? pool = null; + string? poolName = null; + bool requireUpper = false; + bool requireLower = false; + bool hasValueSet = false; + List<(string Text, IOperation At)> fragments = new(); + int? fixedLength = null; + int? maximum = null; + + foreach (IInvocationOperation constraint in constraints) { + switch (constraint.TargetMethod.Name) { + case "Alpha": pool = UpperLetters + LowerLetters; poolName = "Alpha()"; break; + case "Numeric": pool = Digits; poolName = "Numeric()"; break; + case "AlphaNumeric": pool = UpperLetters + LowerLetters + Digits; poolName = "AlphaNumeric()"; break; + + // Casing is not a character set: it constrains the CASE of a fragment's letters and says nothing + // about its other characters. UpperCase().StartingWith("ORD-") is legal — the '-' is not a letter — + // while UpperCase().StartingWith("abc") is not. + case "UpperCase": requireUpper = true; break; + case "LowerCase": requireLower = true; break; + + // A terminal value set changes what the fragments are checked against: they are matched against the + // pooled values rather than laid out side by side, so the length budget below no longer applies. + case "OneOf": hasValueSet = true; break; + + case "WithChars" when constraint.Arguments.Length == 1 && ConstantFacts.TryGetString(constraint.Arguments[0].Value, out string declared): + pool = declared; + poolName = $"WithChars(\"{declared}\")"; + + break; + + case "StartingWith" or "EndingWith" or "Containing" when constraint.Arguments.Length == 1 && ConstantFacts.TryGetString(constraint.Arguments[0].Value, out string fragment): + fragments.Add((fragment, constraint.Arguments[0].Value)); + + break; + + case "WithLength" when constraint.Arguments.Length == 1 && ConstantFacts.TryGetInt32(constraint.Arguments[0].Value, out int length): + fixedLength = length; + + break; + + case "WithMaxLength" when constraint.Arguments.Length == 1 && ConstantFacts.TryGetInt32(constraint.Arguments[0].Value, out int max): + maximum = maximum is null ? max : System.Math.Min(maximum.Value, max); + + break; + } + } + + if (ReportCharacterOutsidePool(context, pool, poolName, fragments)) { return; } + if (ReportLetterAgainstCasing(context, requireUpper, requireLower, fragments)) { return; } + if (hasValueSet) { return; } + + ReportLengthBudget(context, fragments, fixedLength, maximum); + } + + private static bool ReportLetterAgainstCasing(OperationAnalysisContext context, bool requireUpper, bool requireLower, List<(string Text, IOperation At)> fragments) { + if (!requireUpper && !requireLower) { return false; } + + foreach ((string text, IOperation at) in fragments) { + foreach (char character in text) { + if (!char.IsLetter(character)) { continue; } + + bool offends = requireUpper ? char.IsLower(character) : char.IsUpper(character); + if (!offends) { continue; } + + string constraint = requireUpper ? "UpperCase()" : "LowerCase()"; + string wrongCase = requireUpper ? "lowercase" : "uppercase"; + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.StringConstraintsAdmitNoValue, at.Syntax.GetLocation(), + $"{constraint} forbids the {wrongCase} letter '{character}'")); + + return true; + } + } + + return false; + } + + private static bool ReportCharacterOutsidePool(OperationAnalysisContext context, string? pool, string? poolName, List<(string Text, IOperation At)> fragments) { + if (pool is null || pool.Length == 0) { return false; } + + foreach ((string text, IOperation at) in fragments) { + foreach (char character in text) { + if (pool.IndexOf(character) >= 0) { continue; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.StringConstraintsAdmitNoValue, at.Syntax.GetLocation(), + $"'{character}' is not a character {poolName} can draw")); + + return true; + } + } + + return false; + } + + private static void ReportLengthBudget(OperationAnalysisContext context, List<(string Text, IOperation At)> fragments, int? fixedLength, int? maximum) { + if (fragments.Count == 0) { return; } + + int required = fragments.Sum(fragment => fragment.Text.Length); + int? cap = fixedLength ?? maximum; + if (cap is null || required <= cap.Value) { return; } + + string capName = fixedLength is not null ? $"WithLength({fixedLength})" : $"WithMaxLength({maximum})"; + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.StringConstraintsAdmitNoValue, fragments[fragments.Count - 1].At.Syntax.GetLocation(), + $"the anchored fragments need at least {required} characters, which {capName} cannot hold")); + } + +} diff --git a/doc/handwritten/for-users/analyzers/JD014.en.md b/doc/handwritten/for-users/analyzers/JD014.en.md new file mode 100644 index 00000000..9b461e35 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD014.en.md @@ -0,0 +1,53 @@ +# JD014: RejectedConstantArgument + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD014.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +The argument is a compile-time constant the generator's own guard refuses, so the call throws **every time it runs**. Nothing is decided at run time that is not already decided at the call site — yet the failure surfaces only when that arrange line executes, often inside a helper shared by many tests, where it reads as a library problem rather than as the transposition typo it usually is. + +`Between(10, 5)` is the archetype: both parameters are `int`, both orders compile, and the mistake survives review because the call reads plausibly. + +## Noncompliant + +```csharp +Any.Int32().Between(10, 5) // the minimum must be ≤ the maximum — transposed +Any.String().WithLengthBetween(10, 5) // same +Any.String().WithLength(-1) // a size must not be negative +Any.String().WithLength(2_000_000) // above the largest producible size +Any.Int32().MultipleOf(0) // the multiple must be strictly positive +Any.Decimal().WithScale(29) // the scale must be in [0, 28] +Any.String().StartingWith("") // the fragment must not be empty +Any.String().WithChars("") // the character pool must not be empty +Any.String().OneOf() // at least one value is required +Any.DateTime().WithGranularity(TimeSpan.Zero) // the granularity must be strictly positive +``` + +## Compliant + +```csharp +Any.Int32().Between(5, 10) +Any.String().WithLength(12) +Any.Int32().MultipleOf(7) +Any.String().StartingWith("ORD-") +``` + +## Scope + +One rule over one table, rather than a rule per method: the library validates these in one place, with one message shape, and a reader who learns "a constant the guard rejects" has learned all of them. The table mirrors `SizeGuard` and the per-generator guards exactly — where it cannot be certain, it stays silent. + +## What it does not flag + +* A **non-constant** argument. That is the run-time guard's business, and it keeps it: this rule front-loads only the subset the compiler can already see. +* A conflict-asserting negative test — `Check.ThatCode(() => Any.Int32().Between(10, 5))` — where the illegal call is the whole body of a lambda argument. This repository writes hundreds of them. +* A same-named method on a type that is not a JustDummies generator. +* `Containing(item)` on a collection, which is not the string overload. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD014.fr.md b/doc/handwritten/for-users/analyzers/JD014.fr.md new file mode 100644 index 00000000..8ac36d6a --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD014.fr.md @@ -0,0 +1,53 @@ +# JD014 : RejectedConstantArgument + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD014.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | Oui | + +L'argument est une constante de compilation que la garde du générateur refuse : l'appel lève **à chaque exécution**. Rien n'est décidé à l'exécution qui ne le soit déjà au site d'appel — pourtant l'échec ne se manifeste que lorsque cette ligne d'arrangement s'exécute, souvent au fond d'un utilitaire partagé par de nombreux tests, où il se lit comme un problème de bibliothèque plutôt que comme la faute de transposition qu'il est le plus souvent. + +`Between(10, 5)` est l'archétype : les deux paramètres sont des `int`, les deux ordres compilent, et l'erreur survit à la relecture parce que l'appel se lit plausiblement. + +## Non conforme + +```csharp +Any.Int32().Between(10, 5) // le minimum doit être ≤ au maximum — transposés +Any.String().WithLengthBetween(10, 5) // idem +Any.String().WithLength(-1) // une taille ne doit pas être négative +Any.String().WithLength(2_000_000) // au-delà de la plus grande taille productible +Any.Int32().MultipleOf(0) // le multiple doit être strictement positif +Any.Decimal().WithScale(29) // l'échelle doit être dans [0, 28] +Any.String().StartingWith("") // le fragment ne doit pas être vide +Any.String().WithChars("") // le pool de caractères ne doit pas être vide +Any.String().OneOf() // au moins une valeur est requise +Any.DateTime().WithGranularity(TimeSpan.Zero) // la granularité doit être strictement positive +``` + +## Conforme + +```csharp +Any.Int32().Between(5, 10) +Any.String().WithLength(12) +Any.Int32().MultipleOf(7) +Any.String().StartingWith("ORD-") +``` + +## Portée + +Une règle sur une table, plutôt qu'une règle par méthode : la bibliothèque valide tout cela au même endroit, avec une seule forme de message, et un lecteur qui a compris « une constante que la garde refuse » les a toutes comprises. La table reproduit exactement `SizeGuard` et les gardes propres à chaque générateur — là où elle ne peut pas être certaine, elle se tait. + +## Ce qui n'est pas signalé + +* Un argument **non constant**. C'est l'affaire de la garde d'exécution, et elle la conserve : cette règle n'anticipe que le sous-ensemble déjà visible du compilateur. +* Un test négatif vérifiant un conflit — `Check.ThatCode(() => Any.Int32().Between(10, 5))` — où l'appel illégal constitue tout le corps d'une lambda passée en argument. Ce dépôt en écrit des centaines. +* Une méthode homonyme portée par un type qui n'est pas un générateur JustDummies. +* `Containing(item)` sur une collection, qui n'est pas la surcharge chaîne. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD015.en.md b/doc/handwritten/for-users/analyzers/JD015.en.md new file mode 100644 index 00000000..237057fa --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD015.en.md @@ -0,0 +1,53 @@ +# JD015: StringConstraintsAdmitNoValue + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD015.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +The declared constraints contradict each other for the constants written at the call site, so the chain throws a `ConflictingAnyConstraintException` the moment the arrange line runs. + +This is the case [ADR-0035](../../for-maintainers/adr/0035-enforce-structural-any-conflicts-at-compile-time.md) names by hand as the one an analyzer should carry and the type system cannot: + +> `Any.String().Numeric().StartingWith("ORD-")` conflicts because the prefix's letters fall outside the numeric set, while `Any.String().Numeric().StartingWith("123")` is valid; the call site and the static types are identical in both. + +Only the argument's *value* tells them apart — which is what makes it value-dependent, and what puts it on the analyzer's side of the ADR's line. + +## Noncompliant + +```csharp +Any.String().Numeric().StartingWith("ORD-") // 'O' is not a digit +Any.String().LowerCase().StartingWith("ABC") // LowerCase forbids the uppercase 'A' +Any.String().WithLength(3).StartingWith("ORD-") // the prefix needs 4 characters +Any.String().WithMaxLength(6).StartingWith("SKU-").EndingWith("-EUR") // 8 characters into a cap of 6 +``` + +## Compliant + +```csharp +Any.String().Numeric().StartingWith("123") +Any.String().WithChars("ORD-0123456789").StartingWith("ORD-") // widen the pool to include the prefix +Any.String().WithLength(12).StartingWith("ORD-") +``` + +## The two checks, and what the library actually does + +**Character family.** `Alpha()`, `Numeric()`, `AlphaNumeric()` and `WithChars(...)` validate **every character** of every anchored fragment. + +**Casing.** `LowerCase()` and `UpperCase()` are *not* character sets. They constrain the case of a fragment's **letters** and say nothing about its other characters — so `UpperCase().StartingWith("ORD-")` is legal (the `-` is not a letter) while `UpperCase().StartingWith("abc")` is not. This distinction was found by dogfooding: the first version modelled casing as a pool and wrongly flagged a chain the library's own suite asserts is legal. + +**Length budget.** The fragments are laid out side by side and must fit the declared length. The budget is **skipped** once `OneOf(...)` is declared: a terminal value set changes what the fragments are checked against — they are matched against the pooled values rather than laid out — so `OneOf("aba").WithMaxLength(3).Containing("ab").Containing("ba")` is legal even though the fragments sum to 4. + +## What it does not flag + +* A non-constant fragment, pool or length. +* A chain split across statements or variables. The chain must be written as one expression: following a generator through a local would need dataflow, and a rule that claims a chain is unsatisfiable must see every constraint it carries. +* A conflict-asserting negative test, where the illegal chain is the whole body of a lambda argument. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD015.fr.md b/doc/handwritten/for-users/analyzers/JD015.fr.md new file mode 100644 index 00000000..0da0b339 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD015.fr.md @@ -0,0 +1,53 @@ +# JD015 : StringConstraintsAdmitNoValue + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD015.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | Oui | + +Les contraintes déclarées se contredisent pour les constantes écrites au site d'appel : la chaîne lève une `ConflictingAnyConstraintException` dès l'exécution de la ligne d'arrangement. + +C'est le cas que l'[ADR-0035](../../for-maintainers/adr/0035-enforce-structural-any-conflicts-at-compile-time.md) nomme explicitement comme celui qu'un analyseur doit porter et que le système de types ne peut pas porter : + +> `Any.String().Numeric().StartingWith("ORD-")` entre en conflit parce que les lettres du préfixe tombent hors de l'ensemble numérique, tandis qu'`Any.String().Numeric().StartingWith("123")` est valide ; le site d'appel et les types statiques sont identiques dans les deux cas. + +Seule la *valeur* de l'argument les distingue — c'est ce qui rend le cas value-dependent, et ce qui le place du côté analyseur de la frontière tracée par l'ADR. + +## Non conforme + +```csharp +Any.String().Numeric().StartingWith("ORD-") // 'O' n'est pas un chiffre +Any.String().LowerCase().StartingWith("ABC") // LowerCase interdit le 'A' majuscule +Any.String().WithLength(3).StartingWith("ORD-") // le préfixe demande 4 caractères +Any.String().WithMaxLength(6).StartingWith("SKU-").EndingWith("-EUR") // 8 caractères pour un plafond de 6 +``` + +## Conforme + +```csharp +Any.String().Numeric().StartingWith("123") +Any.String().WithChars("ORD-0123456789").StartingWith("ORD-") // élargir le pool pour inclure le préfixe +Any.String().WithLength(12).StartingWith("ORD-") +``` + +## Les deux vérifications, et ce que la bibliothèque fait réellement + +**Famille de caractères.** `Alpha()`, `Numeric()`, `AlphaNumeric()` et `WithChars(...)` valident **chaque caractère** de chaque fragment ancré. + +**Casse.** `LowerCase()` et `UpperCase()` ne sont *pas* des ensembles de caractères. Elles contraignent la casse des **lettres** d'un fragment et ne disent rien de ses autres caractères — ainsi `UpperCase().StartingWith("ORD-")` est légal (le `-` n'est pas une lettre) alors qu'`UpperCase().StartingWith("abc")` ne l'est pas. Cette distinction a été trouvée par la mise à l'épreuve sur le dépôt : la première version modélisait la casse comme un pool et signalait à tort une chaîne que la propre suite de la bibliothèque affirme légale. + +**Budget de longueur.** Les fragments sont juxtaposés et doivent tenir dans la longueur déclarée. Le budget est **ignoré** dès qu'un `OneOf(...)` est déclaré : un ensemble de valeurs terminal change ce à quoi les fragments sont confrontés — ils sont comparés aux valeurs de l'ensemble plutôt que juxtaposés — de sorte qu'`OneOf("aba").WithMaxLength(3).Containing("ab").Containing("ba")` est légal bien que les fragments totalisent 4 caractères. + +## Ce qui n'est pas signalé + +* Un fragment, un pool ou une longueur non constants. +* Une chaîne répartie sur plusieurs instructions ou variables. La chaîne doit être écrite comme une seule expression : suivre un générateur à travers une variable locale exigerait une analyse de flot, et une règle qui affirme qu'une chaîne est insatisfiable doit voir toutes les contraintes qu'elle porte. +* Un test négatif vérifiant un conflit, où la chaîne illégale constitue tout le corps d'une lambda passée en argument. + +--- + +[← 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 9f7d4601..b3bc6718 100644 --- a/doc/handwritten/for-users/analyzers/README.fr.md +++ b/doc/handwritten/for-users/analyzers/README.fr.md @@ -76,6 +76,15 @@ Un générateur est une *recette* immuable, et `Generate()` est la seule chose q | [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. | +## JustDummies — Contraintes + +Ces règles anticipent, à la compilation, le sous-ensemble des vérifications de contraintes de la bibliothèque qui est décidable depuis des constantes. Les vérifications d'exécution demeurent : elles couvrent tous les arguments que celles-ci ne peuvent pas voir. + +| Règle | Sévérité | Défaut | Description | +|-------|----------|--------|-------------| +| [JD014 RejectedConstantArgument](JD014.fr.md) | 🟠 Avertissement | on | Un argument de contrainte est une constante que la garde du générateur refuse : l'appel lève à chaque exécution. | +| [JD015 StringConstraintsAdmitNoValue](JD015.fr.md) | 🟠 Avertissement | on | Les contraintes constantes d'une chaîne `AnyString` n'admettent aucune valeur — un fragment hors de la famille de caractères ou de la casse déclarée, ou des fragments qui ne peuvent pas tenir dans la longueur déclarée. | + ## Configuration La sévérité de chaque règle se règle dans `.editorconfig`, par exemple : diff --git a/doc/handwritten/for-users/analyzers/README.md b/doc/handwritten/for-users/analyzers/README.md index 64bbaa1d..7f46261a 100644 --- a/doc/handwritten/for-users/analyzers/README.md +++ b/doc/handwritten/for-users/analyzers/README.md @@ -76,6 +76,15 @@ A generator is an immutable *recipe*, and `Generate()` is the only thing that ma | [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. | +## JustDummies — Constraints + +These rules front-load, to build time, the subset of the library's run-time constraint checks that is decidable from compile-time constants. The run-time checks stay: they cover every argument these cannot see. + +| Rule | Severity | Default | Description | +|------|----------|---------|-------------| +| [JD014 RejectedConstantArgument](JD014.en.md) | 🟠 Warning | on | A constraint argument is a compile-time constant the generator's own guard refuses, so the call throws every time it runs. | +| [JD015 StringConstraintsAdmitNoValue](JD015.en.md) | 🟠 Warning | on | An AnyString chain's constant constraints admit no value — a fragment outside the declared character family or casing, or fragments that cannot fit the declared length. | + ## Configuring Every rule's severity can be tuned in `.editorconfig`, for example: