refactor(emit-core): promote MRO walk + populateClassOwnedMembers

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.
This commit is contained in:
Gergo Magyar 2026-04-20 13:16:22 +01:00
parent 46fe4c0a39
commit fb824a6877
4 changed files with 161 additions and 128 deletions

View file

@ -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<classDefId, ancestorDefId[]>` 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<string /* DefId */, string[] /* DefId[] */> {
// Step 1: parentsByGraphId.
const parentsByGraphId = new Map<string, string[]>();
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<string, string>();
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<string, string[]>();
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<string, string[]>();
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<string>();
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;
};

View file

@ -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';

View file

@ -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<ScopeId, ParsedFile['scopes'][number]>();
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

View file

@ -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<string /* DefId */, string[] /* DefId[] */> {
// 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<string, string[]>();
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<string, string>();
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<string, string[]>();
for (const [graphId, defId] of defIdByGraphId) {
const ancestors: string[] = [];
const visited = new Set<string>();
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<ScopeId, Scope>();
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)