From f67061cd661b53d720fd181e304f4b8a0dc7df2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 08:14:51 +0000 Subject: [PATCH] wire python scope-based resolution end-to-end (initial pass) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c474dc66-5cf7-445d-8eb4-76501c5e6d67 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus/src/core/ingestion/call-processor.ts | 8 + .../src/core/ingestion/import-processor.ts | 14 +- .../core/ingestion/pipeline-phases/index.ts | 1 + .../ingestion/pipeline-phases/python-scope.ts | 113 ++++ gitnexus/src/core/ingestion/pipeline.ts | 2 + .../src/core/ingestion/python-scope-emit.ts | 567 ++++++++++++++++++ .../src/core/ingestion/resolve-references.ts | 231 +++++++ 7 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/python-scope.ts create mode 100644 gitnexus/src/core/ingestion/python-scope-emit.ts create mode 100644 gitnexus/src/core/ingestion/resolve-references.ts diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 7d8a9da27..2debe56b3 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -37,6 +37,7 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; +import { isRegistryPrimary } from './registry-primary-flag.js'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import { @@ -750,6 +751,8 @@ export const processCalls = async ( const language = getLanguageFromFilename(file.path); if (!language) continue; + // Registry-primary gate: scope-based phase owns CALLS for this lang. + if (isRegistryPrimary(language)) continue; if (!isLanguageAvailable(language)) { if (skippedByLang) { skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1); @@ -2731,6 +2734,11 @@ export const processCallsFromExtracted = async ( await yieldToEventLoop(); } + // Registry-primary gate: skip Python (etc.) entirely when the + // scope-based phase owns CALLS for this language. + const fileLanguage = getLanguageFromFilename(filePath); + if (fileLanguage && isRegistryPrimary(fileLanguage)) continue; + ctx.enableCache(filePath); const widenCache: WidenCache = new Map(); const receiverMap = fileReceiverTypes.get(filePath); diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index b08482716..f2c17af96 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -5,7 +5,8 @@ import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/pa import { getProvider, getProviderForFile, providersWithImplicitWiring } from './languages/index.js'; import type { LanguageProvider } from './language-provider.js'; import { generateId } from '../../lib/utils.js'; -import { getLanguageFromFilename } from 'gitnexus-shared'; +import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; +import { isRegistryPrimary } from './registry-primary-flag.js'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import type { ExtractedImport } from './workers/parse-worker.js'; @@ -42,6 +43,8 @@ function wireImplicitImports( const grouped = new Map(); for (const file of files) { + const lang = getLanguageFromFilename(file); + if (lang && isRegistryPrimary(lang)) continue; const provider = getProviderForFile(file); if (!provider?.implicitImportWirer) continue; let list = grouped.get(provider); @@ -282,6 +285,11 @@ export const processImports = async ( // 1. Check language support first const language = getLanguageFromFilename(file.path); if (!language) continue; + // Registry-primary gate: when REGISTRY_PRIMARY_=1, the + // scope-based pipeline phase (`pythonScopePhase`, etc.) owns + // IMPORTS/CALLS emission for this language. Skip the legacy path + // here so we don't double-emit edges. + if (isRegistryPrimary(language)) continue; if (!isLanguageAvailable(language)) { if (skippedByLang) { skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1); @@ -467,6 +475,10 @@ export const processImportsFromExtracted = async ( } for (const imp of fileImports) { + // Registry-primary gate: skip when the scope-based phase owns + // emission for this language. `imp.language` is set by the parse + // worker; trust it here. + if (isRegistryPrimary(imp.language)) continue; totalImportsFound++; const provider = getProvider(imp.language); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index c05264de1..de2f66d12 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -16,6 +16,7 @@ export { routesPhase, type RoutesOutput, type RouteEntry } from './routes.js'; export { toolsPhase, type ToolsOutput, type ToolDef } from './tools.js'; export { ormPhase, type ORMOutput } from './orm.js'; export { crossFilePhase, type CrossFileOutput } from './cross-file.js'; +export { pythonScopePhase, type PythonScopeOutput } from './python-scope.js'; export { mroPhase, type MROOutput } from './mro.js'; export { communitiesPhase, type CommunitiesOutput } from './communities.js'; export { processesPhase, type ProcessesOutput } from './processes.js'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/python-scope.ts b/gitnexus/src/core/ingestion/pipeline-phases/python-scope.ts new file mode 100644 index 000000000..ed832c17e --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/python-scope.ts @@ -0,0 +1,113 @@ +/** + * Phase: pythonScope + * + * Registry-primary resolution for Python files (RFC #909 Ring 4). + * + * Gated by `REGISTRY_PRIMARY_PYTHON=1` (via `isRegistryPrimary`). When + * the flag is OFF (default), this phase is a no-op: the legacy paths in + * `import-processor.ts` and `call-processor.ts` continue to handle + * Python imports + calls and this phase contributes nothing to the + * graph. + * + * When the flag is ON, this phase: + * 1. Reads every `.py` file in the workspace. + * 2. Drives the scope-based pipeline end-to-end (extract β†’ finalize β†’ + * resolve β†’ emit) via `runPythonScopeResolution`. + * 3. Emits IMPORTS / CALLS / ACCESSES / INHERITS / USES edges directly. + * + * Pairs with the matching gates in `import-processor.ts` and + * `call-processor.ts` that skip Python files when this phase is active β€” + * so we don't double-emit edges from both code paths. + * + * @deps parse (needs Symbol nodes already in the graph so emit-references + * can attach edges to existing Function/Method/Class nodes) + * @reads scannedFiles + * @writes graph (IMPORTS, CALLS, ACCESSES, INHERITS, USES) + */ + +import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import { getPhaseOutput } from './types.js'; +import type { StructureOutput } from './structure.js'; +import { isRegistryPrimary } from '../registry-primary-flag.js'; +import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared'; +import { readFileContents } from '../filesystem-walker.js'; +import { runPythonScopeResolution } from '../python-scope-emit.js'; +import { isDev } from '../utils/env.js'; + +export interface PythonScopeOutput { + /** True when the flag was on and the phase actually ran. */ + readonly ran: boolean; + /** Python files seen by the phase. `0` when `ran === false`. */ + readonly filesProcessed: number; + /** IMPORTS edges emitted by this phase. */ + readonly importsEmitted: number; + /** Reference (CALLS/ACCESSES/INHERITS/USES) edges emitted. */ + readonly referenceEdgesEmitted: number; +} + +const NOOP_OUTPUT: PythonScopeOutput = Object.freeze({ + ran: false, + filesProcessed: 0, + importsEmitted: 0, + referenceEdgesEmitted: 0, +}); + +export const pythonScopePhase: PipelinePhase = { + name: 'pythonScope', + // Depends on `parse` because emit-references attaches edges to + // already-existing Symbol nodes (Function/Method/Class). The legacy + // `parse` phase still creates those nodes; we only replace the + // import + call resolution layer. + deps: ['parse', 'structure'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + if (!isRegistryPrimary(SupportedLanguages.Python)) { + return NOOP_OUTPUT; + } + + const { scannedFiles } = getPhaseOutput(deps, 'structure'); + + // Only `.py` files; the per-language flag scopes by extension via + // `getLanguageFromFilename`. + const pythonScanned = scannedFiles.filter( + (f) => getLanguageFromFilename(f.path) === SupportedLanguages.Python, + ); + if (pythonScanned.length === 0) return NOOP_OUTPUT; + + // Read source for every Python file. `runPythonScopeResolution` + // re-parses each file via `pythonProvider.emitScopeCaptures`; we + // accept the duplicate parse cost in this first cut and can revisit + // by sharing `astCache` across phases if needed. + const filePaths = pythonScanned.map((f) => f.path); + const contents = await readFileContents(ctx.repoPath, filePaths); + const files: { path: string; content: string }[] = []; + for (const fp of filePaths) { + const content = contents.get(fp); + if (content !== undefined) files.push({ path: fp, content }); + } + + const stats = runPythonScopeResolution({ + graph: ctx.graph, + files, + onWarn: (msg) => { + if (isDev) console.warn(`[python-scope] ${msg}`); + }, + }); + + if (isDev) { + console.log( + `🐍 python-scope: ${stats.filesProcessed} files β†’ ${stats.importsEmitted} IMPORTS + ${stats.referenceEdgesEmitted} reference edges (${stats.resolve.unresolved} unresolved sites, ${stats.referenceSkipped} skipped)`, + ); + } + + return { + ran: true, + filesProcessed: stats.filesProcessed, + importsEmitted: stats.importsEmitted, + referenceEdgesEmitted: stats.referenceEdgesEmitted, + }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e1f226289..97cf2ca61 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -30,6 +30,7 @@ import { toolsPhase, ormPhase, crossFilePhase, + pythonScopePhase, mroPhase, communitiesPhase, processesPhase, @@ -80,6 +81,7 @@ function buildPhaseList(options?: PipelineOptions): PipelinePhase[] { toolsPhase, ormPhase, crossFilePhase, + pythonScopePhase, ]; if (!options?.skipGraphPhases) { diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts new file mode 100644 index 000000000..251849e13 --- /dev/null +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -0,0 +1,567 @@ +/** + * `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). + * + * ParsedFile[] (one per .py via `extractParsedFile`) + * β”‚ finalizeScopeModel( + Python hooks adapted to FinalizeHooks) + * β–Ό + * ScopeResolutionIndexes + * β”‚ resolveReferenceSites + * β–Ό + * ReferenceIndex + * β”‚ emitReferencesToGraph (CALLS / ACCESSES / INHERITS / USES) + * β”‚ + emitImportEdgesToGraph (fileβ†’file IMPORTS) + * β–Ό + * KnowledgeGraph + * + * The orchestrator is the public seam between the gitnexus pipeline and + * the language-agnostic scope-resolution machinery in `gitnexus-shared`. + * It wires the Python provider's hooks into `FinalizeOrchestratorOptions` + * and threads the workspace index through the import-target resolver. + * + * Gating lives in the pipeline phase (`pipeline-phases/python-scope.ts`), + * not here β€” this function is "what to do" once we've decided to do it. + */ + +import type { + BindingRef, + ImportEdge, + NodeLabel, + ParsedFile, + Reference, + RegistryProviders, + Scope, + ScopeId, + WorkspaceIndex, +} from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; +import { extractParsedFile } from './scope-extractor-bridge.js'; +import { finalizeScopeModel } from './finalize-orchestrator.js'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; +import { resolveReferenceSites, type ResolveStats } from './resolve-references.js'; +import { pythonProvider } from './languages/python.js'; +import { + pythonArityCompatibility, + pythonMergeBindings, + resolvePythonImportTarget, + type PythonResolveContext, +} from './languages/python/index.js'; +import { generateId } from '../../lib/utils.js'; + +// ─── Public API ───────────────────────────────────────────────────────────── + +export interface RunPythonScopeResolutionInput { + readonly graph: KnowledgeGraph; + readonly files: readonly { readonly path: string; readonly content: string }[]; + /** Optional warning sink (e.g. for telemetry). Failures per-file are non-fatal. */ + readonly onWarn?: (message: string) => void; +} + +export interface RunPythonScopeResolutionStats { + readonly filesProcessed: number; + readonly filesSkipped: number; + readonly importsEmitted: number; + readonly resolve: ResolveStats; + readonly referenceEdgesEmitted: number; + readonly referenceSkipped: number; +} + +/** + * Run the full registry-primary resolution path for `files` and emit the + * resulting CALLS / ACCESSES / INHERITS / USES / IMPORTS edges into + * `graph`. Caller is responsible for ensuring `files` are Python only. + * + * Returns telemetry; never throws on per-file failures (warnings flow + * through `onWarn`). + */ +export function runPythonScopeResolution( + input: RunPythonScopeResolutionInput, +): RunPythonScopeResolutionStats { + const { graph, files } = input; + const onWarn = input.onWarn ?? (() => {}); + + // ── Phase 1: extract each file β†’ ParsedFile ───────────────────────────── + const parsedFiles: ParsedFile[] = []; + let filesSkipped = 0; + for (const file of files) { + const parsed = extractParsedFile(pythonProvider, file.content, file.path, onWarn); + if (parsed === undefined) { + filesSkipped++; + continue; + } + populateMethodOwnerIds(parsed); + parsedFiles.push(parsed); + } + + if (parsedFiles.length === 0) { + return { + filesProcessed: 0, + filesSkipped, + importsEmitted: 0, + resolve: { sitesProcessed: 0, referencesEmitted: 0, unresolved: 0 }, + referenceEdgesEmitted: 0, + referenceSkipped: 0, + }; + } + + // ── Phase 2: finalize β†’ ScopeResolutionIndexes ───────────────────────── + const allFilePaths = new Set(parsedFiles.map((f) => f.filePath)); + // Pre-build a graph-node lookup (used both for MRO bridging and for + // edge emission below). EXTENDS edges already in the graph (from the + // legacy heritage processor in `parse`) drive the MRO chain β€” we + // mirror them into a `MethodDispatchIndex` so receiver-typed + // resolution can walk inherited methods. + const nodeLookup = buildGraphNodeLookup(graph); + const mroByClassDefId = buildPythonMro(graph, parsedFiles, nodeLookup); + + const indexes = finalizeScopeModel(parsedFiles, { + hooks: { + // Adapter: shared `finalize()` calls `resolveImportTarget(targetRaw, + // fromFile, ws)` with `targetRaw` already extracted; the Python + // provider's signature takes a synthetic `ParsedImport`. Wrap it so + // the hook contract is satisfied without leaking provider internals. + resolveImportTarget: (targetRaw, fromFile) => { + const ws: PythonResolveContext = { fromFile, allFilePaths }; + return resolvePythonImportTarget( + { kind: 'named', localName: '_', importedName: '_', targetRaw }, + ws as unknown as WorkspaceIndex, + ); + }, + // Python LEGB precedence: local > import/namespace/reexport > wildcard. + mergeBindings: (existing, incoming, scopeId) => { + // `pythonMergeBindings(scope, bindings)` only consults + // `BindingRef.origin` for tier ordering, not `scope.kind`. A + // shape-stub satisfies the type contract without falsifying + // behavior. + const fakeScope = { id: scopeId } as unknown as Scope; + return pythonMergeBindings(fakeScope, [...existing, ...incoming]); + }, + }, + }); + + // Stitch the MRO into the finalized indexes. `finalizeScopeModel` + // builds an empty MethodDispatchIndex (the comment in + // `finalize-orchestrator.ts:124-129` notes this is a known gap); we + // overwrite with a populated index that wraps the same shape. + (indexes as { methodDispatch: typeof indexes.methodDispatch }).methodDispatch = + buildPopulatedMethodDispatch(mroByClassDefId); + + // ── Phase 3: resolve references via Registry.lookup ───────────────────── + const providers: RegistryProviders = { + // The Python provider's `arityCompatibility` predates the + // RegistryProviders contract and uses `(def, callsite)` argument + // order. The contract is `(callsite, def)`. Adapt at the boundary + // so the provider source stays untouched. + arityCompatibility: (callsite, def) => pythonArityCompatibility(def, callsite), + }; + const { referenceIndex, stats: resolveStats } = resolveReferenceSites({ + scopes: indexes, + providers, + }); + + // ── Phase 4: emit graph edges ─────────────────────────────────────────── + // The shared `emit-references.ts` emits edges between + // `SymbolDefinition.nodeId` values, which use the scope-extractor's + // `def:#:::` format. The CLI's existing + // 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, + ); + + // IMPORTS edges: mirror the legacy file-to-file shape so existing + // queries that aggregate by `File β†’ File` continue to work. Done here + // (not in `emit-references.emitScopeGraph`) because that path emits + // scope-to-scope edges, which are a different schema. Keeping the + // legacy shape avoids churn in downstream consumers and tests. + const importsEmitted = emitImportEdges(graph, indexes.imports, indexes.scopeTree); + + return { + filesProcessed: parsedFiles.length, + filesSkipped, + importsEmitted, + resolve: resolveStats, + referenceEdgesEmitted: emitted, + referenceSkipped: skipped, + }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** + * Build a Python MRO map keyed by scope-resolution Class `DefId`. + * + * The legacy `parse` phase has already emitted EXTENDS edges into the + * graph (via the heritage processor in `parsing-processor.ts`) by the + * time this orchestrator runs (we depend on `parse`). We mirror those + * edges into a `DefId β†’ ancestor DefId[]` map so receiver-typed + * `MethodRegistry.lookup` can walk inherited methods. + * + * MRO ordering: this is a **simple linear walk** (depth-first parent + * chain, dedup by first-seen). Full Python C3 linearization lives in + * the legacy heritage processor; replicating it here is out of scope + * for the first cut. The single-inheritance case β€” which covers the + * existing fixture suite (`User β†’ BaseModel`, `Child β†’ Parent`, + * `Grandchild β†’ Child β†’ Parent`) β€” is identical to C3, so the + * difference only surfaces with diamond hierarchies. Tracked as a + * follow-up alongside generalizing this orchestrator across languages. + */ +function buildPythonMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + // Step 1: build (graph node id) β†’ (parent graph node id[]) from + // EXTENDS edges. Python only has class inheritance via `class + // Child(Parent)`, which the heritage processor maps to EXTENDS + // (not IMPLEMENTS). + const parentsByGraphId = new Map(); + for (const rel of graph.iterRelationships()) { + if (rel.type !== 'EXTENDS') continue; + let list = parentsByGraphId.get(rel.sourceId); + if (list === undefined) { + list = []; + parentsByGraphId.set(rel.sourceId, list); + } + list.push(rel.targetId); + } + + // Step 2: collect every Class def from the parsed scope model and + // build a graph-node β†’ DefId reverse map. + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (def.type !== 'Class') continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + // Step 3: for each Class def, walk parents transitively (depth-first, + // first-seen-wins) and translate each ancestor back to its DefId. + const mroByDefId = new Map(); + for (const [graphId, defId] of defIdByGraphId) { + const ancestors: string[] = []; + const visited = new Set(); + const queue: string[] = [...(parentsByGraphId.get(graphId) ?? [])]; + while (queue.length > 0) { + const cur = queue.shift()!; + if (visited.has(cur)) continue; + visited.add(cur); + const ancDefId = defIdByGraphId.get(cur); + if (ancDefId !== undefined) ancestors.push(ancDefId); + for (const p of parentsByGraphId.get(cur) ?? []) queue.push(p); + } + mroByDefId.set(defId, ancestors); + } + 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 }; +} + +/** + * 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(); + while (current !== null) { + if (visited.has(current)) return undefined; + visited.add(current); + const scope = scopes.scopeTree.getScope(current); + if (scope === undefined) return undefined; + + // 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; + } + 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. + * + * Mutates `parsed.localDefs` in place via type cast β€” `SymbolDefinition` + * is `readonly` for consumers but the extractor returns plain objects. + * Defs are shared by reference between `localDefs` and `Scope.ownedDefs`, + * 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); + + for (const scope of parsed.scopes) { + if (scope.parent === null) continue; + 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 }; diff --git a/gitnexus/src/core/ingestion/resolve-references.ts b/gitnexus/src/core/ingestion/resolve-references.ts new file mode 100644 index 000000000..0c375961a --- /dev/null +++ b/gitnexus/src/core/ingestion/resolve-references.ts @@ -0,0 +1,231 @@ +/** + * `resolveReferenceSites` β€” drain `ReferenceSite[]` from a finalized + * `ScopeResolutionIndexes` into a `ReferenceIndex` by routing each site + * through the appropriate scope-aware `Registry.lookup` (RFC Β§3.2 Phase 4). + * + * This is the missing producer that `emit-references.ts` (#925) was + * waiting on. The two together form the registry-primary resolution + * pipeline: + * + * ScopeResolutionIndexes.referenceSites + * β”‚ resolveReferenceSites + * β–Ό + * ReferenceIndex + * β”‚ emitReferencesToGraph + * β–Ό + * graph: CALLS / ACCESSES / INHERITS / USES edges + * + * ## What this module does + * + * - For each `ReferenceSite`, picks the registry by `kind`: + * Β· `call` / `inherits` β†’ MethodRegistry / ClassRegistry (call-form aware) + * Β· `read` / `write` β†’ FieldRegistry (falls through to MethodRegistry for free names) + * Β· `type-reference` β†’ ClassRegistry + * Β· `import-use` β†’ all three (best-effort name-lookup) + * - Calls `Registry.lookup` with the site's `inScope`, optional + * explicit receiver, and arity. + * - Takes the top-ranked `Resolution` (best by confidence + tie-break + * cascade); folds it into a `Reference` record and bins by source scope. + * + * ## What this module does NOT do + * + * - No AST walks. The `ReferenceSite[]` is already extracted. + * - No language switches. Per-language behavior flows through + * `RegistryProviders.arityCompatibility` (see `RegistryContext`). + * - No multi-candidate fan-out. We pick `[0]` per RFC Β§4.3 ("one-shot + * answer"). The full ranked list is preserved in the per-site + * resolution but not emitted as multiple edges; callers that want + * branch-on-ambiguity behavior should consume the registries directly. + */ + +import { + buildClassRegistry, + buildFieldRegistry, + buildMethodRegistry, + CLASS_KINDS, + FIELD_KINDS, + METHOD_KINDS, + type ClassRegistry, + type FieldRegistry, + type MethodRegistry, + type Reference, + type ReferenceIndex, + type ReferenceSite, + type RegistryContext, + type RegistryProviders, + type Resolution, + type ScopeId, +} from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; + +// ─── Public API ───────────────────────────────────────────────────────────── + +export interface ResolveReferencesInput { + readonly scopes: ScopeResolutionIndexes; + /** Provider hooks consumed by the registries (e.g. `arityCompatibility`). */ + readonly providers?: RegistryProviders; +} + +export interface ResolveStats { + readonly sitesProcessed: number; + readonly referencesEmitted: number; + /** Sites where `Registry.lookup` returned no candidates. */ + readonly unresolved: number; +} + +export interface ResolveReferencesOutput { + readonly referenceIndex: ReferenceIndex; + readonly stats: ResolveStats; +} + +/** + * Resolve every `ReferenceSite` in `scopes.referenceSites` against the + * matching registry and produce a `ReferenceIndex` keyed by source scope + * + target def. + */ +export function resolveReferenceSites(input: ResolveReferencesInput): ResolveReferencesOutput { + const { scopes } = input; + const providers: RegistryProviders = input.providers ?? {}; + + const ctx: RegistryContext = { + scopes: scopes.scopeTree, + defs: scopes.defs, + qualifiedNames: scopes.qualifiedNames, + moduleScopes: scopes.moduleScopes, + methodDispatch: scopes.methodDispatch, + providers, + }; + + const classRegistry = buildClassRegistry(ctx); + const methodRegistry = buildMethodRegistry(ctx); + const fieldRegistry = buildFieldRegistry(ctx); + + // bySourceScope is the canonical index; byTargetDef is derived from it. + const bySourceScope = new Map(); + const byTargetDef = new Map(); + + let sitesProcessed = 0; + let referencesEmitted = 0; + let unresolved = 0; + + for (const site of scopes.referenceSites) { + sitesProcessed++; + + const resolutions = lookupForSite(site, classRegistry, methodRegistry, fieldRegistry); + if (resolutions.length === 0) { + unresolved++; + continue; + } + + const top = resolutions[0]!; + const ref = buildReference(site, top); + referencesEmitted++; + + let bySource = bySourceScope.get(site.inScope); + if (bySource === undefined) { + bySource = []; + bySourceScope.set(site.inScope, bySource); + } + bySource.push(ref); + + let byTarget = byTargetDef.get(top.def.nodeId); + if (byTarget === undefined) { + byTarget = []; + byTargetDef.set(top.def.nodeId, byTarget); + } + byTarget.push(ref); + } + + // Freeze inner arrays so consumers don't accidentally mutate. + const frozenBySource = new Map(); + for (const [k, v] of bySourceScope) frozenBySource.set(k, Object.freeze([...v])); + const frozenByTarget = new Map(); + for (const [k, v] of byTargetDef) frozenByTarget.set(k, Object.freeze([...v])); + + return { + referenceIndex: { bySourceScope: frozenBySource, byTargetDef: frozenByTarget }, + stats: { sitesProcessed, referencesEmitted, unresolved }, + }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** + * Pick the right registry for the site's `kind` and call `lookup`. + * + * The kindβ†’registry mapping mirrors `mapKindToType` in `emit-references.ts`: + * + * | site.kind | primary registry | acceptedKinds source | + * |------------------|-------------------|------------------------------| + * | `call` | MethodRegistry | METHOD_KINDS (Method/Func/Ctor) + * | `inherits` | ClassRegistry | CLASS_KINDS | + * | `type-reference` | ClassRegistry | CLASS_KINDS | + * | `read`/`write` | FieldRegistry | FIELD_KINDS | + * | `import-use` | tiered fallback | METHOD βˆͺ CLASS βˆͺ FIELD | + * + * `import-use` doesn't have a single registry β€” the imported name might + * be a class, a function, or a constant. Try each in priority order and + * return the first non-empty result. Provenance still flows through the + * scope's `bindings` (Step 1 lexical hit), so the lookup is correct + * regardless of which registry surfaces the def. + */ +function lookupForSite( + site: ReferenceSite, + classRegistry: ClassRegistry, + methodRegistry: MethodRegistry, + fieldRegistry: FieldRegistry, +): readonly Resolution[] { + switch (site.kind) { + case 'call': { + const opts: Parameters[2] = { + ...(site.arity !== undefined ? { callsite: { arity: site.arity } } : {}), + ...(site.explicitReceiver !== undefined + ? { explicitReceiver: site.explicitReceiver } + : {}), + }; + return methodRegistry.lookup(site.name, site.inScope, opts); + } + case 'inherits': + case 'type-reference': { + return classRegistry.lookup(site.name, site.inScope); + } + case 'read': + case 'write': { + // Try field first; fall through to method then class so bare-name + // reads of a function (e.g. `cb = save`) still resolve. + const fieldHits = fieldRegistry.lookup(site.name, site.inScope); + if (fieldHits.length > 0) return fieldHits; + const methodHits = methodRegistry.lookup(site.name, site.inScope); + if (methodHits.length > 0) return methodHits; + return classRegistry.lookup(site.name, site.inScope); + } + case 'import-use': { + // Try class, method, then field. The lexical-hit Step 1 in + // `lookupCore` handles the actual binding lookup; the choice of + // registry only narrows `acceptedKinds`. + const classHits = classRegistry.lookup(site.name, site.inScope); + if (classHits.length > 0) return classHits; + const methodHits = methodRegistry.lookup(site.name, site.inScope); + if (methodHits.length > 0) return methodHits; + return fieldRegistry.lookup(site.name, site.inScope); + } + } +} + +/** Compose a `Reference` record from a site + its top resolution. */ +function buildReference(site: ReferenceSite, top: Resolution): Reference { + return { + fromScope: site.inScope, + toDef: top.def.nodeId, + atRange: site.atRange, + kind: site.kind, + confidence: top.confidence, + evidence: top.evidence, + }; +} + +// Re-export the kind sets so consumers don't have to import them +// separately when constructing custom resolution flows. The mappings +// stay in `gitnexus-shared` (single source of truth); this is a +// convenience pass-through only. +export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS };