Skip to content

deadcode: add the two analyzers that can find dead code under internal/ - #673

Open
Philip Lombardi (plombardi89) wants to merge 3 commits into
mainfrom
chore/dead-code-scan
Open

deadcode: add the two analyzers that can find dead code under internal/#673
Philip Lombardi (plombardi89) wants to merge 3 commits into
mainfrom
chore/dead-code-scan

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Step 1 of #670: the tooling, as a report rather than a gate. This PR adds no deletions — those are split by component across eight sibling PRs listed at the bottom.

The problem #670 describes

unused cannot see dead exported code under internal/. That is a documented design decision, not a gap. From honnef.co/go/tools, unused/unused.go:48-62: a package uses its exported named types, and a named type uses its exported methods. Every method on an exported type is therefore transitively live because the type is exported, however provably closed the package is. staticcheck's whole-program mode, which did consider that, was removed.

The problem #670 does not describe

x/tools/cmd/deadcode, the tool the issue proposes, does not detect the seven LinkManager methods the issue was filed about:

$ go tool deadcode -whylive=...netlink.LinkManager.SetLinkNoARP ./...
deadcode: ...LinkManager.SetLinkNoARP is reachable only through reflection

x/tools/go/callgraph/rta/rta.go:281-287,433-437 — an ssa.MakeInterface calls addRuntimeType, which marks every exported method of that type reachable, because reflection could call any of them. RTA is sound with respect to reflection by design and this is the price: one var i any = x anywhere in the program buys every exported method on x's type a clean bill of health for the rest of the analysis.

Which is the same blind spot as unused, arrived at from the other direction. Adding deadcode alone would have closed the issue on a tool that cannot see the case that prompted it.

So this adds two analyzers, not one.

What make deadcode does

Four scans, one report. Nothing is wired into CI and nothing fails the build.

golang.org/x/tools/cmd/deadcode is pinned as a third tool directive next to govulncheck and controller-gen. hack/cmd/deadcode-report owns the verdict, following the split make vulncheck already uses: the analyzer emits JSON, is never asked for a verdict, and a non-zero exit means the scan itself failed. An input that decodes to zero packages is treated as a failed scan rather than a clean tree, because a truncated run is otherwise indistinguishable from one.

Two axes are crossed because both change the answer:

  • Tags. deadcode's own documentation says results hold for a single GOOS/GOARCH/-tags configuration. The tree is scanned once per tag set and the results intersected. DEADCODE_TAGS is kept in step with run.build-tags in .golangci.yaml; a tag in one list and not the other is code one of the two never looks at.
  • -test. Without it, code reachable only from a test reads as dead. That is the signal that matters when a production caller is deleted and its test is not, so both runs are kept and reported separately. Writing a test for dead code moves it between sections rather than resolving it.

hack/cmd/unreferenced asks the cruder question the reflection rule cannot swallow: does any identifier, in any package, in any test, under any build tag, denote this declaration? It covers funcs, methods, types, aliases, consts and vars.

The two are complementary and neither subsumes the other. A cluster of dead functions that only call each other reads as referenced by one and dead by the other. Self-reference is excluded so a recursive dead function is still reported; method receivers are excluded so a type whose only remaining mentions are its own methods' receivers cannot keep itself alive. Struct fields are deliberately out of scope: between struct tags, encoding/json and reflection, a reference count says very little about them.

The filter that decides whether the output is useful or dangerous

wqProvider.NewDepthMetric is not named anywhere in this tree and deleting it would break client-go's workqueue.MetricsProvider. Any method whose receiver implements an interface declaring a method of that name is dropped, with the interface recorded, and the count is always printed so a suppression is never silent. Interfaces are collected from every transitive dependency, which is what covers slog.Handler, prometheus.Collector and yaml.Unmarshaler. On this tree it suppresses 332.

What it found

Run against main, before any of the sibling PRs:

Reported
Unreachable from any binary or test 80
Reachable only from tests 151
Unreferenced declarations 120

Three classes that neither a grep nor deadcode alone could reach:

  • NetlinkCache.LinkList and AddrList. The names occur throughout the package and the node agent, but every one of those is vishvananda/netlink's package-level function, not the cache method that shadows it.
  • Three unexported constants invisible to unused because a group-mate of each is live.
  • A dead method → dead field → dead client-factory-call chain in forge, which field-writes-are-uses makes structurally invisible.

Tests

hack/cmd/deadcode-report has ten tests covering intersection across tag configurations, line-number drift (identity is the declaration, not its position), the test-only section, generated and marker exclusion, and the empty-input-is-a-failure contract.

hack/cmd/unreferenced is tested against two fixture modules under testdata/, asserting the exact finding set: a dead method on a type boxed into an interface is reported, a method needed for fmt.Stringer is not, a test-file declaration is never a candidate, a generated file is skipped, self-reference and method receivers do not count as uses, and a reference under any one tag configuration keeps a declaration alive under all of them.

A note for step 2 of #670

The sibling PRs exposed a property worth knowing before anyone designs the gate: removing a caller makes its callees dead in turn, and neither analyzer can report the second round until the first has landed. Re-running after the deletions surfaced four more findings, one of which was NetlinkCache.HasLink, whose only callers were two of the seven methods from the issue. A gate should expect to need more than one pass after a large deletion.

The rest of the sweep

Deletions are split by component, each independent of the others and of this PR:

Merging this one first lets reviewers of the other eight run make deadcode on their branch to confirm the report is clean for their area. There is no code dependency either way.

Unobserved Prometheus metrics found during the sweep are tracked separately in #672.

Refs #670

`unused` cannot see dead exported code under internal/. That is a documented
design decision, not a gap: honnef.co/go/tools unused/unused.go:48-62 says a
package uses its exported named types and a named type uses its exported
methods, so every method on an exported type is transitively live because the
type is exported. That the package is internal/, and so provably has no
external consumer, is not considered. staticcheck's whole-program mode, which
did consider it, was removed.

x/tools/cmd/deadcode does the analysis `unused` declines to: Rapid Type
Analysis from the main packages. It is already in the module graph as x/tools
v0.49.0, so this pins it with a tool directive alongside govulncheck and
controller-gen and adds a `make deadcode` target.

Two axes have to be crossed for the answer to mean anything:

  - Tags. The tool's own documentation says results are valid for a single
    GOOS/GOARCH/-tags configuration. This tree builds under e2e,
    integrationtest and storageboundary as well as the default set, so it is
    scanned once per tag set and the report intersects them.

  - -test. Without it the roots are the 31 main packages and test-only code
    reads as dead; with it, test binaries are roots too. Both answers are
    useful and they are different questions, so the report has both sections.
    "Reachable only from tests" is the shape a function takes when its
    production caller is deleted and its test is not, which means adding
    coverage to a dead function moves it between sections rather than
    resolving it.

hack/cmd/deadcode-report owns the folding and the presentation, following the
split `make vulncheck` already uses: the analyzer emits JSON and is never
asked for a verdict, and a non-zero exit means the scan itself failed. An
input that decodes to zero packages is treated as a failed scan rather than a
clean tree, because a truncated run is otherwise indistinguishable from one.

This is a report. It is not wired into CI and it does not fail the build.

Refs #670
`make deadcode` reports nothing for the seven dead LinkManager methods that
issue #670 was filed about. Asking it why:

    $ go tool deadcode -whylive=...netlink.LinkManager.SetLinkNoARP ./...
    deadcode: ...LinkManager.SetLinkNoARP is reachable only through reflection

That is not a bug. x/tools/go/callgraph/rta/rta.go, addRuntimeType: converting
a value of type T to an interface materializes T's runtime type, and RTA then
marks every exported method of T reachable, because reflection could call any
of them. RTA is sound with respect to reflection by design, and this is the
price. One `var i any = x` anywhere in the program buys every exported method
on x's type a clean bill of health for the rest of the analysis.

Which is the same blind spot as `unused` ("named types use exported methods"),
arrived at from the other direction. Adding deadcode alone would have left the
motivating case undetectable and the issue closed on a tool that cannot see
it.

hack/cmd/unreferenced asks a cruder question the reflection rule cannot
swallow: does any identifier, in any package, in any test, under any build
tag, denote this declaration? It covers funcs, methods, types, aliases,
consts and vars, and it does not care how many interfaces the receiver is
boxed into.

The two tools are complementary and neither subsumes the other. A cluster of
dead functions that only call each other reads as referenced here and dead
there. Direct self-reference is excluded so a recursive dead function is still
reported; mutual recursion is not unpicked. Method receivers are excluded too,
so a type whose only remaining mentions are its own methods' receivers cannot
keep itself alive.

The one filter that decides whether the output is useful or dangerous is
interface satisfaction. `wqProvider.NewDepthMetric` is not named anywhere in
this tree and deleting it would break client-go's workqueue.MetricsProvider.
Any method whose receiver implements an interface declaring a method of that
name is dropped, with the interface recorded, and the count is always printed
so a suppression is never silent. Interfaces are collected from every
transitive dependency, which is what covers slog.Handler,
prometheus.Collector and yaml.Unmarshaler.

Struct fields are deliberately out of scope: between struct tags,
encoding/json and reflection, a reference count says very little about them.

On the tree as it stands this reports 120 declarations and suppresses 342,
and it finds things neither deadcode nor a grep can: NetlinkCache.LinkList is
dead while the identically named netlink.LinkList it shadows is used
everywhere, and three unexported constants are invisible to `unused` because a
group-mate of each is live.

Still a report. Not wired into CI, does not fail the build.

Refs #670
hack/cmd/unreferenced imports golang.org/x/tools/go/packages, which promotes
x/tools from indirect to direct in go.mod. NOTICE is generated from the direct
module graph, so it gains one entry.

Caught by `make notice-check` in CI, which is the check doing its job: the
tool directive alone would not have moved the module, but importing the
library does.

Refs #670
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.

1 participant