feat(python-scope): collapse free-call edges per (caller, target)

Free calls (no explicit receiver) now emit a single CALLS edge per
(caller, target) pair regardless of how many call sites the caller
contains. Mirrors the legacy DAG's per-pair dedup contract — what
the `default-params`, `variadic`, and `overload` fixtures expect.

Member calls keep position-based dedup so distinct resolved targets
(e.g. UserService.find_user vs AdminService.find_user from the same
caller) still produce distinct edges.

Implementation: bypass `tryEmitEdge` (which dedupes positionally) and
hand-roll the relationship with a position-independent rel.id
(`rel:CALLS:<caller>-><target>`). Site handling is now unconditional —
even when the dedup-collapse skips the actual emit, we mark the site
handled so the shared `emit-references` doesn't fight us with its
fallback.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 10 fail / 181 pass (was 12/179; +2 — both `default
  parameter arity` tests now pass).
- tsc --noEmit clean.
This commit is contained in:
Gergo Magyar 2026-04-20 11:07:58 +01:00
parent 6d220cfc85
commit a201861c71

View file

@ -617,19 +617,31 @@ function emitFreeCallFallback(
const fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
if (fnDef === undefined) continue;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
fnDef,
'python-scope: free-call-import',
seen,
);
if (ok) {
emitted++;
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
}
// Free calls collapse to one CALLS edge per (caller, target)
// pair. Multiple call sites in the same caller body should not
// emit multiple edges (legacy DAG semantics — what
// `default-params` / `variadic` / `overload` tests expect).
// Member calls keep positional dedup elsewhere.
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
if (tgtGraphId === undefined) continue;
// Always mark the site as handled — even when the dedup-collapse
// means we don't add a new edge — so `emit-references` skips its
// potentially-wrong fallback for the same site.
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);
graph.addRelationship({
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.85,
reason: 'python-scope: free-call-import',
});
emitted++;
}
}
return emitted;