Skip to content

bug(resolver): identifier-arg dynamic call resolution fabricates false edges/cycles on name collision (e.g. communities.ts phantom cycle) #1741

Description

@carlos-alm

Summary

extractCallbackReferenceCalls (and its native mirror) unconditionally emits a dynamic call edge for every bare identifier argument passed to any call expression, with no gating on the callee — unlike member_expression args, which are gated by CALLBACK_ACCEPTING_CALLEES/HTTP_VERB_CALLEES (fixed in #1190). When the identifier's name happens to collide with an unrelated exported function elsewhere in the repo, the resolver's global-fallback confidence scoring (0.5, same-grandparent-dir heuristic) binds the two together, fabricating a call edge — and, if it closes a loop, a phantom architectural cycle.

This is not hypothetical: it just produced a false cycle in codegraph's own source during a Titan Paradigm SYNC/FORGE run.

Repro (on this repo)

src/features/communities.ts has:

function analyzeDrift(communities: CommunityObject[], communityDirs: ...): DriftResult {
  ...
  const mergeCandidates = findMergeCandidates(communities);   // line 136 — `communities` is a parameter, passed as a plain arg
  ...
}

export function communitiesData(...): Record<string, unknown> {
  const { communities, communityDirs } = buildCommunityObjects(...);
  const { splitCandidates, mergeCandidates, driftScore } = analyzeDrift(communities, communityDirs); // line 208 — `communities` is a local var, passed as a plain arg
  ...
}

Neither function contains a communities(...) call expression anywhere — communities is only ever a parameter/local-variable name here. But the only function named communities in the repo is the unrelated CLI entry point export function communities(...) in src/presentation/communities.ts:89.

codegraph cycles --functions -T --json reports:

analyzeDrift|src/features/communities.ts
  -> communitiesData|src/features/communities.ts
  -> communities|src/presentation/communities.ts
  -> (back to analyzeDrift)

Querying the DB directly confirms both edges closing the loop are fabricated:

analyzeDrift    -> communities   kind=calls confidence=0.5 dynamic=1 technique=ts-native dynamic_kind=null
communitiesData -> communities   kind=calls confidence=0.5 dynamic=1 technique=ts-native dynamic_kind=null
```//
while the real, correct edge (`presentation/communities.ts:communities` calling `features/communities.ts:communitiesData`) is confidence=1, dynamic=0 — and `features/communities.ts` has zero imports of, or references to, `presentation/communities.ts`. There is no real dependency cycle; the only thing closing the loop is the fabricated edge.

This is not an isolated case — the same `dynamic=1 AND confidence=0.5 AND kind='calls'` signature appears on **326 edges** in this repo's own graph, several of which look like the same pattern (e.g. `extractObjCSymbols` -> `ctx` in `src/cli/index.ts`, matched purely because some unrelated local var/param elsewhere is also named `ctx`).

## Root cause

- Rust: `extract_callback_reference_calls`, `crates/codegraph-core/src/extractors/javascript.rs:2280-2291` — the `"identifier"` arm unconditionally does `calls.push(Call { name: <ident>, dynamic: Some(true), receiver: None, .. })` for every identifier argument of any call expression.
- TS mirror: `extractCallbackReferenceCalls`, `src/extractors/javascript.ts:3377-3378` — `result.push({ name: child.text, line: callLine, dynamic: true })`, same lack of gating.
- The design trade-off is explicitly documented at `src/extractors/javascript.ts:3214-3216`: *"Identifier args ... are always emitted — the collateral damage of dropping them is larger than the FP risk, since plain identifier data args rarely collide with real function names."* That assumption doesn't hold here.
- Resolver: `compute_confidence`, `crates/codegraph-core/src/domain/graph/resolve.rs:291-292` — when a dynamic call has no receiver and no import match, it falls back to a same-grandparent-directory global match at confidence 0.5, which is what binds the stray `communities` identifier to the unrelated exported function.

## Impact

- Fabricates false architectural cycles (as seen here), which can send `/titan-sync` / `/titan-forge` down unnecessary refactor work fixing non-existent problems.
- `codegraph cycles` does not currently discriminate on edge `confidence`/`dynamic` — low-confidence speculative edges are weighted identically to statically-certain ones for cycle detection.

## Suggested fix

- Gate identifier-argument dynamic-call emission the same way `member_expression` args are gated (`CALLBACK_ACCEPTING_CALLEES`/`HTTP_VERB_CALLEES`), in both `javascript.rs` and `javascript.ts` — re-evaluate/replace the "collateral damage" trade-off now that we have a concrete counter-example.
- Separately (lower priority, may want its own issue): consider having `codegraph cycles`/`codegraph check --cycles` exclude or separately flag cycles whose only closing edge(s) are low-confidence dynamic (`confidence < 1 && dynamic = 1`), since these are speculative resolutions, not confirmed structural dependencies.

Not fixed here — this is a resolver/extractor change spanning both engines plus the 326 already-affected edges repo-wide, out of scope for the Titan phase (communities.ts cycle-break) that surfaced it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtitan-auditIssues discovered during Titan audit

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions