deadcode: add the two analyzers that can find dead code under internal/ - #673
Open
Philip Lombardi (plombardi89) wants to merge 3 commits into
Open
deadcode: add the two analyzers that can find dead code under internal/#673Philip Lombardi (plombardi89) wants to merge 3 commits into
Philip Lombardi (plombardi89) wants to merge 3 commits into
Conversation
`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
This was referenced Aug 26, 2026
Philip Lombardi (plombardi89)
force-pushed
the
chore/dead-code-scan
branch
from
August 26, 2026 20:45
25e1b13 to
39bb10c
Compare
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
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.
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
unusedcannot see dead exported code underinternal/. That is a documented design decision, not a gap. Fromhonnef.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 sevenLinkManagermethods the issue was filed about:x/tools/go/callgraph/rta/rta.go:281-287,433-437— anssa.MakeInterfacecallsaddRuntimeType, 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: onevar i any = xanywhere in the program buys every exported method onx'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 deadcodedoesFour scans, one report. Nothing is wired into CI and nothing fails the build.
golang.org/x/tools/cmd/deadcodeis pinned as a thirdtooldirective next togovulncheckandcontroller-gen.hack/cmd/deadcode-reportowns the verdict, following the splitmake vulncheckalready 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:
GOOS/GOARCH/-tagsconfiguration. The tree is scanned once per tag set and the results intersected.DEADCODE_TAGSis kept in step withrun.build-tagsin.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/unreferencedasks 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/jsonand reflection, a reference count says very little about them.The filter that decides whether the output is useful or dangerous
wqProvider.NewDepthMetricis not named anywhere in this tree and deleting it would break client-go'sworkqueue.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 coversslog.Handler,prometheus.Collectorandyaml.Unmarshaler. On this tree it suppresses 332.What it found
Run against
main, before any of the sibling PRs:Three classes that neither a grep nor
deadcodealone could reach:NetlinkCache.LinkListandAddrList. 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.unusedbecause a group-mate of each is live.field-writes-are-usesmakes structurally invisible.Tests
hack/cmd/deadcode-reporthas 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/unreferencedis tested against two fixture modules undertestdata/, asserting the exact finding set: a dead method on a type boxed into an interface is reported, a method needed forfmt.Stringeris 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:
net: netlink, ebpf, metrics, config (includes the seven methods from Dead exported code in internal/ is invisible tounused; evaluate x/tools deadcode #670)net: the superseded node-CIDR controllergantrykube,forgeagentmetalmanoperator,orca,playpenhackdev toolingMerging this one first lets reviewers of the other eight run
make deadcodeon 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