Clear the Sonar code smells, first tranche - #362
#362Open
Reefact wants to merge 13 commits into
mainReefact/first-class-errors:mainfrom
claude/sonar-code-smellsReefact/first-class-errors:claude/sonar-code-smellsCopy head branch name to clipboard
Open
Clear the Sonar code smells, first tranche#362Reefact wants to merge 13 commits intomainReefact/first-class-errors:mainfrom claude/sonar-code-smellsReefact/first-class-errors:claude/sonar-code-smellsCopy head branch name to clipboard
Reefact wants to merge 13 commits into
mainReefact/first-class-errors:mainfrom
claude/sonar-code-smellsReefact/first-class-errors:claude/sonar-code-smellsCopy head branch name to clipboard
Conversation
Sonar S1125 reports 23 `X is false` comparisons. The idiom is a local habit rather than a house style: the codebase negates with `!` in ~192 places and reads `is false` in eight files of the CLI and the generator only. Converting them settles on the dominant form. Every left operand was a primary expression (an invocation or an identifier), so `!` binds exactly as the pattern did and no parenthesis is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
Sonar S8969 reports 27 `!` suppressions the compiler no longer needs. Each sits behind a guard the flow analysis already understands — `string.IsNullOrWhiteSpace` carries `[NotNullWhen(false)]`, so the value is known non-null in the branch that dereferences it — or, in the reader, behind a `ReflectionTypeLoadException.Types` that netstandard2.0 declares unannotated. A `!` that suppresses nothing is worse than noise: it reads as a claim the author checked something, and the next reader cannot tell it from the ones that are load bearing. The one in `GetLoadableTypes` also left `Select(type => type)` behind once removed, so that identity projection goes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
Sonar S3218 reports eleven nested members shadowing an outer one. They are not the same finding twice, so they do not get the same answer. Ten are the library's documented error pattern: a `private static class Code` whose constants deliberately mirror the names of the factory methods beside them. Both sides are always qualified at the point of use — `Code.Malformed` against the `Malformed(...)` call — so the shadowing is nominal, and the sample error types now carry the same suppression the generator's own error types have carried since they were written. The eleventh is real. In `AmbientRandomSource`, the private static method `ReplaySnippet(int)` and the nested `AmbientState.ReplaySnippet` property share a name, and the method's body reads the property — one identifier meaning two things one line apart. The property becomes `Snippet`, which is also the word the file's own vocabulary reserves for the fragment as opposed to the guidance sentence that embeds it. Internal throughout; no public surface moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
Sonar S107 (eight constructors over seven parameters) and S2436 (seven declarations over the generic-parameter ceiling) both report arity. Neither is accidental here, and the ceilings are the wrong lever for any of them. The seven flagged constructors are the private ones of the generator engines. The design is 'constrain once, draw many': every With* call rebuilds an immutable spec, so the constructor takes the whole state by definition. No caller ever writes that argument list — it is private — and a parameter object would rename the list without shortening it. Any.Combine's generic arity is ADR-0015: composition is heterogeneous, so it needs one type parameter per part, and the arity-8 ceiling is the recorded ergonomic decision. Two of these overloads already carried the S107 suppression for the same reason; they now carry its generic-parameter twin beside it. AnyCollection's third parameter is the CRTP self-type that keeps the fluent methods returning the concrete generator instead of the base. The benchmark's PlaceBookingCommand is a request DTO: its parameter count is the payload's, and shrinking it would benchmark a different case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
Eleven searches spelled their intent as arithmetic on an index. `IndexOf(c) >= 0`
asks "does it contain c", `EndsWith("@", Ordinal)` asks about one character, and
`Enum.IsDefined(typeof(T), v)` boxes a value the generic overload takes as-is
(Sonar CA2249, CA1865, CA2263).
One of them is not like the others: `RegexParser` lives in JustDummies, which
still targets netstandard2.0, where `string.Contains(char)` does not exist. That
site takes the string overload, which does. The rest are in net8.0+ test and
sample projects, so they take the character overloads — which compare ordinally
by construction, making the dropped `StringComparison.Ordinal` a no-op rather
than a change of behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
`BookingDate.Parse` read `yyyy-MM-dd` through the `DateOnly.TryParseExact` overload that takes no format provider, so it parsed against the ambient culture (Sonar S6580). The format is fixed and ISO by contract — it is what the request carries — so a machine whose culture uses different date separators or a non-Gregorian calendar would reject a well-formed request, and the sample would teach the defect it exists to demonstrate the absence of. The parse now names `CultureInfo.InvariantCulture` and `DateTimeStyles.None` explicitly. The file already reached for the invariant culture when rendering (`ToString`), fully qualified; both sites now share one `using`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
The four toolchain error factories formatted their messages through `FormattableString.Invariant($"…")`, which allocates a `FormattableString` and its argument array before formatting (Sonar S6618). `string.Create` with the same interpolated string takes the interpolation handler path instead and formats straight into the result. Same invariant culture, same text; GenDoc targets net8.0, where the overload exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
`AnyString` and `CountConstraints` each wrap `SizeGuard` in local helpers that validate and hand the value back. Every one of their fourteen call sites discards the result and calls them as statements (Sonar S3241), so the return type promised a conversion the code never used — and hid that these are guards. They now return `void`. `SizeGuard`'s own helpers keep their return type; those are used as values. `UriSpec` also split on a one-element array where the `char` overload of `string.Split` says the same thing without allocating it (S3878). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
The conflict message picked between three phrasings through a ternary nested in a ternary (Sonar S3358), so the reader had to unpick operator nesting to see that there are simply three cases: an allow-list nothing survives, a flags enum whose combinations are all excluded, and a plain enum whose declared members are. An if/else chain says that, and a comment now names the three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
Sixteen findings across five rules that report structure rather than a defect. None of them is fixable without making the code worse, so each is recorded with the reason at the site. S125 (four) reads prose as disabled code: an equation, a bracketed range or a semicolon inside an explanatory sentence. Those comments carry the reasoning this codebase asks every comment to carry. S3459 and S1144 report the setters of `SnapshotDocument` as unassigned and unused; they are the deserializer's entry point, called by reflection. S4136 wants every same-named overload adjacent. The binder's argument sources are grouped by shape first — scalars, then lists — the way OutcomeTaskExtensions groups by receiver type; each source's scalar pair IS adjacent. S3928 reports two exception `paramName`s as unknown. Both name the parameter the CALLER wrote, which is the one they must fix; the private helper's own parameters name reflection details no consumer can see. S3220 flags the `Expression.Block` overload that is the intended one. S1694 wants two internal abstract roots turned into interfaces; a class cannot be implemented from outside the assembly and keeps the option of shared state. S2094 flags a Spectre.Console settings type that must exist to bind its command. S3246 is left for @Reefact: marking `BindingAssembler<TCommand>` covariant is worth having but moves the committed public-API baseline, so it is flagged rather than taken here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
Sonar S3267 reports 23 loops whose body is one guarded statement. Ten of them genuinely read better as a filter and become `Any`, `All`, `Count` or a `Where` in the header. The other thirteen do not, and four of those would be rewritten into defects: - `OrdinalIntervalSpec` and `WideIntervalSpec` advance the accumulator the condition tests, so each iteration changes what the next one compares against. A `Where` would evaluate every predicate against the value held on entry and skip exclusions. - `CollectionState`, `StringSpec`, `AnyDateTime` and `AnyDateTimeOffset` read the collection their body mutates. `Where` is lazy, so the filter would run against a snapshot taken before the additions it exists to see. - Three loops throw naming the FIRST offending element; a filter discards which one failed. - One relies on a null check for the flow state its body needs, and would need back the null-forgiving operator this branch just removed. Each of those carries its reason at the site. This also corrects the indentation of the suppression attributes added in the previous commit: they were emitted at column zero instead of matching the member they annotate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
Sonar S131 reports three `case` statements with no default branch. Two are in the history-hygiene hook, where anything unmatched really is the ordinary case; they now say so with an explicit no-op rather than leaving the reader to infer it. The third mattered. `_train_field` returned the empty string for a field name its row format does not carry — the same answer it gives for an unknown TRAIN, which `require_train` reads as "no such train". A typo in a field name would therefore have surfaced as a bogus "unknown train" message. It now says which field it did not recognise, on stderr. The other shell findings are left alone deliberately. S7679 asks for each positional parameter to be assigned to a `local` variable, but `local` is not POSIX and these scripts are `#!/bin/sh` — the repository uses none, which is why `_train_field` reaches for prefixed globals instead. Complying would mean either adopting a bashism or adding namespace-polluting assignments to one-line delegators. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
The S3928 suppression on `Malformed` answered SonarAnalyzer but not CA2208, which reports the same `paramName` from the .NET SDK analyzers and so left the finding open. It now carries both, pointing at the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkAL39tGpEy1LbKDk6PP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Clears 144 of the 249 Sonar code smells that are not
CA1510(which is lot 2's netstandard2.0 question and is untouched here). Roughly half are real simplifications, half are findings that are correct to leave and are now recorded with the reason at the site.This is a tranche, not the whole lot. The 105 that remain are inventoried at the bottom, with what each needs. I stopped rather than rush groups that turn on API-shape and refactoring judgement.
Type of change
Changes
Real defects fixed
BookingDate.Parsereadyyyy-MM-ddthrough theDateOnly.TryParseExactoverload with no format provider, so it parsed against the ambient culture. A machine whose culture uses different separators would have rejected a well-formed request — in the sample that exists to demonstrate good binding._train_fieldintrains.shreturned the empty string for an unrecognised field name — the same answer it gives for an unknown train, whichrequire_trainreports as "unknown train". A typo in a field name surfaced as a misleading message; it now names the field on stderr.AmbientRandomSource.ReplaySnippet(int)and the nestedAmbientState.ReplaySnippetproperty shared a name, and the method read the property one line below — one identifier meaning two things. The property is nowSnippet, the word the file's own vocabulary reserves for the fragment as opposed to the guidance sentence.Simplifications
X is false→!X. Not a house style: the codebase negates with!in ~192 places and readis falsein eight files only.[NotNullWhen(false)]), or behind aReflectionTypeLoadException.Typesthat netstandard2.0 declares unannotated. One left an identitySelectbehind, which went with it.Contains, thecharoverloads ofStartsWith/EndsWith, the genericEnum.IsDefined.Any,All,Countor aWherein the header. Five guard helpers that every caller invoked as a statement now returnvoid, and the toolchain error messages usestring.Createrather than allocating aFormattableString.Findings recorded rather than "fixed"
Some would have made the code worse; four would have made it wrong:
OrdinalIntervalSpecandWideIntervalSpecadvance inside the loop the accumulator their condition tests. AWhereevaluates every predicate against the value held on entry — it would silently skip exclusions.CollectionState,StringSpec,AnyDateTimeandAnyDateTimeOffsetread the collection their body mutates.Whereis lazy, so the filter would run against a snapshot taken before the additions it exists to see.Code.Xerror pattern whose constants deliberately mirror their factory names (same suppression the generator's own error types have always carried);Any.Combine's arity, which is ADR-0015; the private constructors of the "constrain once, draw many" engines, which carry the whole immutable state by design; fourS125findings that are prose, not disabled code.Testing
dotnet build FirstClassErrors.sln— succeeded, 0 warnings after every commitdotnet test FirstClassErrors.sln— 13 assemblies, 1 889 tests, 0 failuresFirstClassErrors.Analyzers.UnitTests)Beyond the template:
SonarAnalyzer.CSharp10.30 was injected into the build to measure before and after.S1125,S107andS2436are gone from the solution; theS8969andS3218that remain are all in test projects SonarCloud does not report, as the lot-1 investigation established forS1244. Both shell scripts passsh -n, andtrains.shwas exercised end to end (train_ids,prefix_of,scopes_of, the new unknown-field path,require_train) to confirm unchanged behaviour. Every commit passestools/commit-lint/lint-commit-message.sh.Documentation
doc/updatedJustificationtext at each siteArchitecture decisions
Proposed: ADR-____One item left for you, @Reefact:
S3246asks forBindingAssembler<TCommand>to be covariant (out). The variance is worth having, but it widens the public API surface and moves the committed public-API baseline, which this change did not ask for. It is suppressed with that reason rather than taken.What remains — 105 findings
CA1861static readonlyfields (test projects, mechanical)CA1859CA1822+S2325S7682+S7679+S1192S7679asks forlocal, which is not POSIX; these scripts are#!/bin/shand the repo useslocalnowhere — complying means a bashism or namespace-polluting assignments in one-line delegators.S7682(explicitreturn) is cheap but pure ceremony on the same delegators.SYSLIB1045GeneratedRegexrequirespartialmethods — changes the shape of the declaring classesS3776RegexParsersits at cognitive complexity 28 and deserves its own testsS1192CA1870,CA2231,xUnit1042,S1210SearchValues, equality operators, typed theory data, comparison operatorsNote on overlap with #358 (lot 1): both branches touch
ContinuousIntervalSpec.csandRandomSource.cs. The hunks are in different places and should merge cleanly, but whichever lands second is worth a glance.Related issues
None — this batch comes from the SonarCloud report rather than a tracked issue.
Generated by Claude Code