diff --git a/gitnexus/src/core/ingestion/emit-core/emit-edge.ts b/gitnexus/src/core/ingestion/emit-core/emit-edge.ts new file mode 100644 index 000000000..0f5fe2fc2 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/emit-edge.ts @@ -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, + 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; +} diff --git a/gitnexus/src/core/ingestion/emit-core/emit-imports.ts b/gitnexus/src/core/ingestion/emit-core/emit-imports.ts new file mode 100644 index 000000000..8b9539daa --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/emit-imports.ts @@ -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, + scopeTree: ScopeResolutionIndexes['scopeTree'], + reason = 'scope-resolution: import', +): number { + const seen = new Set(); + 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; +} diff --git a/gitnexus/src/core/ingestion/emit-core/emit-references.ts b/gitnexus/src/core/ingestion/emit-core/emit-references.ts new file mode 100644 index 000000000..404279bc2 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/emit-references.ts @@ -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 }, + nodeLookup: GraphNodeLookup, +): { emitted: number; skipped: number } { + let emitted = 0; + let skipped = 0; + const seen = new Set(); + + 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 }; +} diff --git a/gitnexus/src/core/ingestion/emit-core/graph-id.ts b/gitnexus/src/core/ingestion/emit-core/graph-id.ts new file mode 100644 index 000000000..45afecf58 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/graph-id.ts @@ -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(); + 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; +} diff --git a/gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts b/gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts new file mode 100644 index 000000000..cddeaec37 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts @@ -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; + +export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { + const lookup = new Map(); + 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' + ); +} diff --git a/gitnexus/src/core/ingestion/emit-core/index.ts b/gitnexus/src/core/ingestion/emit-core/index.ts new file mode 100644 index 000000000..3dac359f5 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/index.ts @@ -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//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'; diff --git a/gitnexus/src/core/ingestion/emit-core/method-dispatch-bridge.ts b/gitnexus/src/core/ingestion/emit-core/method-dispatch-bridge.ts new file mode 100644 index 000000000..164147ac6 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/method-dispatch-bridge.ts @@ -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, +): MethodDispatchIndex { + return { + mroByOwnerDefId: mroByDefId, + implsByInterfaceDefId: new Map(), + mroFor(ownerDefId) { + return mroByDefId.get(ownerDefId) ?? EMPTY_DEFS; + }, + implementorsOf() { + return EMPTY_DEFS; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/emit-core/namespace-targets.ts b/gitnexus/src/core/ingestion/emit-core/namespace-targets.ts new file mode 100644 index 000000000..866a2e40f --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/namespace-targets.ts @@ -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 { + const out = new Map(); + const moduleEdges = scopes.imports.get(parsed.moduleScope); + if (moduleEdges === undefined) return out; + + const namespaceLocals = new Set(); + 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; +} diff --git a/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts new file mode 100644 index 000000000..80c3365e1 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts @@ -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(); + 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(); + 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; +} diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index 262d12764..cb81b16f9 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -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 // `::` 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, -): 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; - -function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { - const lookup = new Map(); - 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 }, - nodeLookup: GraphNodeLookup, -): { emitted: number; skipped: number } { - let emitted = 0; - let skipped = 0; - const seen = new Set(); - - 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(); 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(); - 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 { - const out = new Map(); - // `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(); - 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(); - 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, -): 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(); - 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(); 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, - scopeTree: ReturnType['scopeTree'], -): number { - const seen = new Set(); - 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 };