refactor(python-scope): extract language-agnostic emit-core/

Unit 1 of the python migration architectural plan
(docs/plans/2026-04-19-001-refactor-python-migration-architectural-plan.md).

Splits python-scope-emit.ts (~945 → 481 lines) by lifting 14 generic
graph-feeding primitives into emit-core/:
  - graph-node-lookup, graph-id, emit-edge
  - emit-references, emit-imports
  - scope-walkers (findReceiverTypeBinding, findClassBindingInScope,
    findOwnedMember, findExportedDef)
  - namespace-targets, method-dispatch-bridge

Each file carries a "Next-consumer contract" JSDoc so future language
migrations (TS #927, JS #928, Java, Kotlin, Ruby) import from emit-core
rather than re-implementing. python-scope-emit.ts keeps only the four
Python-specific pieces: runPythonScopeResolution (orchestrator),
buildPythonMro, emitReceiverBoundCalls (4 cases), populateMethodOwnerIds
— these move to languages/python/emit/ in Unit 11.

Pure refactor, zero behavior change:
  - flag-off: 191/191 python.test.ts pass (identical baseline).
  - flag-on (REGISTRY_PRIMARY_PYTHON=1): 32 fail / 159 pass (identical
    baseline — the refactor neither fixes nor regresses any test).
  - tsc --noEmit clean.
This commit is contained in:
Gergo Magyar 2026-04-19 21:44:01 +01:00
parent ff376d09a8
commit 4013779a70
10 changed files with 677 additions and 524 deletions

View file

@ -0,0 +1,92 @@
/**
* Graph edge emission primitives.
*
* Two functions:
* - `mapReferenceKindToEdgeType` — translate a scope-resolution
* `Reference.kind` into the corresponding graph edge type.
* - `tryEmitEdge` — given a reference site + target def, resolve
* caller + target to graph ids and emit the edge with
* language-provided reason text, dedup-keyed by
* `(edgeType, callerId, targetId, line, col)`.
*
* Next-consumer contract: any language provider can call `tryEmitEdge`
* from its own post-pass to emit edges it resolves Python-specific
* (or TypeScript-specific, etc.) logic. The dedup key is
* language-agnostic — no language needs to change it.
*/
import type { Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
import type { GraphNodeLookup } from './graph-node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from './graph-id.js';
/**
* Map a `Reference.kind` to a graph edge type. `import-use` is dropped
* (no edge type today — provenance lives on the IMPORTS edge emitted
* by `emitImportEdges`).
*/
export function mapReferenceKindToEdgeType(
kind: Reference['kind'],
): 'CALLS' | 'ACCESSES' | 'EXTENDS' | 'USES' | undefined {
switch (kind) {
case 'call':
return 'CALLS';
case 'read':
case 'write':
return 'ACCESSES';
case 'inherits':
return 'EXTENDS';
case 'type-reference':
return 'USES';
case 'import-use':
return undefined;
default:
return undefined;
}
}
/**
* Resolve caller + target to graph ids and emit the edge. Returns true
* if the edge was emitted (not deduped, not skipped).
*
* `seen` is a language-shared dedup set keyed by
* `${edgeType}:${callerGraphId}->${targetGraphId}:${line}:${col}` so
* multiple language-specific post-passes can share it and never
* double-emit a resolution one of them already produced.
*/
export function tryEmitEdge(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
site: {
readonly inScope: ScopeId;
readonly atRange: { startLine: number; startCol: number };
readonly kind: string;
},
targetDef: SymbolDefinition,
reason: string,
seen: Set<string>,
confidence = 0.85,
): boolean {
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
if (callerGraphId === undefined) return false;
if (targetGraphId === undefined) return false;
if (edgeType === undefined) return false;
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (seen.has(dedupKey)) return false;
seen.add(dedupKey);
graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId: targetGraphId,
type: edgeType,
confidence,
reason,
});
return true;
}

View file

@ -0,0 +1,57 @@
/**
* File→File IMPORTS edge emission from a finalized `ImportEdge` map.
*
* Deduplicates by `(sourceFile, targetFile)` so multi-symbol imports
* from the same module collapse to a single edge — matching the
* legacy schema.
*
* Next-consumer contract: language-agnostic. Any provider with a
* scope-resolution ImportEdge stream emits File→File edges via this
* single function. The `reason` defaults to
* `'scope-resolution: import'`; provider may override if downstream
* filters on reason.
*/
import type { ImportEdge, ScopeId } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
import { generateId } from '../../../lib/utils.js';
export function emitImportEdges(
graph: KnowledgeGraph,
imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
scopeTree: ScopeResolutionIndexes['scopeTree'],
reason = 'scope-resolution: import',
): number {
const seen = new Set<string>();
let emitted = 0;
for (const [scopeId, edges] of imports) {
const scope = scopeTree.getScope(scopeId);
if (scope === undefined) continue;
const sourceFile = scope.filePath;
for (const edge of edges) {
if (edge.targetFile === null) continue;
if (edge.targetFile === sourceFile) continue;
const dedupKey = `${sourceFile}->${edge.targetFile}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
const sourceId = generateId('File', sourceFile);
const targetId = generateId('File', edge.targetFile);
graph.addRelationship({
id: generateId('IMPORTS', dedupKey),
sourceId,
targetId,
type: 'IMPORTS',
confidence: 1.0,
reason,
});
emitted++;
}
}
return emitted;
}

View file

@ -0,0 +1,79 @@
/**
* Translate the resolved `ReferenceIndex` into legacy graph edges.
*
* Per reference:
* 1. Resolve `fromScope` → caller graph-node id by walking the scope
* chain looking for an enclosing Function/Method/Class.
* 2. Resolve `toDef` → target graph-node id via `nodeLookup`.
* 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`)
* with the standard reason format.
*
* Skips (without throwing) when either side fails to map — either side
* may legitimately not exist as a graph node (e.g. a resolved target
* lives in an external file that wasn't ingested into the graph).
*
* Next-consumer contract: this function is the canonical bridge from
* a shared `ReferenceIndex` into per-language graph edges. Every
* registry-primary language provider calls this exactly once with its
* `referenceIndex` output and its own `nodeLookup`.
*/
import type { Reference, ScopeId } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
import { resolveCallerGraphId, resolveDefGraphId } from './graph-id.js';
import { mapReferenceKindToEdgeType } from './emit-edge.js';
import type { GraphNodeLookup } from './graph-node-lookup.js';
export function emitReferencesViaLookup(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
nodeLookup: GraphNodeLookup,
): { emitted: number; skipped: number } {
let emitted = 0;
let skipped = 0;
const seen = new Set<string>();
for (const [fromScope, refs] of referenceIndex.bySourceScope) {
const callerGraphId = resolveCallerGraphId(fromScope, scopes, nodeLookup);
if (callerGraphId === undefined) {
skipped += refs.length;
continue;
}
for (const ref of refs) {
const targetDef = scopes.defs.get(ref.toDef);
if (targetDef === undefined) {
skipped++;
continue;
}
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
if (targetGraphId === undefined) {
skipped++;
continue;
}
const edgeType = mapReferenceKindToEdgeType(ref.kind);
if (edgeType === undefined) {
skipped++;
continue;
}
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${ref.atRange.startLine}:${ref.atRange.startCol}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId: targetGraphId,
type: edgeType,
confidence: ref.confidence,
reason: `scope-resolution: ${ref.kind}`,
});
emitted++;
}
}
return { emitted, skipped };
}

View file

@ -0,0 +1,91 @@
/**
* Scope-resolution → legacy graph-node ID bridging.
*
* Two functions:
* - `resolveDefGraphId` — turn a scope-resolution `SymbolDefinition`
* into the graph's node id for the corresponding legacy node.
* - `resolveCallerGraphId` — walk a scope chain from a reference
* site upward to find the enclosing function/method/class and
* return its graph-node id. Falls back to the File node for
* module-level calls so those still get an edge source.
*
* Next-consumer contract: language-agnostic. Any OO language with
* file-level module semantics (TypeScript, Java, Go, Kotlin) can
* reuse `resolveCallerGraphId` as-is. Languages with different
* top-level semantics (COBOL programs, Rust crate modules) may want
* a different file-level fallback — cross that bridge when they
* migrate.
*/
import type { ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
import { generateId } from '../../../lib/utils.js';
import { isLinkableLabel, type GraphNodeLookup } from './graph-node-lookup.js';
/** Look up a `SymbolDefinition` in the graph node lookup by file+name. */
export function resolveDefGraphId(
filePath: string,
def: { qualifiedName?: string },
nodeLookup: GraphNodeLookup,
): string | undefined {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) return undefined;
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
return nodeLookup.get(`${filePath}::${simpleName}`);
}
/** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
const q = def.qualifiedName;
if (q === undefined || q.length === 0) return undefined;
const dot = q.lastIndexOf('.');
return dot === -1 ? q : q.slice(dot + 1);
}
/**
* Walk the scope chain from `startScope` upward looking for the first
* scope whose `ownedDefs` contains a Function/Method/Class — that's
* our caller anchor. Translate via `nodeLookup` to the graph-node ID.
*
* Module-level references (e.g. Python `u = models.User()` at top
* level) have no enclosing function/method/class. Fall back to the
* File node for the scope's filePath so those calls still get an
* edge source. Matches legacy DAG behavior where module-level CALLS
* edges originate from the file symbol.
*/
export function resolveCallerGraphId(
startScope: ScopeId,
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
): string | undefined {
let current: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
let lastFilePath: string | undefined;
while (current !== null) {
if (visited.has(current)) return undefined;
visited.add(current);
const scope = scopes.scopeTree.getScope(current);
if (scope === undefined) break;
lastFilePath = scope.filePath;
// Prefer Function/Method anchors; fall back to Class.
const fnDef = scope.ownedDefs.find(
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
);
if (fnDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
if (id !== undefined) return id;
}
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
if (classDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
if (id !== undefined) return id;
}
current = scope.parent;
}
// Module-level calls — fall back to the File node for the scope's filePath.
if (lastFilePath !== undefined) {
return generateId('File', lastFilePath);
}
return undefined;
}

View file

@ -0,0 +1,49 @@
/**
* Build a `(filePath, simpleName) → graphNodeId` lookup over the
* graph's Function/Method/Class/Constructor nodes.
*
* Language-agnostic seam. Any language provider migrating to the
* registry-primary path can consume this to translate scope-resolution
* `SymbolDefinition.nodeId` values into the legacy graph-node ID
* format that downstream consumers (queries, edges, MCP) expect.
*
* Next-consumer contract: a TypeScript or Java provider imports this
* module unchanged — the lookup is keyed by (filePath, name) which
* every language produces.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
export type GraphNodeLookup = ReadonlyMap<string, string>;
export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
const lookup = new Map<string, string>();
for (const node of graph.iterNodes()) {
const props = node.properties as { filePath?: string; name?: string };
if (props.filePath === undefined || props.name === undefined) continue;
if (!isLinkableLabel(node.label)) continue;
// Keyed by (filePath, simpleName). Class kinds and method kinds
// share the same simple-name space within a file — a `class Foo`
// and `def Foo()` at the same level is disallowed by Python (and
// most languages), so a single key per (file, name) is unambiguous
// in practice. Method-vs-class disambiguation for resolved
// references happens earlier inside `MethodRegistry.lookup`
// (Step 1 + Step 2).
const key = `${props.filePath}::${props.name}`;
if (!lookup.has(key)) lookup.set(key, node.id);
}
return lookup;
}
export function isLinkableLabel(label: NodeLabel): boolean {
return (
label === 'Function' ||
label === 'Method' ||
label === 'Constructor' ||
label === 'Class' ||
label === 'Interface' ||
label === 'Struct' ||
label === 'Enum'
);
}

View file

@ -0,0 +1,38 @@
/**
* `emit-core/` — language-agnostic graph-feeding primitives for the
* registry-primary scope-resolution pipeline.
*
* Responsibility boundary:
* - `gitnexus-shared/src/scope-resolution/` owns the semantic model
* (indexes, ParsedFile, registries, TypeRef, Reference).
* - `emit-core/` owns the bridge from that model to the legacy
* `KnowledgeGraph` edge format. Lives here (CLI, not shared)
* because graph bridging depends on `KnowledgeGraph` + `generateId`
* which are CLI-local.
* - `languages/<lang>/emit/` owns per-language post-passes that
* compose these primitives with language-specific captures and
* strategies (MRO, ownership, receiver conventions).
*
* Next-consumer contract: when the next language provider migrates
* (TypeScript #927, JavaScript #928, etc.), it imports from this
* module's public surface and never re-implements any of these
* functions. See the per-file JSDoc for per-function reuse notes.
*/
export {
buildGraphNodeLookup,
isLinkableLabel,
type GraphNodeLookup,
} from './graph-node-lookup.js';
export { resolveCallerGraphId, resolveDefGraphId, simpleQualifiedName } from './graph-id.js';
export { mapReferenceKindToEdgeType, tryEmitEdge } from './emit-edge.js';
export { emitReferencesViaLookup } from './emit-references.js';
export { emitImportEdges } from './emit-imports.js';
export {
findReceiverTypeBinding,
findClassBindingInScope,
findOwnedMember,
findExportedDef,
} from './scope-walkers.js';
export { collectNamespaceTargets } from './namespace-targets.js';
export { buildPopulatedMethodDispatch } from './method-dispatch-bridge.js';

View file

@ -0,0 +1,36 @@
/**
* Wrap a `DefId → ancestor DefId[]` MRO map in the shared
* `MethodDispatchIndex` shape so it slots into
* `ScopeResolutionIndexes.methodDispatch`.
*
* `finalizeScopeModel` builds an empty `MethodDispatchIndex` by design
* (per the comment in `finalize-orchestrator.ts`). Per-language
* providers compute MRO their own way (Python C3 walk, Java class
* hierarchy, Ruby mixin chains, etc.) and use this bridge to plug the
* result back into the shared index shape.
*
* Next-consumer contract: any language that computes its own MRO map
* calls `buildPopulatedMethodDispatch(mroByOwnerDefId)` and assigns the
* result to `indexes.methodDispatch`. Interface-implementer tracking
* (`implsByInterfaceDefId`) stays empty in V1 — providers that need it
* can extend the return shape without breaking existing consumers.
*/
import type { MethodDispatchIndex } from 'gitnexus-shared';
const EMPTY_DEFS: readonly string[] = Object.freeze([]);
export function buildPopulatedMethodDispatch(
mroByDefId: ReadonlyMap<string, readonly string[]>,
): MethodDispatchIndex {
return {
mroByOwnerDefId: mroByDefId,
implsByInterfaceDefId: new Map(),
mroFor(ownerDefId) {
return mroByDefId.get(ownerDefId) ?? EMPTY_DEFS;
},
implementorsOf() {
return EMPTY_DEFS;
},
};
}

View file

@ -0,0 +1,44 @@
/**
* Build a per-file `localName → targetFilePath` map over the file's
* module-scope namespace-kind import edges.
*
* Namespace imports (`import X`, `import X as Y`) bind a name that can
* appear as a receiver in member calls (`X.foo()`, `Y.foo()`). Named
* imports (`from X import foo`) bind `foo` directly and are a different
* resolution path.
*
* Why not consult `scope.bindings` directly? For namespace imports
* where the target module has no self-named def,
* `finalize-algorithm.ts:540` skips binding creation entirely, so
* `scope.bindings.get('X')` returns undefined. We iterate
* `indexes.imports` to recover those targets.
*
* Next-consumer contract: any language with namespace-style imports
* (TypeScript `import * as X`, Java static import, Ruby `require`)
* uses this directly. `ParsedImport.kind === 'namespace'` is the
* cross-language hook.
*/
import type { ParsedFile } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
export function collectNamespaceTargets(
parsed: ParsedFile,
scopes: ScopeResolutionIndexes,
): Map<string, string> {
const out = new Map<string, string>();
const moduleEdges = scopes.imports.get(parsed.moduleScope);
if (moduleEdges === undefined) return out;
const namespaceLocals = new Set<string>();
for (const imp of parsed.parsedImports) {
if (imp.kind === 'namespace') namespaceLocals.add(imp.localName);
}
for (const edge of moduleEdges) {
if (edge.targetFile === null) continue;
if (!namespaceLocals.has(edge.localName)) continue;
out.set(edge.localName, edge.targetFile);
}
return out;
}

View file

@ -0,0 +1,131 @@
/**
* Scope-chain lookup primitives shared across language providers.
*
* Four functions:
* - `findReceiverTypeBinding` — walk scope.typeBindings up the chain
* for a receiver name.
* - `findClassBindingInScope` — walk scope.bindings + indexes.bindings
* (pre-finalize + post-finalize) for a class-kind binding. Dual-
* source is required because the cross-file finalize pass produces
* a separate bindings map that is not merged back into scope.bindings.
* - `findOwnedMember` — find a method/field owned by a class def
* across all parsed files by (ownerId, simpleName).
* - `findExportedDef` — find a file-level exported def (top-of-module
* class / function) by simpleName.
*
* Next-consumer contract: every OO or module-capable language hits the
* same pre-finalize / post-finalize binding split and the same
* "resolve member on owner with MRO" pattern. All four are reusable
* as-is for TypeScript, Java, Kotlin, Ruby, etc.
*/
import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
import { simpleQualifiedName } from './graph-id.js';
/**
* Walk the scope chain from `startScope` looking for a typeBinding
* named `receiverName`. Returns the TypeRef or undefined if no binding
* exists in the chain.
*/
export function findReceiverTypeBinding(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
): TypeRef | 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 typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) return typeRef;
currentId = scope.parent;
}
return undefined;
}
/**
* Look up a class-kind binding by name in the given scope's chain.
*
* Walks the scope chain upward and consults TWO sources at each step:
* 1. `scope.bindings` — populated during scope-extraction Pass 2 with
* local declarations (`origin: 'local'`).
* 2. `indexes.bindings` — populated by the cross-file finalize pass
* with import/namespace/wildcard/reexport origins.
*
* Without (2) we'd miss every cross-file class-receiver call.
*/
export function findClassBindingInScope(
startScope: ScopeId,
receiverName: 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(receiverName);
if (localBindings !== undefined) {
for (const b of localBindings) {
if (b.def.type === 'Class' || b.def.type === 'Interface') return b.def;
}
}
const finalizedScopeBindings = scopes.bindings.get(currentId);
const importedBindings = finalizedScopeBindings?.get(receiverName);
if (importedBindings !== undefined) {
for (const b of importedBindings) {
if (b.def.type === 'Class' || b.def.type === 'Interface') 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`.
*/
export function findOwnedMember(
ownerDefId: string,
memberName: string,
parsedFiles: readonly ParsedFile[],
): SymbolDefinition | undefined {
for (const f of parsedFiles) {
for (const def of f.localDefs) {
if (def.ownerId !== ownerDefId) continue;
if (simpleQualifiedName(def) !== memberName) continue;
return def;
}
}
return undefined;
}
/**
* Find a file-level exported def (top-of-module class / function /
* variable) by `simpleName` in a given target file's `parsedFile.localDefs`.
*/
export function findExportedDef(
targetFile: string,
memberName: string,
parsedFiles: readonly ParsedFile[],
): SymbolDefinition | undefined {
for (const f of parsedFiles) {
if (f.filePath !== targetFile) continue;
for (const def of f.localDefs) {
if (simpleQualifiedName(def) !== memberName) continue;
return def;
}
return undefined;
}
return undefined;
}

View file

@ -1,7 +1,7 @@
/**
* `runPythonScopeResolution` — drive the registry-primary resolution
* pipeline end-to-end for the Python files in a workspace and emit
* graph edges (RFC #909 Ring 4 — Python migration).
* graph edges (RFC #909 Ring 3 — Python migration).
*
* ParsedFile[] (one per .py via `extractParsedFile`)
* │ finalizeScopeModel( + Python hooks adapted to FinalizeHooks)
@ -10,8 +10,10 @@
* │ resolveReferenceSites
* ▼
* ReferenceIndex
* │ emitReferencesToGraph (CALLS / ACCESSES / INHERITS / USES)
* │ + emitImportEdgesToGraph (file→file IMPORTS)
* │ emitReferencesViaLookup (shared — emit-core)
* │ + emitReceiverBoundCalls (Python-specific; moves to
* │ languages/python/emit/ in Unit 11)
* │ + emitImportEdges (shared — emit-core)
* ▼
* KnowledgeGraph
*
@ -25,9 +27,6 @@
*/
import type {
BindingRef,
ImportEdge,
NodeLabel,
ParsedFile,
Reference,
RegistryProviders,
@ -48,7 +47,22 @@ import {
resolvePythonImportTarget,
type PythonResolveContext,
} from './languages/python/index.js';
import { generateId } from '../../lib/utils.js';
import {
buildGraphNodeLookup,
buildPopulatedMethodDispatch,
collectNamespaceTargets,
emitImportEdges,
emitReferencesViaLookup,
findClassBindingInScope,
findExportedDef,
findOwnedMember,
findReceiverTypeBinding,
mapReferenceKindToEdgeType,
resolveCallerGraphId,
resolveDefGraphId,
tryEmitEdge,
type GraphNodeLookup,
} from './emit-core/index.js';
// ─── Public API ─────────────────────────────────────────────────────────────
@ -168,11 +182,6 @@ export function runPythonScopeResolution(
// graph nodes (created by `parsing-processor.ts`) use the legacy
// `<Type>:<file>:<qualifiedName>` ID format. Bridging is required so
// edges actually link to existing graph nodes.
//
// We do that bridging here: translate the resolved `Reference`
// records' source + target via `nodeLookup` (built earlier alongside
// the MRO map) before calling `graph.addRelationship`. This keeps
// `emit-references.ts` untouched (it stays pure scope-resolution).
const { emitted, skipped } = emitReferencesViaLookup(graph, indexes, referenceIndex, nodeLookup);
// Python-specific post-pass: emit CALLS edges for dotted references
@ -180,8 +189,10 @@ export function runPythonScopeResolution(
// or a Class name (`Dog.classify()`). The shared `MethodRegistry.lookup`
// only walks `scope.typeBindings` for explicit-receiver resolution — it
// does NOT consult `scope.bindings` for namespace/class-kind entries.
// Rather than extend the shared contract, we cover the gap here with a
// direct receiver → target-module / target-class walk.
// Rather than widen the shared contract, this Python-specific pass
// closes the gap with a direct receiver → target-module / target-class
// walk. Already-emitted edges (via the shared resolver) are deduped by
// the same graph-id `(src → tgt @line:col)` key the main path uses.
const receiverExtras = emitReceiverBoundCalls(
graph,
indexes,
@ -196,7 +207,12 @@ export function runPythonScopeResolution(
// population for `ctx.resolve`), but import-processor's graph edge
// emission is gated per-language in `createImportEdgeHelpers` so
// Python no longer double-emits.
const importsEmitted = emitImportEdges(graph, indexes.imports, indexes.scopeTree);
const importsEmitted = emitImportEdges(
graph,
indexes.imports,
indexes.scopeTree,
'python-scope: import',
);
return {
filesProcessed: parsedFiles.length,
@ -208,7 +224,7 @@ export function runPythonScopeResolution(
};
}
// ─── Internal ───────────────────────────────────────────────────────────────
// ─── Python-specific internals (move to languages/python/emit/ in Unit 11) ──
/**
* Build a Python MRO map keyed by scope-resolution Class `DefId`.
@ -279,143 +295,17 @@ function buildPythonMro(
return mroByDefId;
}
const EMPTY_DEFS: readonly string[] = Object.freeze([]);
/** Wrap a `DefId → ancestor DefId[]` map in the `MethodDispatchIndex` shape. */
function buildPopulatedMethodDispatch(
mroByDefId: ReadonlyMap<string, readonly string[]>,
): import('gitnexus-shared').MethodDispatchIndex {
return {
mroByOwnerDefId: mroByDefId,
implsByInterfaceDefId: new Map(),
mroFor(ownerDefId) {
return mroByDefId.get(ownerDefId) ?? EMPTY_DEFS;
},
implementorsOf() {
return EMPTY_DEFS;
},
};
}
/**
* Build a `(filePath, simpleName, kind) → graphNodeId` lookup over the
* graph's Function/Method/Class/Constructor nodes. Used to translate
* scope-resolution `SymbolDefinition.nodeId` values into the legacy
* graph node ID format that downstream consumers (queries, edges, MCP)
* expect.
*/
type GraphNodeLookup = ReadonlyMap<string, string>;
function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
const lookup = new Map<string, string>();
for (const node of graph.iterNodes()) {
const props = node.properties as { filePath?: string; name?: string };
if (props.filePath === undefined || props.name === undefined) continue;
if (!isLinkableLabel(node.label)) continue;
// Keyed by (filePath, simpleName). Class kinds and method kinds
// share the same simple-name space within a file in Python — no
// overload of "class Foo" + "def Foo()" at the same level — so a
// single key per (file, name) is unambiguous in practice. The
// method-vs-class disambiguation for resolved references happens
// earlier inside `MethodRegistry.lookup` (Step 1 + Step 2).
const key = `${props.filePath}::${props.name}`;
if (!lookup.has(key)) lookup.set(key, node.id);
}
return lookup;
}
function isLinkableLabel(label: NodeLabel): boolean {
return (
label === 'Function' ||
label === 'Method' ||
label === 'Constructor' ||
label === 'Class' ||
label === 'Interface' ||
label === 'Struct' ||
label === 'Enum'
);
}
/**
* Translate the resolved `ReferenceIndex` into legacy graph edges.
*
* Per reference:
* 1. Resolve `fromScope` → caller graph-node id by walking the scope
* chain looking for an enclosing Function/Method/Class.
* 2. Resolve `toDef` → target graph-node id via `nodeLookup`.
* 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`)
* with the standard reason format.
*
* Skips (without throwing) when either side fails to map — either side
* may legitimately not exist as a graph node (e.g., a resolved target
* lives in an external file that wasn't ingested into the graph).
*/
function emitReferencesViaLookup(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
nodeLookup: GraphNodeLookup,
): { emitted: number; skipped: number } {
let emitted = 0;
let skipped = 0;
const seen = new Set<string>();
for (const [fromScope, refs] of referenceIndex.bySourceScope) {
const callerGraphId = resolveCallerGraphId(fromScope, scopes, nodeLookup);
if (callerGraphId === undefined) {
skipped += refs.length;
continue;
}
for (const ref of refs) {
const targetDef = scopes.defs.get(ref.toDef);
if (targetDef === undefined) {
skipped++;
continue;
}
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
if (targetGraphId === undefined) {
skipped++;
continue;
}
const edgeType = mapReferenceKindToEdgeType(ref.kind);
if (edgeType === undefined) {
skipped++;
continue;
}
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${ref.atRange.startLine}:${ref.atRange.startCol}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId: targetGraphId,
type: edgeType,
confidence: ref.confidence,
reason: `python-scope: ${ref.kind}`,
});
emitted++;
}
}
return { emitted, skipped };
}
/**
* Emit CALLS / ACCESSES edges for dotted references whose receiver is a
* namespace-import binding (`import models; models.User()`) or a class
* name in the call scope (`Dog.classify("dog")`).
*
* The shared `MethodRegistry.lookup` only walks `scope.typeBindings`
* when resolving an explicit receiver (`lookupReceiverType`). It never
* consults `scope.bindings` for namespace/class-kind entries, nor does
* it follow an `ImportEdge.targetModuleScope` for cross-module lookups.
* Rather than widen the shared contract, this Python-specific pass
* closes the gap with a direct receiver → target walk. Already-emitted
* edges (via the shared resolver) are deduped by the same graph-id
* `(src → tgt @line:col)` key the main path uses.
* when resolving an explicit receiver. It never consults `scope.bindings`
* for namespace/class-kind entries, nor does it follow an
* `ImportEdge.targetModuleScope` for cross-module lookups. Rather than
* widen the shared contract, this Python-specific pass closes the gap
* with a direct receiver → target walk.
*/
function emitReceiverBoundCalls(
graph: KnowledgeGraph,
@ -430,14 +320,18 @@ function emitReceiverBoundCalls(
const seen = new Set<string>();
for (const refs of referenceIndex.bySourceScope.values()) {
for (const r of refs) {
const kind = mapReferenceKindToEdgeType(r.kind);
if (kind === undefined) continue;
const callerGraphId = resolveCallerGraphId(r.fromScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const targetDef = scopes.defs.get(r.toDef);
if (targetDef === undefined) continue;
// Seed using the same dedup key as emit-references/emit-edge use.
// We recompute by calling the shared helpers indirectly via
// tryEmitEdge shape; cheaper to dupe the key construction here
// since we need the graph ids anyway.
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}`,
);
@ -445,10 +339,6 @@ function emitReceiverBoundCalls(
}
for (const parsed of parsedFiles) {
// Pre-compute per-file "namespace receiver → target file" map from
// the file's module-scope import edges. A map keyed by `localName`
// means `import models` and `import models as m` both yield the
// right target without walking edges N times per reference.
const namespaceTargets = collectNamespaceTargets(parsed, scopes);
for (const site of parsed.referenceSites) {
@ -463,7 +353,7 @@ function emitReceiverBoundCalls(
if (targetFile !== undefined) {
const memberDef = findExportedDef(targetFile, memberName, parsedFiles);
if (memberDef !== undefined) {
const emitted1 = tryEmitEdge(
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
@ -472,7 +362,7 @@ function emitReceiverBoundCalls(
'python-scope: namespace-receiver',
seen,
);
if (emitted1) emitted++;
if (ok) emitted++;
continue;
}
}
@ -480,8 +370,7 @@ function emitReceiverBoundCalls(
// ── Case 2: class-name receiver (`Dog.classify()`) ──────────
const classDef = findClassBindingInScope(site.inScope, receiverName, scopes);
if (classDef !== undefined) {
// Walk the MRO so inherited static/class methods resolve — e.g.
// `Dog.classify()` where `classify` lives on `Animal`.
// Walk the MRO so inherited static/class methods resolve.
const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
@ -489,7 +378,7 @@ function emitReceiverBoundCalls(
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
const emitted2 = tryEmitEdge(
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
@ -498,28 +387,23 @@ function emitReceiverBoundCalls(
'python-scope: class-receiver',
seen,
);
if (emitted2) emitted++;
if (ok) emitted++;
continue;
}
}
// ── Case 3: receiver has a dotted typeBinding (`u: models.User`) ──
// Happens when `u = models.User(...)` fires the qualified-call
// constructor-inferred capture. The shared resolveTypeRef can't
// handle it because User's qualifiedName in models.py is just
// "User" (not "models.User"), so QualifiedNameIndex fallback
// misses. Resolve manually via the namespace map.
const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes);
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
const className = classNameParts.join('.');
const targetFile = namespaceTargets.get(nsName);
if (targetFile !== undefined && className.length > 0) {
const classDef3 = findExportedDef(targetFile, className, parsedFiles);
const targetFile3 = namespaceTargets.get(nsName);
if (targetFile3 !== undefined && className.length > 0) {
const classDef3 = findExportedDef(targetFile3, className, parsedFiles);
if (classDef3 !== undefined) {
const memberDef = findOwnedMember(classDef3.nodeId, memberName, parsedFiles);
if (memberDef !== undefined) {
const emitted3 = tryEmitEdge(
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
@ -528,7 +412,7 @@ function emitReceiverBoundCalls(
'python-scope: dotted-typebinding',
seen,
);
if (emitted3) emitted++;
if (ok) emitted++;
continue;
}
}
@ -536,15 +420,9 @@ function emitReceiverBoundCalls(
}
// ── Case 4: simple typeBinding (`u: U` where U is aliased import)
// Happens when `u = U()` binds to an aliased import
// (`from models import User as U`). `findClassBindingInScope`
// already walks both `scope.bindings` (pre-finalize local defs)
// and `indexes.bindings` (post-finalize imports) for class-kind
// hits, so we reuse it against the typeBinding's rawName.
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
if (ownerDef !== undefined) {
// Walk the MRO chain so inherited methods still resolve.
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
for (const ownerId of chain) {
@ -552,7 +430,7 @@ function emitReceiverBoundCalls(
if (memberDef !== undefined) break;
}
if (memberDef !== undefined) {
const emitted4 = tryEmitEdge(
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
@ -561,7 +439,7 @@ function emitReceiverBoundCalls(
'python-scope: typeref-receiver',
seen,
);
if (emitted4) emitted++;
if (ok) emitted++;
}
}
}
@ -571,294 +449,13 @@ function emitReceiverBoundCalls(
return emitted;
}
/**
* Walk the scope chain from `startScope` looking for a typeBinding
* named `receiverName`. Returns the TypeRef or undefined if no binding
* exists in the chain.
*/
function findReceiverTypeBinding(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
): { readonly rawName: string } | 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 typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) return typeRef;
currentId = scope.parent;
}
return undefined;
}
/**
* Build a `localName → targetFilePath` map over the file's module-scope
* import edges, limited to namespace-kind imports (which is what binds
* a name that can appear as a receiver — `from m import X` binds `X`
* directly, not `m.X`).
*/
function collectNamespaceTargets(
parsed: ParsedFile,
scopes: ScopeResolutionIndexes,
): Map<string, string> {
const out = new Map<string, string>();
// `parsed.parsedImports` carries the raw decomposed imports keyed to
// their target file via `indexes.imports`; we match by source line to
// find the finalized edge with `targetFile`. A simpler traversal:
// walk `indexes.imports.get(module)` and cross-reference `parsedImports`
// to pick out namespace-kind entries.
const moduleEdges = scopes.imports.get(parsed.moduleScope);
if (moduleEdges === undefined) return out;
const namespaceLocals = new Set<string>();
for (const imp of parsed.parsedImports) {
if (imp.kind === 'namespace') namespaceLocals.add(imp.localName);
}
for (const edge of moduleEdges) {
if (edge.targetFile === null) continue;
if (!namespaceLocals.has(edge.localName)) continue;
out.set(edge.localName, edge.targetFile);
}
return out;
}
/**
* Find a file-level exported def (top-of-module class / function /
* variable) by `simpleName` in a given target file's `parsedFile.localDefs`.
*/
function findExportedDef(
targetFile: string,
memberName: string,
parsedFiles: readonly ParsedFile[],
): SymbolDefinition | undefined {
for (const f of parsedFiles) {
if (f.filePath !== targetFile) continue;
for (const def of f.localDefs) {
if (simpleQualifiedName(def) !== memberName) continue;
return def;
}
return undefined;
}
return undefined;
}
/**
* Look up a class-kind binding by name in the given scope's chain.
*
* Walks the scope chain upward and consults TWO sources at each step:
* 1. `scope.bindings` — populated during scope-extraction Pass 2 with
* local declarations (`origin: 'local'`). Holds the class's own
* defining file visible to code in that file.
* 2. `indexes.bindings` — populated by the cross-file finalize pass
* with import/namespace/wildcard/reexport origins. Needed to see
* classes brought in via `from models import Dog` at the call
* site's file.
*
* Without (2) we'd miss every cross-file class-receiver call.
*/
function findClassBindingInScope(
startScope: ScopeId,
receiverName: 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(receiverName);
if (localBindings !== undefined) {
for (const b of localBindings) {
if (b.def.type === 'Class' || b.def.type === 'Interface') return b.def;
}
}
const finalizedScopeBindings = scopes.bindings.get(currentId);
const importedBindings = finalizedScopeBindings?.get(receiverName);
if (importedBindings !== undefined) {
for (const b of importedBindings) {
if (b.def.type === 'Class' || b.def.type === 'Interface') 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`.
*/
function findOwnedMember(
ownerDefId: string,
memberName: string,
parsedFiles: readonly ParsedFile[],
): SymbolDefinition | undefined {
for (const f of parsedFiles) {
for (const def of f.localDefs) {
if (def.ownerId !== ownerDefId) continue;
if (simpleQualifiedName(def) !== memberName) continue;
return def;
}
}
return undefined;
}
function simpleQualifiedName(def: SymbolDefinition): string | undefined {
const q = def.qualifiedName;
if (q === undefined || q.length === 0) return undefined;
const dot = q.lastIndexOf('.');
return dot === -1 ? q : q.slice(dot + 1);
}
/**
* Resolve caller + target to graph ids and emit the edge. Returns true
* if the edge was emitted (not deduped, not skipped).
*/
function tryEmitEdge(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
site: {
readonly inScope: ScopeId;
readonly atRange: { startLine: number; startCol: number };
readonly kind: string;
},
targetDef: SymbolDefinition,
reason: string,
seen: Set<string>,
): boolean {
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
if (callerGraphId === undefined) return false;
if (targetGraphId === undefined) return false;
if (edgeType === undefined) return false;
const dedupKey = `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (seen.has(dedupKey)) return false;
seen.add(dedupKey);
graph.addRelationship({
id: `rel:${dedupKey}`,
sourceId: callerGraphId,
targetId: targetGraphId,
type: edgeType,
confidence: 0.85,
reason,
});
return true;
}
/**
* Walk the scope chain from `startScope` upward looking for the first
* scope whose `ownedDefs` contains a Function/Method/Class — that's
* our caller anchor. Translate via `nodeLookup` to the graph-node ID.
*/
function resolveCallerGraphId(
startScope: ScopeId,
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
): string | undefined {
let current: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
let lastFilePath: string | undefined;
while (current !== null) {
if (visited.has(current)) return undefined;
visited.add(current);
const scope = scopes.scopeTree.getScope(current);
if (scope === undefined) break;
lastFilePath = scope.filePath;
// Prefer Function/Method anchors; fall back to Class.
const fnDef = scope.ownedDefs.find(
(d) => d.type === 'Function' || d.type === 'Method' || d.type === 'Constructor',
);
if (fnDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, fnDef, nodeLookup);
if (id !== undefined) return id;
}
const classDef = scope.ownedDefs.find((d) => isLinkableLabel(d.type));
if (classDef !== undefined) {
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
if (id !== undefined) return id;
}
current = scope.parent;
}
// Module-level calls (e.g. Python `u = models.User()` at top level) have
// no enclosing function/method/class — fall back to the File node for
// the scope's filePath so those calls still get an edge source. Matches
// legacy DAG behavior where module-level CALLS edges originate from
// the file symbol.
if (lastFilePath !== undefined) {
return generateId('File', lastFilePath);
}
return undefined;
}
/** Look up a `SymbolDefinition` in the graph node lookup by file+name. */
function resolveDefGraphId(
filePath: string,
def: { qualifiedName?: string },
nodeLookup: GraphNodeLookup,
): string | undefined {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) return undefined;
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
return nodeLookup.get(`${filePath}::${simpleName}`);
}
/**
* Map a `Reference.kind` to a graph edge type. `import-use` is dropped
* (no edge type today — provenance lives on the IMPORTS edge already
* emitted by `emitImportEdges`).
*/
function mapReferenceKindToEdgeType(
kind: Reference['kind'],
): 'CALLS' | 'ACCESSES' | 'EXTENDS' | 'USES' | undefined {
switch (kind) {
case 'call':
return 'CALLS';
case 'read':
case 'write':
return 'ACCESSES';
case 'inherits':
return 'EXTENDS';
case 'type-reference':
return 'USES';
case 'import-use':
return undefined;
default:
return undefined;
}
}
/**
* Populate `ownerId` on Method/Function/Field defs that live structurally
* inside a `Class` scope.
*
* The scope extractor explicitly does NOT set `ownerId` (see
* `scope-extractor.ts:449-457`); the design comment defers it to a
* "finalize follow-up pass that sees every def already in place." That
* pass doesn't exist yet centrally — building it generically here would
* require knowing per-language ownership rules (Python: methods belong
* to the lexically enclosing class; Ruby: explicit `class << self`;
* etc.).
*
* Python's rule is simple — direct lexical containment — so we apply it
* locally before finalize runs. Without this, `MethodRegistry.lookup`
* Step 2 (`collectOwnedMembers`) returns no candidates because no def
* has the receiver class as its owner, and receiver-typed calls like
* `model.validate()` resolve to nothing.
* Python's ownership rule: methods belong to the lexically enclosing
* class. Applied before finalize so `MethodRegistry.lookup` Step 2
* (`collectOwnedMembers`) finds candidates by class owner.
*
* Mutates `parsed.localDefs` in place via type cast — `SymbolDefinition`
* is `readonly` for consumers but the extractor returns plain objects.
@ -866,13 +463,6 @@ function mapReferenceKindToEdgeType(
* so this single mutation is visible from both sides.
*/
function populateMethodOwnerIds(parsed: ParsedFile): void {
// Build a `(parent scope id) → (class def in that parent's chain)` map.
// Python scope topology (per the extractor):
// Module
// └─ Class scope ← `ownedDefs: [Class def]`
// └─ Function scope ← `ownedDefs: [Function def]`
// So a method's `ownerId` is the Class def owned by its **parent**
// scope (when that parent is a Class scope).
const scopesById = new Map<ScopeId, Scope>();
for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
@ -881,65 +471,11 @@ function populateMethodOwnerIds(parsed: ParsedFile): void {
const parentScope = scopesById.get(scope.parent);
if (parentScope === undefined || parentScope.kind !== 'Class') continue;
// The parent Class scope owns the Class def itself. Pick the first
// class-kind def in that scope (Python only has one class-def per
// class scope).
const classDef = parentScope.ownedDefs.find((d) => d.type === 'Class');
if (classDef === undefined) continue;
// Mutate `ownerId` in place on every def owned by this scope. Defs
// are referenced from both `parsed.localDefs` and `Scope.ownedDefs`
// — one write covers both.
for (const def of scope.ownedDefs) {
(def as { ownerId?: string }).ownerId = classDef.nodeId;
}
}
}
/**
* Emit one File→File IMPORTS edge per linked `ImportEdge`. Deduplicates
* by `(sourceFile, targetFile)` so multi-symbol imports from the same
* module collapse to a single edge — matching the legacy schema.
*/
function emitImportEdges(
graph: KnowledgeGraph,
imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
scopeTree: ReturnType<typeof finalizeScopeModel>['scopeTree'],
): number {
const seen = new Set<string>();
let emitted = 0;
for (const [scopeId, edges] of imports) {
const scope = scopeTree.getScope(scopeId);
if (scope === undefined) continue;
const sourceFile = scope.filePath;
for (const edge of edges) {
if (edge.targetFile === null) continue;
if (edge.targetFile === sourceFile) continue;
const dedupKey = `${sourceFile}->${edge.targetFile}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
const sourceId = generateId('File', sourceFile);
const targetId = generateId('File', edge.targetFile);
graph.addRelationship({
id: generateId('IMPORTS', dedupKey),
sourceId,
targetId,
type: 'IMPORTS',
confidence: 1.0,
reason: 'python-scope: import',
});
emitted++;
}
}
return emitted;
}
// `BindingRef` is intentionally exported back through the public surface
// so callers extending the orchestrator's hook adapters don't have to
// chase the import to `gitnexus-shared`. Pass-through only.
export type { BindingRef };