From 4b608f8e538e5b807c67a290caf04be38a0622f2 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 22 Apr 2026 16:37:05 +0100 Subject: [PATCH] refactor(scope-resolution): consume SemanticModel as single authoritative store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify scope-resolution and legacy parse into one symbol index per the industry pattern (Roslyn / tsc / rust-analyzer). Scope-resolution passes now consume `SemanticModel.methods` / `SemanticModel.fields` / `SemanticModel.symbols` for all symbol-keyed lookups. The legacy DAG already read from these; the drift — two parallel owner-keyed indexes populated by two writers with divergent ownerId semantics — is closed. Changes: * `MethodRegistry.lookupAllByOwner(owner, name)`: new API returning every overload without arity narrowing. Powers `findOwnedMember` / `pickOverload`. * `pipeline/run.ts` reconciliation pass: after `provider.populateOwners(parsed)`, iterate `parsed.localDefs[i]` and register methods/fields into the SemanticModel under the corrected ownerId. Idempotent — skips defs already present under `(ownerId, simple)` by nodeId, so unmigrated languages whose legacy extractor already set ownerId (C#) don't double-register. Closes the Python gap where class-body methods were invisible to `MethodRegistry` because the legacy Python method extractor couldn't resolve `enclosingClassId` at parse time. * `WorkspaceResolutionIndex` slimmed to Scope-valued maps only (`classScopeByDefId`, `moduleScopeByFile`). Dropped `memberByOwner`, `membersByOwner`, `defsByFileAndName`, `callablesBySimpleName` — all symbol-keyed duplicates of SemanticModel indexes. * Walker helpers now consume SemanticModel: - `findOwnedMember(owner, name, model)` → methods then fields fallback (ACCESSES writes target Property/Variable defs too). - `findExportedDefByName` fallback walks every Module scope's `origin === 'local'` bindings via `index.moduleScopeByFile` (preserves the module-export-visibility filter that SymbolTable.fileIndex can't cheaply encode). - `findExportedDef` reads `moduleScope.bindings` directly. * `pickOverload` in receiver-bound-calls.ts falls back to `model.fields.lookupFieldByOwner` when method lookup returns empty, fixing ACCESSES write edges that receive a Property target. * `phase.ts` threads `resolutionContext.model` into `RunScopeResolutionInput`. Boundary rule, enforced by file placement: - symbol-indexed lookups (key = nodeId / name / filePath) → `SemanticModel` - Scope-valued lookups (value = `Scope`) → `WorkspaceResolutionIndex` Research synthesized from web-researcher + Explore + best-practices + system-architect agents; canonical references: Roslyn Overview, rust-analyzer architecture, stack-graphs paper. Verified: - npx tsc --noEmit clean - C# + Python integration 393/393 passing --- .../core/ingestion/model/method-registry.ts | 18 ++ .../passes/free-call-fallback.ts | 23 +-- .../passes/receiver-bound-calls.ts | 29 ++-- .../scope-resolution/pipeline/phase.ts | 11 +- .../scope-resolution/pipeline/run.ts | 47 +++++- .../scope-resolution/scope/walkers.ts | 63 +++++-- .../scope-resolution/workspace-index.ts | 157 ++++-------------- 7 files changed, 184 insertions(+), 164 deletions(-) diff --git a/gitnexus/src/core/ingestion/model/method-registry.ts b/gitnexus/src/core/ingestion/model/method-registry.ts index 327f568e7..75f9834c2 100644 --- a/gitnexus/src/core/ingestion/model/method-registry.ts +++ b/gitnexus/src/core/ingestion/model/method-registry.ts @@ -49,6 +49,16 @@ export interface MethodRegistry { */ lookupMethodByName(name: string): readonly SymbolDefinition[]; + /** + * Return every overload registered under `(ownerNodeId, methodName)`, + * unfiltered by arity or return type. This is the raw owner-scoped + * view — callers that need arity narrowing or unambiguous single- + * result semantics should use `lookupMethodByOwner` instead. + * + * Returns `[]` on miss so callers can iterate without null checks. + */ + lookupAllByOwner(ownerNodeId: string, methodName: string): readonly SymbolDefinition[]; + /** * True iff at least one registered def has `type === 'Function'` — i.e., * a Python/Rust/Kotlin class method emitted by the worker as @@ -162,6 +172,13 @@ export const createMethodRegistry = (): MutableMethodRegistry => { return methodsByName.get(name) ?? EMPTY; }; + const lookupAllByOwner = ( + ownerNodeId: string, + methodName: string, + ): readonly SymbolDefinition[] => { + return methodByOwner.get(`${ownerNodeId}\0${methodName}`) ?? EMPTY; + }; + const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => { const key = `${ownerNodeId}\0${methodName}`; const existing = methodByOwner.get(key); @@ -195,6 +212,7 @@ export const createMethodRegistry = (): MutableMethodRegistry => { return { lookupMethodByOwner, lookupMethodByName, + lookupAllByOwner, register, clear, get hasFunctionMethods() { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 35464ce3d..b2f7fefb0 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -20,6 +20,7 @@ import type { ParsedFile, Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; @@ -32,6 +33,7 @@ export function emitFreeCallFallback( nodeLookup: GraphNodeLookup, _referenceIndex: { readonly bySourceScope: ReadonlyMap }, handledSites: Set, + model: SemanticModel, workspaceIndex?: WorkspaceResolutionIndex, ): number { let emitted = 0; @@ -59,7 +61,7 @@ export function emitFreeCallFallback( // the same name in a single class, choose the best match by // arity + argument types. if (fnDef === undefined && workspaceIndex !== undefined) { - fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex); + fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model); } if (fnDef === undefined) { fnDef = findCallableBindingInScope(site.inScope, site.name, scopes); @@ -93,18 +95,18 @@ export function emitFreeCallFallback( } /** For a constructor call `new X(...)`, return the X class's explicit - * Constructor def (by walking memberByOwner) or the Class def itself - * when no explicit Constructor exists. Matches legacy behavior — - * tests assert targetLabel === 'Class' for implicit ctors and - * targetLabel === 'Constructor' for explicit ones. */ + * Constructor def (by walking the class scope's ownedDefs) or the + * Class def itself when no explicit Constructor exists. Matches + * legacy behavior — tests assert targetLabel === 'Class' for implicit + * ctors and targetLabel === 'Constructor' for explicit ones. */ function pickConstructorOrClass( classDef: SymbolDefinition, workspaceIndex: WorkspaceResolutionIndex | undefined, ): SymbolDefinition { if (workspaceIndex === undefined) return classDef; - const members = workspaceIndex.memberByOwner.get(classDef.nodeId); - if (members === undefined) return classDef; - for (const [, def] of members) { + const classScope = workspaceIndex.classScopeByDefId.get(classDef.nodeId); + if (classScope === undefined) return classDef; + for (const def of classScope.ownedDefs) { if (def.type === 'Constructor') return def; } return classDef; @@ -124,6 +126,7 @@ function pickImplicitThisOverload( }, scopes: ScopeResolutionIndexes, workspaceIndex: WorkspaceResolutionIndex, + model: SemanticModel, ): SymbolDefinition | undefined { // Find the enclosing Class scope by walking parents. let curId: ScopeId | null = site.inScope; @@ -150,8 +153,8 @@ function pickImplicitThisOverload( } if (classDefId === undefined) return undefined; - const overloads = workspaceIndex.membersByOwner.get(classDefId)?.get(site.name); - if (overloads === undefined || overloads.length === 0) return undefined; + const overloads = model.methods.lookupAllByOwner(classDefId, site.name); + if (overloads.length === 0) return undefined; if (overloads.length === 1) return overloads[0]; const argTypes = site.argumentTypes; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index a58ab0669..5463e97ca 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -32,6 +32,7 @@ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; import type { ScopeResolver } from '../contract/scope-resolver.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; @@ -67,6 +68,7 @@ export function emitReceiverBoundCalls( handledSites: Set, provider: ReceiverBoundProviderSubset, index: WorkspaceResolutionIndex, + model: SemanticModel, ): number { let emitted = 0; // Per-pass dedup so the multiple cases don't double-emit if two of @@ -124,7 +126,7 @@ export function emitReceiverBoundCalls( if (impls === undefined) return 0; let n = 0; for (const implDef of impls) { - const implMember = findOwnedMember(implDef.nodeId, memberName, index); + const implMember = findOwnedMember(implDef.nodeId, memberName, model); if (implMember === undefined) continue; if (implMember.nodeId === primaryMemberDef.nodeId) continue; const ok = tryEmitEdge( @@ -161,7 +163,7 @@ export function emitReceiverBoundCalls( const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId); let memberDef: SymbolDefinition | undefined; for (const ownerId of ancestors) { - memberDef = findOwnedMember(ownerId, memberName, index); + memberDef = findOwnedMember(ownerId, memberName, model); if (memberDef !== undefined) break; } if (memberDef !== undefined) { @@ -200,7 +202,7 @@ export function emitReceiverBoundCalls( const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; let memberDef: SymbolDefinition | undefined; for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, index); + memberDef = findOwnedMember(ownerId, memberName, model); if (memberDef !== undefined) break; } if (memberDef !== undefined) { @@ -254,7 +256,7 @@ export function emitReceiverBoundCalls( const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; let memberDef: SymbolDefinition | undefined; for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, index); + memberDef = findOwnedMember(ownerId, memberName, model); if (memberDef !== undefined) break; } if (memberDef !== undefined) { @@ -284,7 +286,7 @@ export function emitReceiverBoundCalls( if (targetFile3 !== undefined && className.length > 0) { const classDef3 = findExportedDef(targetFile3, className, index); if (classDef3 !== undefined) { - const memberDef = findOwnedMember(classDef3.nodeId, memberName, index); + const memberDef = findOwnedMember(classDef3.nodeId, memberName, model); if (memberDef !== undefined) { const ok = tryEmitEdge( graph, @@ -336,7 +338,7 @@ export function emitReceiverBoundCalls( const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, index); + memberDef = findOwnedMember(ownerId, memberName, model); if (memberDef !== undefined) break; } if (memberDef !== undefined) { @@ -369,7 +371,7 @@ export function emitReceiverBoundCalls( const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; for (const ownerId of chain) { - memberDef = pickOverload(ownerId, memberName, site, index); + memberDef = pickOverload(ownerId, memberName, site, model); if (memberDef !== undefined) break; } if (memberDef !== undefined) { @@ -421,7 +423,7 @@ export function emitReceiverBoundCalls( const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; let memberDef: SymbolDefinition | undefined; for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, index); + memberDef = findOwnedMember(ownerId, memberName, model); if (memberDef !== undefined) break; } if (memberDef !== undefined) { @@ -462,11 +464,14 @@ function pickOverload( ownerId: string, memberName: string, site: ParsedFile['referenceSites'][number], - index: WorkspaceResolutionIndex, + model: SemanticModel, ): SymbolDefinition | undefined { - const overloads = index.membersByOwner.get(ownerId)?.get(memberName); - if (overloads === undefined || overloads.length === 0) { - return findOwnedMember(ownerId, memberName, index); + const overloads = model.methods.lookupAllByOwner(ownerId, memberName); + if (overloads.length === 0) { + // Non-callable member (field / property / variable) — ACCESSES + // write/read sites target these too. Fall back to the field + // registry so owner-scoped attribute access resolves. + return model.fields.lookupFieldByOwner(ownerId, memberName); } if (overloads.length === 1) return overloads[0]; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index d050f0a82..67be491c3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -30,13 +30,13 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from '../../pipeline-phases/types.js'; import { getPhaseOutput } from '../../pipeline-phases/types.js'; import type { StructureOutput } from '../../pipeline-phases/structure.js'; +import type { ParseOutput } from '../../pipeline-phases/parse.js'; import { isRegistryPrimary } from '../../registry-primary-flag.js'; import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared'; import { readFileContents } from '../../filesystem-walker.js'; import { runScopeResolution } from './run.js'; import { SCOPE_RESOLVERS } from './registry.js'; import { isDev } from '../../utils/env.js'; -import type { ASTCacheReader } from '../../ast-cache.js'; export interface ScopeResolutionOutput { /** True when at least one language ran. */ @@ -91,7 +91,13 @@ export const scopeResolutionPhase: PipelinePhase = { // skip a second tree-sitter parse. Cache miss is safe (re-parses). // Worker-mode parses leave the cache empty for those files; they // also fall back to a fresh parse — no correctness impact. - const { scopeTreeCache } = getPhaseOutput<{ scopeTreeCache: ASTCacheReader }>(deps, 'parse'); + const parseOutput = getPhaseOutput(deps, 'parse'); + const { scopeTreeCache, resolutionContext } = parseOutput; + // SemanticModel populated during `parse`: scope-resolution consumes + // TypeRegistry / MethodRegistry / SymbolTable lookups instead of + // rebuilding parallel indexes. See ARCHITECTURE.md § "Semantic-model + // source of truth". + const model = resolutionContext.model; let totalFiles = 0; let totalImports = 0; @@ -123,6 +129,7 @@ export const scopeResolutionPhase: PipelinePhase = { const stats = runScopeResolution( { graph: ctx.graph, + model, files, treeCache: scopeTreeCache, onWarn: (msg) => { diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 64ff542ef..e2f217b42 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -25,6 +25,8 @@ import type { ParsedFile, RegistryProviders } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { MutableSemanticModel } from '../../model/semantic-model.js'; +import { simpleQualifiedName } from '../graph-bridge/ids.js'; import { extractParsedFile } from '../../scope-extractor-bridge.js'; import { finalizeScopeModel } from '../../finalize-orchestrator.js'; import { resolveReferenceSites, type ResolveStats } from '../../resolve-references.js'; @@ -40,6 +42,16 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; interface RunScopeResolutionInput { readonly graph: KnowledgeGraph; + /** + * Semantic model populated by the legacy `parse` phase. Scope- + * resolution consumes its `TypeRegistry` / `MethodRegistry` / + * `SymbolTable` lookups instead of rebuilding parallel indexes from + * `ParsedFile[]`. See ARCHITECTURE.md § "Semantic-model source of + * truth". Tests that invoke `runScopeResolution` in isolation pass a + * freshly-created `MutableSemanticModel` populated from the same + * `ParsedFile[]` to mirror the pipeline shape. + */ + readonly model: MutableSemanticModel; readonly files: readonly { readonly path: string; readonly content: string }[]; readonly onWarn?: (message: string) => void; /** @@ -65,7 +77,7 @@ export function runScopeResolution( input: RunScopeResolutionInput, provider: ScopeResolver, ): RunScopeResolutionStats { - const { graph, files } = input; + const { graph, files, model } = input; const onWarn = input.onWarn ?? (() => {}); const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; const tStart = PROF ? process.hrtime.bigint() : 0n; @@ -91,6 +103,37 @@ export function runScopeResolution( parsedFiles.push(parsed); } + // Reconcile scope-resolution's ownership view into the SemanticModel. + // For migrated languages (Python in particular) the legacy extractor + // emits class-body members without `ownerId` — + // `provider.populateOwners(parsed)` above stamps the correct ownerId + // on `parsed.localDefs[i]`. Without this pass those defs would be + // invisible to `model.methods.lookupMethodByOwner` / + // `model.fields.lookupFieldByOwner`, forcing scope-resolution to + // maintain a parallel owner-keyed index. The pass is idempotent: we + // skip defs already present under `(ownerId, simpleName)` by nodeId, + // so re-running it (or running after a language whose legacy + // extractor does populate ownerId, e.g. C#) doesn't introduce + // duplicates. + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + const ownerId = (def as { ownerId?: string }).ownerId; + if (ownerId === undefined) continue; + const simple = simpleQualifiedName(def); + if (simple === undefined) continue; + + if (def.type === 'Method' || def.type === 'Function' || def.type === 'Constructor') { + const existing = input.model.methods.lookupAllByOwner(ownerId, simple); + if (existing.some((e) => e.nodeId === def.nodeId)) continue; + input.model.methods.register(ownerId, simple, def); + } else if (def.type === 'Property' || def.type === 'Variable') { + const existing = input.model.fields.lookupFieldByOwner(ownerId, simple); + if (existing !== undefined && existing.nodeId === def.nodeId) continue; + input.model.fields.register(ownerId, simple, def); + } + } + } + if (parsedFiles.length === 0) { return { filesProcessed: 0, @@ -175,6 +218,7 @@ export function runScopeResolution( handledSites, provider, workspaceIndex, + model, ); const freeCallExtras = emitFreeCallFallback( graph, @@ -183,6 +227,7 @@ export function runScopeResolution( nodeLookup, referenceIndex, handledSites, + model, workspaceIndex, ); const { emitted, skipped } = emitReferencesViaLookup( diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 95cdcabc9..a42fbeeb8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -21,6 +21,7 @@ import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; /** @@ -289,38 +290,70 @@ export function findExportedDefByName( } currentId = scope.parent; } - // Workspace-wide fallback: O(1) lookup via the pre-built - // `callablesBySimpleName` index. First-seen-by-file wins (matches - // the previous nested-loop semantics where the outer iteration is - // `parsedFiles`). - return index.callablesBySimpleName.get(name)?.[0]; + // Workspace-wide fallback: iterate every file's Module scope (via + // the scope-tied `moduleScopeByFile` lookup) and return the first + // locally-declared callable binding matching `name`. Mirrors the + // original `callablesBySimpleName[0]` semantics — first-seen-by- + // file wins, bindings filtered to `origin === 'local'` and the + // callable types Function/Method/Constructor. + for (const [, moduleScope] of index.moduleScopeByFile) { + const refs = moduleScope.bindings.get(name); + if (refs === undefined) continue; + for (const ref of refs) { + if (ref.origin !== 'local') continue; + const t = ref.def.type; + if (t === 'Function' || t === 'Method' || t === 'Constructor') return ref.def; + } + } + return undefined; } /** - * Find a member of a class by simple name — O(1) lookup via the - * pre-built `memberByOwner` index from `WorkspaceResolutionIndex`. + * Find a member of a class by simple name — delegates to + * `SemanticModel.methods` (methods / functions / constructors) with a + * fallback to `SemanticModel.fields` (properties / fields / + * variables). After `runScopeResolution`'s reconciliation pass + * populates both registries from `parsed.localDefs[i].ownerId` + * (post-`populateOwners`), this is the single authoritative view of + * class membership — no parallel scope-resolution index needed. * - * Pre-index baseline: O(N × D) per call (full parsedFiles scan). - * Indexed: O(1) `Map.get`. The receiver-bound dispatcher calls this - * up to (sites × MRO depth) times per workspace. + * Returns the first-seen overload for methods without arity or + * return-type narrowing. Callers that need arity-aware dispatch use + * `lookupMethodByOwner(owner, name, argCount)` directly. */ export function findOwnedMember( ownerDefId: string, memberName: string, - index: WorkspaceResolutionIndex, + model: SemanticModel, ): SymbolDefinition | undefined { - return index.memberByOwner.get(ownerDefId)?.get(memberName); + const method = model.methods.lookupAllByOwner(ownerDefId, memberName)[0]; + if (method !== undefined) return method; + return model.fields.lookupFieldByOwner(ownerDefId, memberName); } /** * Find a file-level def (top-of-module class / function / variable) - * by `simpleName` — O(1) lookup via the pre-built - * `defsByFileAndName` index. + * by simple name — consults the target file's Module scope's + * finalized bindings. Only defs bound at module-scope with + * `origin === 'local'` qualify, matching the historical + * "module-export-visible" semantics. Class methods and class-body + * fields bind at their containing class scope and are naturally + * excluded. + * + * Reads from `WorkspaceResolutionIndex.moduleScopeByFile` (scope-tied + * lookup that doesn't live on `SemanticModel`). */ export function findExportedDef( targetFile: string, memberName: string, index: WorkspaceResolutionIndex, ): SymbolDefinition | undefined { - return index.defsByFileAndName.get(targetFile)?.get(memberName); + const moduleScope = index.moduleScopeByFile.get(targetFile); + if (moduleScope === undefined) return undefined; + const refs = moduleScope.bindings.get(memberName); + if (refs === undefined) return undefined; + for (const ref of refs) { + if (ref.origin === 'local') return ref.def; + } + return undefined; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts index 6f43505f5..8b1314b0e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts @@ -1,58 +1,48 @@ /** - * `WorkspaceResolutionIndex` — pre-computed lookup tables built ONCE + * `WorkspaceResolutionIndex` — scope-tied lookup tables built ONCE * per resolution run, after `populateOwners` and before any * resolution pass. * - * Why: the resolution passes hammer the same lookup patterns - * thousands of times per run. Without an index, every - * `findOwnedMember` / `findExportedDef` / scope-by-defId lookup - * walks `parsedFiles` linearly — O(N × D) per call, multiplied by - * the (N × S × M) call count from the receiver-bound MRO chain. - * One pre-built index turns those into O(1) `Map.get`. + * ## Scope (what lives here vs. what lives in `SemanticModel`) * - * Build cost is one O(totalDefs) pass over `parsedFiles`. Pays for - * itself on the very first MRO walk. + * This index carries only the lookups that return a `Scope` — things + * `SemanticModel` structurally cannot provide: * - * The index is read-only after construction — passes that create - * defs (e.g. provider.populateOwners) MUST run before the index is - * built. + * - `classScopeByDefId` — class def `nodeId` → `Scope`. Needed so + * passes can read `scope.bindings`, `scope.typeBindings`, and + * `scope.ownedDefs`. SemanticModel's `TypeRegistry` carries class + * metadata but not the `Scope`. + * - `moduleScopeByFile` — file path → `Scope` of the root `Module`. + * Used by cross-file return-type propagation, `findExportedDef`, + * and `findExportedDefByName`'s workspace-wide fallback. + * SymbolTable indexes symbols, not scopes. + * + * Symbol lookups live on `SemanticModel`: + * - Owner-keyed method lookup → `model.methods.lookupAllByOwner` + * (populated by the legacy parse phase via `symbolTable.add` AND + * by scope-resolution's reconciliation pass in `runScopeResolution`, + * which adds `parsed.localDefs[i].ownerId` entries missed by the + * legacy extractor for registry-primary languages). + * - Name-keyed callable lookup → `model.methods.lookupMethodByName` + * and `model.symbols.lookupCallableByName`. + * - File-indexed symbol lookup → `model.symbols.lookupExactAll`. + * + * This split preserves the single-source-of-truth invariant + * documented in `ScopeResolver`'s contract file: symbol-indexed + * lookups live on `SemanticModel` for the whole codebase; only + * scope-shaped lookups (which `SemanticModel` doesn't carry) live + * here. + * + * Build cost is O(totalScopes). Read-only after construction. */ -import type { ParsedFile, Scope, SymbolDefinition } from 'gitnexus-shared'; -import { simpleQualifiedName } from './graph-bridge/ids.js'; +import type { ParsedFile, Scope } from 'gitnexus-shared'; export interface WorkspaceResolutionIndex { /** Class def `nodeId` → that class's `Scope`. */ readonly classScopeByDefId: ReadonlyMap; - /** Owner def `nodeId` → (simple-member-name → owned `SymbolDefinition`). - * Replaces `findOwnedMember`'s O(N × D) walk with O(1) lookup. - * Built from `parsed.localDefs` so class-owned members land in the - * right bucket via their `ownerId`. */ - readonly memberByOwner: ReadonlyMap>; - /** Multi-valued variant of `memberByOwner` so consumers narrowing - * by parameter types (overload resolution) can see every candidate. - * `memberByOwner` continues to return the first-seen def to - * preserve existing consumers. */ - readonly membersByOwner: ReadonlyMap>; - - /** File path → (simple-name → first matching module-scope-owned - * `SymbolDefinition`). Backs `findExportedDef` — the lookup for - * `from mod import X` / `mod.X()` targets. Only defs directly - * owned by the file's `Module` scope are indexed here; methods, - * fields, and nested-function defs are NOT visible as file-level - * exports. First-seen-within-module wins. */ - readonly defsByFileAndName: ReadonlyMap>; - - /** Workspace-wide simple-name fallback: simple-name → all matching - * module-scope-owned Function/Method/Constructor defs. Backs the - * `findExportedDefByName` fallback scan. Class methods and nested - * functions are NOT eligible here — they are not import-visible - * callables. */ - readonly callablesBySimpleName: ReadonlyMap; - - /** Module scope by file path — used by cross-file return-type - * propagation and by per-file imports lookup. */ + /** Module scope by file path. */ readonly moduleScopeByFile: ReadonlyMap; } @@ -61,98 +51,17 @@ export function buildWorkspaceResolutionIndex( ): WorkspaceResolutionIndex { const classScopeByDefId = new Map(); const moduleScopeByFile = new Map(); - const memberByOwner = new Map>(); - const membersByOwner = new Map>(); - const defsByFileAndName = new Map>(); - const callablesBySimpleName = new Map(); for (const parsed of parsedFiles) { - // module scope by file const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); if (moduleScope !== undefined) moduleScopeByFile.set(parsed.filePath, moduleScope); - // class scopes for (const scope of parsed.scopes) { if (scope.kind !== 'Class') continue; const cd = scope.ownedDefs.find((d) => d.type === 'Class'); if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope); } - - // Module-export pass — use the module scope's own `bindings` map - // as the source of truth for "what names this module exports". - // The scope extractor populates moduleScope.bindings with exactly - // the names visible at module level: top-level class/function - // declarations, module-level variable assignments, imports, etc. - // Filtering to `origin === 'local'` keeps only locally-defined - // names (not imports or wildcard re-exports brought in from - // elsewhere), which matches the pre-fix invariant that - // defsByFileAndName was built from `parsed.localDefs`. - // - // Class methods, class-body attributes, and nested-function defs - // are NOT in moduleScope.bindings — they're bound at their - // containing (Class or Function) scope — so they're naturally - // excluded, no per-kind filter required. - let fileBucket = defsByFileAndName.get(parsed.filePath); - if (fileBucket === undefined) { - fileBucket = new Map(); - defsByFileAndName.set(parsed.filePath, fileBucket); - } - if (moduleScope !== undefined) { - for (const [, refs] of moduleScope.bindings) { - for (const ref of refs) { - if (ref.origin !== 'local') continue; - const def = ref.def; - const simple = simpleQualifiedName(def); - if (simple === undefined) continue; - // First-seen wins to match `findExportedDef` semantics. - if (!fileBucket.has(simple)) fileBucket.set(simple, def); - if (def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor') { - let bucket = callablesBySimpleName.get(simple); - if (bucket === undefined) { - bucket = []; - callablesBySimpleName.set(simple, bucket); - } - bucket.push(def); - } - } - } - } - - // Member-by-owner pass — keyed on `ownerId`, so it must iterate - // `parsed.localDefs` (class-owned defs live in nested class scopes, - // not the module scope). Requires populateOwners to have run first. - for (const def of parsed.localDefs) { - const ownerId = (def as { ownerId?: string }).ownerId; - if (ownerId === undefined) continue; - const simple = simpleQualifiedName(def); - if (simple === undefined) continue; - let memberBucket = memberByOwner.get(ownerId); - if (memberBucket === undefined) { - memberBucket = new Map(); - memberByOwner.set(ownerId, memberBucket); - } - // First-seen wins to match `findOwnedMember` semantics. - if (!memberBucket.has(simple)) memberBucket.set(simple, def); - - // Multi-valued variant — keeps every overload for - // parameter-type narrowing. - let membersBucket = membersByOwner.get(ownerId); - if (membersBucket === undefined) { - membersBucket = new Map(); - membersByOwner.set(ownerId, membersBucket); - } - const overloads = membersBucket.get(simple); - if (overloads === undefined) membersBucket.set(simple, [def]); - else overloads.push(def); - } } - return { - classScopeByDefId, - membersByOwner, - memberByOwner, - defsByFileAndName, - callablesBySimpleName, - moduleScopeByFile, - }; + return { classScopeByDefId, moduleScopeByFile }; }