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.
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.xmland its own vendored copy ofcom.shared.RunStatsDataBean, each with a caller in its own tree:Declared edges:
svc-a's caller binds tosvc-c's declaration while its own identical copy sits in the same directory.N-1copies orphaned — present insymbol_table, reachable by no declared edge.analysis.jsonindicates a duplicate was arbitrated.Cause
L1Extractor.extractAllbuilds a singleCombinedTypeSolverover every discovered source root (typeSolver(sourceRoots, dependencyDir, discovery)).CombinedTypeSolverreturns 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-deriveddeclarededges. The RTA overlay resolves through WALA's scope, whose winner may differ — in which casedeclaredandrtaedges in one graph could point at different copies. Not tested; needs a build that compiles every copy.Downstream
python-sdkrefuses this input rather than consuming it:JCodeanalyzer._index/JNeo4jBackendraiseduplicate_type_nameon the second declaration spelling onequalified_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.