Repository navigation
Cross-file INFERRED calls resolution lacks import evidence + ambiguous-candidate handling — short common names blow up god_nodes ranking #543
Description
Summary
The current cross-file calls resolution (extract.py around L3345-L3374) builds a global name map keyed by label.strip(\"()\").lstrip(\".\").lower(), then for each unresolved call it adds an INFERRED calls edge to any node whose label matches that key. There's no import-evidence check, no multi-candidate disambiguation, and confidence is hard-coded to 0.8.
For corpora with common short names (log, execute, mutate, replace, find, optional, check, main), this systematically inflates the degree of harmless local helpers until they become the top god_nodes — even though the true call graph never reaches them.
god_nodes() itself already filters file-level hubs / method stubs / isolated stubs (_is_file_node, _is_concept_node), but it does not consult edge confidence or import evidence, so polluted nodes pass straight through.
This is not the same as the calibration issue in #540 — recalibrating the 0.8 score won't change degree, and god_nodes ranks by raw degree.
Reproducer (real corpus, 2026-04-25)
TypeScript ERP repo, ~3080 nodes, 4218 edges, refreshed via /graphify-refresh. Top 8 god_nodes:
| Node | Total degree | Cross-file INFERRED calls |
Same-file edges | EXTRACTED edges |
|---|---|---|---|---|
log() |
150 | 144 | 1 | 6 |
execute() |
80 | 79 | 1 | 1 |
createRawMcpServer() |
78 | 76 | 2 | 2 |
registerAllOriginalTools() |
76 | 74 | 2 | 2 |
optional() |
69 | 67 | 2 | 2 |
mutate() |
66 | 64 | 2 | 2 |
replace() |
49 | 46 | 3 | 3 |
find() |
30 | 28 | 1 | 2 |
Concrete example: log() is a one-line arrow function at openclaw/sync.mjs:99:
const log = (msg) => process.stdout.write(`[sync] ${msg}\n`);It has no export. It cannot be called by scripts/check-cipl-data.ts:check() or any of the other 130 distinct files the graph claims call it. All 144 cross-file INFERRED edges are spurious — the global name map matched log against console.log-style usage in unrelated scripts.
Why "audit, not cleanse" doesn't fully cover this
I read worked/httpx/review.md and the README; I understand the design philosophy is to expose INFERRED edges with verification questions rather than auto-clean them. That's a sound default. But:
- God_nodes is the canonical entry point for human reviewers. When the top ranks are systematically pollution, it actively misleads.
- The
Suggested Questionsaudit hint ("Are the 144 inferred relationships involvinglog()actually correct?") is honest but expensive to act on — a reviewer would need to verify each of 144 edges manually. - The pollution scales with corpus size and naming entropy, not extraction quality.
Proposed directions (any one would help)
(a) Disambiguation at extraction time — when a call name resolves to multiple candidates with no import evidence, drop confidence to 0.4–0.5 instead of 0.8, or skip the edge.
(b) Import-evidence gate — only emit cross-file calls if from <other_file> import <name> (or analog) appears in the calling file. Use AST imports already collected upstream.
(c) Trusted-degree ranking in god_nodes — leave graph.json untouched (preserves audit), but compute god_nodes ranking with a trusted_degree that gives zero weight to cross-file INFERRED calls lacking import evidence. Add an audit section listing pollution candidates separately.
(d) Threshold by candidate count — if a name appears as a node in N+ files, refuse to globally resolve to any of them; require explicit import.
(c) is what I'm planning to apply locally as a postprocess-god-nodes step, but the right home for (a)/(b) is upstream extract.py.
Related
- Bimodal INFERRED confidence-score distribution — calibration needed #540 — INFERRED confidence-score calibration (same surface, different root cause)
- README/L65-L69, L392-L402 — current audit-not-cleanse philosophy
analyze.py_is_file_node/_is_concept_node— existing god_nodes filtering scaffolding
Happy to send a PR for (c) if there's interest in landing it upstream rather than each consumer reinventing it.
Activity
twipixel commentedon Apr 30, 2026
+1, hitting this on a TypeScript pnpm monorepo (~2,400 files, 5,047 nodes after AST extraction). Adding a complementary data point because the multi-copy-helper pattern below isn't covered by the existing report or by the v0.5.5 #598 fix.
Setup
.ts/.tsxfiles (after filtering oute2e/, tests, stories, mocks,.d.ts, configs)document=image=paper=0→ semantic Part B skipped)Reproducer pattern: same-named helper duplicated across sibling apps
The monorepo has the same helper re-implemented per app — not just a common short name in one file:
extract.py(v0.5.5 L3181–L3210) buildsglobal_label_to_nidwith last-write-wins dict semantics on the lowercased label, so all three definitions collapse to whichever file the loop visited last. In my run that was the subway one. Result:getPhaseFromHostdefinitionEXTRACTEDINFERREDThe 88 INFERRED edges land on the wrong definition. Callers in
apps/train/end/,apps/ferry/src/,apps/airport/end-airport/etc. are reported as calling subway's helper — they aren't.This is the same root pattern as the report's
log()/execute()/optional(), but worth flagging separately: in a monorepo it isn't a "common short name" issue — it's structurally guaranteed to happen for any helper duplicated across workspace packages, even when the name is uniquely descriptive (getPhaseFromHost,sendClickLog,getApiBasePath,getEncryptUrl).Side note: v0.5.5's #598 fix doesn't cover this
The
is_member_callskip in #598 helped one related class (e.g.t()fromconst { t } = useTranslation()is no longer a 106-degree phantom in my graph — confirmed dropped to degree 1–2 withEXTRACTEDonly after upgrade). But the bare-call resolution path is unchanged, so multi-copy helpers stay broken.Before/after on the top phantoms in this corpus:
t()Map()filter()Home()requestGet()getPhaseFromHost()getApiBasePath()sendClickLog()So v0.5.5 cleanly cleared the destructure /
new X()/ JSX class of phantoms but left the helper-duplication class in place — and #543 is still the right home for it.Re: proposed fixes
For monorepos specifically, (b) gate on actual import statements is the only proposal that distinguishes legitimate
requestGetcalls (single shared definition genuinely imported by 100+ callers) from misattributedgetPhaseFromHostcalls (3 definitions, callers should resolve to whichever their file imports). Cardinality alone can't disambiguate these.A weaker but cheap heuristic if (b) is too invasive: when
len(candidates_for_label) > 1, prefer the candidate whosesource_fileshares the longest directory prefix with the caller'ssource_file. That alone would routeapps/train/end/...callers toapps/train/shared/.../getPhaseHelper.tsinstead of subway. Confidence staysINFERREDeither way, but the target becomes correct.(c) "trusted degree" postprocessing helps the audit signal but doesn't fix the merged-into-wrong-target problem when other tools downstream consume
graph.json.Happy to share the full graph.json (~4 MB) or extract.py traces if useful.
sdinaro commentedon Apr 30, 2026
Adding a narrower data point for the duplicate-label branch of this same bug class.
In
graphifyy 0.5.4,extract.pybuilds the cross-file resolver map as a single-value dict:Then unresolved calls use only that last-written target:
So even after the v0.5.5 member-call fix from #598, duplicate bare helpers still have a separate failure mode:
foo()defined in multiple sibling packages collapses to whichever node was visited last, and every unresolvedfoocall points there. This is not just over-linking; it can actively route the edge to the wrong definition.Suggested shape of the fix: change the resolver map to
label -> list[candidate]; if there is exactly one candidate, current behavior can remain. If there are multiple candidates, either require import evidence, use longest shared path-prefix as a lower-confidence fallback, or skip/downgrade the edge rather than letting dict overwrite choose a target silently.safishamsi commentedon May 2, 2026
Fixed in v0.6.3. Cross-file INFERRED
callsresolution now skips any callee name that matches 2+ candidates — common short names likelog,execute,findwith no import evidence to disambiguate are no longer resolved, preventing spurious edges from inflating god_nodes degree ranking.maticijus commentedon May 7, 2026
Possible regression / incomplete fix on graphifyy v0.5.0.
Reproduction on a 5,155-node Python trading-system codebase (~321 files mixed Python + minified JS bundle assets):
execute()ranks feat: add Swift language support #9 god node, degree=73index_pz98nbq_executeis sourced from a minified JS bundle (v2-dist/assets/index-_Pz98nBq.js)Cp()/Hp()/_u/rp()(1-3 char minified-JS identifiers) are all top god nodes because they collide with same-shaped Python helper namesThis suggests the import-evidence check shipped in #543 either:
Net effect: a ~5K-node graph's god-nodes ranking is ~50% phantom because of common short names colliding across languages. C1 (largest community) ends up at cohesion 0.01 because collision-edges glue unrelated subsystems together.
Want me to file as a fresh issue, or is this useful evidence on the existing fix's coverage?
Resolve cross-file Swift class relationships from member calls and co…
fix: resolve Python ClassName.method() qualified calls to class-metho…
fix(resolve): test mocks no longer erase the real cross-file call gra…
fix(haxe): stop resolving unmatched calls to a global bare-name id
fix(python): scope the member-call resolver's name indexes to .py
fix(python): resolve module-qualified calls (mod.func()) to their rea…
fix(python): resolve self./cls. calls exactly, including inherited an…
Resolve cross-file Swift class relationships from member calls and co…
fix: resolve Python ClassName.method() qualified calls to class-metho…
fix(resolve): test mocks no longer erase the real cross-file call gra…