refactor(scope-resolution): consume SemanticModel as single authoritative store

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
This commit is contained in:
Gergo Magyar 2026-04-22 16:37:05 +01:00
parent 84a89a42b7
commit 4b608f8e53
7 changed files with 184 additions and 164 deletions

View file

@ -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() {

View file

@ -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<ScopeId, readonly Reference[]> },
handledSites: Set<string>,
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;

View file

@ -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<string>,
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];

View file

@ -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<ScopeResolutionOutput> = {
// 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<ParseOutput>(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<ScopeResolutionOutput> = {
const stats = runScopeResolution(
{
graph: ctx.graph,
model,
files,
treeCache: scopeTreeCache,
onWarn: (msg) => {

View file

@ -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(

View file

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

View file

@ -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<string, Scope>;
/** 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<string, ReadonlyMap<string, SymbolDefinition>>;
/** 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<string, ReadonlyMap<string, readonly SymbolDefinition[]>>;
/** 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<string, ReadonlyMap<string, SymbolDefinition>>;
/** 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<string, readonly SymbolDefinition[]>;
/** 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<string, Scope>;
}
@ -61,98 +51,17 @@ export function buildWorkspaceResolutionIndex(
): WorkspaceResolutionIndex {
const classScopeByDefId = new Map<string, Scope>();
const moduleScopeByFile = new Map<string, Scope>();
const memberByOwner = new Map<string, Map<string, SymbolDefinition>>();
const membersByOwner = new Map<string, Map<string, SymbolDefinition[]>>();
const defsByFileAndName = new Map<string, Map<string, SymbolDefinition>>();
const callablesBySimpleName = new Map<string, SymbolDefinition[]>();
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 };
}