From 4cf81c18c8b42732371379decd3a21089e0d9269 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 15:33:23 +0000 Subject: [PATCH] feat(justdummies): add JD016-JD017 for collection and enum constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JD016 reports a collection chain whose constant count constraints cannot all hold, or which asks for more distinct elements than its element generator can produce — the cardinality gate ADR-0013 records. Both throw at declaration time, so this moves an arrange-time red to a build-time red rather than closing a silent green; the chain usually sits in a helper several call frames from the test that dies on it. JD017 keeps the enum surface separate, because its domain is metadata — the declared members — rather than interval arithmetic, and its mistake has its own model. Any.Enum draws only declared members, which is deliberate and surprising in one place: on a [Flags] enum, writing a combination in OneOf is the natural gesture and the generator refuses it unless AllowingCombinations() is declared, so the message carries that hint rather than leaving it to be guessed. Twelve behaviours were probed against the built library before either rule was written, and dogfooding then corrected the cardinality helper. AllowingCombinations() WIDENS an enum's universe to the OR-closure of its declared members — eight values for four flags — so counting declared members condemned a chain AnyEnumCombinationTests asserts is legal. The helper now stands down when it sees that constraint rather than computing the closure: an unprovable domain must never be treated as a small one, and a deliberate false negative is the right side to err on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GKqyhG9Y4AfdxEYekP2Evq --- ...6CollectionConstraintsAdmitNoValueTests.cs | 238 ++++++++++++++++++ .../Jd017EnumUniverseViolationTests.cs | 174 +++++++++++++ .../AnalyzerReleases.Unshipped.md | 2 + ...llectionConstraintsAdmitNoValueAnalyzer.cs | 180 +++++++++++++ JustDummies.Analyzers/Descriptors.cs | 20 ++ JustDummies.Analyzers/DiagnosticIds.cs | 4 +- .../EnumUniverseViolationAnalyzer.cs | 111 ++++++++ .../for-users/analyzers/JD016.en.md | 48 ++++ .../for-users/analyzers/JD016.fr.md | 48 ++++ .../for-users/analyzers/JD017.en.md | 45 ++++ .../for-users/analyzers/JD017.fr.md | 45 ++++ .../for-users/analyzers/README.fr.md | 2 + doc/handwritten/for-users/analyzers/README.md | 2 + 13 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 JustDummies.Analyzers.UnitTests/Jd016CollectionConstraintsAdmitNoValueTests.cs create mode 100644 JustDummies.Analyzers.UnitTests/Jd017EnumUniverseViolationTests.cs create mode 100644 JustDummies.Analyzers/CollectionConstraintsAdmitNoValueAnalyzer.cs create mode 100644 JustDummies.Analyzers/EnumUniverseViolationAnalyzer.cs create mode 100644 doc/handwritten/for-users/analyzers/JD016.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD016.fr.md create mode 100644 doc/handwritten/for-users/analyzers/JD017.en.md create mode 100644 doc/handwritten/for-users/analyzers/JD017.fr.md diff --git a/JustDummies.Analyzers.UnitTests/Jd016CollectionConstraintsAdmitNoValueTests.cs b/JustDummies.Analyzers.UnitTests/Jd016CollectionConstraintsAdmitNoValueTests.cs new file mode 100644 index 00000000..51220073 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd016CollectionConstraintsAdmitNoValueTests.cs @@ -0,0 +1,238 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd016CollectionConstraintsAdmitNoValueTests { + + private const string EnumDeclarations = """ + public enum Day { Mon, Tue } + """; + + [Theory] + [InlineData("Any.ListOf(Any.Int32()).WithCount(0).NonEmpty()")] + [InlineData("Any.ListOf(Any.Int32()).NonEmpty().WithCount(0)")] + [InlineData("Any.ListOf(Any.Int32()).Empty().NonEmpty()")] + [InlineData("Any.ListOf(Any.Int32()).WithMinCount(5).WithMaxCount(2)")] + [InlineData("Any.ListOf(Any.Int32()).WithCount(2).WithMinCount(5)")] + public async Task Reports_counts_that_cannot_all_hold(string expression) { + string source = $$""" + using JustDummies; + + public static class Sample { + public static void M() { + _ = {{expression}}; + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD016"); + } + + [Fact] + public async Task Reports_more_contained_elements_than_the_cap_allows() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.ListOf(Any.Int32()).WithMaxCount(2).Containing(1).Containing(2).Containing(3); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("cannot fit"); + } + + [Fact] + public async Task Reports_a_set_asking_for_more_than_boolean_can_give() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.SetOf(Any.Boolean()).WithCount(5); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("only 2"); + } + + [Fact] + public async Task Reports_a_distinct_list_exceeding_the_enum_member_count() { + string source = $$""" + using JustDummies; + + {{EnumDeclarations}} + + public static class Sample { + public static void M() { + _ = Any.ListOf(Any.Enum()).Distinct().WithCount(10); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("only 2"); + } + + [Fact] + public async Task Reports_a_distinct_list_exceeding_an_explicit_pool() { + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.SetOf(Any.OneOf("a", "b")).WithCount(3); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + } + + [Theory] + [InlineData("Any.ListOf(Any.Int32()).WithCountBetween(2, 10)")] + [InlineData("Any.ListOf(Any.Int32()).NonEmpty().WithMaxCount(5)")] + [InlineData("Any.ListOf(Any.Int32()).WithCount(3).Containing(1).Containing(2)")] + [InlineData("Any.SetOf(Any.Boolean()).WithCount(2)")] + [InlineData("Any.SetOf(Any.Int32()).WithCount(500)")] + [InlineData("Any.ListOf(Any.Int32()).WithCount(500)")] + 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 CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_non_distinct_list_over_a_small_domain() { + // Without Distinct, repeats are fine: ten booleans in a list is ordinary. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M() { + _ = Any.ListOf(Any.Boolean()).WithCount(10); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), 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.SetOf(Any.Boolean()).WithCount(5)); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_widened_flag_enum_domain() { + // AllowingCombinations widens the universe to the OR-closure of the declared members — eight values for four + // flags — so counting declared members would condemn a legal chain. Live in + // JustDummies.UnitTests/AnyEnumCombinationTests.cs, which asserts WithCount(8) succeeds. + const string source = """ + using System; + using JustDummies; + + [Flags] + public enum Perm { None = 0, Read = 1, Write = 2, Execute = 4 } + + public static class Sample { + public static void M() { + _ = Any.SetOf(Any.Enum().AllowingCombinations()).WithCount(8); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Still_reports_a_flag_enum_without_the_widening() { + const string source = """ + using System; + using JustDummies; + + [Flags] + public enum Perm { None = 0, Read = 1, Write = 2, Execute = 4 } + + public static class Sample { + public static void M() { + _ = Any.SetOf(Any.Enum()).WithCount(5); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("only 4"); + } + + [Fact] + public async Task Does_not_report_an_element_generator_whose_domain_is_unprovable() { + // An unprovable domain must never be treated as a small one. + const string source = """ + using JustDummies; + + public static class Sample { + public static void M(IAny elements) { + _ = Any.SetOf(elements).WithCount(1000); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new CollectionConstraintsAdmitNoValueAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers.UnitTests/Jd017EnumUniverseViolationTests.cs b/JustDummies.Analyzers.UnitTests/Jd017EnumUniverseViolationTests.cs new file mode 100644 index 00000000..45693c63 --- /dev/null +++ b/JustDummies.Analyzers.UnitTests/Jd017EnumUniverseViolationTests.cs @@ -0,0 +1,174 @@ +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using NFluent; + +namespace JustDummies.Analyzers.UnitTests; + +public class Jd017EnumUniverseViolationTests { + + private const string Declarations = """ + using System; + + [Flags] + public enum Perm { None = 0, Read = 1, Write = 2 } + + public enum Day { Mon, Tue } + """; + + [Fact] + public async Task Reports_a_flag_combination_without_AllowingCombinations() { + // The natural thing to write on a [Flags] enum, and the generator refuses it. + string source = $$""" + using JustDummies; + {{Declarations}} + + public static class Sample { + public static void M() { + _ = Any.Enum().OneOf(Perm.Read | Perm.Write); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].Id).IsEqualTo("JD017"); + Check.That(diagnostics[0].GetMessage()).Contains("AllowingCombinations()"); + } + + [Fact] + public async Task Reports_an_undeclared_numeric_value() { + string source = $$""" + using JustDummies; + {{Declarations}} + + public static class Sample { + public static void M() { + _ = Any.Enum().OneOf((Day)99); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("not a declared member of Day"); + } + + [Fact] + public async Task Reports_an_exclusion_that_removes_every_member() { + string source = $$""" + using JustDummies; + {{Declarations}} + + public static class Sample { + public static void M() { + _ = Any.Enum().Except(Day.Mon, Day.Tue); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(1); + Check.That(diagnostics[0].GetMessage()).Contains("no declared Day member remains"); + } + + [Fact] + public async Task Does_not_report_a_combination_once_AllowingCombinations_is_declared() { + string source = $$""" + using JustDummies; + {{Declarations}} + + public static class Sample { + public static void M() { + _ = Any.Enum().AllowingCombinations().OneOf(Perm.Read | Perm.Write); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_declared_members() { + string source = $$""" + using JustDummies; + {{Declarations}} + + public static class Sample { + public static void M() { + _ = Any.Enum().OneOf(Perm.Read, Perm.Write); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_partial_exclusion() { + string source = $$""" + using JustDummies; + {{Declarations}} + + public static class Sample { + public static void M() { + _ = Any.Enum().Except(Day.Mon); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_an_unsubstituted_type_parameter() { + // A generic helper gives no enum to reason about; the rule must bail rather than guess. + string source = $$""" + using System; + using JustDummies; + {{Declarations}} + + public static class Sample { + public static IAny AnyOf() where T : struct, Enum => Any.Enum(); + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + + [Fact] + public async Task Does_not_report_a_conflict_asserting_negative_test() { + string source = $$""" + using System; + using JustDummies; + {{Declarations}} + + public static class Check2 { + public static void ThatCode(Func code) { } + } + + public static class Sample { + public static void M() { + Check2.ThatCode(() => Any.Enum().Except(Day.Mon, Day.Tue)); + } + } + """; + + ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new EnumUniverseViolationAnalyzer(), source); + + Check.That(diagnostics.Length).IsEqualTo(0); + } + +} diff --git a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md index ef167bcd..d07a120f 100644 --- a/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md +++ b/JustDummies.Analyzers/AnalyzerReleases.Unshipped.md @@ -20,3 +20,5 @@ JD012 | JustDummies.Usage | Warning | GeneratorPooledAsValueAnalyze JD013 | JustDummies.Usage | Warning | HeldCollectionPassedToOneOfAnalyzer JD014 | JustDummies.Constraints | Warning | RejectedConstantArgumentAnalyzer JD015 | JustDummies.Constraints | Warning | StringConstraintsAdmitNoValueAnalyzer +JD016 | JustDummies.Constraints | Warning | CollectionConstraintsAdmitNoValueAnalyzer +JD017 | JustDummies.Constraints | Warning | EnumUniverseViolationAnalyzer diff --git a/JustDummies.Analyzers/CollectionConstraintsAdmitNoValueAnalyzer.cs b/JustDummies.Analyzers/CollectionConstraintsAdmitNoValueAnalyzer.cs new file mode 100644 index 00000000..94b56883 --- /dev/null +++ b/JustDummies.Analyzers/CollectionConstraintsAdmitNoValueAnalyzer.cs @@ -0,0 +1,180 @@ +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; + +/// +/// JD016 — reports a collection chain whose constant count constraints cannot all hold, or which asks for more +/// distinct elements than its element generator can produce. Both throw at declaration time, so this moves an +/// arrange-time red to a build-time red — worth it because the chain usually sits in a helper several call frames +/// away from the test that dies on it. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CollectionConstraintsAdmitNoValueAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.CollectionConstraintsAdmitNoValue); + + /// + 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 || !IsCollectionFactory(factory.TargetMethod.Name)) { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + int? exact = null; + int? minimum = null; + int? maximum = null; + bool distinct = factory.TargetMethod.Name is "SetOf" or "DictionaryOf"; + int contained = 0; + IOperation? at = null; + + foreach (IInvocationOperation constraint in constraints) { + at = constraint; + + switch (constraint.TargetMethod.Name) { + case "Empty": exact = 0; break; + case "NonEmpty": minimum = System.Math.Max(minimum ?? 0, 1); break; + case "Distinct": distinct = true; break; + case "Containing" or "ContainingKey" or "ContainingEntry": contained++; break; + + case "WithCount" when TryConstant(constraint, out int count): exact = count; break; + case "WithMinCount" when TryConstant(constraint, out int min): minimum = System.Math.Max(minimum ?? 0, min); break; + case "WithMaxCount" when TryConstant(constraint, out int max): maximum = System.Math.Min(maximum ?? int.MaxValue, max); break; + + case "WithCountBetween" when constraint.Arguments.Length == 2 + && ConstantFacts.TryGetInt32(constraint.Arguments[0].Value, out int low) + && ConstantFacts.TryGetInt32(constraint.Arguments[1].Value, out int high): + minimum = System.Math.Max(minimum ?? 0, low); + maximum = System.Math.Min(maximum ?? int.MaxValue, high); + + break; + } + } + + if (at is null) { return; } + + int effectiveMin = System.Math.Max(minimum ?? 0, exact ?? 0); + int effectiveMax = System.Math.Min(maximum ?? int.MaxValue, exact ?? int.MaxValue); + + if (effectiveMin > effectiveMax) { + Report(context, at, $"the declared counts require at least {effectiveMin} element(s) and at most {effectiveMax}"); + + return; + } + + if (contained > effectiveMax) { + Report(context, at, $"{contained} element(s) are required to be contained, which cannot fit in a collection of at most {effectiveMax}"); + + return; + } + + // The cardinality gate (ADR-0013): a distinct collection cannot hold more elements than its element + // generator has distinct values to give. + if (!distinct) { return; } + if (!TryGetProvableCardinality(factory, symbols, out int cardinality)) { return; } + if (effectiveMin <= cardinality) { return; } + + Report(context, at, $"{effectiveMin} distinct element(s) are required, but the element generator can produce only {cardinality}"); + } + + private static void Report(OperationAnalysisContext context, IOperation at, string reason) { + context.ReportDiagnostic(Diagnostic.Create(Descriptors.CollectionConstraintsAdmitNoValue, at.Syntax.GetLocation(), reason)); + } + + private static bool TryConstant(IInvocationOperation constraint, out int value) { + value = 0; + + return constraint.Arguments.Length == 1 && ConstantFacts.TryGetInt32(constraint.Arguments[0].Value, out value); + } + + private static bool IsCollectionFactory(string name) { + return name is "ListOf" or "ArrayOf" or "SequenceOf" or "SetOf" or "DictionaryOf"; + } + + /// + /// An upper bound on the element generator's distinct domain, for the shapes the compiler can settle. Anything + /// else answers false: an unprovable domain must never be treated as a small one. + /// + private static bool TryGetProvableCardinality(IInvocationOperation factory, KnownSymbols symbols, out int cardinality) { + cardinality = 0; + + if (factory.Arguments.Length == 0) { return false; } + if (GeneratorFacts.Unwrap(factory.Arguments[0].Value) is not IInvocationOperation element) { return false; } + + // Walk the element chain back to its own factory, watching what the constraints along the way do to the + // domain. AllowingCombinations() WIDENS an enum's universe to the OR-closure of its declared members — eight + // values for four flags — so counting declared members there would under-report the domain and condemn a legal + // chain. An unprovable domain must never be treated as a small one, so the rule stands down instead of + // computing the closure: a deliberate false negative, not an oversight. + IInvocationOperation root = element; + while (root.Instance is not null && GeneratorFacts.Unwrap(root.Instance) is IInvocationOperation inner) { + if (root.TargetMethod.Name == "AllowingCombinations") { return false; } + + root = inner; + } + + switch (root.TargetMethod.Name) { + case "Boolean": + cardinality = 2; + + return true; + + case "Enum" when root.TargetMethod.TypeArguments.Length == 1 && root.TargetMethod.TypeArguments[0] is INamedTypeSymbol enumType: + cardinality = enumType.GetMembers().OfType().Count(field => field.HasConstantValue); + + return cardinality > 0; + + case "OneOf" or "ElementOf": + return TryCountDistinctConstants(root, out cardinality); + + default: + return false; + } + } + + private static bool TryCountDistinctConstants(IInvocationOperation pool, out int cardinality) { + cardinality = 0; + + foreach (IArgumentOperation argument in pool.Arguments) { + if (argument.ArgumentKind != ArgumentKind.ParamArray) { continue; } + if (argument.Value is not IArrayCreationOperation { Initializer: { } initializer }) { return false; } + + HashSet distinct = new(); + foreach (IOperation element in initializer.ElementValues) { + Optional constant = GeneratorFacts.Unwrap(element).ConstantValue; + if (!constant.HasValue) { return false; } + + distinct.Add(constant.Value); + } + + cardinality = distinct.Count; + + return cardinality > 0; + } + + return false; + } + +} diff --git a/JustDummies.Analyzers/Descriptors.cs b/JustDummies.Analyzers/Descriptors.cs index 8fd628db..ef291b64 100644 --- a/JustDummies.Analyzers/Descriptors.cs +++ b/JustDummies.Analyzers/Descriptors.cs @@ -162,4 +162,24 @@ internal static class Descriptors { 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)); + public static readonly DiagnosticDescriptor CollectionConstraintsAdmitNoValue = new( + id: DiagnosticIds.CollectionConstraintsAdmitNoValue, + title: "The declared collection constraints admit no value", + messageFormat: "No collection satisfies this chain: {0}", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The count constraints contradict each other for the constants written at the call site, or the chain asks for more distinct elements than its element generator can produce — the cardinality gate ADR-0013 records. Both throw at declaration time, so the value here is a build-time red rather than an arrange-time one: the chain usually sits in a helper several call frames away from the test that dies on it.", + helpLinkUri: HelpLinks.For(DiagnosticIds.CollectionConstraintsAdmitNoValue)); + + public static readonly DiagnosticDescriptor EnumUniverseViolation = new( + id: DiagnosticIds.EnumUniverseViolation, + title: "An enum constraint steps outside the generator's universe", + messageFormat: "Any.Enum draws only declared members: {0}", + category: DiagnosticCategories.Constraints, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Any.Enum() draws uniformly across T's declared members and never an undeclared numeric value. That is deliberate and surprising: on a [Flags] enum, writing a combination in OneOf is the natural thing to do and the generator refuses it unless AllowingCombinations() is declared. An exclusion that removes every declared member is the same category error from the other side.", + helpLinkUri: HelpLinks.For(DiagnosticIds.EnumUniverseViolation)); + } diff --git a/JustDummies.Analyzers/DiagnosticIds.cs b/JustDummies.Analyzers/DiagnosticIds.cs index a94750b1..bec1e951 100644 --- a/JustDummies.Analyzers/DiagnosticIds.cs +++ b/JustDummies.Analyzers/DiagnosticIds.cs @@ -29,6 +29,8 @@ internal static class DiagnosticIds { // Category: Constraints — decidable from compile-time constants public const string RejectedConstantArgument = "JD014"; - public const string StringConstraintsAdmitNoValue = "JD015"; + public const string StringConstraintsAdmitNoValue = "JD015"; + public const string CollectionConstraintsAdmitNoValue = "JD016"; + public const string EnumUniverseViolation = "JD017"; } diff --git a/JustDummies.Analyzers/EnumUniverseViolationAnalyzer.cs b/JustDummies.Analyzers/EnumUniverseViolationAnalyzer.cs new file mode 100644 index 00000000..e9c9ad02 --- /dev/null +++ b/JustDummies.Analyzers/EnumUniverseViolationAnalyzer.cs @@ -0,0 +1,111 @@ +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; + +/// +/// JD017 — reports an enum constraint that steps outside the generator's universe. Any.Enum<T>() draws +/// only declared members, which is deliberate and surprising: on a [Flags] enum, writing a +/// combination in OneOf is the natural thing to do and the generator refuses it unless +/// AllowingCombinations() is declared. +/// +/// +/// Kept apart from the interval rules because the domain is metadata — the declared members — rather than +/// arithmetic, and because the mistake has its own teachable model: the generator yields declared members, so a +/// value that is not one is not a narrowing but a category error. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EnumUniverseViolationAnalyzer : DiagnosticAnalyzer { + + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptors.EnumUniverseViolation); + + /// + public override void Initialize(AnalysisContext context) { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) { + KnownSymbols symbols = KnownSymbols.From(context.Compilation); + if (symbols.Any is null || symbols.IAny is null) { return; } + + context.RegisterOperationAction(operationContext => Analyze(operationContext, symbols), OperationKind.Invocation); + } + + private static void Analyze(OperationAnalysisContext context, KnownSymbols symbols) { + IInvocationOperation invocation = (IInvocationOperation)context.Operation; + + if (invocation.Parent is IInvocationOperation) { return; } + if (!AnyChainFacts.TryGetChain(invocation, symbols, out IReadOnlyList constraints, out IInvocationOperation? factory)) { return; } + if (factory is null || factory.TargetMethod.Name != "Enum") { return; } + if (factory.TargetMethod.TypeArguments.Length != 1 || factory.TargetMethod.TypeArguments[0] is not INamedTypeSymbol enumType) { return; } + if (NegativeTestGuard.IsSoleBodyOfLambdaArgument(invocation.Syntax)) { return; } + + HashSet declared = new(enumType.GetMembers() + .OfType() + .Where(field => field.HasConstantValue) + .Select(field => field.ConstantValue)); + + if (declared.Count == 0) { return; } + + bool combinationsAllowed = constraints.Any(constraint => constraint.TargetMethod.Name == "AllowingCombinations"); + + HashSet excluded = new(); + + foreach (IInvocationOperation constraint in constraints) { + string name = constraint.TargetMethod.Name; + if (name is not ("OneOf" or "Except" or "DifferentFrom")) { continue; } + + foreach (IOperation value in ConstantArguments(constraint)) { + Optional constant = value.ConstantValue; + if (!constant.HasValue) { continue; } + + if (name is "Except" or "DifferentFrom") { excluded.Add(constant.Value); } + + // AllowingCombinations widens the universe to the OR-closure of the declared members, which no longer + // matches a declared value one for one — so the rule stands down rather than approximate it. + if (combinationsAllowed || declared.Contains(constant.Value)) { continue; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.EnumUniverseViolation, value.Syntax.GetLocation(), + $"{constant.Value} is not a declared member of {enumType.Name}" + + (enumType.GetAttributes().Any(IsFlagsAttribute) ? "; declare AllowingCombinations() to draw flag combinations" : string.Empty))); + + return; + } + } + + if (excluded.Count == 0 || !declared.All(excluded.Contains)) { return; } + + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.EnumUniverseViolation, invocation.Syntax.GetLocation(), + $"no declared {enumType.Name} member remains once every exclusion is applied")); + } + + private static IEnumerable ConstantArguments(IInvocationOperation constraint) { + foreach (IArgumentOperation argument in constraint.Arguments) { + if (argument.ArgumentKind == ArgumentKind.ParamArray) { + if (argument.Value is IArrayCreationOperation { Initializer: { } initializer }) { + foreach (IOperation element in initializer.ElementValues) { yield return GeneratorFacts.Unwrap(element); } + } + + continue; + } + + yield return GeneratorFacts.Unwrap(argument.Value); + } + } + + private static bool IsFlagsAttribute(AttributeData attribute) { + return attribute.AttributeClass?.Name == "FlagsAttribute"; + } + +} diff --git a/doc/handwritten/for-users/analyzers/JD016.en.md b/doc/handwritten/for-users/analyzers/JD016.en.md new file mode 100644 index 00000000..2f272673 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD016.en.md @@ -0,0 +1,48 @@ +# JD016: CollectionConstraintsAdmitNoValue + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD016.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +The declared count constraints cannot all hold, or the chain asks for more **distinct** elements than its element generator can produce — the cardinality gate [ADR-0013](../../for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) records. + +Both throw at declaration time, so this rule turns an arrange-time red into a build-time red rather than closing a silent green. That is still worth it: the chain usually sits in an arrange helper several call frames away from the test that dies on it, where the message names two constraints the reader must then go hunting for. + +## Noncompliant + +```csharp +Any.ListOf(Any.Int32()).WithCount(0).NonEmpty() // fixed at 0, then required non-empty +Any.ListOf(Any.Int32()).WithMinCount(5).WithMaxCount(2) // no count satisfies both +Any.ListOf(Any.Int32()).WithMaxCount(2).Containing(1).Containing(2).Containing(3) // 3 items, 2 slots +Any.SetOf(Any.Boolean()).WithCount(5) // 5 distinct booleans do not exist +Any.ListOf(Any.Enum()).Distinct().WithCount(10) // more than the enum has members +``` + +## Compliant + +```csharp +Any.ListOf(Any.Int32()).WithCountBetween(2, 10) +Any.SetOf(Any.Boolean()).WithCount(2) +Any.ListOf(Any.Boolean()).WithCount(10) // no Distinct: repeats are fine +``` + +## Which domains it can prove + +Only the ones the compiler settles: `Any.Boolean()` (2), `Any.Enum()` (the declared member count), and a `OneOf`/`ElementOf` pool of constants (its distinct count). Anything else is unprovable and reported as nothing — **an unprovable domain must never be treated as a small one**. + +`AllowingCombinations()` is a case in point. It *widens* an enum's universe to the OR-closure of its declared members — eight values for four flags — so counting declared members there would condemn a legal chain. The rule stands down instead of computing the closure: a deliberate false negative, found by dogfooding against `AnyEnumCombinationTests`, which asserts `WithCount(8)` succeeds. + +## What it does not flag + +* A count or element generator that is not a compile-time constant. +* A chain split across statements — it must be one expression. +* 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/JD016.fr.md b/doc/handwritten/for-users/analyzers/JD016.fr.md new file mode 100644 index 00000000..f532df3a --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD016.fr.md @@ -0,0 +1,48 @@ +# JD016 : CollectionConstraintsAdmitNoValue + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD016.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | Oui | + +Les contraintes de cardinal déclarées ne peuvent pas toutes tenir, ou la chaîne réclame plus d'éléments **distincts** que son générateur d'éléments ne peut en produire — la barrière de cardinalité que consigne l'[ADR-0013](../../for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md). + +Les deux lèvent à la déclaration : cette règle transforme donc un rouge d'arrangement en rouge de compilation, plutôt que de fermer un vert silencieux. Cela vaut tout de même la peine : la chaîne se trouve d'ordinaire dans un utilitaire d'arrangement à plusieurs cadres d'appel du test qui meurt dessus, où le message nomme deux contraintes que le lecteur doit ensuite aller chercher. + +## Non conforme + +```csharp +Any.ListOf(Any.Int32()).WithCount(0).NonEmpty() // fixé à 0, puis exigé non vide +Any.ListOf(Any.Int32()).WithMinCount(5).WithMaxCount(2) // aucun cardinal ne satisfait les deux +Any.ListOf(Any.Int32()).WithMaxCount(2).Containing(1).Containing(2).Containing(3) // 3 éléments, 2 places +Any.SetOf(Any.Boolean()).WithCount(5) // 5 booléens distincts n'existent pas +Any.ListOf(Any.Enum()).Distinct().WithCount(10) // plus que le nombre de membres de l'enum +``` + +## Conforme + +```csharp +Any.ListOf(Any.Int32()).WithCountBetween(2, 10) +Any.SetOf(Any.Boolean()).WithCount(2) +Any.ListOf(Any.Boolean()).WithCount(10) // sans Distinct : les répétitions sont normales +``` + +## Les domaines qu'elle sait prouver + +Uniquement ceux que le compilateur tranche : `Any.Boolean()` (2), `Any.Enum()` (le nombre de membres déclarés), et un ensemble `OneOf`/`ElementOf` de constantes (son nombre de valeurs distinctes). Tout le reste est improuvable et n'est pas signalé — **un domaine improuvable ne doit jamais être traité comme un petit domaine**. + +`AllowingCombinations()` en est l'illustration. Elle *élargit* l'univers d'un enum à la clôture par OU de ses membres déclarés — huit valeurs pour quatre drapeaux — de sorte que compter les membres déclarés condamnerait une chaîne légale. La règle renonce plutôt que de calculer la clôture : un faux négatif délibéré, découvert par mise à l'épreuve contre `AnyEnumCombinationTests`, qui affirme que `WithCount(8)` réussit. + +## Ce qui n'est pas signalé + +* Un cardinal ou un générateur d'éléments qui n'est pas une constante de compilation. +* Une chaîne répartie sur plusieurs instructions — elle doit être une seule expression. +* 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/JD017.en.md b/doc/handwritten/for-users/analyzers/JD017.en.md new file mode 100644 index 00000000..0f5cbf6d --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD017.en.md @@ -0,0 +1,45 @@ +# JD017: EnumUniverseViolation + +🌍 **Languages:** +🇬🇧 English (this file) | 🇫🇷 [Français](./JD017.fr.md) + +| | | +|---|---| +| **Category** | Constraints (`JustDummies.Constraints`) | +| **Severity** | 🟠 Warning | +| **Enabled by default** | Yes | + +`Any.Enum()` draws uniformly across `TEnum`'s **declared members**, and never an undeclared numeric value. That is deliberate — and surprising in one specific place: on a `[Flags]` enum, writing a combination in `OneOf` is the natural thing to do, and the generator refuses it unless `AllowingCombinations()` is declared. + +An exclusion that removes every declared member is the same category error from the other side: the universe is emptied rather than narrowed. + +## Noncompliant + +```csharp +Any.Enum().OneOf(Permissions.Read | Permissions.Write) // a combination is not a declared member +Any.Enum().OneOf((Day)99) // not a declared member at all +Any.Enum().Except(Day.Mon, Day.Tue) // nothing remains to draw +``` + +## Compliant + +```csharp +Any.Enum().AllowingCombinations().OneOf(Permissions.Read | Permissions.Write) +Any.Enum().OneOf(Permissions.Read, Permissions.Write) +Any.Enum().Except(Day.Mon) +``` + +## Why it is separate from the other constraint rules + +Its domain is **metadata** — the declared members — rather than interval arithmetic, and its mistake has its own teachable model: a value outside the declared set is not a narrowing that happens to be empty, it is a category error. The `[Flags]` case even gets its own hint in the message, because the fix (`AllowingCombinations()`) is not something a reader would guess from "not a declared member". + +## What it does not flag + +* Any constraint once `AllowingCombinations()` is declared. That widens the universe to the OR-closure of the declared members, which no longer matches a declared value one for one, so the rule stands down rather than approximate it. +* A generic helper whose type argument is an unsubstituted type parameter — there is no enum to reason about, and the rule bails rather than guess. +* A non-constant value, or a partial exclusion that leaves at least one member. +* A conflict-asserting negative test. + +--- + +[← All analyzer rules](README.md) diff --git a/doc/handwritten/for-users/analyzers/JD017.fr.md b/doc/handwritten/for-users/analyzers/JD017.fr.md new file mode 100644 index 00000000..99164335 --- /dev/null +++ b/doc/handwritten/for-users/analyzers/JD017.fr.md @@ -0,0 +1,45 @@ +# JD017 : EnumUniverseViolation + +🌍 **Langues :** +🇫🇷 Français (ce fichier) | 🇬🇧 [English](./JD017.en.md) + +| | | +|---|---| +| **Catégorie** | Contraintes (`JustDummies.Constraints`) | +| **Sévérité** | 🟠 Avertissement | +| **Activée par défaut** | Oui | + +`Any.Enum()` tire uniformément parmi les **membres déclarés** de `TEnum`, et jamais une valeur numérique non déclarée. C'est délibéré — et surprenant à un endroit précis : sur un enum `[Flags]`, écrire une combinaison dans `OneOf` est le geste naturel, et le générateur la refuse tant qu'`AllowingCombinations()` n'est pas déclaré. + +Une exclusion qui retire tous les membres déclarés est la même erreur de catégorie vue de l'autre côté : l'univers est vidé plutôt que rétréci. + +## Non conforme + +```csharp +Any.Enum().OneOf(Permissions.Read | Permissions.Write) // une combinaison n'est pas un membre déclaré +Any.Enum().OneOf((Day)99) // pas un membre déclaré du tout +Any.Enum().Except(Day.Mon, Day.Tue) // il ne reste rien à tirer +``` + +## Conforme + +```csharp +Any.Enum().AllowingCombinations().OneOf(Permissions.Read | Permissions.Write) +Any.Enum().OneOf(Permissions.Read, Permissions.Write) +Any.Enum().Except(Day.Mon) +``` + +## Pourquoi elle est séparée des autres règles de contraintes + +Son domaine relève des **métadonnées** — les membres déclarés — et non de l'arithmétique d'intervalles, et sa faute a son propre modèle à enseigner : une valeur hors de l'ensemble déclaré n'est pas un rétrécissement qui se trouve être vide, c'est une erreur de catégorie. Le cas `[Flags]` reçoit même son propre indice dans le message, car la correction (`AllowingCombinations()`) n'est pas quelque chose qu'un lecteur devinerait à partir de « pas un membre déclaré ». + +## Ce qui n'est pas signalé + +* Toute contrainte dès lors qu'`AllowingCombinations()` est déclaré. Cela élargit l'univers à la clôture par OU des membres déclarés, qui ne correspond plus un à un à une valeur déclarée : la règle renonce plutôt que d'approximer. +* Un utilitaire générique dont l'argument de type est un paramètre de type non substitué — il n'y a aucun enum sur lequel raisonner, et la règle s'abstient plutôt que de deviner. +* Une valeur non constante, ou une exclusion partielle qui laisse au moins un membre. +* 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/README.fr.md b/doc/handwritten/for-users/analyzers/README.fr.md index b3bc6718..4ae0e6a6 100644 --- a/doc/handwritten/for-users/analyzers/README.fr.md +++ b/doc/handwritten/for-users/analyzers/README.fr.md @@ -84,6 +84,8 @@ Ces règles anticipent, à la compilation, le sous-ensemble des vérifications d |-------|----------|--------|-------------| | [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. | +| [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. | ## Configuration diff --git a/doc/handwritten/for-users/analyzers/README.md b/doc/handwritten/for-users/analyzers/README.md index 7f46261a..84752612 100644 --- a/doc/handwritten/for-users/analyzers/README.md +++ b/doc/handwritten/for-users/analyzers/README.md @@ -84,6 +84,8 @@ These rules front-load, to build time, the subset of the library's run-time cons |------|----------|---------|-------------| | [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. | +| [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. | ## Configuring