mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
84a89a42b7
commit
4b608f8e53
7 changed files with 184 additions and 164 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue