| Property | Value |
|---|---|
| Category | Constraints (JustDummies.Constraints) |
| Severity | 🟠 Warning |
| Enabled by default | Yes |
A chain declares the same bound twice, and only the tighter of the two survives.
Bounds fold silently and monotonically: a minimum keeps the larger of the two values, a maximum the smaller, and the losing call returns the generator unchanged. Nothing throws, and no run-time report mentions it. So one of the two calls is always dead — the looser one, whichever order you wrote them in.
Noncompliant
string reference = Any.String().WithMinLength(8).WithMinLength(10).Generate(); // draws ≥ 10; the 8 is erased
int quantity = Any.Int32().LessThanOrEqualTo(50).LessThanOrEqualTo(90).Generate(); // draws ≤ 50; the 90 is inert
Compliant
string reference = Any.String().WithMinLength(10).Generate();
int quantity = Any.Int32().LessThanOrEqualTo(50).Generate();
Keep the bound you meant and delete the other. If you meant a range, name it: WithLengthBetween(10, 20), Between(1, 50) — see JD031.
The four vocabularies, both orders
| bound declared twice | generators |
|---|---|
WithMinLength, WithMaxLength | AnyString |
WithMinCount, WithMaxCount | every collection generator |
GreaterThanOrEqualTo, LessThanOrEqualTo, GreaterThan, LessThan | the numeric generators and TimeSpan |
AfterOrEqualTo, BeforeOrEqualTo, After, Before | AnyDateOnly, AnyDateTime, AnyDateTimeOffset, AnyTimeOnly |
Strict bounds are reported here, where JD031 refuses them. JD031 has to name a replacement call and cannot do so for a strict pair without rewriting your numbers; this rule names no replacement, so GreaterThan(1).GreaterThan(5) is as reportable as any other duplicate.
The diagnostic lands on the second declaration — the call that made the chain ambiguous, and the one whose removal you are weighing. It deliberately does not say which of the two survives: that is whichever is tighter, and answering it would mean evaluating arguments this rule never reads, on types like TimeSpan and DateTime where no compile-time constant can represent them.
Why a warning, where JD024 is information
The precedent is JD025, not JD024. A value listed twice in a pool collapses silently, nothing throws, the domain stays well defined — and it is a warning, because the surface reads bigger than it is and the gap surfaces far from where it was written. A bound declared twice is the same shape.
JD024 sits at information for a reason that does not transfer: an inert constraint has a defensible reading, since a team can exclude a sentinel before the range that would produce it exists, so the constraint documents an intent that survives a future widening. A bound written twice inside one expression has no such reading. Both calls are in front of the same reader, the tighter simply erases the looser, and there is no future in which the erased call starts mattering.
What it does not flag
Bounds held under a name. This is the important one, and it is soundness rather than scope:
AnyString atLeastEight = Any.String().WithMinLength(8);
AnyString atLeastTen = atLeastEight.WithMinLength(10);
A generator is an immutable recipe, so atLeastEight still exists and still draws a minimum of eight. It is a legitimate generator in its own right, nothing about WithMinLength(8) is dead, and reporting it would be a false positive. What makes the looser call dead inside a single chain is that the intermediate generator is unnamed and unreachable — the moment it is bound to a name, that stops being true. The rule therefore never follows a generator through a local, a field, a parameter or a second statement.
- A bound reached through an alias.
NonEmpty()is a minimum length of one andPositive()a minimum of one, soAny.String().NonEmpty().WithMinLength(8)does reach the same bound twice — and stays silent. Choosing the alias says something about intent the explicit bound does not, and which of two correct spellings to prefer is ADR-0077 opens in a new tab’s question, not this rule’s. - Two different bounds.
WithMinLength(8).WithMaxLength(20)is a range, not a duplicate. JD031 has something to say about it; this rule does not. - A range form.
Between(1, 50).Between(2, 20)carries two bounds per call, so no whole call dies. Out of scope here, and still covered by JD024 when it narrows nothing. - An exact size.
WithLengthandWithCountare declared once per generator and a second declaration throws, so the run time already reports them, loudly. - A chain asserted to throw, as the other constraint rules do.