Skip to content

Phantom god nodes from member-call name collisions in cross-file resolver #598

Closed

Description

@llongo-allora

Phantom god nodes from member-call name collisions in cross-file resolver

Summary

graphify's per-language AST extractors strip member-expression callees
(this.logger.log(...), obj.find(...), Pkg::baz(...)) down to the
trailing identifier and queue them in raw_calls for cross-file
resolution. The cross-file resolver in extract() then matches that bare
identifier against a global lowercase name → id map. If any other file in
the corpus defines a top-level helper with the same name (e.g. a
function log(...) in a one-off smoke-test script), every unrelated
Logger.log / this.logger.log / logger.log call across the codebase
resolves to that single helper, producing a phantom god node with hundreds
of bogus INFERRED edges.

Real-world impact

A real production codebase (~1,668 TS/JS files, NestJS + Nuxt) was
extracted with graphifyy 0.5.4. The graph reported log() as the #2
god node with 265 inbound edges, all resolving to a 1-file smoke-test
script's private function log(id, name, pass) { ... } at
validation/smoke-test.mjs:L24.

Verification by reading the actual call sites:

  • 10 of the 265 edges were legitimate intra-file calls inside
    validation/smoke-test.mjs.
  • 255 of the 265 edges were Logger.log(...) / this.logger.log(...) /
    logger.log({...}) calls in NestJS services (e.g.
    apps/healthcare-api/src/appointments/appointments-cron.service.ts,
    apps/healthcare-api/src/payments/payments.service.ts,
    apps/healthcare-api/src/integrations/e-prescribing/observability/eprescribing-metrics.ts).
    None of those files import from validation/smoke-test.mjs.

The phantom god node distorted both the cohesion analysis (log was
identified as bridging "nearly every backend community") and the
suggested-questions output. Removing the phantom revealed the real
architectural picture (top god node became useApi() at 561 edges, then
ProvidersService, InventoryService, RedisService, etc.).

Root cause

Two compounding behaviors in graphify/extract.py:

  1. Member-expression callees get truncated (lines 1140–1144 of v0.5.4
    in the generic JS/TS branch, and equivalent code in every other
    per-language extractor): when tree-sitter sees this.logger.log(...),
    only the property field (log) is captured; the receiver chain is
    discarded.

  2. Pure name-based cross-file resolution (lines 3401–3427): the orphan
    log is then looked up in a global lowercase-name → id map. With no
    receiver-type analysis, it always picks the wrong target whenever any
    other file in the corpus exposes a top-level identifier with that name.

This pattern affects every language whose AST distinguishes member access
from bare identifier calls — JS/TS, Go, Rust, Swift, Kotlin, Scala, C++,
PHP, Java, Elixir. Common method names (log, get, find, validate,
process, init, run, handle, etc.) are most at risk because they
collide easily with library API surfaces (NestJS Logger, Vue useFetch,
Express middleware, etc.).

Reproduction

Minimal corpus (3 files):

// validation/smoke-test.mjs
function log(id, name, pass) { console.log(id, name, pass); }
function testAuth() { log('A1', 'auth-login', true); }
testAuth();
// src/appointments.service.ts
import { Logger } from '@nestjs/common';
export class AppointmentsService {
  private readonly logger = new Logger('AppointmentsService');
  async create() { this.logger.log('Creating appointment'); }
}
// src/eprescribing-metrics.ts
import { Logger } from '@nestjs/common';
const logger = new Logger('eprescribing-metrics');
export function logTransmissionAttempt(rxId: string) {
  logger.log({ event: 'tx-attempt', rxId });
}

Expected: log() from validation/smoke-test.mjs should have only
intra-file callers (the local testAuth).

Actual (graphifyy 0.5.4): every Logger.log / logger.log / this.logger.log
call across src/ resolves to validation/smoke-test.mjs:log(), producing
~4 spurious INFERRED edges in this minimal example, scaling linearly with
the number of NestJS service files in real codebases.

A regression test demonstrating the bug + the fix is included in the
attached PR (tests/test_member_call_resolution.py).

Proposed fix

Tag every raw_calls.append(...) entry with the AST node type of the
callee (callee_node_type), then have the cross-file resolver in
extract() skip entries whose callee node type is in a new
_MEMBER_CALL_NODE_TYPES constant. In-file resolution behavior is
intentionally preserved — those matches require the callee to actually
exist as a top-level symbol in the SAME file, which is rarely a phantom.

This is a conservative fix: it only affects cross-file member-call
resolution. Bare-identifier calls (function foo() → foo() in another
file) continue to resolve normally.

Trade-off: legitimate cross-file member calls (e.g. importing a singleton
service then calling service.method() in a member-call expression
where method happens to match a top-level function in the importer's
package) will no longer produce edges. In practice these are rare,
ambiguous without type analysis, and were already low-confidence INFERRED
edges. Net: dramatically fewer phantom edges, slightly fewer real ones.

A follow-up could add proper type-aware resolution using import maps (the
extractor already builds these per file), which would let the resolver
re-introduce the lost legitimate edges with high confidence.

Patch

See attached pull request. The change touches one constant + 6
raw_calls.append(...) sites + 1 guard in the cross-file resolver loop
(~25 net lines added). Includes 3 regression tests that fail on the
unpatched code and pass on the patched code.

Environment

  • Package: graphifyy 0.5.4
  • Python: 3.13.5
  • OS: macOS 24.6.0 (darwin arm64)
  • Tree-sitter language modules: default install set

Activity

  1. added a commit that references this issue on Apr 29, 2026
  2. safishamsi commented on Apr 29, 2026

    @safishamsi
    Member

    Fixed in v0.5.5: member-call callees are no longer cross-file resolved. Go package-qualified calls are correctly preserved via imported package name tracking.

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