Skip to content

fix: reduce dead-code false positives (call methods, private constructors, enum removal)#8

Draft
claude[bot] wants to merge 10 commits into
mainfrom
fix/dead-code-false-positives
Draft

fix: reduce dead-code false positives (call methods, private constructors, enum removal)#8
claude[bot] wants to merge 10 commits into
mainfrom
fix/dead-code-false-positives

Conversation

@claude

@claude claude Bot commented Jul 14, 2026

Copy link
Copy Markdown

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 existing main / @override / operator handling, and each is backed by a test.

What changes for a ciach user

call methods (issue category 3)

  • Before: a callable class's call method (invoked via obj(...), e.g. widget.menuConfigFactory(shipment)) was reported as unused, and --remove deleted it — breaking every call site.
  • After: call methods 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 used call method was flagged every time.

Private constructors (issue category 5)

  • Before: a private constructor used only to prevent instantiation of a utility/constants class (ClassName._()) was reported as unused. Removing it silently re-added the implicit default constructor, making the class publicly instantiable — a quiet semantic change.
  • After: constructors whose own name segment is private (ClassName._, ClassName._named) are skipped by default.

--remove on compact enums (issue category 4)

  • Before: removing a value from a single-line enum such as enum E { a, b, c } deleted from column 0 of the line, eating the enum E { prefix and corrupting the declaration (and every reference to the surviving values).
  • After: the deletion span starts at the value's own token, so the prefix and sibling values always survive, regardless of formatting. Multi-line, dart format-formatted enums are unchanged.

References only from generated code (issue category 1)

  • Before: generated files (*.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. a toJson called from a .g.dart part) came back with zero references and was misreported as unused.
  • After: generated files excluded from the scan are still opened to keep their resolved units warm, so references from them resolve. Their own declarations are still never reported.

How (file references)

  • lib/src/finder.dart_isCallMethod / _isPrivateConstructor predicates + 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.dartdiscoverDartFilesSplit returns (candidates, warmOnly); discoverDartFiles delegates 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 excludes build/, .dart_tool/, etc.
  • lib/src/remover.dart — the enum-value branch of _spanFor now computes the value's own start (reaching to column 0 only when the value is alone on its line), passed to _extendEnumValue (renamed commentStartvalueStart). Comma handling is unchanged.
  • Fixtures: example/lib/callables.dart (a callable class + a utility class with ._()), wired into example/bin/app.dart.
  • Tests: 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).
  • Docs: README.md, example/README.md, CHANGELOG.md (Unreleased).

Test coverage notes

  • dart analyze clean, dart format clean, full suite green (37 tests, was 30).
  • The call-method and private-constructor tests are non-vacuous: I verified that with each skip temporarily disabled the fixture declaration is reported as unused, and with it enabled it is not. (The call method resolves neither as a local-variable call, a field-access call, nor a function-return call — all three fail in isolation.)
  • The compact-enum removal test asserts the enum X { prefix and the surviving values remain and braces stay balanced; the existing multi-line enum tests still pass.
  • Category 1 has no deterministic end-to-end regression test. The bug only manifests once the analysis server evicts an unopened unit, which requires a large project — it can't be forced in a small fixture (in a small package the reference resolves even without the fix). The change is instead unit-tested at the discovery layer (warm ⊇ candidates; generated files in warmOnly, never in candidates). 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), matching main and @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-constructors opt-in flags for symmetry with --operators, that's a small follow-up.
  • Private-constructor rule scope: the tight, defensible rule the symbol data makes easy — a constructor whose name segment after the last . 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

  • Constructors of instantiated classes (issue category 2) — not a bug. The existing suite already proves an instantiated class's constructor is tracked (UsedClass.new is not flagged; only the genuinely-unreferenced UsedClass.named / Unconstructed.new are). No change needed.
  • Enum-value flagging is left as-is: individual unused enum values are still reported (only the removal span was unsafe). Switch-exhaustiveness verification before removal (issue suggestion Add pub.dev topics and issue_tracker metadata #5) is out of scope.

Refs #7.

🤖 Generated with Claude Code

https://claude.ai/code/session_011RVzLgQsFrwq1HhhURx3Sr

claude added 4 commits July 14, 2026 10:11
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
@Komoszek
Komoszek requested review from PiotrRogulski and Copilot and removed request for PiotrRogulski July 14, 2026 10:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 call methods 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.

Comment on lines +60 to +64
/// 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) {
Comment on lines 89 to +93
if (_isInSkippedDir(relative)) {
continue;
}
if (!options.includeGenerated && _isGenerated(entity, relative)) {
warmOnly.add(absolute);
claude added 3 commits July 14, 2026 19:32
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
@PiotrRogulski

Copy link
Copy Markdown
Member

btw @claude, while we're fixing behavior around enums – there's enum kind and enum-value kind, but detected unused values are reported as just enum – so filtering with -k enum-value doesn't list them

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
@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown
Author

Good catch, confirmed. Unused enum values were being reported (and filtered) under the raw analyzer kind enum instead of enum-value, because both the reported kind and the -k filter used SymbolKind.enum directly. Fixed in 97be9f9: enum values now carry the enum-value kind (enum types stay enum), so -k enum-value lists them and -k enum no longer does. Added tests covering both the reported kind and the filter.

claude added 2 commits July 15, 2026 08:29
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants