mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
ff376d09a8
commit
4013779a70
10 changed files with 677 additions and 524 deletions
92
gitnexus/src/core/ingestion/emit-core/emit-edge.ts
Normal file
92
gitnexus/src/core/ingestion/emit-core/emit-edge.ts
Normal 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;
|
||||
}
|
||||
57
gitnexus/src/core/ingestion/emit-core/emit-imports.ts
Normal file
57
gitnexus/src/core/ingestion/emit-core/emit-imports.ts
Normal 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;
|
||||
}
|
||||
79
gitnexus/src/core/ingestion/emit-core/emit-references.ts
Normal file
79
gitnexus/src/core/ingestion/emit-core/emit-references.ts
Normal 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 };
|
||||
}
|
||||
91
gitnexus/src/core/ingestion/emit-core/graph-id.ts
Normal file
91
gitnexus/src/core/ingestion/emit-core/graph-id.ts
Normal 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;
|
||||
}
|
||||
49
gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts
Normal file
49
gitnexus/src/core/ingestion/emit-core/graph-node-lookup.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
38
gitnexus/src/core/ingestion/emit-core/index.ts
Normal file
38
gitnexus/src/core/ingestion/emit-core/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
44
gitnexus/src/core/ingestion/emit-core/namespace-targets.ts
Normal file
44
gitnexus/src/core/ingestion/emit-core/namespace-targets.ts
Normal 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;
|
||||
}
|
||||
131
gitnexus/src/core/ingestion/emit-core/scope-walkers.ts
Normal file
131
gitnexus/src/core/ingestion/emit-core/scope-walkers.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue