From 0682fefc995281f87289212839b13ac39666900d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:18:21 +0000 Subject: [PATCH] feat(justdummies): add JD023-JD024 for scalar chain satisfiability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two rules share one walk over a ScalarConstraintState because they read the same state from opposite sides: JD023 asks whether anything remains, JD024 whether anything changed. JD023 reports an integer chain narrowed to nothing — an empty interval, an empty lattice, an emptied allow-list. The library computes this with one emptiness test; the rule runs the same test over the constants written at the call site and stays silent for every argument it cannot fold. JD024 is the only member of the constraint family the run time NEVER reports. Every other contradiction throws eventually and loudly, while an inert constraint leaves the test green exercising a domain the author did not write. The dangerous case is an exclusion of a sentinel the generator could never draw: it misses silently, and starts mattering the day someone widens the range. Info rather than Warning, because a defensive exclusion kept so the intent survives a future widening is a real and reasonable style. Dogfooding caught a genuine bug in the model, not a misread of the library. Any.Int64().LessThanOrEqualTo(long.MinValue) is legal and yields exactly that value, yet the first version declared it empty: the state used -long.MaxValue as its "unbounded" sentinel, which made long.MinValue unrepresentable. The bounds now run to the true extremes, and a bound genuinely beyond the range — GreaterThan(long.MaxValue) — is carried by an explicit saturation flag rather than by arithmetic that would overflow. Four tests pin the extremes in both directions. Scope is deliberately narrow: integer generators only, one expression, every argument folded. A constraint the model has never seen ends the walk rather than being guessed at — a rule claiming a chain is unsatisfiable has to be certain. Rebased onto main after #374, which carried the analyzer library to collection expressions. The two new files were written before that convention landed, so they are brought to it here the same way: `dotnet format style --diagnostics IDE0028`, run to a fixed point, five single initializers, nothing else touched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GKqyhG9Y4AfdxEYekP2Evq --- .../Jd023Jd024ScalarChainTests.cs | 238 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 2 + JustDummies.Analyzers/Descriptors.cs | 20 ++ JustDummies.Analyzers/DiagnosticIds.cs | 3 + .../ScalarChainAdmitsNoValueAnalyzer.cs | 140 +++++++++++ .../ScalarConstraintState.cs | 179 +++++++++++++ .../for-users/analyzers/JD023.en.md | 49 ++++ .../for-users/analyzers/JD023.fr.md | 49 ++++ .../for-users/analyzers/JD024.en.md | 44 ++++ .../for-users/analyzers/JD024.fr.md | 44 ++++ .../for-users/analyzers/README.fr.md | 2 + doc/handwritten/for-users/analyzers/README.md | 2 + 12 files changed, 772 insertions(+) create mode 100644 JustDummies.Analyzers.UnitTests/Jd023Jd024ScalarChainTests.cs create mode 100644 JustDummies.Analyzers/ScalarChainAdmitsNoValueAnalyzer.cs create mode 100644 JustDummies.Analyzers/ScalarConstraintState.cs create mode 100644 doc/handwritten/for-users/analyzers/JD023.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD023.fr.md create mode 100644 doc/handwritten/for-users/analyzers/JD024.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD024.fr.md diff --git a/JustDummies.Analyzers.UnitTests/Jd023Jd024ScalarChainTests.cs b/JustDummies.Analyzers.UnitTests/Jd023Jd024ScalarChainTests.cs new file mode 100644 index 00000000..50a31763 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd023Jd024ScalarChainTests.cs @@ -0,0 +1,238 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd023ScalarChainAdmitsNoValueTests { + + [Theory] + [InlineData("Any.Int32().Between(1, 10).MultipleOf(20)")] + [InlineData("Any.Int32().GreaterThan(10).LessThan(3)")] + [InlineData("Any.Int32().Positive().LessThan(-5)")] + [InlineData("Any.Int32().Positive().Negative()")] + [InlineData("Any.Int32().Zero().NonZero()")] + [InlineData("Any.Int32().OneOf(5).Except(5)")] + [InlineData("Any.Int64().GreaterThanOrEqualTo(10).LessThanOrEqualTo(9)")] + public async Task Reports_a_chain_that_admits_no_value(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD023"); + } + + [Theory] + [InlineData("Any.Int32().Between(1, 10).MultipleOf(5)")] + [InlineData("Any.Int32().Positive().LessThan(100)")] + [InlineData("Any.Int32().Between(1, 10).Except(5)")] + [InlineData("Any.Int32().OneOf(1, 2, 3).Except(2)")] + [InlineData("Any.Int32().GreaterThan(-100).LessThan(100).MultipleOf(7)")] + public async Task Does_not_report_a_satisfiable_chain(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_non_constant_argument() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M(int bound) { + _ = Any.Int32().GreaterThan(bound).LessThan(3); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_non_integer_generator() { + // The model is integer arithmetic; a floating-point domain does not behave like one. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Double().Positive().LessThan(-5); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + 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.Int32().Positive().Negative()); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} + +public class Jd024ConstraintWithNoEffectTests { + + [Fact] + public async Task Reports_an_exclusion_the_domain_could_never_produce() { + // The dangerous case: the author excluded a sentinel the generator was never going to draw. It silently + // misses, and starts mattering the day someone widens the range. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Int32().Between(1, 10).Except(20); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD024"); + Check.That(diagnostics[0].GetMessage()).Contains("removes no value"); + } + + [Fact] + public async Task Reports_a_bound_already_implied() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Int32().Positive().GreaterThan(-5); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD024"); + Check.That(diagnostics[0].GetMessage()).Contains("already implied"); + } + + [Fact] + public async Task Does_not_report_an_exclusion_that_removes_something() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Int32().Between(1, 10).Except(5); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_bound_that_narrows() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Int32().Positive().GreaterThan(100); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} + +public class Jd023ScalarChainRepresentableExtremesTests { + + [Theory] + [InlineData("Any.Int64().LessThanOrEqualTo(long.MinValue)")] + [InlineData("Any.Int64().GreaterThanOrEqualTo(long.MaxValue)")] + [InlineData("Any.Int32().LessThanOrEqualTo(int.MinValue)")] + public async Task Does_not_report_a_bound_at_a_representable_extreme(string expression) { + // Live in JustDummies.UnitTests/AnySignedIntegerTests.cs, which asserts these generate exactly that value. + // The first version used -long.MaxValue as its "unbounded" sentinel, which made long.MinValue + // unrepresentable and condemned a legal chain. + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Reports_a_bound_beyond_the_representable_range() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.Int64().GreaterThan(long.MaxValue); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new ScalarChainAdmitsNoValueAnalyzer(), source, "JD023", "JD024"); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD023"); + } + +} diff --git a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md index f1152b03..8e96c350 100644 --- a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md +++ b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md @@ -27,3 +27,5 @@ JD019 | JustDummies.Reproducibility | Disabled | CommittedReplaySeedAnalyzer JD020 | JustDummies.Reproducibility | Info | SharedStaticAnyContextAnalyzer JD021 | JustDummies.Reproducibility | Warning | BlankReplaySnippetAnalyzer JD022 | JustDummies.Reproducibility | Info | ParallelDrawWithoutPerItemSeedAnalyzer +JD023 | JustDummies.Constraints | Warning | ScalarChainAdmitsNoValueAnalyzer +JD024 | JustDummies.Constraints | Info | ScalarChainAdmitsNoValueAnalyzer diff --git a/JustDummies.Analyzers/Descriptors.cs b/JustDummies.Analyzers/Descriptors.cs index 3676fdd1..a467b301 100644 --- a/JustDummies.Analyzers/Descriptors.cs +++ b/JustDummies.Analyzers/Descriptors.cs @@ -234,4 +234,24 @@ internal static class Descriptors { description: "The ambient seed scope flows with the execution context, so a scope opened around a parallel loop reaches every worker and their draws interleave: neither the sequence nor the multiset is stable across runs. A scope opened inside the loop body gives each unit of work its own sequence, and the whole run replays — the shape the library's documentation names.", helpLinkUri: HelpLinks.For(DiagnosticIds.ParallelDrawWithoutPerItemSeed)); + public static readonly DiagnosticDescriptor ScalarChainAdmitsNoValue = new( + id: DiagnosticIds.ScalarChainAdmitsNoValue, + title: "The declared scalar constraints admit no value", + messageFormat: "No value satisfies this chain once {0} is applied", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The constant constraints narrow the domain to nothing, so the chain throws a ConflictingAnyConstraintException the moment the arrange line runs. The library computes this with one emptiness test over bounds, lattice and allow-list; this rule runs the same test over the constants written at the call site, and stays silent for every argument it cannot fold.", + helpLinkUri: HelpLinks.For(DiagnosticIds.ScalarChainAdmitsNoValue)); + + public static readonly DiagnosticDescriptor ConstraintWithNoEffect = new( + id: DiagnosticIds.ConstraintWithNoEffect, + title: "A constraint narrows nothing", + messageFormat: "This constraint changes nothing: {0}", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "The constraint is legal and inert: the domain it produces is the one that already existed. This is the only member of the constraint family the run time NEVER reports — every other contradiction throws eventually and loudly, while an inert constraint leaves the test green and exercising a domain the author did not write. The dangerous case is an exclusion of a sentinel the generator could never draw: it silently misses, and starts mattering the day someone widens the range.", + helpLinkUri: HelpLinks.For(DiagnosticIds.ConstraintWithNoEffect)); + } diff --git a/JustDummies.Analyzers/DiagnosticIds.cs b/JustDummies.Analyzers/DiagnosticIds.cs index 3e9f8924..e0e8f7af 100644 --- a/JustDummies.Analyzers/DiagnosticIds.cs +++ b/JustDummies.Analyzers/DiagnosticIds.cs @@ -40,4 +40,7 @@ internal static class DiagnosticIds { public const string BlankReplaySnippet = "JD021"; public const string ParallelDrawWithoutPerItemSeed = "JD022"; + public const string ScalarChainAdmitsNoValue = "JD023"; + public const string ConstraintWithNoEffect = "JD024"; + } diff --git a/JustDummies.Analyzers/ScalarChainAdmitsNoValueAnalyzer.cs b/JustDummies.Analyzers/ScalarChainAdmitsNoValueAnalyzer.cs new file mode 100644 index 00000000..eeeedd50 --- /dev/null +++ b/JustDummies.Analyzers/ScalarChainAdmitsNoValueAnalyzer.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// JD023 — reports a scalar chain whose constant constraints leave no value at all, and JD024 — a constraint that +/// narrows nothing. The two share one walk because they read the same state from opposite sides: one asks whether +/// anything remains, the other whether anything changed. +/// +/// +/// JD024 is the only member of the constraint family the run time never reports. Every other contradiction throws +/// eventually and loudly; an inert constraint leaves the test green while it exercises a domain the author did not +/// write. That is why it is worth an Info rule rather than nothing. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ScalarChainAdmitsNoValueAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.ScalarChainAdmitsNoValue, Descriptors.ConstraintWithNoEffect); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Any is null || symbols.IAny is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (invocation.Parent is IInvocationOperation) { return; } + if (!AnyChainFacts.TryGetChain(invocation, symbols, out IReadOnlyList constraints, out IInvocationOperation? factory)) { return; } + if (factory is null || !IsIntegerFactory(factory.TargetMethod.Name)) { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + ScalarConstraintState state = ScalarConstraintState.Unconstrained(); + + foreach (IInvocationOperation constraint in constraints) { + if (!TryReadArguments(constraint, out IReadOnlyList arguments)) { return; } + + string name = constraint.TargetMethod.Name; + + // The exclusion that removes nothing: silent at run time, and the reason JD024 exists. + if (name is "Except" or "DifferentFrom" && state.ExclusionIsInert(arguments)) { + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.ConstraintWithNoEffect, constraint.Syntax.GetLocation(), + $"{name} removes no value the generator could produce")); + + return; + } + + ScalarConstraintState? next = state.Apply(name, arguments); + if (next is null) { return; } + + if (next.IsEmpty()) { + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.ScalarChainAdmitsNoValue, constraint.Syntax.GetLocation(), name)); + + return; + } + + if (IsNarrowingConstraint(name) && state.NarrowsNothing(next)) { + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.ConstraintWithNoEffect, constraint.Syntax.GetLocation(), + $"{name} is already implied by the constraints declared before it")); + + return; + } + + state = next; + } + } + + // A bound whose job is to narrow. Applying one that changes nothing is what JD024 reports; Positive() after + // GreaterThan(5) is the shape, and it reads as a tightening that is not one. + private static bool IsNarrowingConstraint(string name) { + return name is "GreaterThan" or "GreaterThanOrEqualTo" or "LessThan" or "LessThanOrEqualTo" or "Between" or "Positive" or "Negative"; + } + + private static bool TryReadArguments(IInvocationOperation constraint, out IReadOnlyList arguments) { + List values = []; + arguments = values; + + foreach (IArgumentOperation argument in constraint.Arguments) { + if (argument.ArgumentKind == ArgumentKind.ParamArray) { + if (argument.Value is not IArrayCreationOperation { Initializer: { } initializer }) { return false; } + + foreach (IOperation element in initializer.ElementValues) { + if (!TryReadInteger(element, out long value)) { return false; } + + values.Add(value); + } + + continue; + } + + if (!TryReadInteger(argument.Value, out long single)) { return false; } + + values.Add(single); + } + + return true; + } + + private static bool TryReadInteger(IOperation operation, out long value) { + value = 0; + + Optional constant = GeneratorFacts.Unwrap(operation).ConstantValue; + if (!constant.HasValue) { return false; } + + switch (constant.Value) { + case int i: value = i; return true; + case long l: value = l; return true; + case short s: value = s; return true; + case byte b: value = b; return true; + case sbyte sb: value = sb; return true; + default: return false; + } + } + + // Only the integer generators: the model is integer arithmetic, and a floating-point or decimal domain does not + // behave like one. + private static bool IsIntegerFactory(string name) { + return name is "Int32" or "Int16" or "Int64" or "Byte" or "SByte" or "UInt16" or "UInt32" or "UInt64"; + } + +} diff --git a/JustDummies.Analyzers/ScalarConstraintState.cs b/JustDummies.Analyzers/ScalarConstraintState.cs new file mode 100644 index 00000000..d3f24f34 --- /dev/null +++ b/JustDummies.Analyzers/ScalarConstraintState.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.Linq; + +using Microsoft.CodeAnalysis.Operations; + +namespace JustDummies.Analyzers; + +/// +/// The integer domain a scalar chain has narrowed to, rebuilt constraint by constraint in declaration order. +/// Both JD023 (the domain became empty) and JD024 (a constraint narrowed nothing) read it: one asks +/// whether anything remains, the other whether anything changed. +/// +/// +/// Integers only, and only where every argument folds to a constant. A chain carrying one unfoldable argument +/// stops being tracked rather than being guessed at: a rule that claims a chain is unsatisfiable must be certain. +/// Bounds are kept in so a constraint at cannot overflow the +/// arithmetic that tests them. +/// +internal sealed class ScalarConstraintState { + + private ScalarConstraintState(long minimum, long maximum, long? multipleOf, HashSet? allowed, HashSet excluded, bool saturated = false) { + Minimum = minimum; + Maximum = maximum; + MultipleOf = multipleOf; + Allowed = allowed; + Excluded = excluded; + Saturated = saturated; + } + + public long Minimum { get; } + public long Maximum { get; } + public long? MultipleOf { get; } + public HashSet? Allowed { get; } + public HashSet Excluded { get; } + + /// + /// Set when a bound asked for values beyond the representable range — GreaterThan(long.MaxValue). The + /// domain is empty, and saying so needs a flag rather than an out-of-range bound, because the bounds run to + /// the extremes: LessThanOrEqualTo(long.MinValue) is a legal chain that yields exactly one value. + /// + public bool Saturated { get; } + + public static ScalarConstraintState Unconstrained() { + return new ScalarConstraintState(long.MinValue, long.MaxValue, null, null, []); + } + + /// Whether no value at all survives the constraints declared so far. + public bool IsEmpty() { + if (Saturated) { return true; } + if (Allowed is not null) { return !Allowed.Any(Admits); } + if (Minimum > Maximum) { return true; } + + // A small finite range can be emptied by its exclusions alone, with the bounds still consistent: + // Zero().NonZero() pins [0, 0] and then forbids the only value in it. + if (Excluded.Count > 0 && FitsInAWalk()) { + for (long value = Minimum; value <= Maximum; value++) { + if (Admits(value)) { return false; } + } + + return true; + } + + return MultipleOf is long step && !HasMultipleInRange(step); + } + + // Only walk a range small enough to enumerate, and far enough from the extremes that the arithmetic cannot + // overflow. A wider range is never declared empty by exclusions: no realistic exclusion set could empty it. + private bool FitsInAWalk() { + return Minimum > long.MinValue / 2 && Maximum < long.MaxValue / 2 && Maximum - Minimum < 64; + } + + /// Whether survives every constraint declared so far. + public bool Admits(long value) { + if (value < Minimum || value > Maximum) { return false; } + if (Excluded.Contains(value)) { return false; } + if (MultipleOf is long step && step != 0 && value % step != 0) { return false; } + + return Allowed is null || Allowed.Contains(value); + } + + /// + /// Applies one constraint, returning the narrowed state — or null when the constraint is one this model + /// does not track, which abandons the chain rather than misreading it. + /// + public ScalarConstraintState? Apply(string name, IReadOnlyList arguments) { + switch (name) { + case "Positive": return WithMinimum(1); + case "Negative": return WithMaximum(-1); + case "Zero": return WithMinimum(0)?.WithMaximum(0); + case "NonZero": return WithExcluded(0); + + // Nothing is greater than the largest representable value, nor less than the smallest: those two ask for + // an empty domain rather than for a bound, and computing one would overflow. + case "GreaterThan" when arguments.Count == 1: + return arguments[0] == long.MaxValue ? Saturate() : WithMinimum(arguments[0] + 1); + + case "LessThan" when arguments.Count == 1: + return arguments[0] == long.MinValue ? Saturate() : WithMaximum(arguments[0] - 1); + + case "GreaterThanOrEqualTo" when arguments.Count == 1: return WithMinimum(arguments[0]); + case "LessThanOrEqualTo" when arguments.Count == 1: return WithMaximum(arguments[0]); + + case "Between" when arguments.Count == 2: return WithMinimum(arguments[0])?.WithMaximum(arguments[1]); + case "MultipleOf" when arguments.Count == 1 && arguments[0] != 0: + return new ScalarConstraintState(Minimum, Maximum, arguments[0] < 0 ? -arguments[0] : arguments[0], Allowed, Excluded); + + case "OneOf" when arguments.Count > 0: + return new ScalarConstraintState(Minimum, Maximum, MultipleOf, [.. arguments], Excluded); + + case "Except" or "DifferentFrom" when arguments.Count > 0: { + HashSet excluded = [.. Excluded, .. arguments]; + + return new ScalarConstraintState(Minimum, Maximum, MultipleOf, Allowed, excluded); + } + + // Anything else — a granularity, a scale, a name this model has never seen — ends the walk. + default: return null; + } + } + + /// Whether applying would leave the domain exactly as it is. + public bool NarrowsNothing(ScalarConstraintState candidate) { + return candidate.Minimum == Minimum + && candidate.Maximum == Maximum + && candidate.MultipleOf == MultipleOf + && candidate.Excluded.Count == Excluded.Count + && (candidate.Allowed?.Count ?? -1) == (Allowed?.Count ?? -1); + } + + /// + /// Whether an exclusion removes a value the domain could never have produced anyway — the silent case, where + /// the author excluded a sentinel the generator was never going to draw. + /// + public bool ExclusionIsInert(IReadOnlyList values) { + return values.Count > 0 && values.All(value => !Admits(value)); + } + + private ScalarConstraintState Saturate() { + return new ScalarConstraintState(Minimum, Maximum, MultipleOf, Allowed, Excluded, saturated: true); + } + + private ScalarConstraintState? WithMinimum(long minimum) { + return minimum <= Minimum + ? new ScalarConstraintState(Minimum, Maximum, MultipleOf, Allowed, Excluded) + : new ScalarConstraintState(minimum, Maximum, MultipleOf, Allowed, Excluded); + } + + private ScalarConstraintState? WithMaximum(long maximum) { + return maximum >= Maximum + ? new ScalarConstraintState(Minimum, Maximum, MultipleOf, Allowed, Excluded) + : new ScalarConstraintState(Minimum, maximum, MultipleOf, Allowed, Excluded); + } + + private ScalarConstraintState WithExcluded(long value) { + HashSet excluded = [.. Excluded, value]; + + return new ScalarConstraintState(Minimum, Maximum, MultipleOf, Allowed, excluded); + } + + // Is there any multiple of step inside [Minimum, Maximum] that survives the exclusions? The range can be huge, so + // this walks the lattice from its first multiple rather than the range itself, and gives up (answering "yes") + // once the walk is longer than any realistic exclusion set could rule out. + private bool HasMultipleInRange(long step) { + if (step == 0) { return false; } + + long first = Minimum >= 0 + ? (Minimum + step - 1) / step * step + : -((-Minimum) / step) * step; + + for (long candidate = first, seen = 0; candidate <= Maximum && seen < 64; candidate += step, seen++) { + if (!Excluded.Contains(candidate) && (Allowed is null || Allowed.Contains(candidate))) { return true; } + } + + // The walk gave up before finding one; only a range genuinely wider than the walk can still hold a multiple. + // Compare by division so the subtraction cannot overflow at the representable extremes. + return Maximum / 2 - Minimum / 2 >= step * 32; + } + +} diff --git a/doc/handwritten/for-users/analyzers/JD023.en.md b/doc/handwritten/for-users/analyzers/JD023.en.md new file mode 100644 index 00000000..ea3fa185 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD023.en.md @@ -0,0 +1,49 @@ +# JD023: ScalarChainAdmitsNoValue + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD023.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +The constant constraints narrow the integer domain to nothing, so the chain throws a `ConflictingAnyConstraintException` the moment the arrange line runs. + +The library computes this with one emptiness test over bounds, lattice and allow-list. This rule runs the same test over the constants written at the call site — and stays silent for every argument it cannot fold. + +## Noncompliant + +```csharp +Any.Int32().Between(1, 10).MultipleOf(20) // no multiple of 20 in [1, 10] +Any.Int32().GreaterThan(10).LessThan(3) // empty interval +Any.Int32().Positive().Negative() // empty by construction +Any.Int32().Zero().NonZero() // the only value left is then forbidden +Any.Int32().OneOf(5).Except(5) // the allow-list is emptied +``` + +## Compliant + +```csharp +Any.Int32().Between(1, 10).MultipleOf(5) +Any.Int32().GreaterThan(-100).LessThan(100) +Any.Int32().OneOf(1, 2, 3).Except(2) +``` + +## Scope, and one boundary worth knowing + +**Integer generators only** — `Int32`, `Int16`, `Int64`, `Byte`, `SByte`, `UInt16`, `UInt32`, `UInt64`. The model is integer arithmetic, and a floating-point or decimal domain does not behave like one. + +Bounds run to the **representable extremes**. `Any.Int64().LessThanOrEqualTo(long.MinValue)` is a legal chain that yields exactly one value, and is not reported; only a bound asking for something genuinely beyond the range — `GreaterThan(long.MaxValue)` — empties the domain. The first version of this rule got that wrong, using `-long.MaxValue` as its "unbounded" sentinel, which made `long.MinValue` unrepresentable and condemned a chain the library's own suite asserts is legal. + +## What it does not flag + +* A chain with any argument that does not fold to a constant. +* A chain split across statements — it must be one expression. +* A constraint the model does not track: the walk ends rather than guessing. +* A conflict-asserting negative test. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD023.fr.md b/doc/handwritten/for-users/analyzers/JD023.fr.md new file mode 100644 index 00000000..d1946a8e --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD023.fr.md @@ -0,0 +1,49 @@ +# JD023 : ScalarChainAdmitsNoValue + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD023.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | Oui | + +Les contraintes constantes réduisent le domaine entier à rien : la chaîne lève une `ConflictingAnyConstraintException` dès l'exécution de la ligne d'arrangement. + +La bibliothèque calcule cela par un unique test de vacuité sur les bornes, le treillis et la liste d'autorisation. Cette règle exécute le même test sur les constantes écrites au site d'appel — et se tait pour tout argument qu'elle ne peut pas replier. + +## Non conforme + +```csharp +Any.Int32().Between(1, 10).MultipleOf(20) // aucun multiple de 20 dans [1, 10] +Any.Int32().GreaterThan(10).LessThan(3) // intervalle vide +Any.Int32().Positive().Negative() // vide par construction +Any.Int32().Zero().NonZero() // la seule valeur restante est ensuite interdite +Any.Int32().OneOf(5).Except(5) // la liste d'autorisation est vidée +``` + +## Conforme + +```csharp +Any.Int32().Between(1, 10).MultipleOf(5) +Any.Int32().GreaterThan(-100).LessThan(100) +Any.Int32().OneOf(1, 2, 3).Except(2) +``` + +## Portée, et une limite qui mérite d'être connue + +**Générateurs entiers uniquement** — `Int32`, `Int16`, `Int64`, `Byte`, `SByte`, `UInt16`, `UInt32`, `UInt64`. Le modèle est de l'arithmétique entière, et un domaine flottant ou décimal ne se comporte pas ainsi. + +Les bornes vont jusqu'aux **extrêmes représentables**. `Any.Int64().LessThanOrEqualTo(long.MinValue)` est une chaîne légale qui produit exactement une valeur, et n'est pas signalée ; seule une borne réclamant quelque chose de réellement hors plage — `GreaterThan(long.MaxValue)` — vide le domaine. La première version de cette règle s'est trompée là-dessus, en prenant `-long.MaxValue` comme sentinelle « non borné », ce qui rendait `long.MinValue` inexprimable et condamnait une chaîne que la propre suite de la bibliothèque affirme légale. + +## Ce qui n'est pas signalé + +* Une chaîne dont un argument ne se replie pas en constante. +* Une chaîne répartie sur plusieurs instructions — elle doit être une seule expression. +* Une contrainte que le modèle ne suit pas : la marche s'arrête plutôt que de deviner. +* Un test négatif vérifiant un conflit. + +--- + +[← Toutes les règles d'analyse](README.fr.md) diff --git a/doc/handwritten/for-users/analyzers/JD024.en.md b/doc/handwritten/for-users/analyzers/JD024.en.md new file mode 100644 index 00000000..0fe2a143 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD024.en.md @@ -0,0 +1,44 @@ +# JD024: ConstraintWithNoEffect + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD024.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🔵 Info | +| **Enabled by default** | Yes | + +The constraint is legal and **inert**: the domain it produces is the one that already existed. + +This is the only member of the constraint family the run time **never** reports. Every other contradiction throws eventually and loudly; an inert constraint leaves the test green while it exercises a domain the author did not write. + +The dangerous case is an exclusion of a sentinel the generator could never draw. It silently misses — and starts mattering the day someone widens the range, at which point the sentinel begins appearing and a distant test starts flaking. + +## Noncompliant + +```csharp +Any.Int32().Between(1, 10).Except(20) // JD024: 20 was never in [1, 10] +Any.Int32().Positive().GreaterThan(-5) // JD024: Positive() already requires ≥ 1 +``` + +## Compliant + +```csharp +Any.Int32().Between(1, 30).Except(20) // the exclusion now removes something +Any.Int32().Positive().GreaterThan(100) // the bound now narrows +``` + +## Info rather than Warning + +A defensive or documentary constraint is a real and reasonable style: a team writes `.Except(0)` on a range that already excludes 0 so the intent survives a future widening. The rule states the fact without insisting it is a defect. + +## What it does not flag + +* An exclusion that removes at least one reachable value. +* A bound that genuinely narrows the domain. +* Anything on a non-integer generator, or a chain with a non-constant argument. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD024.fr.md b/doc/handwritten/for-users/analyzers/JD024.fr.md new file mode 100644 index 00000000..5a1e7c7c --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD024.fr.md @@ -0,0 +1,44 @@ +# JD024 : ConstraintWithNoEffect + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD024.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🔵 Info | +| **Activée par défaut** | Oui | + +La contrainte est légale et **inerte** : le domaine qu'elle produit est celui qui existait déjà. + +C'est le seul membre de la famille des contraintes que l'exécution ne signale **jamais**. Toutes les autres contradictions finissent par lever, bruyamment ; une contrainte inerte laisse le test au vert alors qu'il exerce un domaine que l'auteur n'a pas écrit. + +Le cas dangereux est l'exclusion d'une valeur sentinelle que le générateur n'aurait jamais pu tirer. Elle manque sa cible en silence — et se met à compter le jour où quelqu'un élargit la plage, moment où la sentinelle commence à apparaître et où un test lointain devient instable. + +## Non conforme + +```csharp +Any.Int32().Between(1, 10).Except(20) // JD024 : 20 n'a jamais été dans [1, 10] +Any.Int32().Positive().GreaterThan(-5) // JD024 : Positive() exige déjà ≥ 1 +``` + +## Conforme + +```csharp +Any.Int32().Between(1, 30).Except(20) // l'exclusion retire maintenant quelque chose +Any.Int32().Positive().GreaterThan(100) // la borne rétrécit maintenant +``` + +## Info plutôt qu'avertissement + +Une contrainte défensive ou documentaire est un style réel et raisonnable : une équipe écrit `.Except(0)` sur une plage qui exclut déjà 0 pour que l'intention survive à un élargissement futur. La règle énonce le fait sans prétendre qu'il s'agit d'un défaut. + +## Ce qui n'est pas signalé + +* Une exclusion qui retire au moins une valeur atteignable. +* Une borne qui rétrécit réellement le domaine. +* Quoi que ce soit sur un générateur non entier, ou une chaîne avec un argument non constant. + +--- + +[← 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 db0e8986..f8a9d051 100644 --- a/doc/handwritten/for-users/analyzers/README.fr.md +++ b/doc/handwritten/for-users/analyzers/README.fr.md @@ -91,6 +91,8 @@ Ces règles anticipent, à la compilation, le sous-ensemble des vérifications d | [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. | | [JD016 CollectionConstraintsAdmitNoValue](JD016.fr.md) | 🟠 Avertissement | on | Les contraintes de cardinal d'une chaîne de collection ne peuvent pas toutes tenir, ou elle réclame plus d'éléments distincts que son générateur d'éléments ne peut en produire. | | [JD017 EnumUniverseViolation](JD017.fr.md) | 🟠 Avertissement | on | Une contrainte d'enum sort des membres déclarés — une combinaison de drapeaux sans `AllowingCombinations()`, ou une exclusion qui vide l'univers. | +| [JD023 ScalarChainAdmitsNoValue](JD023.fr.md) | 🟠 Avertissement | on | Les contraintes constantes d'une chaîne entière réduisent le domaine à rien — bornes, treillis ou liste d'autorisation. | +| [JD024 ConstraintWithNoEffect](JD024.fr.md) | 🔵 Info | on | Une contrainte ne rétrécit rien : exclusion d'une valeur que le domaine ne pouvait pas produire, ou borne déjà impliquée. La seule famille de contraintes que l'exécution ne signale jamais. | ## Configuration diff --git a/doc/handwritten/for-users/analyzers/README.md b/doc/handwritten/for-users/analyzers/README.md index 6a65ab75..2d486b86 100644 --- a/doc/handwritten/for-users/analyzers/README.md +++ b/doc/handwritten/for-users/analyzers/README.md @@ -91,6 +91,8 @@ These rules front-load, to build time, the subset of the library's run-time cons | [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. | | [JD016 CollectionConstraintsAdmitNoValue](JD016.en.md) | 🟠 Warning | on | A collection chain's count constraints cannot all hold, or it asks for more distinct elements than its element generator can produce. | | [JD017 EnumUniverseViolation](JD017.en.md) | 🟠 Warning | on | An enum constraint steps outside the declared members — a flag combination without AllowingCombinations(), or an exclusion that empties the universe. | +| [JD023 ScalarChainAdmitsNoValue](JD023.en.md) | 🟠 Warning | on | An integer chain's constant constraints narrow the domain to nothing — bounds, lattice or allow-list. | +| [JD024 ConstraintWithNoEffect](JD024.en.md) | 🔵 Info | on | A constraint narrows nothing: an exclusion of a value the domain could never produce, or a bound already implied. The only constraint family the run time never reports. | ## Configuring