fix: reduce dead-code false positives (call methods, private constructors, enum removal)#8
fix: reduce dead-code false positives (call methods, private constructors, enum removal)#8claude[bot] wants to merge 10 commits into
Conversation
For a compact single-line enum (enum E { a, b, c }), removing a value
started the deletion span at column 0 of the value's line, which ate the
'enum E {' prefix and preceding values. Start the span at the value's own
token instead, only reaching up to column 0 (to take leading doc-comment
lines and indentation) when the value sits on its own line. Multi-line,
dart-formatted enums are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
Generated files (*.g.dart, ...) were excluded from the file list used both for candidate collection and for didOpen cache-warming, so a declaration referenced only from generated code (e.g. a toJson called from a .g.dart part) could be misreported as unused once the analysis server evicts the unopened unit. Split discovery into candidates vs. a warm-only set: generated files excluded from the scan are still opened to keep their resolved units warm, but their own declarations are never reported. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
A 'call' method makes its object callable via implicit-call syntax (obj()), which the analysis server's reference search cannot resolve back to the declaration -- the same limitation as for infix operators -- so a used call method was reported as unused every time. A private constructor (ClassName._, ClassName._named) is the standard pattern for preventing instantiation of a utility/constants class: intentionally never referenced, and removing it would re-add the implicit default constructor and silently make the class instantiable. Both are now skipped unconditionally, like main and vm:entry-point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
There was a problem hiding this comment.
Pull request overview
This PR reduces several common dead-code false positives in ciach’s Dart analysis by adding guards for patterns the analysis server can’t reliably resolve (implicit call, private utility constructors) and by making --remove safer for compact enums. It also improves correctness for declarations referenced only from generated code by warming excluded generated units in the analysis server.
Changes:
- Skip
callmethods and private constructors (ClassName._*) from unused reporting by default. - Split Dart file discovery into “scan candidates” vs “warm-only generated files”, and open warm-only files to keep references resolvable.
- Fix enum-value removal spans so single-line enums (
enum E { a, b, c }) are not corrupted by--remove.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/src/finder.dart | Adds call/private-constructor skip predicates and a Phase 0 to warm excluded generated files. |
| lib/src/file_discovery.dart | Introduces discoverDartFilesSplit returning (candidates, warmOnly) to support warming. |
| lib/src/remover.dart | Adjusts enum-value span computation to start at the value token (not column 0) for compact enums. |
| test/finder_test.dart | Adds coverage ensuring call methods and private ctors are skipped by default. |
| test/remover_test.dart | Adds regression tests for removing enum values from compact single-line enums. |
| test/file_discovery_test.dart | New tests validating candidate vs warm-only discovery behavior for generated files. |
| example/lib/callables.dart | New example fixture demonstrating callable class + utility class private constructor. |
| example/bin/app.dart | Wires the new callable/utility fixture into the example app usage. |
| README.md | Documents the new skip behaviors and generated-file warming behavior. |
| example/README.md | Lists the new example fixture file. |
| CHANGELOG.md | Adds Unreleased notes for the new behavior changes and fixes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// The warm-only set is deliberately *not* filtered by include/exclude globs: | ||
| /// its only purpose is to let reference queries resolve, and a reference can | ||
| /// legitimately live in a generated file the user isn't scanning. Files inside | ||
| /// skipped directories (`build/`, `.dart_tool/`, …) are still excluded. | ||
| DiscoveredDartFiles discoverDartFilesSplit(FinderOptions options) { |
| if (_isInSkippedDir(relative)) { | ||
| continue; | ||
| } | ||
| if (!options.includeGenerated && _isGenerated(entity, relative)) { | ||
| warmOnly.add(absolute); |
Add `--generated-suffix` (repeatable) and the matching `FinderOptions.additionalGeneratedSuffixes` option so projects whose code generators emit files with a custom suffix (e.g. `.gc.dart`) and without the conventional `GENERATED CODE - DO NOT MODIFY BY HAND` banner can exclude those files from the scan. Configured suffixes are merged with the built-in generated-suffix set in `_isGenerated`, so matching files are dropped from the scan candidates but still opened for reference resolution, exactly like the built-in generated files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
Removing several values from the same compact single-line enum
(`enum E { a, b, c }`) in one pass previously left cosmetic corruption:
doubled spaces where a middle value was dropped, and — when a trailing
run of values was removed — the separator comma that preceded the run
was orphaned (`enum E { a, }`), because each value's deletion span was
computed independently and then merged.
Consume the blank left between a removed value's comma and the next
value on the same line, and, after merging an enum's per-value spans,
absorb a comma left dangling between the last surviving value and a
trailing removed run. Any subset now leaves the `enum Name {` header and
every surviving value intact, whether the removed values are adjacent or
not and whether they include the first or last value. The same-line
guards keep multi-line, dart-formatted enums untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
Removing a value from a compact single-line enum that has a leading doc or
annotation comment used to walk the deletion span upward into the enum type's
own comment, collapsing the whole declaration to ` }` and deleting the
comment, the `enum E {` header, and any surviving values. The upward walk for
a value's leading metadata is now suppressed whenever the value shares its
line with the header, so the leading comment, the header, and the kept values
all survive. Multi-line enums (each value on its own line, with its own
optional doc comment) are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
|
btw @claude, while we're fixing behavior around enums – there's |
The analysis server tags enum values with the same symbol kind as the enum type itself, so a detected unused value was labelled `enum` and the `--kinds enum-value` filter never listed it. Remap enum values to the enum-value kind for both reporting and kind filtering; unused enum types keep the enum kind. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
|
Good catch, confirmed. Unused enum values were being reported (and filtered) under the raw analyzer kind |
An enum value reached only through `.values` iteration — the qualified `EnumType.values` form (found among the enum type's references) or the implicit bare `values` getter inside the enum's own body (detected by a source scan) — is reachable through iteration without being named individually, so a reference search found none and reported every value as dead. Collect the enums whose `.values` is iterated and skip their values. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
Only the classic prevent-instantiation marker — a class's sole, zero-parameter private constructor (`Foo._();`) — is skipped now. Other private constructors (one of several in a class, or one taking parameters) can be genuinely dead and are reported when never referenced. Determine "sole constructor" by counting constructor siblings in the document-symbol tree, and "zero parameters" from the constructor's signature detail; when the server omits the detail, conservatively treat it as the marker to avoid regressing the classic pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr
Requested by Łukasz Komoszyński · Slack thread
Addresses several false-positive categories reported in #7, where acting on
ciach's output on a real Flutter project broke the build. Each fix is a minimal, name/span-based guard in keeping with the existingmain/@override/ operator handling, and each is backed by a test.What changes for a
ciachusercallmethods (issue category 3)callmethod (invoked viaobj(...), e.g.widget.menuConfigFactory(shipment)) was reported as unused, and--removedeleted it — breaking every call site.callmethods are skipped by default, exactly like operator overloads. The analysis server can't resolve implicit-call syntax back to the declaration, the same limitation it has with infix operators (a + b), so a usedcallmethod was flagged every time.Private constructors (issue category 5)
ClassName._()) was reported as unused. Removing it silently re-added the implicit default constructor, making the class publicly instantiable — a quiet semantic change.ClassName._,ClassName._named) are skipped by default.--removeon compact enums (issue category 4)enum E { a, b, c }deleted from column 0 of the line, eating theenum E {prefix and corrupting the declaration (and every reference to the surviving values).dart format-formatted enums are unchanged.References only from generated code (issue category 1)
*.g.dart, …) were excluded both as scan candidates and from the analysis server's cache-warming step. On a large project the server evicts unopened units, so a declaration referenced only from generated code (e.g. atoJsoncalled from a.g.dartpart) came back with zero references and was misreported as unused.How (file references)
lib/src/finder.dart—_isCallMethod/_isPrivateConstructorpredicates + two guards in_shouldConsider(mirrors the existing operator guard). New Phase 0 warms the generated files via_warmFile, driven by the split discovery result.lib/src/file_discovery.dart—discoverDartFilesSplitreturns(candidates, warmOnly);discoverDartFilesdelegates to it. The warm-only set is deliberately not filtered by include/exclude globs (references can live in files you aren't scanning) but still excludesbuild/,.dart_tool/, etc.lib/src/remover.dart— the enum-value branch of_spanFornow computes the value's own start (reaching to column 0 only when the value is alone on its line), passed to_extendEnumValue(renamedcommentStart→valueStart). Comma handling is unchanged.example/lib/callables.dart(a callable class + a utility class with._()), wired intoexample/bin/app.dart.test/finder_test.dart(call/private-ctor skip),test/remover_test.dart(compact single-line enum, middle + last value),test/file_discovery_test.dart(candidate vs. warm split).README.md,example/README.md,CHANGELOG.md(Unreleased).Test coverage notes
dart analyzeclean,dart formatclean, full suite green (37 tests, was 30).callmethod resolves neither as a local-variable call, a field-access call, nor a function-return call — all three fail in isolation.)enum X {prefix and the surviving values remain and braces stay balanced; the existing multi-line enum tests still pass.warmOnly, never incandidates). It is directionally safe: opening more files can only reveal more references, so it can only move a declaration from "unused" to "used", never the reverse — it cannot cause over-deletion.Design decisions to confirm
call/ private-constructor skips are unconditional (no CLI flag), matchingmainand@pragma('vm:entry-point')rather than the opt-out flags for@override/operators. Rationale: both are effectively always false positives. If you'd prefer--call-methods/--private-constructorsopt-in flags for symmetry with--operators, that's a small follow-up..starts with_. This does not attempt full "class has only static members" analysis (issue suggestion Add --remove flag to delete unused declarations from source #4); that would need richer symbol data for little extra safety. The unnamed constructor of a private class (_Foo, no.) is intentionally not treated as private here.Not changed by design
UsedClass.newis not flagged; only the genuinely-unreferencedUsedClass.named/Unconstructed.neware). No change needed.Refs #7.
🤖 Generated with Claude Code
https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr