mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
feat(python-scope): free-call fallback consults finalized bindings
Unit 7 — closes the cross-file free-call gap. The shared `MethodRegistry.lookup` walks `scope.bindings` (pre-finalize local-only) for free-call resolution. Cross-file imports land in `indexes.bindings` (post-finalize). Without the dual-source lookup, `from x import f; f()` resolves to "unresolved" and no CALLS edge is emitted. Two changes: - `emit-core/scope-walkers.ts`: new `findCallableBindingInScope` — same dual-source pattern as `findClassBindingInScope`, but accepts Function/Method/Constructor. Promoted to emit-core because every language with cross-file imports needs the same lookup. - `python-scope-emit.ts emitFreeCallFallback`: post-pass that walks every free-call reference site, looks up the callee with the new helper, and emits via `tryEmitEdge`. Pre-seeds `seen` from the shared resolver's emissions so we never double-count. Verification: - Flag-off: 191/191 (identical baseline). - Flag-on: 22 fail / 169 pass (was 28/163; +6 tests including the Python overload dispatch fixtures, ancestor-directory imports, and same-name module-alias collision). - tsc --noEmit clean.
This commit is contained in:
parent
42e1146b76
commit
eb897540f3
3 changed files with 127 additions and 1 deletions
|
|
@ -31,6 +31,7 @@ export { emitImportEdges } from './emit-imports.js';
|
|||
export {
|
||||
findReceiverTypeBinding,
|
||||
findClassBindingInScope,
|
||||
findCallableBindingInScope,
|
||||
findOwnedMember,
|
||||
findExportedDef,
|
||||
} from './scope-walkers.js';
|
||||
|
|
|
|||
|
|
@ -91,6 +91,52 @@ export function findClassBindingInScope(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a callable (Function/Method/Constructor) by name in the
|
||||
* given scope's chain. Uses the dual-source pattern (scope.bindings +
|
||||
* indexes.bindings) so cross-file imports are visible — without it
|
||||
* free calls to imported functions never resolve via the post-pass.
|
||||
*
|
||||
* Mirrors `findClassBindingInScope` exactly; only the accepted
|
||||
* def-type predicate differs.
|
||||
*/
|
||||
export function findCallableBindingInScope(
|
||||
startScope: ScopeId,
|
||||
callableName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
|
||||
const localBindings = scope.bindings.get(callableName);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
|
||||
return b.def;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalizedScopeBindings = scopes.bindings.get(currentId);
|
||||
const importedBindings = finalizedScopeBindings?.get(callableName);
|
||||
if (importedBindings !== undefined) {
|
||||
for (const b of importedBindings) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
|
||||
return b.def;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a member of a class by simple name — a def whose `ownerId`
|
||||
* matches the class's nodeId and whose simple name matches `memberName`.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
collectNamespaceTargets,
|
||||
emitImportEdges,
|
||||
emitReferencesViaLookup,
|
||||
findCallableBindingInScope,
|
||||
findClassBindingInScope,
|
||||
findExportedDef,
|
||||
findOwnedMember,
|
||||
|
|
@ -201,6 +202,20 @@ export function runPythonScopeResolution(
|
|||
referenceIndex,
|
||||
);
|
||||
|
||||
// Free-call finalized-binding fallback. The shared `MethodRegistry.lookup`
|
||||
// walks `scope.bindings` for the call's name, but `scope.bindings`
|
||||
// only carries pre-finalize local declarations. Cross-file imports
|
||||
// land in `indexes.bindings` (post-finalize). Without consulting that
|
||||
// second source, every imported `f()` call resolves to "unresolved".
|
||||
// Mirror the dual-source pattern from `findClassBindingInScope`.
|
||||
const freeCallExtras = emitFreeCallFallback(
|
||||
graph,
|
||||
indexes,
|
||||
parsedFiles,
|
||||
nodeLookup,
|
||||
referenceIndex,
|
||||
);
|
||||
|
||||
// IMPORTS edges: the scope-resolution path now owns Python file→file
|
||||
// IMPORTS edge emission when `REGISTRY_PRIMARY_PYTHON=1`. The legacy
|
||||
// `processImports` path still runs (heritage needs its `importMap`
|
||||
|
|
@ -219,7 +234,7 @@ export function runPythonScopeResolution(
|
|||
filesSkipped,
|
||||
importsEmitted,
|
||||
resolve: resolveStats,
|
||||
referenceEdgesEmitted: emitted + receiverExtras,
|
||||
referenceEdgesEmitted: emitted + receiverExtras + freeCallExtras,
|
||||
referenceSkipped: skipped,
|
||||
};
|
||||
}
|
||||
|
|
@ -496,6 +511,70 @@ function emitReceiverBoundCalls(
|
|||
return emitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit CALLS edges for free-call reference sites whose target is
|
||||
* imported (or otherwise visible only via post-finalize scope.bindings).
|
||||
*
|
||||
* The shared `MethodRegistry.lookup` only consults `scope.bindings`
|
||||
* (pre-finalize / local-only) for free calls. Cross-file imports land
|
||||
* in `indexes.bindings` (post-finalize). Without this fallback, every
|
||||
* `from x import f; f()` resolves to "unresolved".
|
||||
*
|
||||
* Same dual-source pattern as `findClassBindingInScope` — but accepts
|
||||
* Function/Method/Constructor instead of Class. Pre-seeds `seen` from
|
||||
* the shared resolver's emissions so we don't double-emit.
|
||||
*/
|
||||
function emitFreeCallFallback(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
|
||||
): number {
|
||||
let emitted = 0;
|
||||
const seen = new Set<string>();
|
||||
// Pre-seed `seen` with whatever the shared resolver + receiver-bound
|
||||
// pass already emitted so we never double-count an edge that another
|
||||
// path produced.
|
||||
for (const refs of referenceIndex.bySourceScope.values()) {
|
||||
for (const r of refs) {
|
||||
const targetDef = scopes.defs.get(r.toDef);
|
||||
if (targetDef === undefined) continue;
|
||||
const callerGraphId = resolveCallerGraphId(r.fromScope, scopes, nodeLookup);
|
||||
if (callerGraphId === undefined) continue;
|
||||
const tgtGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
|
||||
if (tgtGraphId === undefined) continue;
|
||||
const kind = mapReferenceKindToEdgeType(r.kind);
|
||||
if (kind === undefined) continue;
|
||||
seen.add(
|
||||
`${kind}:${callerGraphId}->${tgtGraphId}:${r.atRange.startLine}:${r.atRange.startCol}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'call') continue;
|
||||
if (site.explicitReceiver !== undefined) continue;
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
|
||||
* Practical Python rarely exceeds 3-4 hops; the cap just prevents
|
||||
* pathological recursion if the receiver text turns out to be malformed. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue