Skip to content

Duplicate FQNs across source roots bind to the last root, silently #269

Description

@rahlk

When one analysis covers several source roots that each declare the same fully-qualified type — vendored copies of a shared library across microservice repos, the common case for a bundled -i <parent> run — the symbol solver binds every caller to one copy, and it is not the caller's own.

Reproduction

Three services under one parent, each with its own pom.xml and its own vendored copy of com.shared.RunStatsDataBean, each with a caller in its own tree:

svc-a/src/main/java/com/shared/{RunStatsDataBean.java, CallerA.java}
svc-b/src/main/java/com/shared/{RunStatsDataBean.java, CallerB.java}
svc-c/src/main/java/com/shared/RunStatsDataBean.java
codeanalyzer -i . --app-name dt -a 2 --no-rta

Declared edges:

svc-a/CallerA.go()  ->  svc-c/RunStatsDataBean.origin()     prov=['declared']
svc-b/CallerB.go()  ->  svc-c/RunStatsDataBean.origin()     prov=['declared']
  • Last source root wins, positionally. With only svc-a and svc-b present the winner was svc-b; adding svc-c moved every caller to svc-c.
  • Not caller-local. svc-a's caller binds to svc-c's declaration while its own identical copy sits in the same directory.
  • Deterministic across runs — reproducible wrongness rather than flakiness, which is worse for trust because the graph looks stable.
  • N-1 copies orphaned — present in symbol_table, reachable by no declared edge.
  • No warning, no error, exit 0. Nothing in stderr or in analysis.json indicates a duplicate was arbitrated.

Cause

L1Extractor.extractAll builds a single CombinedTypeSolver over every discovered source root (typeSolver(sourceRoots, dependencyDir, discovery)). CombinedTypeSolver returns the first solver that resolves a name, so resolution is a function of source-root enumeration order rather than of the referencing compilation unit.

Two fixes, separable

1. Detect and report (small, behavior-preserving). The analyzer has already parsed every declaration when L1 completes; finding one FQN declared in more than one module is a pass over the symbol table it just built. Warn by default and fail under --strict. This converts silent mis-binding into a known condition, which is the part that matters for anyone deciding whether to trust the call graph.

2. Make resolution caller-local (larger). Give each source root its own solver ordering — own root first, then the rest — so a caller resolves against its own copy. Costs one parser configuration per source root instead of one shared, so a slower L1. It narrows rather than eliminates the problem: a caller whose own root lacks the type still falls back to an arbitrary pick, though a documented one.

Fix 1 is worth doing regardless of whether 2 lands.

Scope of the measurement

Run with --no-rta, so these are solver-derived declared edges. The RTA overlay resolves through WALA's scope, whose winner may differ — in which case declared and rta edges in one graph could point at different copies. Not tested; needs a build that compiles every copy.

Downstream

python-sdk refuses this input rather than consuming it: JCodeanalyzer._index / JNeo4jBackend raise duplicate_type_name on the second declaration spelling one qualified_name (cldk/analysis/java/backend.py:515). So a bundled analysis of vendored duplicates is currently unusable through the SDK — loudly, which is the correct outcome, but the analyzer should be the one to say so first.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions