Skip to content

Cross-file INFERRED calls resolution lacks import evidence + ambiguous-candidate handling — short common names blow up god_nodes ranking #543

Closed

Description

@AlphaMinusOne

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:

  1. God_nodes is the canonical entry point for human reviewers. When the top ranks are systematically pollution, it actively misleads.
  2. The Suggested Questions audit hint ("Are the 144 inferred relationships involving log() actually correct?") is honest but expensive to act on — a reviewer would need to verify each of 144 edges manually.
  3. 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

Happy to send a PR for (c) if there's interest in landing it upstream rather than each consumer reinventing it.

Activity

  1. twipixel commented on Apr 30, 2026

    @twipixel

    +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

    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:

    apps/train/shared/src/helpers/project/getPhaseHelper.ts        :: getPhaseFromHost()
    apps/airport/end-airport/shared/helpers/url/getPhase.ts        :: getPhaseFromHost()
    apps/subway/cs-subwayline/src/helpers/project/getPhaseFromHost.ts :: getPhaseFromHost()
    

    extract.py (v0.5.5 L3181–L3210) builds global_label_to_nid with 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:

    getPhaseFromHost definition location degree EXTRACTED INFERRED
    subway/cs-subwayline (winner) 89 1 88
    train/shared (loser) 3 3 0
    airport/end-airport (loser) 1 1 0

    The 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_call skip in #598 helped one related class (e.g. t() from const { t } = useTranslation() is no longer a 106-degree phantom in my graph — confirmed dropped to degree 1–2 with EXTRACTED only 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:

    god node v0.4.23 deg (INFERRED) v0.5.5 deg (INFERRED) status
    t() 106 (105) 1 (0) ✅ fixed
    Map() 95 (90) gone ✅ fixed
    filter() 56 (?) gone ✅ fixed
    Home() 35 (?) gone ✅ fixed
    requestGet() 103 (102) 109 (108) ❌ unchanged — single shared definition, may be largely correct but indistinguishable from phantom
    getPhaseFromHost() 85 (84) 89 (88) ❌ unchanged, multi-copy phantom
    getApiBasePath() 75 (72) 78 (75) ❌ unchanged
    sendClickLog() 66 (63) 67 (64) ❌ unchanged

    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 requestGet calls (single shared definition genuinely imported by 100+ callers) from misattributed getPhaseFromHost calls (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 whose source_file shares the longest directory prefix with the caller's source_file. That alone would route apps/train/end/... callers to apps/train/shared/.../getPhaseHelper.ts instead of subway. Confidence stays INFERRED either 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.

  2. sdinaro commented on Apr 30, 2026

    @sdinaro

    Adding a narrower data point for the duplicate-label branch of this same bug class.

    In graphifyy 0.5.4, extract.py builds the cross-file resolver map as a single-value dict:

    # extract.py:3401-3406
    global_label_to_nid: dict[str, str] = {}
    for n in all_nodes:
        raw = n.get("label", "")
        normalised = raw.strip("()").lstrip(".")
        if normalised:
            global_label_to_nid[normalised.lower()] = n["id"]

    Then unresolved calls use only that last-written target:

    # extract.py:3414-3423
    tgt = global_label_to_nid.get(callee.lower())
    ...
    "confidence": "INFERRED",
    "confidence_score": 0.8,

    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 unresolved foo call 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.

  3. safishamsi commented on May 2, 2026

    @safishamsi
    Member

    Fixed in v0.6.3. Cross-file INFERRED calls resolution now skips any callee name that matches 2+ candidates — common short names like log, execute, find with no import evidence to disambiguate are no longer resolved, preventing spurious edges from inflating god_nodes degree ranking.

  4. maticijus commented on May 7, 2026

    @maticijus

    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=73
    • The SOLE node index_pz98nbq_execute is sourced from a minified JS bundle (v2-dist/assets/index-_Pz98nBq.js)
    • BUT 70/73 of its neighbors are Python files: routers (13), data-store (10), npt-delta (3), sector-flow, signal-enrichment, etc.
    • Same pattern at top-10 god-node level: Cp() / Hp() / _u / rp() (1-3 char minified-JS identifiers) are all top god nodes because they collide with same-shaped Python helper names

    This suggests the import-evidence check shipped in #543 either:

    • doesn't apply to cross-language identifier matching (JS → Python), or
    • is bypassed when one of the candidate files is non-Python (no import statement to evidence against)

    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?

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