From fb824a6877eadbdf843eedd94407578b23d310fb Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 20 Apr 2026 13:16:22 +0100 Subject: [PATCH] refactor(emit-core): promote MRO walk + populateClassOwnedMembers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G-Units 4-5 of the emit-pipeline generalization plan. - emit-core/build-mro.ts — generic buildMro takes a LinearizeStrategy hook receiving (classDefId, directParents, parentsByDefId). Three shared steps (collect EXTENDS, build defId-by-graphId, walk per class) + parametric linearization. Default strategy is BFS-with- visited (Python's depth-first first-seen, also correct for single-inheritance languages). - emit-core/scope-walkers.ts: + populateClassOwnedMembers — generic OO ownership rule (methods + class-body fields). Both rules ship together because every OO language migrated so far (Python; planned TS/JS/Java/Kotlin) wants both. Languages that need different rules can compose with this as a base step. python-scope-emit.ts shrinks 384 → 255 lines. Verification: - REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191. - REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191. - tsc --noEmit clean. --- .../src/core/ingestion/emit-core/build-mro.ts | 107 ++++++++++++++ .../src/core/ingestion/emit-core/index.ts | 2 + .../core/ingestion/emit-core/scope-walkers.ts | 46 ++++++ .../src/core/ingestion/python-scope-emit.ts | 134 +----------------- 4 files changed, 161 insertions(+), 128 deletions(-) create mode 100644 gitnexus/src/core/ingestion/emit-core/build-mro.ts diff --git a/gitnexus/src/core/ingestion/emit-core/build-mro.ts b/gitnexus/src/core/ingestion/emit-core/build-mro.ts new file mode 100644 index 000000000..5df478c14 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/build-mro.ts @@ -0,0 +1,107 @@ +/** + * Generic MRO (method-resolution-order) builder. + * + * Walks the graph's `EXTENDS` edges to recover an inheritance map, + * then asks the per-language `LinearizeStrategy` to order each class's + * ancestors. Returns `Map` ready to plug + * into `MethodDispatchIndex` via `buildPopulatedMethodDispatch`. + * + * **Why a strategy hook:** linearization differs across languages. + * - Python (depth-first first-seen, single inheritance): trivially + * correct; multi-inheritance falls back to BFS dedup. Real C3 + * would handle diamond hierarchies — defer until we hit one. + * - Java (single-inheritance only): walk one parent. + * - C++ (multiple inheritance): C3-like or BFS depending on how + * strict the consumer needs to be. + * - Languages without inheritance (COBOL): return empty list. + * + * The strategy receives the FULL ancestry context (`directParents` + + * `parentsByDefId`) so C3 implementations have what they need. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../graph/types.js'; +import type { GraphNodeLookup } from './graph-node-lookup.js'; +import type { LinearizeStrategy } from './emit-provider.js'; +import { resolveDefGraphId } from './graph-id.js'; + +/** + * Build an MRO map keyed by scope-resolution Class `DefId`. + * + * Steps: + * 1. Collect EXTENDS edges from the graph → `parentsByGraphId`. + * 2. Collect Class defs from `parsedFiles` and translate to graph + * ids via `nodeLookup` → `defIdByGraphId` (the bridge between + * scope-resolution DefId and the legacy graph node id). + * 3. For each Class def, ask `linearize` for its ancestor order. + */ +export function buildMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + linearize: LinearizeStrategy, +): Map { + // Step 1: parentsByGraphId. + 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: defIdByGraphId — translate graph ids to scope-resolution DefIds. + 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 2b: invert parentsByGraphId into parentsByDefId — the + // strategy works in DefId space. + const parentsByDefId = new Map(); + for (const [childGraphId, parents] of parentsByGraphId) { + const childDefId = defIdByGraphId.get(childGraphId); + if (childDefId === undefined) continue; + const parentDefIds: string[] = []; + for (const p of parents) { + const pd = defIdByGraphId.get(p); + if (pd !== undefined) parentDefIds.push(pd); + } + parentsByDefId.set(childDefId, parentDefIds); + } + + // Step 3: linearize per class. + const mroByDefId = new Map(); + for (const defId of defIdByGraphId.values()) { + const directParents = parentsByDefId.get(defId) ?? []; + mroByDefId.set(defId, linearize(defId, directParents, parentsByDefId)); + } + return mroByDefId; +} + +/** + * Default linearization: depth-first BFS-with-visited, first-seen + * wins. Correct for single-inheritance languages and for Python's + * simplified MRO. Multi-inheritance diamond hierarchies need a real + * C3 implementation; per-language overrides land here. + */ +export const defaultLinearize: LinearizeStrategy = (_classDefId, directParents, parentsByDefId) => { + const ancestors: string[] = []; + const visited = new Set(); + const queue: string[] = [...directParents]; + while (queue.length > 0) { + const cur = queue.shift()!; + if (visited.has(cur)) continue; + visited.add(cur); + ancestors.push(cur); + for (const p of parentsByDefId.get(cur) ?? []) queue.push(p); + } + return ancestors; +}; diff --git a/gitnexus/src/core/ingestion/emit-core/index.ts b/gitnexus/src/core/ingestion/emit-core/index.ts index 638600dfd..0072e509b 100644 --- a/gitnexus/src/core/ingestion/emit-core/index.ts +++ b/gitnexus/src/core/ingestion/emit-core/index.ts @@ -36,6 +36,7 @@ export { findExportedDefByName, findOwnedMember, findExportedDef, + populateClassOwnedMembers, } from './scope-walkers.js'; export { emitFreeCallFallback } from './emit-free-call.js'; export { emitReceiverBoundCalls } from './emit-receiver-bound.js'; @@ -48,3 +49,4 @@ export { followChainPostFinalize, propagateImportedReturnTypes } from './propaga export { collectNamespaceTargets } from './namespace-targets.js'; export { buildPopulatedMethodDispatch } from './method-dispatch-bridge.js'; export type { ArityVerdict, EmitProvider, LinearizeStrategy } from './emit-provider.js'; +export { buildMro, defaultLinearize } from './build-mro.js'; diff --git a/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts index 0ab7a15a5..e57c2f0dc 100644 --- a/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts +++ b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts @@ -137,6 +137,52 @@ export function findCallableBindingInScope( return undefined; } +/** + * Populate `ownerId` on every def structurally owned by a Class + * scope — methods (defs in Function scopes whose parent is Class) + * and class-body fields (defs directly in Class scopes). + * + * Generic OO ownership rule. Languages that want richer ownership + * (e.g. inner-class qualification) can compose with this as a base + * step. + * + * 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. + */ +export function populateClassOwnedMembers(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + for (const scope of parsed.scopes) { + // Methods: function scope whose parent is a Class scope. Owner is + // the parent's Class def. + if (scope.parent !== null) { + const parentScope = scopesById.get(scope.parent); + if (parentScope !== undefined && parentScope.kind === 'Class') { + const classDef = parentScope.ownedDefs.find((d) => d.type === 'Class'); + if (classDef !== undefined) { + for (const def of scope.ownedDefs) { + (def as { ownerId?: string }).ownerId = classDef.nodeId; + } + } + } + } + // Class-body fields: defs directly owned by a Class scope (the + // class def itself excluded). + if (scope.kind === 'Class') { + const classDef = scope.ownedDefs.find((d) => d.type === 'Class'); + if (classDef !== undefined) { + for (const def of scope.ownedDefs) { + if (def === classDef) continue; + (def as { ownerId?: string }).ownerId = classDef.nodeId; + } + } + } + } +} + /** * Walk a scope chain upward looking for the innermost enclosing * Class scope and return that class's def. Used by per-language diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index ecfd696fd..1cd06b288 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -26,13 +26,7 @@ * not here — this function is "what to do" once we've decided to do it. */ -import type { - ParsedFile, - RegistryProviders, - Scope, - ScopeId, - WorkspaceIndex, -} from 'gitnexus-shared'; +import type { ParsedFile, RegistryProviders, Scope, WorkspaceIndex } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../graph/types.js'; import { extractParsedFile } from './scope-extractor-bridge.js'; import { finalizeScopeModel } from './finalize-orchestrator.js'; @@ -46,15 +40,16 @@ import { } from './languages/python/index.js'; import { buildGraphNodeLookup, + buildMro, buildPopulatedMethodDispatch, + defaultLinearize, emitFreeCallFallback, emitImportEdges, emitReceiverBoundCalls, emitReferencesViaLookup, + populateClassOwnedMembers, propagateImportedReturnTypes, - resolveDefGraphId, type EmitProvider, - type GraphNodeLookup, } from './emit-core/index.js'; import { SupportedLanguages } from 'gitnexus-shared'; @@ -99,7 +94,7 @@ export function runPythonScopeResolution( filesSkipped++; continue; } - populateMethodOwnerIds(parsed); + populateClassOwnedMembers(parsed); parsedFiles.push(parsed); } @@ -122,7 +117,7 @@ export function runPythonScopeResolution( // mirror them into a `MethodDispatchIndex` so receiver-typed // resolution can walk inherited methods. const nodeLookup = buildGraphNodeLookup(graph); - const mroByClassDefId = buildPythonMro(graph, parsedFiles, nodeLookup); + const mroByClassDefId = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); const indexes = finalizeScopeModel(parsedFiles, { hooks: { @@ -240,123 +235,6 @@ export function runPythonScopeResolution( }; } -// ─── Python-specific internals (move to languages/python/emit/ in Unit 11) ── - -/** - * 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; -} - -/** - * Populate `ownerId` on Method/Function/Field defs that live structurally - * inside a `Class` scope. - * - * 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. - * Defs are shared by reference between `localDefs` and `Scope.ownedDefs`, - * so this single mutation is visible from both sides. - */ -function populateMethodOwnerIds(parsed: ParsedFile): void { - const scopesById = new Map(); - for (const scope of parsed.scopes) scopesById.set(scope.id, scope); - - for (const scope of parsed.scopes) { - // Methods (Function scopes whose PARENT is Class): set ownerId - // on every def in the function's own scope. - if (scope.parent !== null) { - const parentScope = scopesById.get(scope.parent); - if (parentScope !== undefined && parentScope.kind === 'Class') { - const classDef = parentScope.ownedDefs.find((d) => d.type === 'Class'); - if (classDef !== undefined) { - for (const def of scope.ownedDefs) { - (def as { ownerId?: string }).ownerId = classDef.nodeId; - } - } - } - } - // Class-body fields (defs structurally owned by the Class scope - // itself — class-body annotations like `name: str`): set ownerId - // on every def except the class itself. - if (scope.kind === 'Class') { - const classDef = scope.ownedDefs.find((d) => d.type === 'Class'); - if (classDef !== undefined) { - for (const def of scope.ownedDefs) { - if (def === classDef) continue; - (def as { ownerId?: string }).ownerId = classDef.nodeId; - } - } - } - } -} - /** Minimal `EmitProvider` carrying only the hooks the receiver-bound * pass currently consults. The full provider (with mergeBindings, * resolveImportTarget, arityCompatibility, buildMro, populateOwners)