diff --git a/AGENTS.md b/AGENTS.md index e6cefed11f..c9e2158c00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 7b0f175b14..fc0abde9a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g GitNexus MCP rules are in the ` # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index bd89dfc622..4024bf0704 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -19,6 +19,7 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js'; // Language support export { SupportedLanguages } from './languages.js'; export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js'; +export type { MroStrategy } from './mro-strategy.js'; // Pipeline progress export type { PipelinePhase, PipelineProgress } from './pipeline.js'; diff --git a/gitnexus-shared/src/mro-strategy.ts b/gitnexus-shared/src/mro-strategy.ts new file mode 100644 index 0000000000..6168c67c02 --- /dev/null +++ b/gitnexus-shared/src/mro-strategy.ts @@ -0,0 +1,23 @@ +/** + * MRO (Method Resolution Order) strategy — shared between CLI and any + * future consumer that reasons about multiple-inheritance semantics. + * + * Lives in `gitnexus-shared` so the low-level resolution module + * (`core/ingestion/model/resolve.ts`) does not need to import from + * `languages/` — keeping the `model/` layer free of language-registry + * coupling. + * + * Strategy semantics: + * - `first-wins`: BFS ancestor walk, first match wins (default). + * - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++). + * - `c3`: C3-linearized ancestor order, first match wins (Python). + * - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full + * interface-default ambiguity is handled at graph level. + * - `qualified-syntax`: No auto-resolution (Rust — requires `::m`). + */ +export type MroStrategy = + | 'first-wins' + | 'c3' + | 'leftmost-base' + | 'implements-split' + | 'qualified-syntax'; diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 5edd727375..ce0364a4d6 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,11 +1,11 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import type { SymbolDefinition, SymbolTable } from './symbol-table.js'; -import { CLASS_TYPES, CALLABLE_TYPES } from './symbol-table.js'; +import type { SymbolDefinition, SymbolTableReader } from './model/symbol-table.js'; +import { CLASS_TYPES, CALL_TARGET_TYPES } from './model/symbol-table.js'; import Parser from 'tree-sitter'; -import type { ResolutionContext } from './resolution-context.js'; -import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js'; -import type { TieredCandidates } from './resolution-context.js'; +import type { ResolutionContext } from './model/resolution-context.js'; +import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js'; +import type { TieredCandidates } from './model/resolution-context.js'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; @@ -32,24 +32,24 @@ import { } from './utils/call-analysis.js'; import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; -import type { HeritageMap } from './heritage-map.js'; -import { c3Linearize } from './mro-processor.js'; +import type { HeritageMap } from './model/heritage-map.js'; import type { BindingAccumulator } from './binding-accumulator.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, ExtractedAssignment, - ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, FileConstructorBindings, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js'; import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; import { extractParsedCallSite } from './call-sites/extract-language-call-site.js'; +import { lookupMethodByOwnerWithMRO } from './model/resolve.js'; /** Per-file resolved type bindings for exported symbols. * Populated during call processing, consumed by Phase 14 re-resolution pass. */ @@ -204,7 +204,7 @@ function collectExportedBindings( * exported symbols that have callables with known return types. */ export function buildExportedTypeMapFromGraph( graph: KnowledgeGraph, - symbolTable: SymbolTable, + symbolTable: SymbolTableReader, ): ExportedTypeMap { const result: ExportedTypeMap = new Map(); graph.forEachNode((node) => { @@ -652,7 +652,7 @@ function findInterfaceDispatchTargets( const results: ResolveResult[] = []; for (const implFile of implFiles) { - const methods = ctx.symbols.lookupExactAll(implFile, calledName); + const methods = ctx.model.symbols.lookupExactAll(implFile, calledName); for (const method of methods) { if (method.nodeId !== primaryNodeId) { results.push({ @@ -808,7 +808,7 @@ export const processCalls = async ( const importedReturnTypes = importedReturnTypesMap?.get(file.path); const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); const typeEnv = buildTypeEnv(tree, language, { - symbolTable: ctx.symbols, + model: ctx.model, parentMap, importedBindings, importedReturnTypes, @@ -817,7 +817,7 @@ export const processCalls = async ( extractFunctionName: provider?.methodExtractor?.extractFunctionName, }); if (typeEnv && exportedTypeMap) { - const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); + const fileExports = collectExportedBindings(typeEnv, file.path, ctx.model.symbols, graph); if (fileExports) exportedTypeMap.set(file.path, fileExports); } if (bindingAccumulator) { @@ -1021,7 +1021,7 @@ export const processCalls = async ( description: item.accessorType, }, }); - ctx.symbols.add(file.path, item.propName, nodeId, 'Property', { + ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', { ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), ...(item.declaredType ? { declaredType: item.declaredType } : {}), }); @@ -1093,8 +1093,8 @@ export const processCalls = async ( if ( isSubclassOf(ctorType, receiverTypeName, parentMap) || isSubclassOf(ctorType, receiverTypeName, globalParentMap) || - (ctx.symbols.lookupClassByName(ctorType).length > 0 && - ctx.symbols.lookupClassByName(receiverTypeName).length > 0) + (ctx.model.types.lookupClassByName(ctorType).length > 0 && + ctx.model.types.lookupClassByName(receiverTypeName).length > 0) ) { receiverTypeName = ctorType; } @@ -1299,7 +1299,7 @@ export const processCalls = async ( return collectedHeritage; }; -// CALLABLE_TYPES imported from symbol-table.ts — single source of truth. +// FREE_CALLABLE_TYPES imported from symbol-table.ts — single source of truth. const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); @@ -1320,10 +1320,14 @@ const filterCallableCandidates = ( } else { const types = candidates.filter((c) => CONSTRUCTOR_TARGET_TYPES.has(c.type)); kindFiltered = - types.length > 0 ? types : candidates.filter((c) => CALLABLE_TYPES.has(c.type)); + types.length > 0 ? types : candidates.filter((c) => CALL_TARGET_TYPES.has(c.type)); } } else { - kindFiltered = candidates.filter((c) => CALLABLE_TYPES.has(c.type)); + // CALL_TARGET_TYPES (not FREE_CALLABLE_TYPES) — the post-A4 filter must + // also admit Method and Constructor candidates, which are now unioned + // into the pool from `model.methods.lookupMethodByName` rather than + // `symbols.lookupCallableByName`. + kindFiltered = candidates.filter((c) => CALL_TARGET_TYPES.has(c.type)); } if (kindFiltered.length === 0) return []; @@ -1360,7 +1364,7 @@ const countCallableCandidates = ( const typeOk = callForm === 'constructor' ? CONSTRUCTOR_TARGET_TYPES.has(c.type) - : CALLABLE_TYPES.has(c.type); + : CALL_TARGET_TYPES.has(c.type); if (!typeOk) continue; // Arity filter if ( @@ -1573,11 +1577,27 @@ const resolveModuleAliasedCall = ( ); } if (filtered.length === 0) { - // Widen to global callable index scoped to the aliased module file. + // Widen to global callable+method indexes scoped to the aliased module + // file. Function+ownerId (Python/Rust/Kotlin) is still routed to both + // indexes until Unit 5 unblocks, so dedup by nodeId. const cacheKey = `${call.calledName}\0${moduleFile}`; let defs = widenCache?.get(cacheKey); if (!defs) { - defs = ctx.symbols.lookupCallableByName(call.calledName); + const rawCallable = ctx.model.symbols.lookupCallableByName(call.calledName); + const rawMethods = ctx.model.methods.lookupMethodByName(call.calledName); + const widenCombined: SymbolDefinition[] = []; + const widenSeen = new Set(); + for (const d of rawCallable) { + if (widenSeen.has(d.nodeId)) continue; + widenSeen.add(d.nodeId); + widenCombined.push(d); + } + for (const d of rawMethods) { + if (widenSeen.has(d.nodeId)) continue; + widenSeen.add(d.nodeId); + widenCombined.push(d); + } + defs = widenCombined; widenCache?.set(cacheKey, defs); } filtered = filterCallableCandidates(defs, call.argCount, call.callForm).filter( @@ -1618,11 +1638,26 @@ const resolveMemberCallByFile = ( const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId)); const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); - const methodPool = filterCallableCandidates( - ctx.symbols.lookupCallableByName(calledName), - argCount, - callForm, - ); + // A4 (plan 006, Unit 4): consult both indexes. Strictly-labeled + // Method/Constructor are disjoint, but Function+ownerId (Python/Rust/ + // Kotlin) is routed into BOTH indexes by `wrappedAdd` until Unit 5 + // unblocks — dedup by nodeId so overload disambiguation doesn't see + // phantom duplicates. + const rawCallablePool = ctx.model.symbols.lookupCallableByName(calledName); + const rawMethodPool = ctx.model.methods.lookupMethodByName(calledName); + const combinedPool: SymbolDefinition[] = []; + const combinedSeen = new Set(); + for (const def of rawCallablePool) { + if (combinedSeen.has(def.nodeId)) continue; + combinedSeen.add(def.nodeId); + combinedPool.push(def); + } + for (const def of rawMethodPool) { + if (combinedSeen.has(def.nodeId)) continue; + combinedSeen.add(def.nodeId); + combinedPool.push(def); + } + const methodPool = filterCallableCandidates(combinedPool, argCount, callForm); const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath)); if (fileFiltered.length === 1) { return toResolveResult(fileFiltered[0], typeResolved.tier); @@ -1951,7 +1986,7 @@ const resolveFieldOwnership = ( const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type)); if (!classDef) return undefined; - return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined; + return ctx.model.fields.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined; }; /** @@ -1987,10 +2022,12 @@ const resolveMethodByOwner = ( const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; - // MRO walking needs a language hint; compute once and reuse for every candidate. - // Unknown extension → fall back to plain direct lookup (D1-D4 still runs on miss). + // MRO walking needs a language hint so we can derive the per-language + // strategy; compute it once and reuse for every candidate. Unknown + // extension → fall back to plain direct lookup (D1-D4 still runs on miss). const language = heritageMap ? getLanguageFromFilename(filePath) : null; - const canWalkMRO = heritageMap != null && language != null; + const mroStrategy = language != null ? getProvider(language).mroStrategy : null; + const canWalkMRO = heritageMap != null && mroStrategy != null; // Iterate all class-like candidates tracking the first unambiguous hit. // Zero-allocation fast path: the common case is exactly one class candidate, @@ -2014,11 +2051,11 @@ const resolveMethodByOwner = ( candidate.nodeId, methodName, heritageMap, - ctx.symbols, - language, + ctx.model, + mroStrategy, argCount, ) - : ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName, argCount); + : ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount); if (!def) continue; if (!firstDef) { firstDef = def; @@ -2212,8 +2249,8 @@ export const resolveFreeCall = ( * Resolve a constructor or static call using class-scoped lookup (no fuzzy lookup). * Used for `new User()` / `User()` calls where the calledName targets a class. * - * Uses {@link SymbolTable.lookupClassByName} for O(1) class lookup and - * {@link SymbolTable.lookupMethodByOwner} for constructor resolution. + * Uses {@link TypeRegistry.lookupClassByName} for O(1) class lookup and + * {@link MethodRegistry.lookupMethodByOwner} for constructor resolution. * {@link resolveCallTarget} delegates here for constructor and free-form calls * that target a class. * @@ -2265,7 +2302,7 @@ export const resolveStaticCall = ( // is supplied, the caller has already paid for the tiered lookup, so this // pre-check still prevents the class-candidate filter + lookupMethodByOwner // loop from running on obviously non-class targets. - const allClasses = ctx.symbols.lookupClassByName(className); + const allClasses = ctx.model.types.lookupClassByName(className); if (allClasses.length === 0) return null; // 2. Scope via ctx.resolve for import-tier information. Reuse the caller's @@ -2296,7 +2333,7 @@ export const resolveStaticCall = ( let firstDef: SymbolDefinition | undefined; let ambiguous = false; for (const candidate of classCandidates) { - const def = ctx.symbols.lookupMethodByOwner(candidate.nodeId, className, argCount); + const def = ctx.model.methods.lookupMethodByOwner(candidate.nodeId, className, argCount); if (!def || def.type !== 'Constructor') continue; if (!firstDef) { firstDef = def; @@ -2372,138 +2409,6 @@ export const resolveStaticCall = ( return null; }; -// --------------------------------------------------------------------------- -// MRO-aware method resolution via HeritageMap (SM-9) -// --------------------------------------------------------------------------- - -/** - * Per-HeritageMap cache of C3 linearization results keyed by owner nodeId. - * - * HeritageMap instances are immutable after construction, so C3 output is - * stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain - * when the HeritageMap is garbage collected (end of ingestion run), so we - * never need to manually invalidate it. - * - * `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent - * hierarchy) so we don't re-run the expensive linearization repeatedly. - */ -const c3LinearizationCache = new WeakMap>(); - -const getCachedC3Linearization = ( - ownerNodeId: string, - heritageMap: HeritageMap, -): readonly string[] | null => { - let perHmCache = c3LinearizationCache.get(heritageMap); - if (!perHmCache) { - perHmCache = new Map(); - c3LinearizationCache.set(heritageMap, perHmCache); - } - const cached = perHmCache.get(ownerNodeId); - if (cached !== undefined) return cached; - const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap); - const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null; - perHmCache.set(ownerNodeId, result); - return result; -}; - -/** - * Build a parentMap from HeritageMap for use with c3Linearize. - * Traverses the parent chain starting from startNodeId, collecting all - * parent→children relationships into a Map. - */ -const buildParentMapFromHeritage = ( - startNodeId: string, - heritageMap: HeritageMap, -): Map => { - const parentMap = new Map(); - const visited = new Set(); - const queue = [startNodeId]; - - while (queue.length > 0) { - const nodeId = queue.shift()!; - if (visited.has(nodeId)) continue; - visited.add(nodeId); - const parents = heritageMap.getParents(nodeId); - if (parents.length > 0) { - parentMap.set(nodeId, parents); - for (const p of parents) { - if (!visited.has(p)) queue.push(p); - } - } - } - - return parentMap; -}; - -/** - * Look up a method on an owner class, walking the parent chain via HeritageMap - * when the method isn't found on the direct owner. - * - * Respects the 5 per-language MRO strategies: - * - `first-wins`: BFS ancestor walk, first match wins (default) - * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); - * HeritageMap preserves insertion order matching source declaration, - * so BFS order is equivalent to leftmost-base semantics - * - `c3`: C3-linearized ancestor order, first match wins (Python) - * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — - * full ambiguity detection for multiple interface defaults - * is handled by computeMRO at graph level - * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined - * - * Delegates to mro-processor.ts c3Linearize for C3 strategy. - * - * @internal Exported only to enable unit testing in isolation. The proper - * entry point for callers outside this module is {@link resolveMethodByOwner}, - * which handles receiver-type resolution before delegating here. - */ -export const lookupMethodByOwnerWithMRO = ( - ownerNodeId: string, - methodName: string, - heritageMap: HeritageMap, - symbols: SymbolTable, - language: SupportedLanguages, - argCount?: number, -): SymbolDefinition | undefined => { - // Direct lookup first (child override — no walk needed). - // argCount is threaded through so arity-differing overloads on the direct - // owner can be disambiguated before the MRO walk starts. - const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName, argCount); - if (direct) return direct; - - const strategy = getProvider(language).mroStrategy; - - // Rust: requires qualified syntax (::method), no auto-resolution - if (strategy === 'qualified-syntax') return undefined; - - // Determine ancestor walk order based on MRO strategy. - // readonly to accept the cached (frozen) c3 linearization without copying. - let ancestors: readonly string[]; - if (strategy === 'c3') { - // Delegate to mro-processor.ts C3 linearization (memoized per HeritageMap - // so repeated calls for the same owner within an ingestion run reuse the - // linearization instead of rebuilding the parent map and re-running C3). - // c3Linearize returns ancestors only (excludes the owner itself), - // matching heritageMap.getAncestors() semantics. - const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap); - // Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy). - // Note: BFS order may not preserve Python MRO semantics in these edge - // cases, but cyclic/inconsistent hierarchies are invalid in Python anyway. - ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId); - } else { - // first-wins, leftmost-base, implements-split: BFS order via HeritageMap - ancestors = heritageMap.getAncestors(ownerNodeId); - } - - // Walk ancestors in MRO order — first match wins. - // argCount narrows overloaded ancestors the same way as the direct lookup. - for (const ancestorId of ancestors) { - const method = symbols.lookupMethodByOwner(ancestorId, methodName, argCount); - if (method) return method; - } - - return undefined; -}; - /** * Create a deduplicated ACCESSES edge emitter for a single source node. * Each (sourceId, fieldNodeId) pair is emitted at most once per source. diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 5e89d34bb5..8dd1a9f6c1 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -1,7 +1,7 @@ // gitnexus/src/core/ingestion/field-types.ts import type { TypeEnvironment } from './type-env.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SymbolTableReader } from './model/symbol-table.js'; import { SupportedLanguages } from 'gitnexus-shared'; /** @@ -57,7 +57,7 @@ export interface FieldExtractorContext { /** Type environment for resolution */ typeEnv: TypeEnvironment; /** Symbol table for FQN lookups */ - symbolTable: SymbolTable; + symbolTable: SymbolTableReader; /** Current file path */ filePath: string; /** Language ID */ diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 37c3653a83..bc8628fa51 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -19,47 +19,34 @@ import { ASTCache } from './ast-cache.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { generateId } from '../../lib/utils.js'; -import { getLanguageFromFilename } from 'gitnexus-shared'; +import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; -import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from './languages/index.js'; import { getTreeSitterBufferSize } from './constants.js'; -import type { ExtractedHeritage } from './workers/parse-worker.js'; -import type { ResolutionContext } from './resolution-context.js'; -import { TIER_CONFIDENCE } from './resolution-context.js'; +import type { + ExtractedHeritage, + HeritageResolutionStrategy, + HeritageStrategyLookup, +} from './model/heritage-map.js'; +import { resolveExtendsType } from './model/heritage-map.js'; +import type { ResolutionContext } from './model/resolution-context.js'; +import { TIER_CONFIDENCE } from './model/resolution-context.js'; /** - * Determine whether a heritage.extends capture is actually an IMPLEMENTS relationship. - * Uses the symbol table first (authoritative — Tier 1); falls back to provider-defined - * heuristics for external symbols not present in the graph: - * - interfaceNamePattern: matched against parent name (e.g., /^I[A-Z]/ for C#/Java) - * - heritageDefaultEdge: 'IMPLEMENTS' causes all unresolved parents to map to IMPLEMENTS - * - All others: default EXTENDS + * Derive the heritage-resolution strategy for a language from its + * `LanguageProvider`. This is the production wiring that `buildHeritageMap` + * and the standalone `resolveExtendsType` call site use — the model layer + * itself stays unaware of the provider registry. */ -/** Exported for implementor-map construction (C#/Java: `extends` rows in base_list may be interfaces). */ -export const resolveExtendsType = ( - parentName: string, - currentFilePath: string, - ctx: ResolutionContext, - language: SupportedLanguages, -): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => { - const resolved = ctx.resolve(parentName, currentFilePath); - if (resolved && resolved.candidates.length > 0) { - const isInterface = resolved.candidates[0].type === 'Interface'; - return isInterface - ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } - : { type: 'EXTENDS', idPrefix: 'Class' }; - } - // Unresolved symbol — fall back to provider-defined heuristics - const provider = getProvider(language); - if (provider.interfaceNamePattern?.test(parentName)) { - return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; - } - if (provider.heritageDefaultEdge === 'IMPLEMENTS') { - return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; - } - return { type: 'EXTENDS', idPrefix: 'Class' }; +export const getHeritageStrategyForLanguage: HeritageStrategyLookup = ( + lang: SupportedLanguages, +): HeritageResolutionStrategy => { + const provider = getProvider(lang); + return { + interfaceNamePattern: provider.interfaceNamePattern, + defaultEdge: provider.heritageDefaultEdge ?? 'EXTENDS', + }; }; /** @@ -180,7 +167,7 @@ export const processHeritage = async ( parentClassName, file.path, ctx, - language, + getHeritageStrategyForLanguage(language), ); const child = resolveHeritageId( @@ -296,7 +283,7 @@ export const processHeritageFromExtracted = async ( h.parentName, h.filePath, ctx, - fileLanguage, + getHeritageStrategyForLanguage(fileLanguage), ); const child = resolveHeritageId( diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 3c52fa7a6a..3dff470942 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -12,7 +12,11 @@ import type { ExtractedImport } from './workers/parse-worker.js'; import { getTreeSitterBufferSize } from './constants.js'; import { loadImportConfigs } from './language-config.js'; import { buildSuffixIndex } from './import-resolvers/utils.js'; -import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js'; +import type { + ResolutionContext, + ModuleAliasMap, + NamedImportMap, +} from './model/resolution-context.js'; import type { ImportResult, ResolveCtx, @@ -61,30 +65,6 @@ function wireImplicitImports( // Avoids expanding every Go package import into N individual ImportMap edges. export type PackageMap = Map>; -// Type: Map> -// Tracks which specific names a file imports from which sources (TS/Python only). -// Used to tighten Tier 2a resolution: `import { User } from './models'` -// means only `User` (not `Repo`) is visible from models.ts via this import. -// Stores both the resolved source path and the original exported name so that -// aliased imports (`import { User as U }`) can resolve U → User in the source file. -export interface NamedImportBinding { - sourcePath: string; - exportedName: string; -} -export type NamedImportMap = Map>; - -/** - * Check if a file path is directly inside a package directory identified by its suffix. - * Used by the symbol resolver for Go and C# directory-level import matching. - */ -export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean { - // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" - const normalized = '/' + filePath.replace(/\\/g, '/'); - if (!normalized.includes(dirSuffix)) return false; - const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length); - return !afterDir.includes('/'); -} - // ImportResolutionContext is defined in ./import-resolvers/types.ts — re-exported here for consumers. export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext { diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 38997cb0da..141ba59afa 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -9,7 +9,7 @@ * so adding a language to the enum without creating a provider is a compiler error. */ -import type { SupportedLanguages } from 'gitnexus-shared'; +import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; import type { ClassExtractor } from './class-types.js'; @@ -26,13 +26,10 @@ import type { NodeLabel } from 'gitnexus-shared'; export type CaptureMap = Record; // ── Strategy tag types ───────────────────────────────────────────────────── -/** MRO strategy for multiple inheritance resolution. */ -export type MroStrategy = - | 'first-wins' - | 'c3' - | 'leftmost-base' - | 'implements-split' - | 'qualified-syntax'; +// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above +// so `core/ingestion/model/resolve.ts` can consume it without importing from +// this file (which would pull in the full language-registry dependency graph). + /** How a language handles imports — determines wildcard synthesis behavior. */ export type ImportSemantics = 'named' | 'wildcard' | 'namespace'; diff --git a/gitnexus/src/core/ingestion/model/field-registry.ts b/gitnexus/src/core/ingestion/model/field-registry.ts new file mode 100644 index 0000000000..45fe5c86a6 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/field-registry.ts @@ -0,0 +1,53 @@ +/** + * Field Registry + * + * Owner-scoped field/property index extracted from SymbolTable. + * Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface FieldRegistry { + /** Look up a field/property by its owning class nodeId and field name. */ + lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableFieldRegistry extends FieldRegistry { + /** Register a field/property under its owner. */ + register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createFieldRegistry = (): MutableFieldRegistry => { + const fieldByOwner = new Map(); + + const lookupFieldByOwner = ( + ownerNodeId: string, + fieldName: string, + ): SymbolDefinition | undefined => { + return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); + }; + + const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => { + fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def); + }; + + const clear = (): void => { + fieldByOwner.clear(); + }; + + return { lookupFieldByOwner, register, clear }; +}; diff --git a/gitnexus/src/core/ingestion/heritage-map.ts b/gitnexus/src/core/ingestion/model/heritage-map.ts similarity index 60% rename from gitnexus/src/core/ingestion/heritage-map.ts rename to gitnexus/src/core/ingestion/model/heritage-map.ts index 46d0c2120d..16ecf33051 100644 --- a/gitnexus/src/core/ingestion/heritage-map.ts +++ b/gitnexus/src/core/ingestion/model/heritage-map.ts @@ -7,16 +7,78 @@ * resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge * queries. * - * Combines two previously separate concerns: + * Combines two concerns: * 1. **Parent/ancestor lookup** (MRO-aware method resolution) * 2. **Implementor lookup** (interface dispatch — which files contain * classes implementing a given interface) */ -import type { ExtractedHeritage } from './workers/parse-worker.js'; import type { ResolutionContext } from './resolution-context.js'; -import { getLanguageFromFilename } from 'gitnexus-shared'; -import { resolveExtendsType } from './heritage-processor.js'; +import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; + +// --------------------------------------------------------------------------- +// ExtractedHeritage — the shape produced by the parse worker / heritage +// extractor. Defined here so `model/` has no upward imports; consumers +// import this type from the model module. +// --------------------------------------------------------------------------- + +export interface ExtractedHeritage { + filePath: string; + className: string; + parentName: string; + /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ + kind: string; +} + +// --------------------------------------------------------------------------- +// Heritage resolution strategy (the per-language knobs that drive +// `resolveExtendsType` below). Pulled out as an explicit strategy object so +// the model layer depends on a plain data shape rather than on the language +// provider registry. +// --------------------------------------------------------------------------- + +export interface HeritageResolutionStrategy { + /** If set and the parent name matches, force IMPLEMENTS even when the + * symbol is unresolved (e.g. `/^I[A-Z]/` for C# / Java). */ + readonly interfaceNamePattern?: RegExp; + /** Fallback edge for unresolved parents when the name pattern doesn't + * match (Swift uses 'IMPLEMENTS' for protocol conformance). */ + readonly defaultEdge: 'EXTENDS' | 'IMPLEMENTS'; +} + +/** Callback used by `buildHeritageMap` to look up the resolution strategy + * for a given language. Injected by callers so the model module doesn't + * depend on `../languages/index.js`. */ +export type HeritageStrategyLookup = (lang: SupportedLanguages) => HeritageResolutionStrategy; + +/** + * Determine whether a heritage.extends capture is actually an IMPLEMENTS + * relationship. Consults the symbol table first (authoritative — Tier 1 / + * Tier 2 resolution); falls back to the injected {@link HeritageResolutionStrategy} + * heuristics for external symbols not present in the graph. + */ +export const resolveExtendsType = ( + parentName: string, + currentFilePath: string, + ctx: ResolutionContext, + strategy: HeritageResolutionStrategy, +): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => { + const resolved = ctx.resolve(parentName, currentFilePath); + if (resolved && resolved.candidates.length > 0) { + const isInterface = resolved.candidates[0].type === 'Interface'; + return isInterface + ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } + : { type: 'EXTENDS', idPrefix: 'Class' }; + } + // Unresolved symbol — fall back to strategy heuristics. + if (strategy.interfaceNamePattern?.test(parentName)) { + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + if (strategy.defaultEdge === 'IMPLEMENTS') { + return { type: 'IMPLEMENTS', idPrefix: 'Interface' }; + } + return { type: 'EXTENDS', idPrefix: 'Class' }; +}; // --------------------------------------------------------------------------- // Public types @@ -41,6 +103,12 @@ export interface HeritageMap { /** Shared empty set returned when no implementors are found. */ const EMPTY_SET: ReadonlySet = new Set(); +/** Default strategy used when `buildHeritageMap` is called without an + * explicit `getHeritageStrategy` callback — the fallback for a language + * whose provider sets no interface-name pattern and no non-default + * `heritageDefaultEdge`. */ +const DEFAULT_HERITAGE_STRATEGY: HeritageResolutionStrategy = { defaultEdge: 'EXTENDS' }; + // --------------------------------------------------------------------------- // Builder // --------------------------------------------------------------------------- @@ -49,18 +117,18 @@ const EMPTY_SET: ReadonlySet = new Set(); * Build a HeritageMap from accumulated ExtractedHeritage records. * * Resolves class/interface/struct/trait names to nodeIds via - * `ctx.symbols.lookupClassByName`. When a name resolves to multiple + * `ctx.model.types.lookupClassByName`. When a name resolves to multiple * candidates, all are recorded (partial-class / cross-file scenario). * Unresolvable names are silently skipped — a missing parent is better * than a wrong edge. * * Also builds the implementor index (interface name → implementing file - * paths) that was previously maintained by `buildImplementorMap` in - * call-processor.ts. + * paths) used by interface-dispatch in call resolution. */ export const buildHeritageMap = ( heritage: readonly ExtractedHeritage[], ctx: ResolutionContext, + getHeritageStrategy?: HeritageStrategyLookup, ): HeritageMap => { // childNodeId → Set (Set to deduplicate cross-chunk duplicates) const directParents = new Map>(); @@ -70,8 +138,8 @@ export const buildHeritageMap = ( for (const h of heritage) { // ── Parent lookup (nodeId-based) ──────────────────────────────── - const childDefs = ctx.symbols.lookupClassByName(h.className); - const parentDefs = ctx.symbols.lookupClassByName(h.parentName); + const childDefs = ctx.model.types.lookupClassByName(h.className); + const parentDefs = ctx.model.types.lookupClassByName(h.parentName); if (childDefs.length > 0 && parentDefs.length > 0) { for (const child of childDefs) { @@ -99,16 +167,15 @@ export const buildHeritageMap = ( // // Known limitation: `getImplementorFiles` is keyed by interface **name** // (string), so two interfaces with the same unqualified name in different - // packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. This - // matches the behavior of the prior standalone `ImplementorMap` and is - // not a regression introduced by this consolidation. + // packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. let isImpl = false; if (h.kind === 'implements') { isImpl = true; } else if (h.kind === 'extends') { const lang = getLanguageFromFilename(h.filePath); if (lang) { - const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang); + const strategy = getHeritageStrategy?.(lang) ?? DEFAULT_HERITAGE_STRATEGY; + const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, strategy); isImpl = type === 'IMPLEMENTS'; } } diff --git a/gitnexus/src/core/ingestion/model/index.ts b/gitnexus/src/core/ingestion/model/index.ts new file mode 100644 index 0000000000..27f939171e --- /dev/null +++ b/gitnexus/src/core/ingestion/model/index.ts @@ -0,0 +1,88 @@ +/** + * Semantic Model — public module surface. + * + * Barrel re-export for the `model/` module. Consumers outside `model/` + * should import from this file rather than reaching into individual + * registry files. + * + * The model is owner-scoped type/method/field knowledge layered above + * `SymbolTable`. File-indexed and name-keyed callable lookups stay in + * `SymbolTable` by design. + */ + +// Unified semantic model (factory + interfaces). SemanticModel is the +// top-level container and owns the file/callable SymbolTable as a +// nested `symbols` field. +export { + type SemanticModel, + type MutableSemanticModel, + createSemanticModel, +} from './semantic-model.js'; + +// SymbolTable is exclusively owned by SemanticModel. Re-exported here +// for the rare caller that needs the file/callable interface in +// isolation (e.g. tests). +export { + type SymbolTableReader, + type SymbolTableWriter, + createSymbolTable, +} from './symbol-table.js'; + +// Type registry (classes, structs, interfaces, enums, records, impls) +export { + type TypeRegistry, + type MutableTypeRegistry, + createTypeRegistry, +} from './type-registry.js'; + +// Method registry (owner-scoped methods with arity-aware overload lookup) +export { + type MethodRegistry, + type MutableMethodRegistry, + createMethodRegistry, +} from './method-registry.js'; + +// Field registry (owner-scoped fields/properties) +export { + type FieldRegistry, + type MutableFieldRegistry, + createFieldRegistry, +} from './field-registry.js'; + +// MRO-aware method resolution (C3, first-wins, leftmost-base, implements-split, +// qualified-syntax). Pure function that depends only on the model + HeritageMap. +// `MroStrategy` itself lives in `gitnexus-shared`; re-exported here for +// consumers that reach model behavior through the barrel. +export { lookupMethodByOwnerWithMRO } from './resolve.js'; + +// Named-import types and package-dir helper. Re-exported so barrel +// consumers don't need to reach into a specific model file. +export { + type NamedImportBinding, + type NamedImportMap, + isFileInPackageDir, +} from './resolution-context.js'; + +// Heritage types. `buildHeritageMap` + `resolveExtendsType` are exported +// directly from `heritage-map.ts` and are not re-surfaced here to keep +// the barrel narrow. +export { + type ExtractedHeritage, + type HeritageResolutionStrategy, + type HeritageStrategyLookup, +} from './heritage-map.js'; + +// Behavior-grouped dispatch table for SymbolTable.add() routing. +// See registration-table.ts module JSDoc for the behavior group taxonomy +// and "how to add a new NodeLabel" checklist. +// NOTE: createRegistrationTable, RegistrationHook, and RegistrationTableDeps +// are deliberately NOT re-exported here — they are factory internals of +// SemanticModel and should only be imported directly from registration-table.js +// by semantic-model.ts and the registration-table.test.ts file. +export { + CALLABLE_ONLY_LABELS, + INERT_LABELS, + DISPATCH_LABELS, + ALL_NODE_LABELS, + type LabelBehavior, +} from './registration-table.js'; diff --git a/gitnexus/src/core/ingestion/model/method-registry.ts b/gitnexus/src/core/ingestion/model/method-registry.ts new file mode 100644 index 0000000000..be28e5782a --- /dev/null +++ b/gitnexus/src/core/ingestion/model/method-registry.ts @@ -0,0 +1,204 @@ +/** + * Method Registry + * + * Owner-scoped method index extracted from SymbolTable. + * Stores Method/Constructor/Function-with-ownerId symbols keyed by + * `ownerNodeId\0methodName` for O(1) lookup. Supports overloads + * (array values) and arity-based filtering. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface MethodRegistry { + /** + * Look up a method by owner class + name, optionally filtered by arity. + * + * When `argCount` is provided, overloads whose parameter count doesn't + * accommodate the call's argument count are filtered out before the + * returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate + * arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that + * would otherwise collide on the shared `ownerId + methodName` key. + * + * Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`, + * both returning `void`) still collapse to the first match — callers must + * gate D0 on overload concern before invoking this function for that case. + */ + lookupMethodByOwner( + ownerNodeId: string, + methodName: string, + argCount?: number, + ): SymbolDefinition | undefined; + + /** + * Flat-by-name lookup across all owners. Returns every method registered + * with the given unqualified name, in registration order, accumulated + * across owners and overloads. + * + * Required by Tier 3 global resolution: Method and Constructor do not + * land in `SymbolTable.callableByName`, so Tier 3 reaches them through + * this flat index instead. Returns `[]` on miss — never `undefined` — + * so callers can concatenate without null checks. + * + * Reference identity: each returned def is the same object reference + * stored under `lookupMethodByOwner`, so a method symbol occupies one + * allocation reachable from two indexes. + */ + lookupMethodByName(name: 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 + * `Function + ownerId` rather than as a strict `Method` label. Such defs + * are double-indexed: they land in `SymbolTable.callableByName` (via the + * Function callable-index gate) AND in this registry (via the + * dispatch-key normalization in `wrappedAdd`). Tier 3 resolution must + * then dedup the two indexes by nodeId. + * + * When this flag is false, the callable and method indexes are + * guaranteed disjoint and Tier 3 can skip the dedup pass entirely. + * The flag is monotonic (false→true once, never back) for the lifetime + * of the MethodRegistry. + */ + readonly hasFunctionMethods: boolean; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableMethodRegistry extends MethodRegistry { + /** Register a method under its owner. Supports multiple overloads. */ + register(ownerNodeId: string, methodName: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createMethodRegistry = (): MutableMethodRegistry => { + const methodByOwner = new Map(); + // Secondary flat-by-name index. Values are the SAME SymbolDefinition + // references stored under `methodByOwner` — no copy, just a second key. + // Populated in lockstep by `register()` and emptied by `clear()`. + const methodsByName = new Map(); + const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + // Set once when a Function+ownerId def lands here, powers the Tier 3 + // dedup fast-path. Monotonic: never unset except on `clear()`. + let hasFunctionMethodsFlag = false; + + const lookupMethodByOwner = ( + ownerNodeId: string, + methodName: string, + argCount?: number, + ): SymbolDefinition | undefined => { + const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`); + if (!defs || defs.length === 0) return undefined; + + // Arity narrowing: when an argCount is provided and there are multiple + // overloads, keep only those whose parameterCount can accommodate the + // call. This resolves arity-differing overloads (e.g. C++ `greet()` vs + // `greet(string)`) that share the same `ownerId + methodName` key. + // + // Candidates with `parameterCount === undefined` (extractor didn't + // populate the count — typically variadic or unknown) are retained + // conservatively so that legitimate variadic matches still resolve. + // + // Streaming loop avoids allocating a filtered array on the common + // "arity selects 0 or 1 match" path. We scan once, count arity + // matches, and only materialize a narrowed array if at least one + // match was found and at least one non-match exists. If arity rules + // out every candidate, fall back to the unfiltered set so the + // caller's fuzzy path still has something to work with. + let pool: readonly SymbolDefinition[] = defs; + if (argCount !== undefined && defs.length > 1) { + let matchedCount = 0; + let rejectedCount = 0; + for (const d of defs) { + if (d.parameterCount === undefined) { + matchedCount++; + continue; + } + const min = d.requiredParameterCount ?? d.parameterCount; + if (argCount >= min && argCount <= d.parameterCount) matchedCount++; + else rejectedCount++; + } + // Only narrow when the filter actually discriminates: at least one + // match AND at least one rejection. Pure-match and pure-reject + // paths both keep the unfiltered pool (the latter because fallback + // semantics demand it). + if (matchedCount > 0 && rejectedCount > 0) { + const arityMatched: SymbolDefinition[] = []; + for (const d of defs) { + if (d.parameterCount === undefined) { + arityMatched.push(d); + continue; + } + const min = d.requiredParameterCount ?? d.parameterCount; + if (argCount >= min && argCount <= d.parameterCount) arityMatched.push(d); + } + pool = arityMatched; + } + } + + if (pool.length === 1) return pool[0]; + // Multiple overloads after arity narrowing: return first if all share + // the same defined returnType (safe for chain resolution), undefined if + // return types differ (truly ambiguous — can't determine which overload). + const firstReturnType = pool[0].returnType; + if (firstReturnType === undefined) return undefined; + for (let i = 1; i < pool.length; i++) { + if (pool[i].returnType !== firstReturnType) return undefined; + } + return pool[0]; + }; + + const lookupMethodByName = (name: string): readonly SymbolDefinition[] => { + return methodsByName.get(name) ?? EMPTY; + }; + + const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => { + const key = `${ownerNodeId}\0${methodName}`; + const existing = methodByOwner.get(key); + if (existing) { + existing.push(def); + } else { + methodByOwner.set(key, [def]); + } + const byName = methodsByName.get(methodName); + if (byName) { + byName.push(def); + } else { + methodsByName.set(methodName, [def]); + } + // A `Function`-typed def reaching MethodRegistry means the worker + // emitted a Python/Rust/Kotlin class method as `Function + ownerId`. + // It was already written into `SymbolTable.callableByName` by the + // upstream Function callable-index gate, so the two indexes are no + // longer disjoint for this registry's lifetime — Tier 3 must dedup. + if (!hasFunctionMethodsFlag && def.type === 'Function') { + hasFunctionMethodsFlag = true; + } + }; + + const clear = (): void => { + methodByOwner.clear(); + methodsByName.clear(); + hasFunctionMethodsFlag = false; + }; + + return { + lookupMethodByOwner, + lookupMethodByName, + register, + clear, + get hasFunctionMethods() { + return hasFunctionMethodsFlag; + }, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/registration-table.ts b/gitnexus/src/core/ingestion/model/registration-table.ts new file mode 100644 index 0000000000..a5bb14f212 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/registration-table.ts @@ -0,0 +1,333 @@ +/** + * Registration Dispatch Table + * + * Behavior-grouped O(1) dispatch table for routing `SymbolTable.add()` + * registrations into the semantic registries. Replaces the cascading + * `if/else` ladder in `symbol-table.ts` with a `Map` + * whose entries point to closure-captured hooks. + * + * ## Ownership diagram + * + * SemanticModel + * ├── types (TypeRegistry) ← classLikeHook / implHook write here + * ├── methods (MethodRegistry) ← methodHook writes here + * ├── fields (FieldRegistry) ← propertyHook writes here + * └── symbols (SymbolTable) ← owns fileIndex + callableByName, + * calls dispatch() in add() + * + * ## Behavior groups (5 hooks, 13 table entries) + * + * | Group | NodeLabel values | Hook | Skip callable? | + * |---------------|---------------------------------------------------|--------------|----------------| + * | class-like | Class, Struct, Interface, Enum, Record, Trait | classLikeHook | no | + * | method-like | Method, Constructor | methodHook | no | + * | property | Property | propertyHook | YES | + * | impl-block | Impl | implHook | no | + * | callable-only | Function, Macro, Delegate | (no entry) | no | + * + * Every other `NodeLabel` is "inert" — reached by `fileIndex` only. No + * specialized registry, no callable index append. + * + * ## How to add a new NodeLabel + * + * 1. Add the variant to the `NodeLabel` union in `gitnexus-shared/src/graph/types.ts`. + * 2. Decide which behavior group it belongs to by asking "which lookups must + * return this symbol?" (not "what language feature is it?"). A new Swift + * `Extension` is class-like if you want owner-scoped method lookup on it; + * a new Kotlin `Object` is class-like for the same reason. + * 3. Either: + * - Add a table entry here pointing at one of the existing hooks, OR + * - Add it to `CALLABLE_ONLY_LABELS` if it is a free callable, OR + * - Add it to `INERT_LABELS` if it's metadata-only (File, Folder, Decorator, + * etc.) — never queried via owner/class lookups. + * 4. If none of the above fit — the new kind needs a brand-new registry — + * design the registry first in `model/`, then add a new hook closure + * and table entries. Update `DISPATCH_LABELS` / the exhaustiveness guard + * accordingly. + * + * The runtime exhaustiveness guard in `symbol-table.ts` will warn if a + * `NodeLabel` is missing from all three sets. + */ + +import type { NodeLabel } from 'gitnexus-shared'; +import type { SymbolDefinition, ClassLikeLabel, FreeCallableLabel } from './symbol-table.js'; +import { FREE_CALLABLE_TYPES } from './symbol-table.js'; +import type { MutableTypeRegistry } from './type-registry.js'; +import type { MutableMethodRegistry } from './method-registry.js'; +import type { MutableFieldRegistry } from './field-registry.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Registration hook — a pure side-effectful function closed over a + * specific registry. Performs the specialized registry write into the + * appropriate owner-scoped registry for one NodeLabel. + * + * Closure capture is the isolation mechanism: `propertyHook` literally + * cannot call `types.registerClass` because its closure does not hold + * a reference to `types`. This is the runtime half of the principle of + * least authority — the compile-time half is enforced by TypeScript. + * + * The callable-index gate lives inside `SymbolTable.add()` via the + * `FREE_CALLABLE_TYPES` allowlist — the dispatch table does not + * participate in that decision. + */ +export type RegistrationHook = (name: string, def: SymbolDefinition) => void; + +/** + * Dependencies required to build the dispatch table. Matches the shape + * that `createSemanticModel()` passes into `createRegistrationTable()`. + */ +export interface RegistrationTableDeps { + readonly types: MutableTypeRegistry; + readonly methods: MutableMethodRegistry; + readonly fields: MutableFieldRegistry; +} + +// --------------------------------------------------------------------------- +// Single source of truth: NodeLabel → behavior category +// --------------------------------------------------------------------------- + +/** + * Behavior category for a NodeLabel during ingestion. Determines which + * registry (if any) receives the symbol write during `SymbolTable.add()`: + * + * - `dispatch` — owner-scoped registry write via the dispatch table + * (Class/Struct/Interface/Enum/Record/Trait → types.registerClass, + * Method/Constructor → methods.register, + * Property → fields.register, + * Impl → types.registerImpl) + * - `callable-only` — no specialized registry; symbol appears in + * `callableByName` via `SymbolTable.add()`'s + * FREE_CALLABLE_TYPES gate (Function/Macro/Delegate) + * - `inert` — no registry, no callable index; file-index only + * (metadata / structural nodes like Project, Module, + * Import, Decorator, etc.) + * + * `Function` has a twist: `Function`-with-`ownerId` (Python `def` in a + * class body, Rust trait method, Kotlin companion method) is pre-normalized + * to `Method` in `createSemanticModel`'s `wrappedAdd` before dispatch lookup, + * so only free functions actually flow through the callable-only path. + */ +export type LabelBehavior = 'dispatch' | 'callable-only' | 'inert'; + +/** + * **Single source of truth** for NodeLabel classification. Every NodeLabel + * has exactly one behavior category — enforced at compile time by the + * `as const satisfies Record` combo: + * + * - **Completeness** — `Record` requires every + * NodeLabel to be a key. Missing a label fails to compile with + * "Property 'X' is missing in type ..." naming the drifted label. + * - **No extras** — `satisfies` performs excess-property checking on + * object literals, so a non-NodeLabel string key fails to compile. + * - **No duplicates** — object keys are unique by construction. A label + * cannot be classified into two categories by accident. + * - **Valid values** — `LabelBehavior` is a narrow union, so a typo in + * the category name fails to compile. + * + * Adding a new NodeLabel to `gitnexus-shared`: TypeScript will flag this + * file as incomplete. Add the new label with its behavior category and + * the three `*_LABELS` Sets + `ALL_NODE_LABELS` array below are derived + * automatically — no separate list to update, no runtime drift detection + * needed. + * + * NOTE: `Type` and `CodeElement` are inert wrappers for language features + * that don't yet have a dedicated registry (typedefs, synthesized dynamic + * calls). If future work needs owner-scoped lookup for them, change their + * category to `'dispatch'` and add a hook in `createRegistrationTable`. + * Do not special-case them inside `SymbolTable.add()`. + */ +const LABEL_BEHAVIOR = { + // dispatch — owner-scoped registry writes + Class: 'dispatch', + Struct: 'dispatch', + Interface: 'dispatch', + Enum: 'dispatch', + Record: 'dispatch', + Trait: 'dispatch', + Method: 'dispatch', + Constructor: 'dispatch', + Property: 'dispatch', + Impl: 'dispatch', + + // callable-only — file index + callableByName, no owner scope + Function: 'callable-only', + Macro: 'callable-only', + Delegate: 'callable-only', + + // inert — file index only + Project: 'inert', + Package: 'inert', + Module: 'inert', + Folder: 'inert', + File: 'inert', + Variable: 'inert', + Decorator: 'inert', + Import: 'inert', + Type: 'inert', + CodeElement: 'inert', + Community: 'inert', + Process: 'inert', + Typedef: 'inert', + Union: 'inert', + Namespace: 'inert', + TypeAlias: 'inert', + Const: 'inert', + Static: 'inert', + Annotation: 'inert', + Template: 'inert', + Section: 'inert', + Route: 'inert', + Tool: 'inert', +} as const satisfies Record & + // Cross-invariant 1 — every class-like label (participates in + // qualifiedName fallback in `SymbolTable.add()`) MUST be classified as + // 'dispatch'. Adding a label to `CLASS_TYPES_TUPLE` without classifying + // it as 'dispatch' fails with a type error naming the drifted label. + Record & + // Cross-invariant 2 — every free-callable label (gate in + // `SymbolTable.add()` via `FREE_CALLABLE_TYPES`) MUST be classified as + // 'callable-only'. Adding a label to `FREE_CALLABLE_TUPLE` without + // classifying it as 'callable-only' fails with a type error naming the + // drifted label. + Record; + +// --------------------------------------------------------------------------- +// Derived runtime collections — all keyed off LABEL_BEHAVIOR +// --------------------------------------------------------------------------- + +/** + * All known NodeLabels, derived from the keys of `LABEL_BEHAVIOR`. The + * `satisfies Record` bijection above proves + * that `Object.keys(LABEL_BEHAVIOR)` is exactly the NodeLabel set — + * the cast to `NodeLabel[]` is sound, not a type-system bypass. + * + * Consumers (e.g., the semantic-model barrel re-export for tests) can + * rely on this list being complete by construction. No runtime drift + * check is needed or possible — the type system is the proof. + */ +export const ALL_NODE_LABELS: readonly NodeLabel[] = Object.keys(LABEL_BEHAVIOR) as NodeLabel[]; + +const labelsWithBehavior = (behavior: LabelBehavior): NodeLabel[] => + ALL_NODE_LABELS.filter((label) => LABEL_BEHAVIOR[label] === behavior); + +/** + * NodeLabel values that are free callables — appear in `callableByName` + * but have no owner-scoped specialized registry. Alias of + * {@link FREE_CALLABLE_TYPES} exported here for taxonomy-test use. The + * compile-time cross-invariant on `LABEL_BEHAVIOR` above guarantees the + * alias and the LABEL_BEHAVIOR `callable-only` classification cannot + * drift. + */ +export const CALLABLE_ONLY_LABELS: ReadonlySet = FREE_CALLABLE_TYPES; + +/** + * NodeLabel values that touch only the file index — no specialized + * registry, no callable index. + */ +export const INERT_LABELS: ReadonlySet = new Set(labelsWithBehavior('inert')); + +/** + * NodeLabel values that have a dispatch table entry. `createRegistrationTable` + * below must provide a hook for exactly this set — the test file's behavior- + * group tests and the integration tests pin the hook↔label correspondence. + */ +export const DISPATCH_LABELS: ReadonlySet = new Set(labelsWithBehavior('dispatch')); + +/** + * Type-level extraction of every label classified as `'dispatch'` in + * {@link LABEL_BEHAVIOR}. Used by {@link createRegistrationTable} as the + * key set of its internal object literal, so the `satisfies + * Record` check fails at build time if + * a dispatch-classified label is missing a hook, or a hook is wired to + * a non-dispatch label. This closes the last compile-time gap between + * `LABEL_BEHAVIOR` and the dispatch table. + */ +type DispatchLabel = { + [K in NodeLabel]: (typeof LABEL_BEHAVIOR)[K] extends 'dispatch' ? K : never; +}[NodeLabel]; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Build the dispatch table. Must be called once per `createSymbolTable` + * invocation so each hook closes over that SymbolTable's injected + * registries. Reusing a single module-level instance would cause hooks + * to write into the wrong SemanticModel. + */ +export const createRegistrationTable = ( + deps: RegistrationTableDeps, +): Map => { + const { types, methods, fields } = deps; + + // Hook 1: class-like — Class, Struct, Interface, Enum, Record, Trait. + // Shared reference — six table entries point at this one closure. + const classLikeHook: RegistrationHook = (name, def) => { + const qualifiedKey = def.qualifiedName ?? name; + types.registerClass(name, qualifiedKey, def); + }; + + // Hook 2: method-like — Method, Constructor. Silently skipped if the + // caller did not provide an ownerId (Property without ownerId is + // treated the same way). + const methodHook: RegistrationHook = (name, def) => { + if (def.ownerId) { + methods.register(def.ownerId, name, def); + } + }; + + // Hook 3: property — Property. Silently skipped without ownerId. + // Property is not in `FREE_CALLABLE_TYPES`, so `SymbolTable.add()` already + // excludes it from `callableByName`; common property names like + // `id` / `name` / `type` never pollute the callable index. + const propertyHook: RegistrationHook = (name, def) => { + if (def.ownerId) { + fields.register(def.ownerId, name, def); + } + }; + + // Hook 4: impl-block — Rust `impl` blocks. Kept separate from classLikeHook + // because heritage resolution must not treat Impls as class candidates + // (an Impl is not a parent type, it's an ancillary dispatch table). + const implHook: RegistrationHook = (name, def) => { + types.registerImpl(name, def); + }; + + // Single source of truth for the label → hook mapping. The + // `satisfies Record` intersection + // fails at build time if (a) any label classified as 'dispatch' in + // `LABEL_BEHAVIOR` is missing here, or (b) any key here is not + // classified as 'dispatch'. This is the compile-time twin of the + // runtime taxonomy — no drift possible. + const dispatchByLabel = { + // class-like — six labels share the single `classLikeHook` closure, + // kept in lockstep with `CLASS_TYPES_TUPLE` via the + // `Record` cross-invariant on + // `LABEL_BEHAVIOR`. + Class: classLikeHook, + Struct: classLikeHook, + Interface: classLikeHook, + Enum: classLikeHook, + Record: classLikeHook, + Trait: classLikeHook, + // method-like — routed via dispatch-key normalization in + // `wrappedAdd` so Function+ownerId also reaches `methodHook`. + Method: methodHook, + Constructor: methodHook, + // property — callable-index exclusion is enforced by + // `SymbolTable.add()` (Property is not in `FREE_CALLABLE_TYPES`). + Property: propertyHook, + // impl-block — Rust `impl` blocks. Separate from classLikeHook because + // heritage resolution must not treat Impls as class candidates. + Impl: implHook, + } as const satisfies Record; + + return new Map( + Object.entries(dispatchByLabel) as [NodeLabel, RegistrationHook][], + ); +}; diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/model/resolution-context.ts similarity index 55% rename from gitnexus/src/core/ingestion/resolution-context.ts rename to gitnexus/src/core/ingestion/model/resolution-context.ts index 06b2c89beb..b56524b362 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/model/resolution-context.ts @@ -1,9 +1,7 @@ /** * Resolution Context * - * Single implementation of tiered name resolution. Replaces the duplicated - * tier-selection logic previously split between symbol-resolver.ts and - * call-processor.ts. + * Single implementation of tiered name resolution. * * Resolution tiers (highest confidence first): * 1. Same file (lookupExactAll — authoritative) @@ -20,11 +18,112 @@ * (three O(1) index lookups with a narrow, type-specific result set). */ -import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; -import { createSymbolTable } from './symbol-table.js'; -import type { NamedImportMap } from './import-processor.js'; -import { isFileInPackageDir } from './import-processor.js'; -import { walkBindingChain } from './named-binding-processor.js'; +import type { SymbolDefinition, SymbolTableReader } from './symbol-table.js'; +import type { MutableSemanticModel } from './semantic-model.js'; +import { createSemanticModel } from './semantic-model.js'; + +// --------------------------------------------------------------------------- +// Named-import types — describe how a file imports specific names from a +// source file. Consumed by the Tier 2a-named binding-chain walker below. +// --------------------------------------------------------------------------- + +/** + * A single named binding in a source file (e.g. `import { User as U }`). + * Stores both the resolved source path and the original exported name so + * that aliased imports can resolve U → User in the source file. + */ +export interface NamedImportBinding { + sourcePath: string; + exportedName: string; +} + +/** + * Map>. + * + * Tracks which specific names a file imports from which sources (TS / Python + * / Rust / Java-static / ...). Used to tighten Tier 2a resolution: + * `import { User } from './models'` means only `User` (not `Repo`) is + * visible from models.ts via this import. + */ +export type NamedImportMap = Map>; + +/** + * Check if a file path is directly inside a package directory identified by + * its suffix. Used by Tier 2b package-scoped resolution (Go / C#). + */ +export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean { + // Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/" + const normalized = '/' + filePath.replace(/\\/g, '/'); + if (!normalized.includes(dirSuffix)) return false; + const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length); + return !afterDir.includes('/'); +} + +/** Maximum re-export hops walkBindingChain will follow before giving up. + * A hard cap is needed to defend against pathological cycles that slip + * past the `visited` Set (e.g. a binding chain whose key is equal by + * string value but visits distinct modules). Five hops covers the + * common TypeScript monorepo pattern (component → pkg/index → + * packages/index → root/index → types/index). Chains longer than this + * fall through to Tier 2a-import / Tier 2b / Tier 3 resolution, which + * is a silent false-negative that the caller may or may not recover + * from. If a real repo hits this limit, raise it — there is no + * correctness reason to keep it at exactly 5. */ +const MAX_BINDING_CHAIN_DEPTH = 5; + +/** + * Walk a named-binding re-export chain through NamedImportMap. + * + * When file A imports { User } from B, and B re-exports { User } from C, + * the NamedImportMap for A points to B, but B has no User definition. + * This function follows the chain: A → B → C until a definition is found. + * + * Returns the definitions found at the end of the chain, or null if the + * chain breaks (missing binding, circular reference, or + * {@link MAX_BINDING_CHAIN_DEPTH} exceeded). Internal to + * resolution-context — not exported from the model barrel. + */ +function walkBindingChain( + name: string, + currentFilePath: string, + symbolTable: SymbolTableReader, + namedImportMap: NamedImportMap, +): readonly SymbolDefinition[] | null { + // Fast exit: most files have no named imports at all. Skip the Set + // allocation + loop entry on the common empty-binding path so resolve() + // stays allocation-free for the typical call site. + const firstBindings = namedImportMap.get(currentFilePath); + if (!firstBindings) return null; + const firstBinding = firstBindings.get(name); + if (!firstBinding) return null; + + let lookupFile = currentFilePath; + let lookupName = name; + const visited = new Set(); + + for (let depth = 0; depth < MAX_BINDING_CHAIN_DEPTH; depth++) { + const bindings = depth === 0 ? firstBindings : namedImportMap.get(lookupFile); + if (!bindings) return null; + + const binding = depth === 0 ? firstBinding : bindings.get(lookupName); + if (!binding) return null; + + const key = `${binding.sourcePath}:${binding.exportedName}`; + if (visited.has(key)) return null; // circular + visited.add(key); + + const targetName = binding.exportedName; + const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName); + + if (resolvedDefs.length > 0) return resolvedDefs; + + // No definition in source file → follow re-export chain + lookupFile = binding.sourcePath; + lookupName = targetName; + } + + return null; +} /** Resolution tier for tracking, logging, and test assertions. */ export type ResolutionTier = 'same-file' | 'import-scoped' | 'global'; @@ -59,8 +158,13 @@ export interface ResolutionContext { resolve(name: string, fromFile: string): TieredCandidates | null; // --- Data access (for pipeline wiring, not resolution) --- - /** Symbol table — used by parsing-processor to populate symbols. */ - readonly symbols: SymbolTable; + /** Semantic model — the top-level container for types, methods, fields, + * and the nested file/callable SymbolTable. Typed as + * {@link MutableSemanticModel} because `ResolutionContext` is the + * lifecycle owner — the pipeline registers symbols through it during + * the fan-out phase. Resolvers that only query should annotate their + * own fields as {@link SemanticModel} to drop write access. */ + readonly model: MutableSemanticModel; /** Raw maps — used by import-processor to populate import data. */ readonly importMap: ImportMap; readonly packageMap: PackageMap; @@ -86,7 +190,8 @@ export interface ResolutionContext { } export const createResolutionContext = (): ResolutionContext => { - const symbols = createSymbolTable(); + const model = createSemanticModel(); + const symbols = model.symbols; const importMap: ImportMap = new Map(); const packageMap: PackageMap = new Map(); const namedImportMap: NamedImportMap = new Map(); @@ -194,27 +299,75 @@ export const createResolutionContext = (): ResolutionContext => { // Tier 3: Global — targeted O(1) index lookups for each symbol category. // Class-like symbols (Class, Struct, Interface, Enum, Record, Trait) are // covered by lookupClassByName; Rust impl blocks by lookupImplByName - // (separate to avoid polluting heritage resolution); callables (Function, - // Method, Constructor, Macro, Delegate) by lookupCallableByName. - // The three indexes cover disjoint symbol types so no dedup is needed. - // Consumers must check candidates.length and refuse ambiguous matches. + // (separate to avoid polluting heritage resolution); free callables + // (Function, Macro, Delegate) by lookupCallableByName; owner-scoped + // methods and constructors by `model.methods.lookupMethodByName`. + // + // FREE_CALLABLE_TYPES excludes Method/Constructor, so strictly-labeled + // methods are disjoint between the two indexes. + // + // Partial-state caveat: Python/Rust/Kotlin class methods are emitted + // as Function + ownerId — `rawSymbols.add` routes them through both + // the Function callable index AND, via the dispatch-key normalization + // in `wrappedAdd`, the method registry. The same `SymbolDefinition` + // reference lands in both `callableDefs` and `methodDefs`, so the + // Set-based dedup below is required. // // Known exclusion: TypeAlias, Const, and Variable are NOT reachable at - // Tier 3 — they don't belong to any of the three indexes. In practice - // they were never useful as Tier 3 candidates: TypeAlias is not a call - // target, Const/Variable are resolved via import or same-file tiers. - // If a future language needs them at Tier 3, add a dedicated index. - // Macro (C/C++) and Delegate (C#) ARE included in the callable index + // Tier 3 — they don't belong to any of the indexes. TypeAlias is not + // a call target; Const/Variable are resolved via import or same-file + // tiers. Macro (C/C++) and Delegate (C#) stay in the callable index // since call-processor.ts treats them as callable targets. - const classDefs = symbols.lookupClassByName(name); - const implDefs = symbols.lookupImplByName(name); + const classDefs = model.types.lookupClassByName(name); + const implDefs = model.types.lookupImplByName(name); const callableDefs = symbols.lookupCallableByName(name); + const methodDefs = model.methods.lookupMethodByName(name); - if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) { + if ( + classDefs.length === 0 && + implDefs.length === 0 && + callableDefs.length === 0 && + methodDefs.length === 0 + ) { tierMiss++; return null; } - const globalDefs = [...classDefs, ...implDefs, ...callableDefs]; + + // Fast path: if no `Function + ownerId` class method was ever + // registered into the method registry (the only source of + // cross-index duplication), the callable and method indexes are + // guaranteed disjoint and we can concat without dedup. + if (!model.methods.hasFunctionMethods) { + const globalDefs: SymbolDefinition[] = [ + ...classDefs, + ...implDefs, + ...callableDefs, + ...methodDefs, + ]; + tierGlobal++; + return { candidates: globalDefs, tier: 'global' }; + } + + // Slow path: dedup by nodeId because the same SymbolDefinition + // reference can land in both `callableDefs` (via the Function + // callable-index gate) and `methodDefs` (via the dispatch-key + // normalization routing Function+ownerId into MethodRegistry). + // Dedup covers all four index reads so any nodeId overlap (even + // theoretical ones between classDefs/implDefs) is caught. + const globalDefs: SymbolDefinition[] = []; + const seen = new Set(); + const pushUnique = (pool: readonly SymbolDefinition[]): void => { + for (const def of pool) { + if (seen.has(def.nodeId)) continue; + seen.add(def.nodeId); + globalDefs.push(def); + } + }; + pushUnique(classDefs); + pushUnique(implDefs); + pushUnique(callableDefs); + pushUnique(methodDefs); + tierGlobal++; return { candidates: globalDefs, tier: 'global' }; }; @@ -271,7 +424,7 @@ export const createResolutionContext = (): ResolutionContext => { }); const clear = (): void => { - symbols.clear(); + model.clear(); importMap.clear(); packageMap.clear(); namedImportMap.clear(); @@ -288,7 +441,7 @@ export const createResolutionContext = (): ResolutionContext => { return { resolve, - symbols, + model, importMap, packageMap, namedImportMap, diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts new file mode 100644 index 0000000000..1066306677 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -0,0 +1,284 @@ +/** + * Deterministic Resolution Functions + * + * Pure functions that resolve methods across the inheritance hierarchy + * using only the SemanticModel registries and HeritageMap — NO dependency + * on resolution-context.ts (circular dependency risk). + */ + +import type { SymbolDefinition } from './symbol-table.js'; +import type { SemanticModel } from './semantic-model.js'; +import type { HeritageMap } from './heritage-map.js'; +import type { MroStrategy } from 'gitnexus-shared'; + +// --------------------------------------------------------------------------- +// MRO primitives. +// +// `c3Linearize` and its BFS helper `gatherAncestors` live here so the model +// layer stays a pure leaf — mro-processor.ts (graph-level MRO emission) +// imports `c3Linearize` from this file. +// --------------------------------------------------------------------------- + +/** + * Gather all ancestor IDs in BFS / topological order. + * Returns the linearized list of ancestor IDs (excluding the class itself). + */ +function gatherAncestors(classId: string, parentMap: Map): string[] { + const visited = new Set(); + const order: string[] = []; + const queue: string[] = [...(parentMap.get(classId) ?? [])]; + + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + order.push(id); + const grandparents = parentMap.get(id); + if (grandparents) { + for (const gp of grandparents) { + if (!visited.has(gp)) queue.push(gp); + } + } + } + + return order; +} + +/** + * Compute C3 linearization for a class given a parentMap. + * Returns an array of ancestor IDs in C3 order (excluding the class itself), + * or null if linearization fails (inconsistent or cyclic hierarchy). + * + * Used internally by `lookupMethodByOwnerWithMRO` for the Python MRO + * strategy and re-exported for mro-processor.ts (graph-level MRO emission). + */ +export function c3Linearize( + classId: string, + parentMap: Map, + cache: Map, + inProgress?: Set, +): string[] | null { + if (cache.has(classId)) return cache.get(classId)!; + + // Cycle detection: if we're already computing this class, the hierarchy is cyclic + const visiting = inProgress ?? new Set(); + if (visiting.has(classId)) { + cache.set(classId, null); + return null; + } + visiting.add(classId); + + const directParents = parentMap.get(classId); + if (!directParents || directParents.length === 0) { + visiting.delete(classId); + cache.set(classId, []); + return []; + } + + // Compute linearization for each parent first + const parentLinearizations: string[][] = []; + for (const pid of directParents) { + const pLin = c3Linearize(pid, parentMap, cache, visiting); + if (pLin === null) { + visiting.delete(classId); + cache.set(classId, null); + return null; + } + parentLinearizations.push([pid, ...pLin]); + } + + // Add the direct parents list as the final sequence + const sequences = [...parentLinearizations, [...directParents]]; + const result: string[] = []; + + while (sequences.some((s) => s.length > 0)) { + // Find a good head: one that doesn't appear in the tail of any other sequence + let head: string | null = null; + for (const seq of sequences) { + if (seq.length === 0) continue; + const candidate = seq[0]; + const inTail = sequences.some( + (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, + ); + if (!inTail) { + head = candidate; + break; + } + } + + if (head === null) { + // Inconsistent hierarchy + visiting.delete(classId); + cache.set(classId, null); + return null; + } + + result.push(head); + + // Remove the chosen head from all sequences + for (const seq of sequences) { + if (seq.length > 0 && seq[0] === head) { + seq.shift(); + } + } + } + + visiting.delete(classId); + cache.set(classId, result); + return result; +} + +// `gatherAncestors` is exported so mro-processor.ts can reuse the same +// BFS traversal for graph-level MRO emission. +export { gatherAncestors }; + +// --------------------------------------------------------------------------- +// C3 linearization cache (per HeritageMap, auto-drained via WeakMap) +// --------------------------------------------------------------------------- + +/** + * Per-HeritageMap cache of C3 linearization results keyed by owner nodeId. + * + * HeritageMap instances are immutable after construction, so C3 output is + * stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain + * when the HeritageMap is garbage collected (end of ingestion run), so we + * never need to manually invalidate it. + * + * `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent + * hierarchy) so we don't re-run the expensive linearization repeatedly. + */ +const c3LinearizationCache = new WeakMap>(); + +const getCachedC3Linearization = ( + ownerNodeId: string, + heritageMap: HeritageMap, +): readonly string[] | null => { + let perHmCache = c3LinearizationCache.get(heritageMap); + if (!perHmCache) { + perHmCache = new Map(); + c3LinearizationCache.set(heritageMap, perHmCache); + } + const cached = perHmCache.get(ownerNodeId); + if (cached !== undefined) return cached; + const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap); + const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null; + perHmCache.set(ownerNodeId, result); + return result; +}; + +// --------------------------------------------------------------------------- +// Heritage → parentMap conversion +// --------------------------------------------------------------------------- + +/** + * Build a parentMap from HeritageMap for use with c3Linearize. + * Traverses the parent chain starting from startNodeId, collecting all + * parent→children relationships into a Map. + * + * Uses a head-pointer BFS (queue[head++]) instead of Array.shift() to avoid + * O(n) per-dequeue re-indexing. For wide/shallow hierarchies common in + * large Java/C# codebases this keeps the walk linear in ancestor count. + */ +const buildParentMapFromHeritage = ( + startNodeId: string, + heritageMap: HeritageMap, +): Map => { + const parentMap = new Map(); + const visited = new Set(); + const queue: string[] = [startNodeId]; + let head = 0; + + while (head < queue.length) { + const nodeId = queue[head++]!; + if (visited.has(nodeId)) continue; + visited.add(nodeId); + const parents = heritageMap.getParents(nodeId); + if (parents.length > 0) { + parentMap.set(nodeId, parents); + for (const p of parents) { + if (!visited.has(p)) queue.push(p); + } + } + } + + return parentMap; +}; + +// --------------------------------------------------------------------------- +// MRO-aware method lookup +// --------------------------------------------------------------------------- + +/** + * Look up a method on an owner class, walking the parent chain via HeritageMap + * when the method isn't found on the direct owner. + * + * Respects the 5 per-language MRO strategies: + * - `first-wins`: BFS ancestor walk, first match wins (default) + * - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++); + * HeritageMap preserves insertion order matching source declaration, + * so BFS order is equivalent to leftmost-base semantics + * - `c3`: C3-linearized ancestor order, first match wins (Python) + * - `implements-split`: BFS ancestor walk, first match wins (Java/C#) — + * full ambiguity detection for multiple interface defaults + * is handled by computeMRO at graph level + * - `qualified-syntax`: No auto-resolution (Rust) — returns undefined + * + * Uses the `c3Linearize` defined in this file (also consumed by + * mro-processor.ts for graph-level MRO emission) for the `c3` strategy. + * + * Depends only on {@link SemanticModel} + {@link HeritageMap} + an + * {@link MroStrategy} literal — NO dependency on SymbolTable, the language + * registry, or resolution-context, which keeps the `model/` module free of + * cross-layer imports. Callers derive the strategy from their language + * provider before invoking this function. + * + * @internal This is the low-level MRO walker. Exported so call-processor's + * higher-level resolvers (and unit tests) can invoke it directly. Callers + * outside `core/ingestion/` should use the higher-level resolvers in + * call-processor.ts instead of depending on this function. + */ +export const lookupMethodByOwnerWithMRO = ( + ownerNodeId: string, + methodName: string, + heritageMap: HeritageMap, + model: SemanticModel, + strategy: MroStrategy, + argCount?: number, +): SymbolDefinition | undefined => { + // Direct lookup first (child override — no walk needed). + // argCount is threaded through so arity-differing overloads on the direct + // owner can be disambiguated before the MRO walk starts. + const direct = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount); + if (direct) return direct; + + // Rust: requires qualified syntax (::method), no auto-resolution + if (strategy === 'qualified-syntax') return undefined; + + // Determine ancestor walk order based on MRO strategy. + // readonly to accept the cached (frozen) c3 linearization without copying. + let ancestors: readonly string[]; + if (strategy === 'c3') { + // C3 linearization (memoized per HeritageMap + // so repeated calls for the same owner within an ingestion run reuse the + // linearization instead of rebuilding the parent map and re-running C3). + // c3Linearize returns ancestors only (excludes the owner itself), + // matching heritageMap.getAncestors() semantics. + const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap); + // Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy). + // Note: BFS order may not preserve Python MRO semantics in these edge + // cases, but cyclic/inconsistent hierarchies are invalid in Python anyway. + ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId); + } else { + // first-wins, leftmost-base, implements-split: BFS order via HeritageMap + ancestors = heritageMap.getAncestors(ownerNodeId); + } + + // Walk ancestors in MRO order — first match wins. + // argCount narrows overloaded ancestors the same way as the direct lookup. + for (const ancestorId of ancestors) { + const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount); + if (method) return method; + } + + return undefined; +}; diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts new file mode 100644 index 0000000000..9a822f8be4 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -0,0 +1,193 @@ +/** + * Semantic Model + * + * Top-level orchestrator for all resolution-time data. Owns: + * + * - Three owner-scoped registries (types, methods, fields) + * - A nested SymbolTable (file + callable name indexes) wrapped so + * that `add()` fans out into the registries via the dispatch table + * + * ## DAG direction + * + * gitnexus-shared (NodeLabel) — leaf + * ↑ + * symbol-table.ts — pure file/callable index + * ↑ + * model/type-registry / method-registry / field-registry + * ↑ + * model/registration-table.ts — dispatch table factory + * ↑ + * model/semantic-model.ts — THIS FILE (orchestrator) + * ↑ + * resolve.ts, call-processor.ts, resolution-context.ts, ... + * + * `symbol-table.ts` is a leaf — it never imports from `./model/`. This + * file (semantic-model.ts) is the ONLY place where SymbolTable and the + * owner-scoped registries are composed. Upstream consumers pass around + * the `SemanticModel` interface and reach into `.symbols` for file-scoped + * operations or `.types` / `.methods` / `.fields` for owner-scoped ones. + * + * ## Fan-out via wrapped add() + * + * `createSemanticModel()` creates a pure SymbolTable, creates the three + * registries, builds a dispatch table via `createRegistrationTable`, and + * exposes a SymbolTable-shaped façade whose `add()`: + * + * 1. Calls `rawSymbols.add()` — writes the fileIndex + callable index + * and returns the fully-built `SymbolDefinition`. + * 2. Runs pre-dispatch normalization (`Function`-with-`ownerId` routes + * as `Method`). + * 3. Looks up the dispatch table and invokes the hook, which writes to + * the appropriate owner-scoped registry. + * + * The wrapper is the only place where the two layers are combined. A + * direct `createSymbolTable()` caller (e.g. an isolated unit test) gets + * the pure, registry-free behavior — no surprises, no hidden side + * effects. + */ + +import type { NodeLabel } from 'gitnexus-shared'; +import type { TypeRegistry, MutableTypeRegistry } from './type-registry.js'; +import type { MethodRegistry, MutableMethodRegistry } from './method-registry.js'; +import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js'; +import { createTypeRegistry } from './type-registry.js'; +import { createMethodRegistry } from './method-registry.js'; +import { createFieldRegistry } from './field-registry.js'; +import type { + SymbolTableReader, + SymbolTableWriter, + SymbolDefinition, + AddMetadata, +} from './symbol-table.js'; +import { createSymbolTable } from './symbol-table.js'; +import { createRegistrationTable } from './registration-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +/** + * Aggregated read-only view of the semantic registries plus the nested + * file/callable SymbolTable. + * + * `symbols` is typed as {@link SymbolTableReader} — consumers can query + * symbols but cannot register new ones or trigger a reset. Callers that + * need to register symbols or reset state must hold a + * {@link MutableSemanticModel} reference instead, which widens + * `symbols` back to {@link SymbolTableWriter} and adds `clear()` on the + * model itself. + * + * This segregation is the runtime half of the principle of least + * authority: a resolver that receives `SemanticModel` physically cannot + * mutate the index, so it cannot desync the leaf from the owner-scoped + * registries even accidentally. + */ +export interface SemanticModel { + readonly types: TypeRegistry; + readonly methods: MethodRegistry; + readonly fields: FieldRegistry; + readonly symbols: SymbolTableReader; +} + +// --------------------------------------------------------------------------- +// Mutable interface +// --------------------------------------------------------------------------- + +/** Mutable variant — exposes the MutableX registries, a Writer-typed + * `symbols` facade, and a full-cascade reset. This is the interface + * held by the lifecycle owner (pipeline, resolution-context); resolvers + * that only query should hold the narrower {@link SemanticModel}. */ +export interface MutableSemanticModel extends SemanticModel { + readonly types: MutableTypeRegistry; + readonly methods: MutableMethodRegistry; + readonly fields: MutableFieldRegistry; + readonly symbols: SymbolTableWriter; + /** Clear all registries AND the nested SymbolTable. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- +// +// NodeLabel taxonomy drift detection lives in `registration-table.ts` as a +// pure compile-time check — the `LABEL_BEHAVIOR` map is +// `Record` with `as const satisfies`, which proves +// coverage, uniqueness, and no-extra-keys at build time. No runtime guard +// is needed because drift is structurally impossible in the source. + +export const createSemanticModel = (): MutableSemanticModel => { + // 1. Create the pure, registry-unaware SymbolTable leaf. + // rawSymbols is the only handle in the codebase whose type (the + // internal createSymbolTable return) includes `.clear()`. cascadeClear + // below reaches it here; no external caller receives this variable. + const rawSymbols = createSymbolTable(); + + // 2. Create the three owner-scoped registries. + const types = createTypeRegistry(); + const methods = createMethodRegistry(); + const fields = createFieldRegistry(); + + // 3. Build the dispatch table, closed over THIS instance's registries. + const dispatchTable = createRegistrationTable({ types, methods, fields }); + + // 4. Wrap rawSymbols so `add()` fans out into the registries via the + // dispatch table. See module JSDoc for the three-step contract. + const wrappedAdd = ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ): SymbolDefinition => { + const def = rawSymbols.add(filePath, name, nodeId, type, metadata); + + // Function-with-ownerId (Python `def` in a class body, Rust trait + // method, Kotlin companion method) routes as Method. Keeps the + // dispatch table single-purpose. + const dispatchKey: NodeLabel = + type === 'Function' && metadata?.ownerId !== undefined ? 'Method' : type; + + const hook = dispatchTable.get(dispatchKey); + if (hook) { + hook(name, def); + } + + return def; + }; + + // Cascade clear: single source of truth for "reset the entire model". + // Wired into both `model.clear()` AND `model.symbols.clear()` so that a + // caller holding only a SymbolTable reference can't leave the + // owner-scoped registries populated while the file/callable indexes go + // empty (the phantom-resolution failure mode). + const cascadeClear = (): void => { + types.clear(); + methods.clear(); + fields.clear(); + rawSymbols.clear(); + }; + + // Writer-typed facade: exposes reads + add, but NO `clear` field. + // Callers holding a `SemanticModel.symbols` reference cannot desync + // the leaf indexes from the owner-scoped registries. Consumers that + // only query should widen their annotation to SymbolTableReader for + // least-authority clarity. + const symbols: SymbolTableWriter = { + add: wrappedAdd, + lookupExact: rawSymbols.lookupExact, + lookupExactFull: rawSymbols.lookupExactFull, + lookupExactAll: rawSymbols.lookupExactAll, + lookupCallableByName: rawSymbols.lookupCallableByName, + getFiles: rawSymbols.getFiles, + getStats: rawSymbols.getStats, + }; + + return { + types, + methods, + fields, + symbols, + clear: cascadeClear, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/symbol-table.ts b/gitnexus/src/core/ingestion/model/symbol-table.ts new file mode 100644 index 0000000000..0469987789 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/symbol-table.ts @@ -0,0 +1,381 @@ +/** + * Symbol Table — file-indexed + callable-name symbol storage. + * + * This module is a PURE LEAF in the ingestion DAG. It owns two orthogonal + * O(1) indexes: + * + * 1. fileIndex — Map> + * for same-file lookups (Tier 1 resolution) + * 2. callableByName — Map + * for name-keyed callable lookups (Tier 3 widen) + * + * SymbolTable deliberately knows NOTHING about the owner-scoped registries + * (types, methods, fields) that sit above it in the DAG. Those registries + * live in `model/` and depend on SymbolTable, not the other way around. + * {@link createSemanticModel} composes this pure SymbolTable with the + * registries and wraps `add()` to fan out registrations into both layers. + * + * DAG direction (strictly enforced): + * + * gitnexus-shared (NodeLabel) — leaf type + * ↑ + * symbol-table.ts — THIS FILE (pure storage) + * ↑ + * model/type-registry.ts, method-registry.ts, field-registry.ts + * ↑ + * model/registration-table.ts — dispatch table factory + * ↑ + * model/semantic-model.ts — orchestrator, wraps add() + * ↑ + * model/resolve.ts, call-processor.ts, resolution-context.ts, ... + * + * No arrow ever points downward from this file. If you are tempted to + * import from `./model/` here, you are going the wrong way — move the + * logic up the DAG instead. + */ + +import type { NodeLabel } from 'gitnexus-shared'; + +/** + * Class-like NodeLabels — used for qualifiedName fallback inside + * `SymbolTable.add()` and (via import into `model/registration-table.ts`) + * as the single source of truth for which labels route to classHook + * in the dispatch table. + * + * Exported as a `readonly` tuple so that `typeof CLASS_TYPES_TUPLE[number]` + * yields a precise literal union (`ClassLikeLabel`). The model layer + * imports this tuple and uses `Record` in a + * `satisfies` intersection to enforce at COMPILE TIME that every label + * listed here is also classified as dispatch in `LABEL_BEHAVIOR`. Adding + * a new class-like label to this tuple without updating `LABEL_BEHAVIOR` + * fails TypeScript. + * + * Traits are class-like for heritage resolution: PHP `use Trait;`, Rust + * `impl Trait for Struct`, and Scala traits all contribute methods to the + * hierarchy of their using/implementing type. + */ +export const CLASS_TYPES_TUPLE = [ + 'Class', + 'Struct', + 'Interface', + 'Enum', + 'Record', + 'Trait', +] as const satisfies readonly NodeLabel[]; + +export type ClassLikeLabel = (typeof CLASS_TYPES_TUPLE)[number]; + +export const CLASS_TYPES: ReadonlySet = new Set(CLASS_TYPES_TUPLE); + +/** Free-callable labels — single source of truth for "callables that have + * NO owner scope". Methods and constructors are owner-scoped and live in + * `MethodRegistry` — Tier 3 reaches them via + * `model.methods.lookupMethodByName`. See `resolution-context.ts` Tier 3 + * for how both indexes are consulted together. + * + * Exported as a `readonly` tuple so that `typeof FREE_CALLABLE_TUPLE[number]` + * yields a precise literal union (`FreeCallableLabel`). `registration-table.ts` + * imports this type and uses `Record` in + * a `satisfies` intersection to enforce at COMPILE TIME that every label + * listed here is also classified as `callable-only` in `LABEL_BEHAVIOR`. + * Adding a label to this tuple without updating `LABEL_BEHAVIOR` fails + * TypeScript. + * + * Partial-state caveat: Python/Rust/Kotlin class methods are emitted by + * the worker as `Function` + `ownerId` (not `Method`), so they still land + * here via the `Function` entry. Collapsing those three languages onto the + * `Method` label is pending a `def.type` preservation decision. + */ +export const FREE_CALLABLE_TUPLE = [ + 'Function', + 'Macro', // C/C++ + 'Delegate', // C# +] as const satisfies readonly NodeLabel[]; + +export type FreeCallableLabel = (typeof FREE_CALLABLE_TUPLE)[number]; + +export const FREE_CALLABLE_TYPES: ReadonlySet = new Set(FREE_CALLABLE_TUPLE); + +/** Symbol types that can be the TARGET of a call in the resolver's kind + * filter — superset of {@link FREE_CALLABLE_TYPES} that also admits + * owner-scoped methods and constructors pulled in from `MethodRegistry`. + * + * Why the split: `FREE_CALLABLE_TYPES` now has a narrow meaning (free + * callables indexed in `callableByName`), but call resolution still + * needs to accept Method and Constructor candidates once they have been + * unioned in from `model.methods.lookupMethodByName`. The resolver uses + * this constant for kind filtering in + * `filterCallableCandidates` / `countCallableCandidates`. + */ +export const CALL_TARGET_TYPES: ReadonlySet = new Set([ + ...FREE_CALLABLE_TYPES, + 'Method', + 'Constructor', +]); + +export interface SymbolDefinition { + nodeId: string; + filePath: string; + type: NodeLabel; + /** Canonical dot-separated qualified type name for class-like symbols + * (e.g. `App.Models.User`). Falls back to the simple symbol name when no + * package/namespace/module scope exists or no explicit qualified metadata is provided. */ + qualifiedName?: string; + parameterCount?: number; + /** Number of required (non-optional, non-default) parameters. + * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ + requiredParameterCount?: number; + /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). + * Populated when parameter types are resolvable from AST (any typed language). */ + parameterTypes?: string[]; + /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ + returnType?: string; + /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ + declaredType?: string; + /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ + ownerId?: string; +} + +/** + * Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate + * type alias so callers and wrappers can share the same shape. + */ +export interface AddMetadata { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; + returnType?: string; + declaredType?: string; + ownerId?: string; + qualifiedName?: string; +} + +/** + * Pure read-only view over the file and callable indexes. Does NOT + * include `add()` or `clear()`. + * + * Used by consumers that only query symbols (resolvers, type-env, field + * extractors). The interface is strictly observational — holding a + * `SymbolTableReader` cannot mutate the table in any way. + * + * For consumers that also need to register symbols, use + * {@link SymbolTableWriter}, which extends this interface with `add()`. + * Neither interface exposes `clear()` — that capability lives on the + * internal factory return type and is reachable only inside + * `SemanticModel` via `rawSymbols`. + * + * Segregating the observer contract from the mutation contract means + * callers holding only a Reader can never desync the model. + */ +export interface SymbolTableReader { + /** + * High Confidence: Look for a symbol specifically inside a file. + * Returns the Node ID if found. + */ + lookupExact: (filePath: string, name: string) => string | undefined; + + /** + * High Confidence: Look for a symbol in a specific file, returning full definition. + * Returns first matching definition — use lookupExactAll for overloaded methods. + */ + lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined; + + /** + * High Confidence: Look for ALL symbols with this name in a specific file. + * Returns all definitions, including overloaded methods with the same name. + * The returned array is a view into the live internal index — callers + * MUST NOT mutate it. Use `readonly` to enforce this at the type level. + */ + lookupExactAll: (filePath: string, name: string) => readonly SymbolDefinition[]; + + /** + * Look up callable symbols (Function, Macro, Delegate) by name. + * O(1) via dedicated eagerly-populated index keyed by symbol name. + * Returned array is a view into the live index — do not mutate. + */ + lookupCallableByName: (name: string) => readonly SymbolDefinition[]; + + /** + * Iterate all indexed file paths. + * Used by Tier 2b (package-scoped) resolution to walk files matching a + * package directory suffix without a global name scan. + */ + getFiles: () => IterableIterator; + + /** + * Debugging: See how many files are tracked. + */ + getStats: () => { + fileCount: number; + }; +} + +/** + * Writer view — reads + symbol registration. Does NOT include `clear()`. + * + * `MutableSemanticModel.symbols` is typed as this interface, so the + * lifecycle owner can register symbols and query them. Full-model + * resets flow through `model.clear()`. + * + * The cascading `clear()` capability lives exclusively on the internal + * factory return type ({@link createSymbolTable}) — a private handle + * held only by `SemanticModel` via `rawSymbols`. + */ +export interface SymbolTableWriter extends SymbolTableReader { + /** + * Register a symbol in the file and (if callable) name-keyed indexes. + * + * Returns the constructed {@link SymbolDefinition} so higher-layer + * wrappers (e.g. `createSemanticModel`) can reuse it without rebuilding + * the def. This keeps the fan-out in one allocation. + */ + add: ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ) => SymbolDefinition; +} + +/** + * Internal return type for {@link createSymbolTable} — extends the + * writer with `clear()`. This capability is intentionally NOT exported + * as a named interface; consumers should hold a `SymbolTableReader` or + * `SymbolTableWriter` instead. + * + * `SemanticModel`'s constructor is the only caller of `createSymbolTable`, + * and it retains the returned handle as the private `rawSymbols` + * reference so `cascadeClear` can reach `clear()`. Every other consumer + * receives the narrower `SymbolTableWriter` facade on `model.symbols`. + */ +interface InternalSymbolTable extends SymbolTableWriter { + /** + * Cleanup memory. Clears only the file and callable indexes owned here — + * owner-scoped registries are cleared by their respective owners via + * `model.clear()`. + */ + clear: () => void; +} + +export const createSymbolTable = (): InternalSymbolTable => { + // 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup. + // Structure: FilePath -> (SymbolName -> SymbolDefinition[]) + // Array allows overloaded methods (same name, different signatures) to coexist. + const fileIndex = new Map>(); + + // 2. Eagerly-populated Callable Index — maintained on add(). + // Structure: SymbolName -> [Callable Definitions] + // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. + const callableByName = new Map(); + + const add = ( + filePath: string, + name: string, + nodeId: string, + type: NodeLabel, + metadata?: AddMetadata, + ): SymbolDefinition => { + const qualifiedName = CLASS_TYPES.has(type) + ? (metadata?.qualifiedName ?? name) + : metadata?.qualifiedName; + const def: SymbolDefinition = { + nodeId, + filePath, + type, + ...(qualifiedName !== undefined ? { qualifiedName } : {}), + ...(metadata?.parameterCount !== undefined + ? { parameterCount: metadata.parameterCount } + : {}), + ...(metadata?.requiredParameterCount !== undefined + ? { requiredParameterCount: metadata.requiredParameterCount } + : {}), + ...(metadata?.parameterTypes !== undefined + ? { parameterTypes: metadata.parameterTypes } + : {}), + ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), + ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), + ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), + }; + + // A. File Index — unconditional. + if (!fileIndex.has(filePath)) { + fileIndex.set(filePath, new Map()); + } + const fileMap = fileIndex.get(filePath)!; + if (!fileMap.has(name)) { + fileMap.set(name, [def]); + } else { + fileMap.get(name)!.push(def); + } + + // B. Callable Index — gated by FREE_CALLABLE_TYPES. + // Note: Property is NOT in FREE_CALLABLE_TYPES, so it never lands here. + // This is the single source of truth for callable-index membership; + // the higher-layer dispatch table only decides owner-scoped routing. + // + // Fallback: `Method` or `Constructor` without an `ownerId` is an + // extractor contract violation (AST-degraded parse, or a buggy + // language extractor). The owner-scoped dispatch hook silently + // skips such defs because it has no owner to key them under, so + // without this fallback they would be invisible at Tier 3 global + // resolution. Route them through `callableByName` so they remain + // reachable by name — matching pre-dispatch-table behavior. + const isOrphanedOwnerScoped = + (type === 'Method' || type === 'Constructor') && metadata?.ownerId === undefined; + if (FREE_CALLABLE_TYPES.has(type) || isOrphanedOwnerScoped) { + const existing = callableByName.get(name); + if (existing) { + existing.push(def); + } else { + callableByName.set(name, [def]); + } + } + + return def; + }; + + const lookupExact = (filePath: string, name: string): string | undefined => { + const defs = fileIndex.get(filePath)?.get(name); + return defs?.[0]?.nodeId; + }; + + const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => { + const defs = fileIndex.get(filePath)?.get(name); + return defs?.[0]; + }; + + const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => { + return fileIndex.get(filePath)?.get(name) ?? []; + }; + + const lookupCallableByName = (name: string): SymbolDefinition[] => { + return callableByName.get(name) ?? []; + }; + + /** Returns a live iterator over all indexed file paths (fileIndex.keys()). + * The iterator is invalidated if add() changes fileIndex.size during + * iteration (ES2015 Map spec). Safe in the current pipeline because all + * symbols are added before resolution begins. */ + const getFiles = (): IterableIterator => fileIndex.keys(); + + const getStats = () => ({ + fileCount: fileIndex.size, + }); + + const clear = () => { + fileIndex.clear(); + callableByName.clear(); + }; + + return { + add, + lookupExact, + lookupExactFull, + lookupExactAll, + lookupCallableByName, + getFiles, + getStats, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/model/type-registry.ts b/gitnexus/src/core/ingestion/model/type-registry.ts new file mode 100644 index 0000000000..95d52dbc17 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/type-registry.ts @@ -0,0 +1,113 @@ +/** + * Type Registry + * + * Class/struct/interface index extracted from SymbolTable. + * Eagerly-populated indexes keyed by symbol name and qualified name. + * Also includes a separate index for Rust Impl blocks. + */ + +import type { SymbolDefinition } from './symbol-table.js'; + +// --------------------------------------------------------------------------- +// Public read-only interface +// --------------------------------------------------------------------------- + +export interface TypeRegistry { + /** + * Look up class-like definitions (Class, Struct, Interface, Enum, Record, Trait) + * by simple name. Returns all matching definitions across files + * (e.g. partial classes). Returned array is a view into the live + * internal index — do not mutate. + */ + lookupClassByName(name: string): readonly SymbolDefinition[]; + + /** + * Look up class-like definitions by canonical qualified name. + * Qualified names are normalized to dot-separated scope segments across languages, + * e.g. `App.Models.User`, `com.example.User`, or `Admin.User`. + * Returned array is a view into the live index — do not mutate. + */ + lookupClassByQualifiedName(qualifiedName: string): readonly SymbolDefinition[]; + + /** + * Look up Impl nodes by name. Used by Tier 3 resolution to include Rust + * impl blocks alongside class-like candidates. + * Returned array is a view into the live index — do not mutate. + */ + lookupImplByName(name: string): readonly SymbolDefinition[]; +} + +// --------------------------------------------------------------------------- +// Mutable interface (used internally by SymbolTable.add / clear) +// --------------------------------------------------------------------------- + +export interface MutableTypeRegistry extends TypeRegistry { + /** Register a class-like type by name and qualified name. */ + registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void; + /** Register a Rust Impl block by name. */ + registerImpl(name: string, def: SymbolDefinition): void; + /** Clear all entries. */ + clear(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export const createTypeRegistry = (): MutableTypeRegistry => { + const classByName = new Map(); + const classByQualifiedName = new Map(); + const implByName = new Map(); + + const lookupClassByName = (name: string): SymbolDefinition[] => { + return classByName.get(name) ?? []; + }; + + const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { + return classByQualifiedName.get(qualifiedName) ?? []; + }; + + const lookupImplByName = (name: string): SymbolDefinition[] => { + return implByName.get(name) ?? []; + }; + + const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => { + const existing = classByName.get(name); + if (existing) { + existing.push(def); + } else { + classByName.set(name, [def]); + } + + const qualifiedMatches = classByQualifiedName.get(qualifiedName); + if (qualifiedMatches) { + qualifiedMatches.push(def); + } else { + classByQualifiedName.set(qualifiedName, [def]); + } + }; + + const registerImpl = (name: string, def: SymbolDefinition): void => { + const existing = implByName.get(name); + if (existing) { + existing.push(def); + } else { + implByName.set(name, [def]); + } + }; + + const clear = (): void => { + classByName.clear(); + classByQualifiedName.clear(); + implByName.clear(); + }; + + return { + lookupClassByName, + lookupClassByQualifiedName, + lookupImplByName, + registerClass, + registerImpl, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/mro-processor.ts b/gitnexus/src/core/ingestion/mro-processor.ts index e44fbd3e7b..f73da20fcf 100644 --- a/gitnexus/src/core/ingestion/mro-processor.ts +++ b/gitnexus/src/core/ingestion/mro-processor.ts @@ -23,6 +23,7 @@ import { KnowledgeGraph } from '../graph/types.js'; import { generateId } from '../../lib/utils.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from './languages/index.js'; +import { c3Linearize, gatherAncestors } from './model/resolve.js'; // --------------------------------------------------------------------------- // Public types @@ -93,115 +94,9 @@ function buildAdjacency(graph: KnowledgeGraph) { return { parentMap, methodMap, parentEdgeType }; } -/** - * Gather all ancestor IDs in BFS / topological order. - * Returns the linearized list of ancestor IDs (excluding the class itself). - */ -function gatherAncestors(classId: string, parentMap: Map): string[] { - const visited = new Set(); - const order: string[] = []; - const queue: string[] = [...(parentMap.get(classId) ?? [])]; - - while (queue.length > 0) { - const id = queue.shift()!; - if (visited.has(id)) continue; - visited.add(id); - order.push(id); - const grandparents = parentMap.get(id); - if (grandparents) { - for (const gp of grandparents) { - if (!visited.has(gp)) queue.push(gp); - } - } - } - - return order; -} - -// --------------------------------------------------------------------------- -// C3 linearization (Python MRO) -// --------------------------------------------------------------------------- - -/** - * Compute C3 linearization for a class given a parentMap. - * Returns an array of ancestor IDs in C3 order (excluding the class itself), - * or null if linearization fails (inconsistent or cyclic hierarchy). - */ -export function c3Linearize( - classId: string, - parentMap: Map, - cache: Map, - inProgress?: Set, -): string[] | null { - if (cache.has(classId)) return cache.get(classId)!; - - // Cycle detection: if we're already computing this class, the hierarchy is cyclic - const visiting = inProgress ?? new Set(); - if (visiting.has(classId)) { - cache.set(classId, null); - return null; - } - visiting.add(classId); - - const directParents = parentMap.get(classId); - if (!directParents || directParents.length === 0) { - visiting.delete(classId); - cache.set(classId, []); - return []; - } - - // Compute linearization for each parent first - const parentLinearizations: string[][] = []; - for (const pid of directParents) { - const pLin = c3Linearize(pid, parentMap, cache, visiting); - if (pLin === null) { - visiting.delete(classId); - cache.set(classId, null); - return null; - } - parentLinearizations.push([pid, ...pLin]); - } - - // Add the direct parents list as the final sequence - const sequences = [...parentLinearizations, [...directParents]]; - const result: string[] = []; - - while (sequences.some((s) => s.length > 0)) { - // Find a good head: one that doesn't appear in the tail of any other sequence - let head: string | null = null; - for (const seq of sequences) { - if (seq.length === 0) continue; - const candidate = seq[0]; - const inTail = sequences.some( - (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1, - ); - if (!inTail) { - head = candidate; - break; - } - } - - if (head === null) { - // Inconsistent hierarchy - visiting.delete(classId); - cache.set(classId, null); - return null; - } - - result.push(head); - - // Remove the chosen head from all sequences - for (const seq of sequences) { - if (seq.length > 0 && seq[0] === head) { - seq.shift(); - } - } - } - - visiting.delete(classId); - cache.set(classId, result); - return result; -} +// `gatherAncestors` and `c3Linearize` live in `./model/resolve.ts` and +// are imported at the top of this file for internal use by `computeMRO` +// and the method-override edge emitter. // --------------------------------------------------------------------------- // Language-specific resolution diff --git a/gitnexus/src/core/ingestion/named-binding-processor.ts b/gitnexus/src/core/ingestion/named-binding-processor.ts deleted file mode 100644 index 4340bf9e41..0000000000 --- a/gitnexus/src/core/ingestion/named-binding-processor.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; -import type { NamedImportMap } from './import-processor.js'; - -/** - * Walk a named-binding re-export chain through NamedImportMap. - * - * When file A imports { User } from B, and B re-exports { User } from C, - * the NamedImportMap for A points to B, but B has no User definition. - * This function follows the chain: A→B→C until a definition is found. - * - * Returns the definitions found at the end of the chain, or null if the - * chain breaks (missing binding, circular reference, or depth exceeded). - * Max depth 5 to prevent infinite loops. - */ -export function walkBindingChain( - name: string, - currentFilePath: string, - symbolTable: SymbolTable, - namedImportMap: NamedImportMap, -): SymbolDefinition[] | null { - let lookupFile = currentFilePath; - let lookupName = name; - const visited = new Set(); - - for (let depth = 0; depth < 5; depth++) { - const bindings = namedImportMap.get(lookupFile); - if (!bindings) return null; - - const binding = bindings.get(lookupName); - if (!binding) return null; - - const key = `${binding.sourcePath}:${binding.exportedName}`; - if (visited.has(key)) return null; // circular - visited.add(key); - - const targetName = binding.exportedName; - const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName); - - if (resolvedDefs.length > 0) return resolvedDefs; - - // No definition in source file → follow re-export chain - lookupFile = binding.sourcePath; - lookupName = targetName; - } - - return null; -} diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index f37f1a52fd..9cf8394fe9 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -4,7 +4,9 @@ import Parser from 'tree-sitter'; import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SymbolTableReader, SymbolTableWriter } from './model/symbol-table.js'; +// SymbolTableReader is used for the FieldExtractorContext stub; the +// parsing functions themselves need Writer because they call .add(). import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js'; @@ -36,7 +38,6 @@ import type { ExtractedImport, ExtractedCall, ExtractedAssignment, - ExtractedHeritage, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, @@ -45,6 +46,7 @@ import type { FileScopeBindings, ExtractedORMQuery, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -70,7 +72,7 @@ export interface WorkerExtractedData { const processParsingWithWorkers = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, workerPool: WorkerPool, onFileProgress?: FileProgressCallback, @@ -250,13 +252,19 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } -/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential path has a real - * SymbolTable, but it's incomplete at this stage — use the stub for safety). */ -const NOOP_SYMBOL_TABLE_SEQ = { - lookupExactAll: () => [], +/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential + * path has a real SymbolTable, but it's incomplete at this stage — use + * the stub for safety). Implements the full {@link SymbolTableReader} + * surface so future extractor additions don't silently fall off an + * `as unknown as` cast. */ +const NOOP_SYMBOL_TABLE_SEQ: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, -} as unknown as SymbolTable; + lookupExactAll: () => [], + lookupCallableByName: () => [], + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), +}; function seqGetFieldInfo( classNode: SyntaxNode, @@ -278,7 +286,7 @@ function seqGetFieldInfo( const processParsingSequential = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, onFileProgress?: FileProgressCallback, ) => { @@ -650,7 +658,7 @@ const processParsingSequential = async ( export const processParsing = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], - symbolTable: SymbolTable, + symbolTable: SymbolTableWriter, astCache: ASTCache, onFileProgress?: FileProgressCallback, workerPool?: WorkerPool, diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 42c630289b..c3776d50a9 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -27,7 +27,7 @@ import { type ExportedTypeMap, buildExportedTypeMapFromGraph, } from './call-processor.js'; -import { buildHeritageMap } from './heritage-map.js'; +import { buildHeritageMap } from './model/heritage-map.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js'; import { expoFileToRouteURL } from './route-extractors/expo.js'; import { phpFileToRouteURL } from './route-extractors/php.js'; @@ -47,21 +47,22 @@ import type { ExtractedCall, ExtractedDecoratorRoute, ExtractedFetchCall, - ExtractedHeritage, ExtractedORMQuery, ExtractedRoute, ExtractedToolDef, FileConstructorBindings, } from './workers/parse-worker.js'; +import type { ExtractedHeritage } from './model/heritage-map.js'; import { processHeritage, processHeritageFromExtracted, extractExtractedHeritageFromFiles, + getHeritageStrategyForLanguage, } from './heritage-processor.js'; import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; import { processProcesses } from './process-processor.js'; -import { createResolutionContext } from './resolution-context.js'; +import { createResolutionContext } from './model/resolution-context.js'; import { createASTCache } from './ast-cache.js'; import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; import { PipelineResult } from '../../types/pipeline.js'; @@ -334,7 +335,7 @@ async function runCrossFileBindingPropagation( // For the worker path, buildTypeEnv runs inside workers without SymbolTable, // so exported bindings must be collected from graph + SymbolTable in main thread. if (exportedTypeMap.size === 0 && graph.nodeCount > 0) { - const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols); + const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols); for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports); } @@ -361,7 +362,7 @@ async function runCrossFileBindingPropagation( filesWithGaps++; break; } - const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); + const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName); if (def?.returnType) { filesWithGaps++; break; @@ -413,11 +414,15 @@ async function runCrossFileBindingPropagation( } } - const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols); + const importedReturns = buildImportedReturnTypes( + filePath, + ctx.namedImportMap, + ctx.model.symbols, + ); const importedRawReturns = buildImportedRawReturnTypes( filePath, ctx.namedImportMap, - ctx.symbols, + ctx.model.symbols, ); if (seeded.size === 0 && importedReturns.size === 0) continue; if (!allPathSet.has(filePath)) continue; @@ -657,7 +662,7 @@ async function runChunkedParseAndResolve( allORMQueries: ExtractedORMQuery[]; bindingAccumulator: BindingAccumulator; }> { - const symbolTable = ctx.symbols; + const symbolTable = ctx.model.symbols; const parseableScanned = scannedFiles.filter((f) => { const lang = getLanguageFromFilename(f.path); @@ -978,7 +983,9 @@ async function runChunkedParseAndResolve( // Build unified HeritageMap (parent lookup + implementor index) after all chunks. const fullWorkerHeritageMap = - deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined; + deferredWorkerHeritage.length > 0 + ? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; if (deferredWorkerCalls.length > 0) { await processCallsFromExtracted( @@ -1058,7 +1065,9 @@ async function runChunkedParseAndResolve( } // Build unified HeritageMap from all sequential heritage (parent lookup + implementor index). const sequentialHeritageMap = - allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : undefined; + allSequentialHeritage.length > 0 + ? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage) + : undefined; // Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk. // Reuse the file contents cached in Pass 1 instead of re-reading from disk. diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts deleted file mode 100644 index eb7a62079f..0000000000 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ /dev/null @@ -1,439 +0,0 @@ -import type { NodeLabel } from 'gitnexus-shared'; - -export const CLASS_TYPES = new Set([ - 'Class', - 'Struct', - 'Interface', - 'Enum', - 'Record', - // Traits are class-like for heritage resolution: PHP `use Trait;`, Rust - // `impl Trait for Struct`, and Scala traits all contribute methods to the - // hierarchy of their using/implementing type. Including Trait here lets - // buildHeritageMap resolve `h.parentName` to a Trait nodeId so the MRO - // walker can visit the trait and find its methods. - 'Trait', -]); - -/** Callable symbol types indexed in callableByName for Tier 3 resolution - * and D2 widen in call-processor.ts. Single source of truth — do not - * duplicate this set elsewhere. */ -export const CALLABLE_TYPES = new Set([ - 'Function', - 'Method', - 'Constructor', - 'Macro', // C/C++ - 'Delegate', // C# -]); - -export interface SymbolDefinition { - nodeId: string; - filePath: string; - type: NodeLabel; - /** Canonical dot-separated qualified type name for class-like symbols - * (e.g. `App.Models.User`). Falls back to the simple symbol name when no - * package/namespace/module scope exists or no explicit qualified metadata is provided. */ - qualifiedName?: string; - parameterCount?: number; - /** Number of required (non-optional, non-default) parameters. - * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ - requiredParameterCount?: number; - /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). - * Populated when parameter types are resolvable from AST (any typed language). - * Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */ - parameterTypes?: string[]; - /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ - returnType?: string; - /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ - declaredType?: string; - /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ - ownerId?: string; -} - -export interface SymbolTable { - /** - * Register a new symbol definition - */ - add: ( - filePath: string, - name: string, - nodeId: string, - type: NodeLabel, - metadata?: { - parameterCount?: number; - requiredParameterCount?: number; - parameterTypes?: string[]; - returnType?: string; - declaredType?: string; - ownerId?: string; - qualifiedName?: string; - }, - ) => void; - - /** - * High Confidence: Look for a symbol specifically inside a file - * Returns the Node ID if found - */ - lookupExact: (filePath: string, name: string) => string | undefined; - - /** - * High Confidence: Look for a symbol in a specific file, returning full definition. - * Includes type information needed for heritage resolution (Class vs Interface). - * Returns first matching definition — use lookupExactAll for overloaded methods. - */ - lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined; - - /** - * High Confidence: Look for ALL symbols with this name in a specific file. - * Returns all definitions, including overloaded methods with the same name. - * Used by resolution-context to pass all same-file overloads to candidate filtering. - */ - lookupExactAll: (filePath: string, name: string) => SymbolDefinition[]; - - /** - * Look up callable symbols (Function, Method, Constructor, Macro, Delegate) by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Used by Tier 3 resolution and ReturnTypeLookup to resolve callee → return type. - */ - lookupCallableByName: (name: string) => SymbolDefinition[]; - - /** - * Look up a field/property by its owning class nodeId and field name. - * O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`. - * Returns undefined when no matching property exists or the owner is ambiguous. - */ - lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined; - - /** - * Look up a method by its owning class nodeId and method name. - * O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0methodName`. - * For overloaded methods (same owner + name): returns the first match when all - * overloads share the same returnType, undefined when return types differ (ambiguous). - * Used by walkMixedChain for deterministic cross-class chain resolution. - */ - /** - * Lookup a method by owner class + name, optionally filtered by arity. - * - * When `argCount` is provided, overloads whose parameter count doesn't - * accommodate the call's argument count are filtered out before the - * returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate - * arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that - * would otherwise collide on the shared `ownerId + methodName` key. - * - * Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`, - * both returning `void`) still collapse to the first match — callers must - * gate D0 on overload concern before invoking this function for that case. - */ - lookupMethodByOwner: ( - ownerNodeId: string, - methodName: string, - argCount?: number, - ) => SymbolDefinition | undefined; - - /** - * Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Returns all matching definitions across files (e.g. partial classes). - * Used by Phase 1 semantic-model tasks to replace filtered global lookups. - */ - lookupClassByName: (name: string) => SymbolDefinition[]; - - /** - * Look up class-like definitions by canonical qualified name. - * Qualified names are normalized to dot-separated scope segments across languages, - * e.g. `App.Models.User`, `com.example.User`, or `Admin.User`. - * Top-level class-like symbols with no explicit scope are indexed under their simple name. - */ - lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[]; - - /** - * Look up Impl nodes by name. - * O(1) via dedicated eagerly-populated index keyed by symbol name. - * Used by Tier 3 resolution to include Rust impl blocks alongside - * class-like candidates so method lookups on `impl User { fn save() }` work - * correctly (Rust methods are indexed under the Impl nodeId, not the Struct). - */ - lookupImplByName: (name: string) => SymbolDefinition[]; - - /** - * Iterate all indexed file paths. - * Used by Tier 2b (package-scoped) resolution to walk files matching a - * package directory suffix without a global name scan. - */ - getFiles: () => IterableIterator; - - /** - * Debugging: See how many symbols are tracked - */ - getStats: () => { - fileCount: number; - }; - - /** - * Cleanup memory - */ - clear: () => void; -} - -export const createSymbolTable = (): SymbolTable => { - // 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup. - // Structure: FilePath -> (SymbolName -> SymbolDefinition[]) - // Array allows overloaded methods (same name, different signatures) to coexist. - const fileIndex = new Map>(); - - // 2. Eagerly-populated Callable Index — maintained on add(). - // Structure: SymbolName -> [Callable Definitions] - // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. - const callableByName = new Map(); - - // 3. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". - // Only Property symbols with ownerId and declaredType are indexed. - const fieldByOwner = new Map(); - - // 4. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". - // Method symbols with ownerId are indexed. Supports overloads (array values). - const methodByOwner = new Map(); - - // 5. Eagerly-populated Class-type Index — keyed by symbol name. - // Only Class, Struct, Interface, Enum, Record symbols are indexed. - const classByName = new Map(); - const classByQualifiedName = new Map(); - - // 6. Eagerly-populated Impl Index — keyed by symbol name. - // Rust impl blocks (type 'Impl') are stored here to keep them out of - // classByName (which drives heritage resolution) while still being - // reachable from Tier 3 resolution for method lookup. - const implByName = new Map(); - - // Use the module-level CALLABLE_TYPES constant (exported for call-processor.ts). - - const add = ( - filePath: string, - name: string, - nodeId: string, - type: NodeLabel, - metadata?: { - parameterCount?: number; - requiredParameterCount?: number; - parameterTypes?: string[]; - returnType?: string; - declaredType?: string; - ownerId?: string; - qualifiedName?: string; - }, - ) => { - const qualifiedName = CLASS_TYPES.has(type) - ? (metadata?.qualifiedName ?? name) - : metadata?.qualifiedName; - const def: SymbolDefinition = { - nodeId, - filePath, - type, - ...(qualifiedName !== undefined ? { qualifiedName } : {}), - ...(metadata?.parameterCount !== undefined - ? { parameterCount: metadata.parameterCount } - : {}), - ...(metadata?.requiredParameterCount !== undefined - ? { requiredParameterCount: metadata.requiredParameterCount } - : {}), - ...(metadata?.parameterTypes !== undefined - ? { parameterTypes: metadata.parameterTypes } - : {}), - ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), - ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), - ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), - }; - - // A. Add to File Index (shared reference — zero additional memory) - if (!fileIndex.has(filePath)) { - fileIndex.set(filePath, new Map()); - } - const fileMap = fileIndex.get(filePath)!; - if (!fileMap.has(name)) { - fileMap.set(name, [def]); - } else { - fileMap.get(name)!.push(def); - } - - // B. Properties go to fieldByOwner index only — skip other indexes to prevent - // namespace pollution for common names like 'id', 'name', 'type'. - // Index ALL properties (even without declaredType) so write-access tracking - // can resolve field ownership for dynamically-typed languages (Ruby, JS). - if (type === 'Property' && metadata?.ownerId) { - fieldByOwner.set(`${metadata.ownerId}\0${name}`, def); - // Still add to fileIndex above (for lookupExact), but skip other indexes - return; - } - - // C. Methods, constructors, and ownerId-bound Functions go to - // methodByOwner index. - // - // Some language extractors emit class methods as `Function` with an - // `ownerId` — notably Python (`def method(self):` inside a class body), - // Rust trait methods, and Kotlin object/companion methods. Treating - // `Function` with ownerId the same as `Method` here makes D0 - // (`resolveMemberCall`) work uniformly across all supported languages - // instead of silently falling through to D1-D4 widening. - if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) { - const key = `${metadata.ownerId}\0${name}`; - const existing = methodByOwner.get(key); - if (existing) { - existing.push(def); - } else { - methodByOwner.set(key, [def]); - } - } - - // C2. Class-like types go to classByName index. - if (CLASS_TYPES.has(type)) { - const existing = classByName.get(name); - if (existing) { - existing.push(def); - } else { - classByName.set(name, [def]); - } - - const qualifiedKey = qualifiedName ?? name; - const qualifiedMatches = classByQualifiedName.get(qualifiedKey); - if (qualifiedMatches) { - qualifiedMatches.push(def); - } else { - classByQualifiedName.set(qualifiedKey, [def]); - } - } - - // C3. Rust Impl blocks go to implByName (separate from classByName to avoid - // polluting heritage resolution with Impl nodes as parent candidates). - if (type === 'Impl') { - const existing = implByName.get(name); - if (existing) { - existing.push(def); - } else { - implByName.set(name, [def]); - } - } - - // D. Eagerly maintain callable index (like classByName, implByName). - if (CALLABLE_TYPES.has(type)) { - const existing = callableByName.get(name); - if (existing) { - existing.push(def); - } else { - callableByName.set(name, [def]); - } - } - }; - - const lookupExact = (filePath: string, name: string): string | undefined => { - const defs = fileIndex.get(filePath)?.get(name); - return defs?.[0]?.nodeId; - }; - - const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => { - const defs = fileIndex.get(filePath)?.get(name); - return defs?.[0]; - }; - - const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => { - return fileIndex.get(filePath)?.get(name) ?? []; - }; - - const lookupCallableByName = (name: string): SymbolDefinition[] => { - return callableByName.get(name) ?? []; - }; - - const lookupFieldByOwner = ( - ownerNodeId: string, - fieldName: string, - ): SymbolDefinition | undefined => { - return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); - }; - - const lookupMethodByOwner = ( - ownerNodeId: string, - methodName: string, - argCount?: number, - ): SymbolDefinition | undefined => { - const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`); - if (!defs || defs.length === 0) return undefined; - - // Arity narrowing: when an argCount is provided and there are multiple - // overloads, keep only those whose parameterCount can accommodate the - // call. This resolves arity-differing overloads (e.g. C++ `greet()` vs - // `greet(string)`) that share the same `ownerId + methodName` key. - // - // Candidates with `parameterCount === undefined` (extractor didn't - // populate the count — typically variadic or unknown) are retained - // conservatively so that legitimate variadic matches still resolve. - let pool = defs; - if (argCount !== undefined && defs.length > 1) { - const arityMatched = defs.filter((d) => { - if (d.parameterCount === undefined) return true; - const min = d.requiredParameterCount ?? d.parameterCount; - return argCount >= min && argCount <= d.parameterCount; - }); - // Only adopt the arity-narrowed pool when it found matches; if arity - // rules out every candidate, fall back to the unfiltered set so the - // caller's fuzzy path still has something to work with. - if (arityMatched.length > 0) pool = arityMatched; - } - - if (pool.length === 1) return pool[0]; - // Multiple overloads after arity narrowing: return first if all share - // the same defined returnType (safe for chain resolution), undefined if - // return types differ (truly ambiguous — can't determine which overload). - const firstReturnType = pool[0].returnType; - if (firstReturnType === undefined) return undefined; - for (let i = 1; i < pool.length; i++) { - if (pool[i].returnType !== firstReturnType) return undefined; - } - return pool[0]; - }; - - const lookupClassByName = (name: string): SymbolDefinition[] => { - return classByName.get(name) ?? []; - }; - - const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => { - return classByQualifiedName.get(qualifiedName) ?? []; - }; - - const lookupImplByName = (name: string): SymbolDefinition[] => { - return implByName.get(name) ?? []; - }; - - /** Returns a live iterator over all indexed file paths (fileIndex.keys()). - * The iterator is invalidated if add() changes fileIndex.size during - * iteration (ES2015 Map spec). Safe in the current pipeline because all - * symbols are added before resolution begins. */ - const getFiles = (): IterableIterator => fileIndex.keys(); - - const getStats = () => ({ - fileCount: fileIndex.size, - }); - - const clear = () => { - fileIndex.clear(); - callableByName.clear(); - fieldByOwner.clear(); - methodByOwner.clear(); - classByName.clear(); - classByQualifiedName.clear(); - implByName.clear(); - }; - - return { - add, - lookupExact, - lookupExactFull, - lookupExactAll, - lookupCallableByName, - lookupFieldByOwner, - lookupMethodByOwner, - lookupClassByName, - lookupClassByQualifiedName, - lookupImplByName, - getFiles, - getStats, - clear, - }; -}; diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index c8eba58194..368de762b5 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -21,7 +21,7 @@ import { stripNullable, extractReturnTypeName, } from './type-extractors/shared.js'; -import type { SymbolTable } from './symbol-table.js'; +import type { SemanticModel } from './model/semantic-model.js'; import type { NodeLabel } from 'gitnexus-shared'; /** @@ -416,11 +416,8 @@ const findEnclosingScopeKey = ( * Only `.has()` is exposed — the SymbolTable doesn't support iteration. * Results are memoized to avoid redundant class-index scans across declarations. */ -const createClassNameLookup = ( - localNames: Set, - symbolTable?: SymbolTable, -): ClassNameLookup => { - if (!symbolTable) return localNames; +const createClassNameLookup = (localNames: Set, model?: SemanticModel): ClassNameLookup => { + if (!model) return localNames; const memo = new Map(); return { @@ -428,7 +425,7 @@ const createClassNameLookup = ( if (localNames.has(name)) return true; const cached = memo.get(name); if (cached !== undefined) return cached; - const result = symbolTable + const result = model.types .lookupClassByName(name) .some((def) => def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct'); memo.set(name, result); @@ -481,20 +478,20 @@ const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']); type ClassDefRef = { nodeId: string; type: string; filePath: string }; const lookupClassDefsByName = ( - symbolTable: SymbolTable, + model: SemanticModel, name: string, allowedTypes: ReadonlySet = CLASS_LIKE_TYPES, -): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); +): ClassDefRef[] => model.types.lookupClassByName(name).filter((d) => allowedTypes.has(d.type)); /** Memoize class definition lookups during fixpoint iteration. * SymbolTable is immutable during type resolution, so results never change. * Eliminates redundant array allocations + filter scans across iterations. */ -const createClassDefCache = (symbolTable?: SymbolTable) => { +const createClassDefCache = (model?: SemanticModel) => { const cache = new Map(); return (typeName: string) => { let result = cache.get(typeName); if (result === undefined) { - result = symbolTable ? lookupClassDefsByName(symbolTable, typeName) : []; + result = model ? lookupClassDefsByName(model, typeName) : []; cache.set(typeName, result); } return result; @@ -615,22 +612,22 @@ const resolveFieldType = ( receiver: string, field: string, scopeEnv: ReadonlyMap, - symbolTable?: SymbolTable, + model?: SemanticModel, getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { - if (!symbolTable) return undefined; + if (!model) return undefined; const receiverType = scopeEnv.get(receiver); if (!receiverType) return undefined; - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); const classDefs = lookup(receiverType); if (classDefs.length !== 1) return undefined; // Direct lookup first - const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field); + const fieldDef = model.fields.lookupFieldByOwner(classDefs[0].nodeId, field); if (fieldDef?.declaredType) return extractReturnTypeName(fieldDef.declaredType); // MRO parent chain walking on miss const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const f = symbolTable.lookupFieldByOwner(nodeId, field); + const f = model.fields.lookupFieldByOwner(nodeId, field); return f?.declaredType ? extractReturnTypeName(f.declaredType) : undefined; }); return inherited; @@ -644,30 +641,30 @@ const resolveMethodReturnType = ( receiver: string, method: string, scopeEnv: ReadonlyMap, - symbolTable?: SymbolTable, + model?: SemanticModel, getClassDefs?: (typeName: string) => ClassDefRef[], parentMap?: ReadonlyMap, ): string | undefined => { - if (!symbolTable) return undefined; + if (!model) return undefined; let receiverType = scopeEnv.get(receiver); // When substituteThisReceiver replaced $this/self with the enclosing class name, // the receiver IS the type — look it up directly as a class name. if (!receiverType) { - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); if (lookup(receiver).length > 0) receiverType = receiver; } if (!receiverType) return undefined; - const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name)); + const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name)); const classDefs = lookup(receiverType); if (classDefs.length === 0) return undefined; // Direct lookup first const directMethodLookups = classDefs.map((d) => ({ classDef: d, - methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method), + methodDef: model.methods.lookupMethodByOwner(d.nodeId, method), })); const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => { if (methodDef) return false; - return symbolTable + return model.symbols .lookupExactAll(classDef.filePath, method) .some((d) => d.ownerId === classDef.nodeId); }); @@ -681,7 +678,7 @@ const resolveMethodReturnType = ( // MRO parent chain walking on miss if (methods.length === 0) { const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => { - const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method); + const parentMethod = model.methods.lookupMethodByOwner(nodeId, method); if (!parentMethod?.returnType) return undefined; return extractReturnTypeName(parentMethod.returnType); }); @@ -707,11 +704,11 @@ const resolveFixpointBindings = ( pendingItems: Array<{ scope: string } & PendingAssignment>, env: TypeEnv, returnTypeLookup: ReturnTypeLookup, - symbolTable?: SymbolTable, + model?: SemanticModel, parentMap?: ReadonlyMap, ): void => { if (pendingItems.length === 0) return; - const getClassDefs = createClassDefCache(symbolTable); + const getClassDefs = createClassDefCache(model); const resolved = new Set(); for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) { let changed = false; @@ -740,7 +737,7 @@ const resolveFixpointBindings = ( item.receiver, item.field, scopeEnv, - symbolTable, + model, getClassDefs, parentMap, ); @@ -750,7 +747,7 @@ const resolveFixpointBindings = ( item.receiver, item.method, scopeEnv, - symbolTable, + model, getClassDefs, parentMap, ); @@ -785,7 +782,7 @@ const resolveFixpointBindings = ( * Uses an options object to allow future extensions without positional parameter sprawl. */ export interface BuildTypeEnvOptions { - symbolTable?: SymbolTable; + model?: SemanticModel; parentMap?: ReadonlyMap; /** Pre-resolved bindings from upstream files (Phase 14). * Seeded into FILE_SCOPE after walk() for names with no local binding. @@ -837,7 +834,7 @@ export const buildTypeEnv = ( enclosingClassNameCache.clear(); enclosingParentClassNameCache.clear(); - const symbolTable = options?.symbolTable; + const model = options?.model; const parentMap = options?.parentMap; const extractFuncNameHook = options?.extractFunctionName; const env: TypeEnv = new Map(); @@ -848,7 +845,7 @@ export const buildTypeEnv = ( // e.g., `Animal a = new Dog()` → constructorTypeMap.set('func@42\0a', 'Dog') const constructorTypeMap = new Map(); const localClassNames = new Set(); - const classNames = createClassNameLookup(localClassNames, symbolTable); + const classNames = createClassNameLookup(localClassNames, model); const provider = getProvider(language); const config = provider.typeConfig; const bindings: ConstructorBinding[] = []; @@ -856,29 +853,47 @@ export const buildTypeEnv = ( // Build ReturnTypeLookup: SymbolTable is authoritative when it has an unambiguous match. // Cross-file importedReturnTypes are consulted ONLY when SymbolTable has 0 matches. // Ambiguous (2+) → undefined, no cross-file fallback (conservative, local-first principle). + // Post-A4 Unit 4: callableByName no longer holds Method/Constructor, so + // for-loop binding inference must also consult methodsByName to find + // return types on class methods (e.g. `user.getItems()` iteration). + // Take `model` as an explicit argument so the non-null precondition + // is visible at the type level. Callers must enter these via an + // `if (model)` guard on their side and pass the narrowed reference. + const getCallableUnionCount = (m: SemanticModel, callee: string): number => { + return ( + m.symbols.lookupCallableByName(callee).length + m.methods.lookupMethodByName(callee).length + ); + }; + const getFirstCallable = (m: SemanticModel, callee: string) => { + const free = m.symbols.lookupCallableByName(callee); + if (free.length > 0) return free[0]; + const methods = m.methods.lookupMethodByName(callee); + return methods.length > 0 ? methods[0] : undefined; + }; + const returnTypeLookup: ReturnTypeLookup = { lookupReturnType(callee: string): string | undefined { // SymbolTable is authoritative when it has an unambiguous match - if (symbolTable) { + if (model) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupCallableByName(callee); - if (callables.length === 1) { - const rawReturn = callables[0].returnType; + const count = getCallableUnionCount(model, callee); + if (count === 1) { + const rawReturn = getFirstCallable(model, callee)?.returnType; if (rawReturn) return extractReturnTypeName(rawReturn); } // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) - if (callables.length > 1) return undefined; + if (count > 1) return undefined; } // No match (0 results or no symbolTable) → fall back to cross-file return options?.importedReturnTypes?.get(callee); }, lookupRawReturnType(callee: string): string | undefined { - if (symbolTable) { + if (model) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupCallableByName(callee); - if (callables.length === 1) return callables[0].returnType; + const count = getCallableUnionCount(model, callee); + if (count === 1) return getFirstCallable(model, callee)?.returnType; // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) - if (callables.length > 1) return undefined; + if (count > 1) return undefined; } // Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]') // NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName) @@ -1229,7 +1244,7 @@ export const buildTypeEnv = ( seedImportedBindings(env, options.importedBindings); } - resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap); + resolveFixpointBindings(pendingItems, env, returnTypeLookup, model, parentMap); // Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge): // For-loop nodes whose iterables were unresolved at walk-time may now be @@ -1256,7 +1271,7 @@ export const buildTypeEnv = ( return scopeEnv && !scopeEnv.has(item.lhs); }); if (unresolvedBefore.length > 0) { - resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, symbolTable); + resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, model); } } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f229b4cadf..32dd32aec7 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -15,7 +15,8 @@ import { createRequire } from 'node:module'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from '../languages/index.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js'; -import type { SymbolTable } from '../symbol-table.js'; +import type { SymbolTableReader } from '../model/symbol-table.js'; +import type { ExtractedHeritage } from '../model/heritage-map.js'; /** Language grammar type accepted by Parser.setLanguage(). */ type TreeSitterLanguage = Parameters[0]; @@ -181,13 +182,8 @@ export interface ExtractedAssignment { receiverTypeName?: string; } -export interface ExtractedHeritage { - filePath: string; - className: string; - parentName: string; - /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ - kind: string; -} +// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is +// re-exported at the top of this file. export interface ExtractedRoute { filePath: string; @@ -459,14 +455,20 @@ function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null { /** * Minimal no-op SymbolTable stub for FieldExtractorContext in the worker. - * Field extraction only uses symbolTable.lookupExactAll for optional type resolution — - * returning [] causes the extractor to use the raw type string, which is fine for us. + * Field extraction only uses symbolTable.lookupExactAll for optional type + * resolution — returning [] causes the extractor to use the raw type + * string, which is fine for us. Every other method is a no-op so the + * stub remains safe if a future FieldExtractor consults it through the + * full {@link SymbolTableReader} surface. */ -const NOOP_SYMBOL_TABLE = { - lookupExactAll: () => [], +const NOOP_SYMBOL_TABLE: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, -} as unknown as SymbolTable; + lookupExactAll: () => [], + lookupCallableByName: () => [], + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), +}; /** * Get (or extract and cache) field info for a class node. diff --git a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts index e244a094c6..da5c49042d 100644 --- a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts +++ b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts @@ -8,7 +8,7 @@ import { } from '../../src/core/ingestion/filesystem-walker.js'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; diff --git a/gitnexus/test/integration/qualified-class-lookups.test.ts b/gitnexus/test/integration/qualified-class-lookups.test.ts index 1d6fa4fc6c..eb628b73a1 100644 --- a/gitnexus/test/integration/qualified-class-lookups.test.ts +++ b/gitnexus/test/integration/qualified-class-lookups.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from 'vitest'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { processParsing } from '../../src/core/ingestion/parsing-processor.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; describe('qualified class lookups', () => { it('derives canonical dot-separated names from namespaces, packages, and modules', async () => { const graph = createKnowledgeGraph(); - const symbolTable = createSymbolTable(); + const model = createSemanticModel(); + // model.symbols is the SymbolTable leaf that processParsing writes into. + // Fan-out writes still reach model.types / model.methods / model.fields + // via SemanticModel's wrappedAdd — this alias is purely for convenience + // at call sites that want the SymbolTable-shaped interface. + const symbolTable = model.symbols; const astCache = createASTCache(); await processParsing( @@ -34,33 +39,38 @@ describe('qualified class lookups', () => { astCache, ); - const userMatches = symbolTable.lookupClassByName('User'); + const userMatches = model.types.lookupClassByName('User'); expect(userMatches).toHaveLength(3); expect(userMatches.map((match) => match.qualifiedName).sort()).toEqual( ['Admin.User', 'Data.Auth.User', 'Services.Auth.User'].sort(), ); - const servicesUser = symbolTable.lookupClassByQualifiedName('Services.Auth.User'); + const servicesUser = model.types.lookupClassByQualifiedName('Services.Auth.User'); expect(servicesUser).toHaveLength(1); expect(servicesUser[0].filePath).toBe('src/Services/User.cs'); expect(servicesUser[0].qualifiedName).toBe('Services.Auth.User'); - const dataUser = symbolTable.lookupClassByQualifiedName('Data.Auth.User'); + const dataUser = model.types.lookupClassByQualifiedName('Data.Auth.User'); expect(dataUser).toHaveLength(1); expect(dataUser[0].filePath).toBe('src/Data/User.cs'); - const javaConfig = symbolTable.lookupClassByQualifiedName('com.example.models.Config'); + const javaConfig = model.types.lookupClassByQualifiedName('com.example.models.Config'); expect(javaConfig).toHaveLength(1); expect(javaConfig[0].qualifiedName).toBe('com.example.models.Config'); - const rubyUser = symbolTable.lookupClassByQualifiedName('Admin.User'); + const rubyUser = model.types.lookupClassByQualifiedName('Admin.User'); expect(rubyUser).toHaveLength(1); expect(rubyUser[0].qualifiedName).toBe('Admin.User'); }); it('falls back to the simple name for top-level class-like symbols', async () => { const graph = createKnowledgeGraph(); - const symbolTable = createSymbolTable(); + const model = createSemanticModel(); + // model.symbols is the SymbolTable leaf that processParsing writes into. + // Fan-out writes still reach model.types / model.methods / model.fields + // via SemanticModel's wrappedAdd — this alias is purely for convenience + // at call sites that want the SymbolTable-shaped interface. + const symbolTable = model.symbols; const astCache = createASTCache(); await processParsing( @@ -70,11 +80,11 @@ describe('qualified class lookups', () => { astCache, ); - const simpleMatches = symbolTable.lookupClassByName('User'); + const simpleMatches = model.types.lookupClassByName('User'); expect(simpleMatches).toHaveLength(1); expect(simpleMatches[0].qualifiedName).toBe('User'); - const matches = symbolTable.lookupClassByQualifiedName('User'); + const matches = model.types.lookupClassByQualifiedName('User'); expect(matches).toHaveLength(1); expect(matches[0].qualifiedName).toBe('User'); }); diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 17e42897e0..01b707c13f 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -4,7 +4,7 @@ import { extractReceiverName, } from '../../src/core/ingestion/utils/call-analysis.js'; import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Python from 'tree-sitter-python'; @@ -452,9 +452,13 @@ describe('ownerId on SymbolDefinition', () => { expect(def!.ownerId).toBeUndefined(); }); - it('propagates ownerId through lookupCallableByName', () => { + it('propagates ownerId through a free Function registration', () => { + // Post-A4 Unit 4, Method is no longer in FREE_CALLABLE_TYPES so this test + // exercises ownerId propagation through the free-callable index using + // a Function label. Method-with-ownerId propagation is covered via + // methodsByName in method-registry.test.ts. const st = createSymbolTable(); - st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', { + st.add('src/foo.ts', 'save', 'Function:src/foo.ts:save', 'Function', { ownerId: 'Class:src/foo.ts:User', }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index e69250cebb..4bbc5d4b0b 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -7,22 +7,22 @@ import { extractConsumerAccessedKeys, processNextjsFetchRoutes, } from '../../src/core/ingestion/call-processor.js'; -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; import type { ExtractedAssignment, ExtractedCall, ExtractedFetchCall, - ExtractedHeritage, FileConstructorBindings, } from '../../src/core/ingestion/workers/parse-worker.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('processCallsFromExtracted', () => { let graph: ReturnType; @@ -34,7 +34,7 @@ describe('processCallsFromExtracted', () => { }); it('creates CALLS relationship for same-file resolution', async () => { - ctx.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); const calls: ExtractedCall[] = [ { @@ -55,7 +55,7 @@ describe('processCallsFromExtracted', () => { }); it('creates CALLS relationship for import-resolved resolution', async () => { - ctx.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -75,7 +75,12 @@ describe('processCallsFromExtracted', () => { }); it('resolves unique global symbol with moderate confidence', async () => { - ctx.symbols.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function'); + ctx.model.symbols.add( + 'src/other.ts', + 'uniqueFunc', + 'Function:src/other.ts:uniqueFunc', + 'Function', + ); const calls: ExtractedCall[] = [ { @@ -94,8 +99,8 @@ describe('processCallsFromExtracted', () => { }); it('refuses ambiguous global symbols — no CALLS edge created', async () => { - ctx.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); - ctx.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); + ctx.model.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); + ctx.model.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); const calls: ExtractedCall[] = [ { @@ -125,7 +130,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses non-callable symbols even when the name resolves', async () => { - ctx.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); + ctx.model.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); const calls: ExtractedCall[] = [ { @@ -140,7 +145,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses CALLS edges to Interface symbols', async () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', @@ -161,7 +166,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses CALLS edges to Enum symbols', async () => { - ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); ctx.importMap.set('src/index.ts', new Set(['src/status.ts'])); const calls: ExtractedCall[] = [ @@ -177,8 +182,8 @@ describe('processCallsFromExtracted', () => { }); it('prefers same-file over import-resolved', async () => { - ctx.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); - ctx.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); + ctx.model.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -198,8 +203,8 @@ describe('processCallsFromExtracted', () => { }); it('handles multiple calls from the same file', async () => { - ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); - ctx.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); + ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.model.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, @@ -211,10 +216,10 @@ describe('processCallsFromExtracted', () => { }); it('uses arity to disambiguate import-scoped callable candidates', async () => { - ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { + ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { + ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); @@ -237,10 +242,10 @@ describe('processCallsFromExtracted', () => { }); it('refuses ambiguous call targets when arity does not produce a unique match', async () => { - ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { + ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1, }); - ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { + ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); @@ -259,7 +264,7 @@ describe('processCallsFromExtracted', () => { }); it('calls progress callback', async () => { - ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, @@ -279,7 +284,7 @@ describe('processCallsFromExtracted', () => { // ---- Constructor-aware resolution (Phase 2) ---- it('resolves constructor call to Class when no Constructor node exists', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -300,10 +305,16 @@ describe('processCallsFromExtracted', () => { }); it('resolves constructor call to Constructor node over Class node', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { - parameterCount: 1, - }); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User', + 'Constructor', + { + parameterCount: 1, + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -324,7 +335,7 @@ describe('processCallsFromExtracted', () => { }); it('refuses Class target without callForm=constructor (existing behavior)', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -342,7 +353,7 @@ describe('processCallsFromExtracted', () => { }); it('constructor call falls back to callable types when no Constructor/Class found', async () => { - ctx.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [ @@ -362,12 +373,24 @@ describe('processCallsFromExtracted', () => { }); it('constructor arity filtering narrows overloaded constructors', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { - parameterCount: 0, - }); - ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { - parameterCount: 2, - }); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User(0)', + 'Constructor', + { + parameterCount: 0, + }, + ); + ctx.model.symbols.add( + 'src/models.ts', + 'User', + 'Constructor:src/models.ts:User(2)', + 'Constructor', + { + parameterCount: 2, + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [ @@ -388,10 +411,10 @@ describe('processCallsFromExtracted', () => { }); it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => { - ctx.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { + ctx.model.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1, }); - ctx.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { + ctx.model.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1, }); ctx.importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts'])); @@ -414,11 +437,11 @@ describe('processCallsFromExtracted', () => { it('return type inference: binds variable to return type of callee', async () => { // getUser() returns User, and User has a save() method - ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts', 'src/models.ts'])); @@ -450,11 +473,11 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: unwraps Promise to User', async () => { - ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', { returnType: 'Promise', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -484,9 +507,15 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: skips when return type is primitive', async () => { - ctx.symbols.add('src/utils.ts', 'getCount', 'Function:src/utils.ts:getCount', 'Function', { - returnType: 'number', - }); + ctx.model.symbols.add( + 'src/utils.ts', + 'getCount', + 'Function:src/utils.ts:getCount', + 'Function', + { + returnType: 'number', + }, + ); ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -514,10 +543,10 @@ describe('processCallsFromExtracted', () => { }); it('return type inference: skips ambiguous callees (multiple definitions)', async () => { - ctx.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { + ctx.model.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { + ctx.model.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', { returnType: 'Repo', }); @@ -547,8 +576,8 @@ describe('processCallsFromExtracted', () => { it('return type inference: prefers constructor binding over return type', async () => { // If the callee IS a class, constructor binding wins (existing behavior) - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); @@ -583,11 +612,11 @@ describe('processCallsFromExtracted', () => { // getUser is in the SymbolTable but WITHOUT a returnType (e.g., inferred return type // that the structure processor did not capture). The BindingAccumulator for // src/api.ts has getUser → User as a file-scope binding. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { // No returnType provided — simulates a structure-processor gap }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -635,11 +664,11 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — SymbolTable return type takes precedence', async () => { // When the SymbolTable DOES have a returnType, the accumulator should not override it. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { returnType: 'User', }); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -688,8 +717,8 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — skips when callee not in namedImportMap', async () => { // Callee is not tracked in namedImportMap (e.g. a local function), so accumulator // lookup is skipped. No CALLS edge expected since there is no binding source. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); // No namedImportMap entry for getUser @@ -708,8 +737,8 @@ describe('processCallsFromExtracted', () => { // but also exists on multiple types so fuzzy lookup is ambiguous without a // receiver type. Add a second owner so that unconstrained fuzzy lookup won't // match unambiguously. - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); @@ -741,9 +770,9 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — unwraps Promise type from accumulator', async () => { // Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it. - ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -790,7 +819,7 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => { // Accumulator stores a primitive type — should not create a CALLS edge. - ctx.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function'); + ctx.model.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function'); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts'])); ctx.namedImportMap.set( 'src/consumer.ts', @@ -834,9 +863,9 @@ describe('processCallsFromExtracted', () => { it('Phase 9: BindingAccumulator fallback — handles aliased import (localName ≠ exportedName)', async () => { // import { getUser as fetchUser } from './api' — namedImportMap maps localName to exportedName - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -889,16 +918,21 @@ describe('processCallsFromExtracted', () => { // The local definition has no returnType annotation. The accumulator has // getUser → User from api.ts. The fallback must NOT fire because the // same-file definition is authoritative (tier: 'same-file'). - ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add( + 'src/consumer.ts', + 'getUser', + 'Function:src/consumer.ts:getUser', + 'Function', + ); + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); // Place User and save in non-imported files so import-scoped member-call resolution // can't resolve save without a receiver type. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); // Only import api.ts — NOT models.ts, so save can't be found via import scope. @@ -952,19 +986,19 @@ describe('processCallsFromExtracted', () => { // x has no receiver type at all, and save is ambiguous (two owners) → 0 edges. // Either way, no CALLS edge. But we verify the accumulator's wrong type did NOT leak // by checking that no ACCESSES edge to BadType is created. - ctx.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); - ctx.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); + ctx.model.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); // BadType has no methods — if the accumulator wrongly types x as BadType, // the receiver type is set but save won't resolve at all. - ctx.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); + ctx.model.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); ctx.importMap.set( 'src/consumer.ts', new Set(['src/api-v1.ts', 'src/api-v2.ts', 'src/models.ts']), @@ -1017,8 +1051,8 @@ describe('processCallsFromExtracted', () => { it('Phase 9 tier gating: no callable candidates but named import — fallback fires', async () => { // getUser is not in the SymbolTable at all (e.g. definition not parsed). // namedImportMap has the import, accumulator has the type. Fallback should fire. - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); @@ -1067,14 +1101,19 @@ describe('processCallsFromExtracted', () => { // consumer.ts has a local getUser() without returnType annotation. // No import of getUser exists. The accumulator has getUser → User from api.ts. // Tier is 'same-file' so fallback must NOT fire. - ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ctx.model.symbols.add( + 'src/consumer.ts', + 'getUser', + 'Function:src/consumer.ts:getUser', + 'Function', + ); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { ownerId: 'Class:src/models.ts:User', }); // Add a second 'save' so fuzzy lookup is ambiguous without receiver type - ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); - ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { ownerId: 'Class:src/other.ts:OtherClass', }); @@ -1120,12 +1159,23 @@ describe('processCallsFromExtracted', () => { // User.save@100 and Repo.save@200 are two methods named "save" in different classes. // Each has a local variable "db" pointing to a different type. // Without @startIndex in the key, the second binding would overwrite the first. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); - ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); + ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache', }); ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts'])); @@ -1178,10 +1228,21 @@ describe('processCallsFromExtracted', () => { it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => { // Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -1213,12 +1274,23 @@ describe('processCallsFromExtracted', () => { it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => { // Two save@* scopes in the same file bind "db" to different types — truly ambiguous. - ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class'); - ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); - ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', { - ownerId: 'Class:src/db/Database.ts:Database', - }); - ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { + ctx.model.symbols.add( + 'src/db/Database.ts', + 'Database', + 'Class:src/db/Database.ts:Database', + 'Class', + ); + ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class'); + ctx.model.symbols.add( + 'src/db/Database.ts', + 'query', + 'Method:src/db/Database.ts:query', + 'Method', + { + ownerId: 'Class:src/db/Database.ts:Database', + }, + ); + ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', { ownerId: 'Class:src/db/Cache.ts:Cache', }); ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts'])); @@ -1251,9 +1323,9 @@ describe('processCallsFromExtracted', () => { }); it('scope-aware bindings: same varName in different functions resolves to correct type', async () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); - ctx.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); + ctx.model.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const constructorBindings: FileConstructorBindings[] = [ @@ -1312,12 +1384,16 @@ describe('processCalls — Phase P class lookup fallback', () => { const dogId = 'Class:models/Dog.java:Dog'; const fetchBallId = 'Method:models/Dog.java:fetchBall'; - ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); - ctx.symbols.add(dogFile, 'Dog', dogId, 'Class'); - ctx.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId }); + ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.model.symbols.add(dogFile, 'Dog', dogId, 'Class'); + ctx.model.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId }); ctx.importMap.set(appFile, new Set([contractFile, dogFile])); - const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + // SM-20 wire-up: resolveMemberCall's constructor-override branch queries + // the model directly (ctx.model.types.lookupClassByName), not the + // legacy SymbolTable wrapper. Spy on the model method to preserve the + // test's intent: verify which class names are looked up during override. + const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName'); await processCalls( graph, @@ -1358,16 +1434,26 @@ class App { const otherDogFile = 'models/OtherDog.java'; const petId = 'Interface:models/Pet.java:Pet'; - ctx.symbols.add(contractFile, 'Pet', petId, 'Interface'); - ctx.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', { + ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface'); + ctx.model.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', { ownerId: 'Class:models/Dog.java:Dog', }); - ctx.symbols.add(otherDogFile, 'fetchBall', 'Method:models/OtherDog.java:fetchBall', 'Method', { - ownerId: 'Class:models/OtherDog.java:OtherDog', - }); + ctx.model.symbols.add( + otherDogFile, + 'fetchBall', + 'Method:models/OtherDog.java:fetchBall', + 'Method', + { + ownerId: 'Class:models/OtherDog.java:OtherDog', + }, + ); ctx.importMap.set(appFile, new Set([contractFile, dogFile, otherDogFile])); - const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName'); + // SM-20 wire-up: resolveMemberCall's constructor-override branch queries + // the model directly (ctx.model.types.lookupClassByName), not the + // legacy SymbolTable wrapper. Spy on the model method to preserve the + // test's intent: verify which class names are looked up during override. + const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName'); await processCalls( graph, @@ -2061,10 +2147,12 @@ describe('processCallsFromExtracted — interface dispatch', () => { const implAExecuteId = 'Method:impl/A.java:execute'; const implBExecuteId = 'Method:impl/B.java:execute'; - ctx.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface'); - ctx.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { ownerId: actionIfaceId }); - ctx.symbols.add(implA, 'execute', implAExecuteId, 'Method'); - ctx.symbols.add(implB, 'execute', implBExecuteId, 'Method'); + ctx.model.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface'); + ctx.model.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { + ownerId: actionIfaceId, + }); + ctx.model.symbols.add(implA, 'execute', implAExecuteId, 'Method'); + ctx.model.symbols.add(implB, 'execute', implBExecuteId, 'Method'); ctx.importMap.set(runnerFile, new Set([ifaceFile])); graph.addNode({ @@ -2100,8 +2188,8 @@ describe('processCallsFromExtracted — interface dispatch', () => { { filePath: 'impl/B.java', className: 'B', parentName: 'Action', kind: 'implements' }, ]; // Need class symbols for heritage map to resolve implementors - ctx.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class'); - ctx.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class'); + ctx.model.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class'); + ctx.model.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class'); const heritageMap = buildHeritageMap(heritage, ctx); const calls: ExtractedCall[] = [ @@ -2153,9 +2241,9 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const childId = 'class:models/Child.java:Child'; const parentMethodId = 'method:models/Parent.java:parentMethod'; - ctx.symbols.add(parentFile, 'Parent', parentId, 'Class'); - ctx.symbols.add(childFile, 'Child', childId, 'Class'); - ctx.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', { + ctx.model.symbols.add(parentFile, 'Parent', parentId, 'Class'); + ctx.model.symbols.add(childFile, 'Child', childId, 'Class'); + ctx.model.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', { ownerId: parentId, returnType: 'String', }); @@ -2211,26 +2299,28 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { }); it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => { - // Setup: Class Obj has a method `doWork` that is findable via tiered - // resolution (import-scoped lookup), but intentionally NOT registered in - // methodByOwner (no `ownerId` property). heritageMap is provided but has - // no ancestry entry for class:Obj. Expected flow: - // D0: lookupMethodByOwner(classId, 'doWork') → undefined - // heritageMap.getAncestors(classId) → [] - // lookupMethodByOwnerWithMRO returns undefined → D0 miss - // D1-D4: receiver type resolves to Obj; D2 widens via lookupCallableByName; - // D3 file-filter picks the only candidate in Obj's file. + // Setup: Class Obj exists in the same file as a `doWork` Method. The + // Method is registered under a DIFFERENT ownerId (`class:OtherOwner`) + // so lookupMethodByOwner('class:Obj', 'doWork') misses on the direct + // lookup. heritageMap is empty for class:Obj, so MRO walk yields no + // parents. Expected flow: + // D0: lookupMethodByOwner + MRO walk both miss → D0 fallthrough + // D1-D4: receiver type resolves to Obj; D3 file-filter picks the + // `doWork` candidate via its co-located file path. // Guarantees D0 miss does not swallow the call — D1-D4 still runs. const classFile = 'src/models/Obj.java'; const appFile = 'src/services/App.java'; const classId = 'class:models/Obj.java:Obj'; const doWorkId = 'method:models/Obj.java:doWork'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); - // Intentionally omit ownerId so methodByOwner has no entry — forces D0 miss. - ctx.symbols.add(classFile, 'doWork', doWorkId, 'Method', { + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); + // Post-A4: Method+ownerId routes through methodsByName. Using a + // different ownerId than the receiver type forces the direct + // lookupMethodByOwner miss that the test exercises. + ctx.model.symbols.add(classFile, 'doWork', doWorkId, 'Method', { returnType: 'void', parameterCount: 0, + ownerId: 'class:models/Obj.java:OtherOwner', }); ctx.importMap.set(appFile, new Set([classFile])); @@ -2321,15 +2411,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const methodIntId = 'method:models/Obj.java:method(int)'; const methodStringId = 'method:models/Obj.java:method(String)'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); // int overload added FIRST so lookupMethodByOwner would return it. - ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, parameterTypes: ['int'], }); - ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, @@ -2384,16 +2474,16 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const methodIntId = 'method:models/Obj.java:method(int)'; const methodStringId = 'method:models/Obj.java:method(String)'; - ctx.symbols.add(classFile, 'Obj', classId, 'Class'); + ctx.model.symbols.add(classFile, 'Obj', classId, 'Class'); // int overload added FIRST — without the guard this would be returned by // lookupMethodByOwner's same-return-type fast path. - ctx.symbols.add(classFile, 'method', methodIntId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, parameterTypes: ['int'], }); - ctx.symbols.add(classFile, 'method', methodStringId, 'Method', { + ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', { ownerId: classId, returnType: 'String', parameterCount: 1, @@ -2439,13 +2529,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authSaveId = 'method:auth_mod.py:save'; const userSaveId = 'method:user_mod.py:save'; - ctx.symbols.add(authModFile, 'User', authUserId, 'Class'); - ctx.symbols.add(userModFile, 'User', userUserId, 'Class'); - ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', { ownerId: authUserId, returnType: 'bool', }); - ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', { ownerId: userUserId, returnType: 'bool', }); @@ -2509,13 +2599,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authSaveId = 'method:src/auth_mod.py:save'; const userSaveId = 'method:src/user_mod.py:save'; - ctx.symbols.add(authModFile, 'User', authUserId, 'Class'); - ctx.symbols.add(userModFile, 'User', userUserId, 'Class'); - ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class'); + ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class'); + ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', { ownerId: authUserId, returnType: 'bool', }); - ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', { + ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', { ownerId: userUserId, returnType: 'bool', }); @@ -2566,13 +2656,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsSaveId = 'method:src/models.py:User:save'; const authSaveId = 'method:src/auth.py:Widget:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); - ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', { ownerId: authWidgetId, returnType: 'None', }); @@ -2616,10 +2706,10 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const authWidgetId = 'class:src/auth.py:Widget'; const authSaveId = 'method:src/auth.py:Widget:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class'); // NO save on User — deliberately absent to force null-route. - ctx.symbols.add(authFile, 'save', authSaveId, 'Method', { + ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', { ownerId: authWidgetId, returnType: 'None', }); @@ -2658,8 +2748,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsUserId = 'class:src/models.py:User'; const modelsSaveId = 'method:src/models.py:User:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); @@ -2697,8 +2787,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const modelsUserId = 'class:src/models.py:User'; const modelsSaveId = 'method:src/models.py:User:save'; - ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); - ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { + ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class'); + ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', { ownerId: modelsUserId, returnType: 'None', }); @@ -2740,14 +2830,14 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const userCtorId = 'Constructor:src/models/User.ts:User(string)'; const repoCtorId = 'Constructor:src/models/Repo.ts:User(number)'; - ctx.symbols.add(userFile, 'User', userClassId, 'Class'); - ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); - ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ctx.model.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', { ownerId: userClassId, parameterCount: 1, parameterTypes: ['string'], }); - ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { ownerId: repoClassId, parameterCount: 1, parameterTypes: ['number'], @@ -2784,15 +2874,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { const userCtorId = 'Constructor:src/models/User.ts:User(string)'; const repoCtorId = 'Constructor:src/models/Repo.ts:User(string)'; - ctx.symbols.add(userFile, 'User', userClassId, 'Class'); - ctx.symbols.add(repoFile, 'User', repoClassId, 'Class'); + ctx.model.symbols.add(userFile, 'User', userClassId, 'Class'); + ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class'); // Both constructors take `string` — genuinely ambiguous. - ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', { + ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', { ownerId: userClassId, parameterCount: 1, parameterTypes: ['string'], }); - ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { + ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', { ownerId: repoClassId, parameterCount: 1, parameterTypes: ['string'], @@ -2833,11 +2923,17 @@ describe('processAssignmentsFromExtracted', () => { // carries getUser → User from the source file. The constructor binding // binds x = getUser(). The assignment x.address = value should produce // an ACCESSES write edge to User.address via the accumulator fallback. - ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/models.ts', 'address', 'Property:src/models.ts:address', 'Property', { - ownerId: 'Class:src/models.ts:User', - }); + ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add( + 'src/models.ts', + 'address', + 'Property:src/models.ts:address', + 'Property', + { + ownerId: 'Class:src/models.ts:User', + }, + ); ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); ctx.namedImportMap.set( 'src/consumer.ts', @@ -2889,9 +2985,9 @@ describe('D2 widen path: lookupCallableByName via module alias', () => { // pointing to auth.py. login() is defined only in auth.py (not imported // by consumer.py). The D2 widen path should find login via the global // callable index filtered to the aliased module file. - ctx.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function'); + ctx.model.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function'); // Consumer has a same-file function that shadows 'login' at Tier 1 - ctx.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function'); + ctx.model.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function'); // Module alias: consumer.py → auth → src/auth.py ctx.moduleAliasMap.set('src/consumer.py', new Map([['auth', 'src/auth.py']])); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index 751618146f..2795f5833b 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -8,7 +8,7 @@ import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-c import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js'; import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js'; import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Python from 'tree-sitter-python'; @@ -26,7 +26,13 @@ const parse = (code: string) => { return parser.parse(code); }; -// Mock context for tests +// Mock context for tests. symbolTable comes from createSemanticModel().symbols +// (the facade) rather than createSymbolTable() (the raw leaf) — this mirrors +// production, where FieldExtractorContext always receives the SemanticModel- +// wrapped facade so any .add() write dispatches through the owner-scoped +// registries. No current field extractor calls symbolTable.add(), but +// matching the production shape prevents silent drift if a future extractor +// starts registering dynamically-discovered properties. const createMockContext = (): FieldExtractorContext => ({ typeEnv: { lookup: () => undefined, @@ -35,7 +41,7 @@ const createMockContext = (): FieldExtractorContext => ({ allScopes: () => new Map(), constructorTypeMap: new Map(), } as TypeEnvironment, - symbolTable: createSymbolTable(), + symbolTable: createSemanticModel().symbols, filePath: 'test.ts', language: SupportedLanguages.TypeScript, }); diff --git a/gitnexus/test/unit/heritage-map.test.ts b/gitnexus/test/unit/heritage-map.test.ts index b4a6b1c4f8..1b206bfe5f 100644 --- a/gitnexus/test/unit/heritage-map.test.ts +++ b/gitnexus/test/unit/heritage-map.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; +import { getHeritageStrategyForLanguage } from '../../src/core/ingestion/heritage-processor.js'; describe('buildHeritageMap', () => { let ctx: ResolutionContext; @@ -17,8 +18,8 @@ describe('buildHeritageMap', () => { describe('getParents', () => { it('returns direct parents for a single extends relationship', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -29,8 +30,8 @@ describe('buildHeritageMap', () => { }); it('returns direct parents for implements relationship', () => { - ctx.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class'); - ctx.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface'); + ctx.model.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class'); + ctx.model.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -46,8 +47,8 @@ describe('buildHeritageMap', () => { }); it('returns direct parents for trait-impl relationship', () => { - ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -63,9 +64,14 @@ describe('buildHeritageMap', () => { }); it('returns multiple parents when class extends and implements', () => { - ctx.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/serializable.ts', 'Serializable', 'iface:Serializable', 'Interface'); + ctx.model.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add( + 'src/serializable.ts', + 'Serializable', + 'iface:Serializable', + 'Interface', + ); const heritage: ExtractedHeritage[] = [ { filePath: 'src/admin.ts', className: 'Admin', parentName: 'User', kind: 'extends' }, @@ -90,7 +96,7 @@ describe('buildHeritageMap', () => { }); it('skips heritage records where child class is not in symbol table', () => { - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -107,7 +113,7 @@ describe('buildHeritageMap', () => { }); it('skips heritage records where parent class is not in symbol table', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -123,7 +129,7 @@ describe('buildHeritageMap', () => { }); it('skips self-references', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/a.ts', className: 'A', parentName: 'A', kind: 'extends' }, @@ -134,8 +140,8 @@ describe('buildHeritageMap', () => { }); it('deduplicates cross-chunk duplicates', () => { - ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -151,9 +157,9 @@ describe('buildHeritageMap', () => { describe('getAncestors', () => { it('returns full ancestor chain for multi-level inheritance', () => { - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' }, @@ -173,10 +179,10 @@ describe('buildHeritageMap', () => { // B C // \ / // D - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' }, @@ -194,8 +200,8 @@ describe('buildHeritageMap', () => { }); it('protects against cycles', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' }, @@ -212,9 +218,9 @@ describe('buildHeritageMap', () => { }); it('protects against multi-node cycles (A→B→C→A)', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); // A → B → C → A (3-node cycle) const heritage: ExtractedHeritage[] = [ @@ -232,7 +238,7 @@ describe('buildHeritageMap', () => { }); it('returns empty array for node with no parents', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const map = buildHeritageMap([], ctx); expect(map.getAncestors('class:A')).toEqual([]); @@ -249,9 +255,9 @@ describe('buildHeritageMap', () => { for (let i = 0; i < 40; i++) { const childName = `Level${i}`; const parentName = `Level${i + 1}`; - ctx.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class'); + ctx.model.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class'); if (i === 39) { - ctx.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class'); + ctx.model.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class'); } heritage.push({ filePath: `src/${childName}.ts`, @@ -289,9 +295,9 @@ describe('buildHeritageMap', () => { describe('getImplementorFiles', () => { it('records direct implements edges per interface name', () => { - ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('b.java', 'D', 'class:D', 'Class'); - ctx.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface'); + ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('b.java', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface'); const heritage: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' }, @@ -302,9 +308,9 @@ describe('buildHeritageMap', () => { }); it('only records implementors for interface parents, not class parents', () => { - ctx.symbols.add('a.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('iface.java', 'I', 'iface:I', 'Interface'); + ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('iface.java', 'I', 'iface:I', 'Interface'); const heritage: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' }, @@ -326,7 +332,7 @@ describe('buildHeritageMap', () => { // Only the child class is registered; the parent interface has no symbol. // resolveExtendsType must fall through to the provider heuristic and // classify `IDisposable` as IMPLEMENTS. - ctx.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class'); + ctx.model.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -336,14 +342,14 @@ describe('buildHeritageMap', () => { kind: 'extends', }, ]; - const map = buildHeritageMap(heritage, ctx); + const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage); expect(map.getImplementorFiles('IDisposable')).toEqual(new Set(['src/Service.cs'])); }); it('records Swift extends→IMPLEMENTS via heritageDefaultEdge when parent is unresolved', () => { // Swift provider has heritageDefaultEdge: 'IMPLEMENTS'. // Unresolved parents should default to IMPLEMENTS (protocol conformance). - ctx.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class'); + ctx.model.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -353,7 +359,7 @@ describe('buildHeritageMap', () => { kind: 'extends', }, ]; - const map = buildHeritageMap(heritage, ctx); + const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage); expect(map.getImplementorFiles('SomeProtocol')).toEqual(new Set(['src/MyView.swift'])); }); @@ -361,8 +367,8 @@ describe('buildHeritageMap', () => { // Java/C# path: when ctx.resolve finds a matching symbol whose type is // Interface, resolveExtendsType returns IMPLEMENTS via the symbol lookup // (not the interfaceNamePattern fallback). - ctx.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface'); + ctx.model.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -377,8 +383,8 @@ describe('buildHeritageMap', () => { }); it('records Kotlin implements edges', () => { - ctx.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -393,8 +399,8 @@ describe('buildHeritageMap', () => { }); it('records TypeScript implements edges', () => { - ctx.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class'); - ctx.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface'); + ctx.model.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class'); + ctx.model.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -409,8 +415,8 @@ describe('buildHeritageMap', () => { }); it('records PHP implements edges', () => { - ctx.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class'); - ctx.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class'); + ctx.model.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -427,8 +433,8 @@ describe('buildHeritageMap', () => { it('does not record Rust trait-impl entries in the implementor index', () => { // Documented limitation: trait-impl is intentionally not added to the // implementor index — interface dispatch does not traverse trait objects. - ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); + ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface'); const heritage: ExtractedHeritage[] = [ { @@ -445,9 +451,9 @@ describe('buildHeritageMap', () => { }); it('heritage merged across chunks matches single-pass (chunk-order invariant)', () => { - ctx.symbols.add('a.java', 'A', 'class:A', 'Class'); - ctx.symbols.add('b.java', 'B', 'class:B', 'Class'); - ctx.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface'); + ctx.model.symbols.add('a.java', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('b.java', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface'); const chunk1: ExtractedHeritage[] = [ { filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' }, @@ -464,10 +470,10 @@ describe('buildHeritageMap', () => { describe('chunk-order invariant', () => { it('produces same result regardless of heritage record order', () => { - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); const heritage1: ExtractedHeritage[] = [ { filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' }, diff --git a/gitnexus/test/unit/heritage-processor.test.ts b/gitnexus/test/unit/heritage-processor.test.ts index 9c007e268d..e4b4be211d 100644 --- a/gitnexus/test/unit/heritage-processor.test.ts +++ b/gitnexus/test/unit/heritage-processor.test.ts @@ -4,8 +4,8 @@ import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('processHeritageFromExtracted', () => { let graph: ReturnType; @@ -18,8 +18,8 @@ describe('processHeritageFromExtracted', () => { describe('extends', () => { it('creates EXTENDS relationship between classes', async () => { - ctx.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.model.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -58,7 +58,7 @@ describe('processHeritageFromExtracted', () => { }); it('skips self-inheritance', async () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -76,8 +76,13 @@ describe('processHeritageFromExtracted', () => { describe('implements', () => { it('creates IMPLEMENTS relationship', async () => { - ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add( + 'src/service.ts', + 'UserService', + 'Class:src/service.ts:UserService', + 'Class', + ); + ctx.model.symbols.add( 'src/interfaces.ts', 'IService', 'Interface:src/interfaces.ts:IService', @@ -103,8 +108,8 @@ describe('processHeritageFromExtracted', () => { describe('trait-impl (Rust)', () => { it('creates IMPLEMENTS relationship for trait impl', async () => { - ctx.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); - ctx.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); + ctx.model.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); + ctx.model.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); const heritage: ExtractedHeritage[] = [ { @@ -125,8 +130,13 @@ describe('processHeritageFromExtracted', () => { describe('C# interface resolution from extends captures', () => { it('emits IMPLEMENTS when parent is an Interface in symbol table', async () => { - ctx.symbols.add('src/Service.cs', 'UserService', 'Class:src/Service.cs:UserService', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add( + 'src/Service.cs', + 'UserService', + 'Class:src/Service.cs:UserService', + 'Class', + ); + ctx.model.symbols.add( 'src/IService.cs', 'IService', 'Interface:src/IService.cs:IService', @@ -153,8 +163,8 @@ describe('processHeritageFromExtracted', () => { }); it('emits EXTENDS when parent is a Class in symbol table', async () => { - ctx.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); - ctx.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); + ctx.model.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); + ctx.model.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); const heritage: ExtractedHeritage[] = [ { @@ -246,15 +256,20 @@ describe('processHeritageFromExtracted', () => { }); it('handles mixed class + interface base_list from C#', async () => { - ctx.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); - ctx.symbols.add('src/Base.cs', 'BaseRepository', 'Class:src/Base.cs:BaseRepository', 'Class'); - ctx.symbols.add( + ctx.model.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); + ctx.model.symbols.add( + 'src/Base.cs', + 'BaseRepository', + 'Class:src/Base.cs:BaseRepository', + 'Class', + ); + ctx.model.symbols.add( 'src/IRepo.cs', 'IRepository', 'Interface:src/IRepo.cs:IRepository', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/IDisp.cs', 'IDisposable', 'Interface:src/IDisp.cs:IDisposable', @@ -314,7 +329,7 @@ describe('processHeritageFromExtracted', () => { it('still uses symbol table authoritatively for Swift (Tier 1 takes precedence)', async () => { // When the parent is in the symbol table as a Class, EXTENDS wins even in Swift - ctx.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); + ctx.model.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); const heritage: ExtractedHeritage[] = [ { diff --git a/gitnexus/test/unit/import-processor.test.ts b/gitnexus/test/unit/import-processor.test.ts index eb5fe5c19a..0396be2cb4 100644 --- a/gitnexus/test/unit/import-processor.test.ts +++ b/gitnexus/test/unit/import-processor.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { buildImportResolutionContext } from '../../src/core/ingestion/import-processor.js'; import type { ImportResolutionContext } from '../../src/core/ingestion/import-resolvers/types.js'; -import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; describe('ResolutionContext.importMap', () => { it('creates an empty Map', () => { diff --git a/gitnexus/test/unit/model/field-registry.test.ts b/gitnexus/test/unit/model/field-registry.test.ts new file mode 100644 index 0000000000..4c6e093d78 --- /dev/null +++ b/gitnexus/test/unit/model/field-registry.test.ts @@ -0,0 +1,74 @@ +/** + * Unit tests for FieldRegistry (SM-20). + * + * FieldRegistry is the simplest of the three owner-scoped registries — + * one flat Map keyed on `ownerNodeId\0fieldName`. These tests pin the + * basic register/lookup/clear contract and the owner-scope isolation. + */ + +import { describe, it, expect } from 'vitest'; +import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'prop:test', type: 'Property', ...overrides }); + +describe('FieldRegistry', () => { + it('lookupFieldByOwner returns undefined when the registry is empty', () => { + const reg = createFieldRegistry(); + expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('register + lookup round-trips the exact def reference', () => { + const reg = createFieldRegistry(); + const def = makeDef({ nodeId: 'prop:User.name', declaredType: 'string' }); + + reg.register('class:User', 'name', def); + + expect(reg.lookupFieldByOwner('class:User', 'name')).toBe(def); + }); + + it('isolates fields by ownerNodeId — same field name on two classes does not collide', () => { + const reg = createFieldRegistry(); + const userName = makeDef({ nodeId: 'prop:User.name' }); + const orderName = makeDef({ nodeId: 'prop:Order.name' }); + + reg.register('class:User', 'name', userName); + reg.register('class:Order', 'name', orderName); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + expect(reg.lookupFieldByOwner('class:Order', 'name')?.nodeId).toBe('prop:Order.name'); + }); + + it('last-wins on duplicate (ownerNodeId, fieldName) — registry is flat, not an overload list', () => { + const reg = createFieldRegistry(); + const first = makeDef({ nodeId: 'prop:User.name#first' }); + const second = makeDef({ nodeId: 'prop:User.name#second' }); + + reg.register('class:User', 'name', first); + reg.register('class:User', 'name', second); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#second'); + }); + + it('clear() empties the registry', () => { + const reg = createFieldRegistry(); + reg.register('class:User', 'name', makeDef()); + reg.register('class:Order', 'total', makeDef()); + + reg.clear(); + + expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(reg.lookupFieldByOwner('class:Order', 'total')).toBeUndefined(); + }); + + it('allows re-registration after clear', () => { + const reg = createFieldRegistry(); + reg.register('class:User', 'name', makeDef({ nodeId: 'prop:first' })); + reg.clear(); + reg.register('class:User', 'name', makeDef({ nodeId: 'prop:second' })); + + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:second'); + }); +}); diff --git a/gitnexus/test/unit/model/helpers.ts b/gitnexus/test/unit/model/helpers.ts new file mode 100644 index 0000000000..04856f8d30 --- /dev/null +++ b/gitnexus/test/unit/model/helpers.ts @@ -0,0 +1,27 @@ +/** + * Shared test helpers for the model/ unit tests. + * + * Keep this file minimal — just the factory functions that every + * registry/table test needs. Anything domain-specific belongs in the + * test file that uses it. + */ + +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; + +/** + * Build a {@link SymbolDefinition} with sensible defaults. Every field + * is overridable. Defaults produce a Method-typed def so the caller + * only has to override for other shapes. + */ +export const makeDef = (overrides: Partial = {}): SymbolDefinition => ({ + nodeId: 'def:test', + filePath: 'src/test.ts', + type: 'Method', + ...overrides, +}); + +/** + * Alias for {@link makeDef} kept for readability in method-registry + * tests where "makeMethod" reads more naturally at the call site. + */ +export const makeMethod = makeDef; diff --git a/gitnexus/test/unit/model/method-registry.test.ts b/gitnexus/test/unit/model/method-registry.test.ts new file mode 100644 index 0000000000..bdb534202d --- /dev/null +++ b/gitnexus/test/unit/model/method-registry.test.ts @@ -0,0 +1,374 @@ +/** + * Unit tests for MethodRegistry (SM-20). + * + * MethodRegistry is the most complex of the three owner-scoped registries + * because it supports C++/Java/C# overloads. Lookup does two layers of + * narrowing after the primary `ownerNodeId + methodName` key match: + * + * 1. Arity filter: when `argCount` is provided and there are multiple + * overloads, keep only those whose parameterCount range can match. + * Variadic candidates (`parameterCount === undefined`) are retained. + * If arity excludes EVERY candidate, fall back to the full pool so + * fuzzy resolution still has something to work with (the "arity + * fallback" branch — flagged as an untested branch by the testing + * reviewer). + * + * 2. Return-type dedup: among the remaining candidates, if every def + * shares the same defined returnType, return the first. If return + * types differ, return undefined (truly ambiguous). + */ + +import { describe, it, expect } from 'vitest'; +import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js'; +import { makeMethod } from './helpers.js'; + +describe('MethodRegistry — basic lookup', () => { + it('returns undefined when the registry is empty', () => { + const reg = createMethodRegistry(); + expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + }); + + it('register + lookup round-trips the def reference', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + expect(reg.lookupMethodByOwner('class:User', 'save')).toBe(def); + }); + + it('isolates methods by ownerNodeId — same method name on two classes does not collide', () => { + const reg = createMethodRegistry(); + const userSave = makeMethod({ nodeId: 'method:User.save' }); + const orderSave = makeMethod({ nodeId: 'method:Order.save' }); + + reg.register('class:User', 'save', userSave); + reg.register('class:Order', 'save', orderSave); + + expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:User.save'); + expect(reg.lookupMethodByOwner('class:Order', 'save')?.nodeId).toBe('method:Order.save'); + }); +}); + +describe('MethodRegistry — arity narrowing', () => { + it('narrows overloads by parameterCount when argCount is provided', () => { + const reg = createMethodRegistry(); + const greetEmpty = makeMethod({ nodeId: 'method:greet#0', parameterCount: 0 }); + const greetString = makeMethod({ + nodeId: 'method:greet#1', + parameterCount: 1, + returnType: 'void', + }); + + reg.register('class:User', 'greet', greetEmpty); + reg.register('class:User', 'greet', greetString); + + // argCount 0 matches only the 0-arg overload + expect(reg.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe('method:greet#0'); + // argCount 1 matches only the 1-arg overload + expect(reg.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe('method:greet#1'); + }); + + it('arity fallback — when no overload matches argCount, returns from the full pool (testing reviewer T-01)', () => { + // This is the explicit arity-fallback branch flagged as untested. + // Without the fallback, `save(1)` / `save(2)` with argCount=3 would + // return undefined. With the fallback, it returns one of them so the + // caller's fuzzy resolution path can still make progress. + const reg = createMethodRegistry(); + const save1 = makeMethod({ nodeId: 'method:save#1', parameterCount: 1, returnType: 'void' }); + const save2 = makeMethod({ nodeId: 'method:save#2', parameterCount: 2, returnType: 'void' }); + + reg.register('class:User', 'save', save1); + reg.register('class:User', 'save', save2); + + // argCount 3 matches neither; fallback returns one of them (first + // wins because both share the same returnType 'void'). + const result = reg.lookupMethodByOwner('class:User', 'save', 3); + expect(result).toBeDefined(); + expect(result?.nodeId).toBe('method:save#1'); + }); + + it('requiredParameterCount range — argCount between required and total is accepted (testing reviewer T-02)', () => { + // Default parameters: `bar(a, b=1, c=2)` has requiredParameterCount: 1, + // parameterCount: 3. Calls with argCount 1, 2, and 3 must all match. + const reg = createMethodRegistry(); + const bar = makeMethod({ + nodeId: 'method:bar', + parameterCount: 3, + requiredParameterCount: 1, + returnType: 'int', + }); + // Add a second overload so arity filtering engages (defs.length > 1). + const barOther = makeMethod({ + nodeId: 'method:bar#other', + parameterCount: 5, + requiredParameterCount: 5, + returnType: 'int', + }); + + reg.register('class:Calc', 'bar', bar); + reg.register('class:Calc', 'bar', barOther); + + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 1)?.nodeId).toBe('method:bar'); + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 2)?.nodeId).toBe('method:bar'); + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 3)?.nodeId).toBe('method:bar'); + // argCount 5 matches the second overload only + expect(reg.lookupMethodByOwner('class:Calc', 'bar', 5)?.nodeId).toBe('method:bar#other'); + }); + + it('variadic fallback — defs with parameterCount=undefined are retained during arity narrowing', () => { + const reg = createMethodRegistry(); + const fixed = makeMethod({ + nodeId: 'method:print#fixed', + parameterCount: 1, + returnType: 'void', + }); + const variadic = makeMethod({ + nodeId: 'method:print#variadic', + parameterCount: undefined, + returnType: 'void', + }); + + reg.register('class:Logger', 'print', fixed); + reg.register('class:Logger', 'print', variadic); + + // argCount 5 excludes fixed (5 > parameterCount 1) but retains + // variadic (parameterCount=undefined bypasses the range check). + // Result: variadic is the only surviving candidate. + const result = reg.lookupMethodByOwner('class:Logger', 'print', 5); + expect(result?.nodeId).toBe('method:print#variadic'); + }); + + it('variadic + matching fixed — argCount in fixed range keeps both, first wins on shared returnType', () => { + const reg = createMethodRegistry(); + const fixed = makeMethod({ + nodeId: 'method:print#fixed', + parameterCount: 2, + returnType: 'void', + }); + const variadic = makeMethod({ + nodeId: 'method:print#variadic', + parameterCount: undefined, + returnType: 'void', + }); + + reg.register('class:Logger', 'print', fixed); + reg.register('class:Logger', 'print', variadic); + + // argCount 2 satisfies fixed's range AND keeps variadic. + // Both share returnType 'void', so first-registered wins. + const result = reg.lookupMethodByOwner('class:Logger', 'print', 2); + expect(result?.nodeId).toBe('method:print#fixed'); + }); +}); + +describe('MethodRegistry — return-type dedup', () => { + it('returns first when all overloads share the same returnType', () => { + const reg = createMethodRegistry(); + const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: 'int' }); + const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' }); + + reg.register('class:X', 'compute', a); + reg.register('class:X', 'compute', b); + + // Two overloads with same arity & same returnType → first wins + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)?.nodeId).toBe('method:a'); + }); + + it('returns undefined when overloads differ in returnType (truly ambiguous)', () => { + const reg = createMethodRegistry(); + const intVersion = makeMethod({ + nodeId: 'method:int', + parameterCount: 1, + returnType: 'int', + }); + const stringVersion = makeMethod({ + nodeId: 'method:string', + parameterCount: 1, + returnType: 'string', + }); + + reg.register('class:X', 'compute', intVersion); + reg.register('class:X', 'compute', stringVersion); + + // Same arity, different returnType → undefined (ambiguous) + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined(); + }); + + it('returns undefined when firstReturnType is itself undefined', () => { + const reg = createMethodRegistry(); + const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: undefined }); + const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' }); + + reg.register('class:X', 'compute', a); + reg.register('class:X', 'compute', b); + + // First def has no declared returnType → bail out as undefined + expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined(); + }); + + it('single-overload methods skip the dedup path', () => { + const reg = createMethodRegistry(); + reg.register( + 'class:X', + 'only', + makeMethod({ nodeId: 'method:only', parameterCount: 1, returnType: undefined }), + ); + + // Only one candidate → returned directly regardless of returnType + expect(reg.lookupMethodByOwner('class:X', 'only', 1)?.nodeId).toBe('method:only'); + }); +}); + +describe('MethodRegistry — clear()', () => { + it('empties the registry', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod()); + reg.register('class:Order', 'update', makeMethod()); + + reg.clear(); + + expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(reg.lookupMethodByOwner('class:Order', 'update')).toBeUndefined(); + }); + + it('allows re-registration after clear', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:first' })); + reg.clear(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:second' })); + + expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:second'); + }); +}); + +// --------------------------------------------------------------------------- +// lookupMethodByName — flat-by-name secondary index (A4 / plan 006) +// --------------------------------------------------------------------------- + +describe('MethodRegistry — lookupMethodByName', () => { + it('returns an empty array when no method with that name is registered', () => { + const reg = createMethodRegistry(); + expect(reg.lookupMethodByName('save')).toEqual([]); + }); + + it('returns a singleton array after one registration', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(def); + }); + + it('accumulates homonym registrations across different owners in order', () => { + const reg = createMethodRegistry(); + const userSave = makeMethod({ nodeId: 'method:User.save' }); + const orderSave = makeMethod({ nodeId: 'method:Order.save' }); + + reg.register('class:User', 'save', userSave); + reg.register('class:Order', 'save', orderSave); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(2); + expect(result).toEqual([userSave, orderSave]); + }); + + it('accumulates overloads under the same owner', () => { + const reg = createMethodRegistry(); + const overload1 = makeMethod({ nodeId: 'method:User.save#0', parameterCount: 0 }); + const overload2 = makeMethod({ nodeId: 'method:User.save#1', parameterCount: 1 }); + + reg.register('class:User', 'save', overload1); + reg.register('class:User', 'save', overload2); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(2); + expect(result).toEqual([overload1, overload2]); + }); + + it('returns an empty array after clear()', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' })); + + reg.clear(); + + expect(reg.lookupMethodByName('save')).toEqual([]); + }); + + it('re-registering after clear only returns post-clear defs', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' })); + reg.clear(); + const fresh = makeMethod({ nodeId: 'method:fresh' }); + reg.register('class:User', 'save', fresh); + + const result = reg.lookupMethodByName('save'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(fresh); + }); + + it('returns the same SymbolDefinition reference as lookupMethodByOwner (dual-index identity)', () => { + const reg = createMethodRegistry(); + const def = makeMethod({ nodeId: 'method:User.save' }); + + reg.register('class:User', 'save', def); + + const byOwner = reg.lookupMethodByOwner('class:User', 'save'); + const byName = reg.lookupMethodByName('save'); + + expect(byName).toHaveLength(1); + expect(Object.is(byName[0], byOwner)).toBe(true); + }); + + it('does not return methods with different names', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save' })); + reg.register('class:User', 'load', makeMethod({ nodeId: 'method:User.load' })); + + expect(reg.lookupMethodByName('save')).toHaveLength(1); + expect(reg.lookupMethodByName('load')).toHaveLength(1); + expect(reg.lookupMethodByName('missing')).toEqual([]); + }); +}); + +describe('hasFunctionMethods flag', () => { + it('is false for a fresh registry', () => { + const reg = createMethodRegistry(); + expect(reg.hasFunctionMethods).toBe(false); + }); + + it('stays false after registering only strict-Method defs', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save', type: 'Method' })); + reg.register( + 'class:User', + 'load', + makeMethod({ nodeId: 'method:User.load', type: 'Constructor' }), + ); + expect(reg.hasFunctionMethods).toBe(false); + }); + + it('flips to true when a Function-typed def (Python/Rust/Kotlin class method) is registered', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + expect(reg.hasFunctionMethods).toBe(true); + }); + + it('stays true after further strict-Method registrations', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + reg.register('class:Dog', 'bark', makeMethod({ nodeId: 'method:Dog.bark', type: 'Method' })); + expect(reg.hasFunctionMethods).toBe(true); + }); + + it('resets to false after clear()', () => { + const reg = createMethodRegistry(); + reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' })); + expect(reg.hasFunctionMethods).toBe(true); + reg.clear(); + expect(reg.hasFunctionMethods).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/model/registration-table.test.ts b/gitnexus/test/unit/model/registration-table.test.ts new file mode 100644 index 0000000000..067b7fc20d --- /dev/null +++ b/gitnexus/test/unit/model/registration-table.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from 'vitest'; +import { + createRegistrationTable, + CALLABLE_ONLY_LABELS, + INERT_LABELS, + DISPATCH_LABELS, +} from '../../../src/core/ingestion/model/registration-table.js'; +import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js'; +import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js'; +import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js'; +import { ALL_NODE_LABELS } from '../../../src/core/ingestion/model/index.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeDeps = () => ({ + types: createTypeRegistry(), + methods: createMethodRegistry(), + fields: createFieldRegistry(), +}); + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'node:test', type: 'Class', ...overrides }); + +// --------------------------------------------------------------------------- +// Basic factory + table shape +// --------------------------------------------------------------------------- + +describe('createRegistrationTable', () => { + it('returns a Map with one entry per DISPATCH_LABELS value', () => { + const table = createRegistrationTable(makeDeps()); + expect(table.size).toBe(DISPATCH_LABELS.size); + for (const label of DISPATCH_LABELS) { + expect(table.has(label)).toBe(true); + } + }); + + it('every DISPATCH_LABELS entry maps to a hook function', () => { + const table = createRegistrationTable(makeDeps()); + for (const [, hook] of table) { + expect(typeof hook).toBe('function'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Kind taxonomy exhaustiveness +// --------------------------------------------------------------------------- + +describe('NodeLabel taxonomy coverage', () => { + // ALL_NODE_LABELS is imported from model/index.ts (re-exported from + // semantic-model.ts) so that the production list and the test list + // cannot drift. If the shared NodeLabel union gains a new member, add + // it to the single list in semantic-model.ts AND to one of the + // registration-table allowlists in the same commit. + + it('every NodeLabel appears in exactly one of DISPATCH / CALLABLE_ONLY / INERT', () => { + for (const label of ALL_NODE_LABELS) { + const inDispatch = DISPATCH_LABELS.has(label); + const inCallableOnly = CALLABLE_ONLY_LABELS.has(label); + const inInert = INERT_LABELS.has(label); + const count = Number(inDispatch) + Number(inCallableOnly) + Number(inInert); + expect(count, `label ${label} must be in exactly one category`).toBe(1); + } + }); + + it('CALLABLE_ONLY_LABELS includes Function, Macro, Delegate', () => { + expect(CALLABLE_ONLY_LABELS.has('Function')).toBe(true); + expect(CALLABLE_ONLY_LABELS.has('Macro')).toBe(true); + expect(CALLABLE_ONLY_LABELS.has('Delegate')).toBe(true); + }); + + it('DISPATCH_LABELS includes all 10 routed kinds', () => { + const expected = [ + 'Class', + 'Struct', + 'Interface', + 'Enum', + 'Record', + 'Trait', + 'Method', + 'Constructor', + 'Property', + 'Impl', + ] as const; + for (const label of expected) { + expect(DISPATCH_LABELS.has(label)).toBe(true); + } + expect(DISPATCH_LABELS.size).toBe(expected.length); + }); + + it('INERT_LABELS includes metadata-only node kinds', () => { + expect(INERT_LABELS.has('File')).toBe(true); + expect(INERT_LABELS.has('Folder')).toBe(true); + expect(INERT_LABELS.has('Namespace')).toBe(true); + expect(INERT_LABELS.has('Variable')).toBe(true); + expect(INERT_LABELS.has('Import')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Behavior group coverage — every label in a behavior group routes to the +// group's registry write, regardless of how hooks are implemented (shared +// closure, per-label closure, etc.). These tests survive an internal +// refactor to per-label closures for tracing/metrics — unlike +// reference-equality assertions on the hook functions themselves. +// --------------------------------------------------------------------------- + +describe('class-like behavior group — all 6 labels route to types.registerClass', () => { + const CLASS_LIKE_LABELS = ['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Trait'] as const; + + for (const label of CLASS_LIKE_LABELS) { + it(`${label} writes to types.registerClass`, () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: `${label.toLowerCase()}:User`, + type: label, + qualifiedName: `app.User`, + }); + table.get(label)!('User', def); + expect(deps.types.lookupClassByName('User')).toHaveLength(1); + }); + } +}); + +describe('method-like behavior group — Method and Constructor route to methods.register', () => { + for (const label of ['Method', 'Constructor'] as const) { + it(`${label} writes to methods.register when ownerId is set`, () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: `${label.toLowerCase()}:save`, + type: label, + ownerId: 'class:User', + }); + table.get(label)!('save', def); + expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe( + `${label.toLowerCase()}:save`, + ); + }); + } +}); + +describe('behavior group isolation', () => { + it('class-like hooks never touch methods or fields', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'class:User', + type: 'Class', + ownerId: 'unrelated', + }); + table.get('Class')!('User', def); + // No method or field registered — class hook is isolated to types. + expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined(); + expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined(); + }); + + it('Impl hooks write to types.registerImpl, never to types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + table.get('Impl')!('User', def); + expect(deps.types.lookupImplByName('User')).toHaveLength(1); + expect(deps.types.lookupClassByName('User')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end hook behavior with real registries +// --------------------------------------------------------------------------- + +describe('hook behavior (real registries, no mocks)', () => { + it('classLikeHook writes to types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'class:User', type: 'Class', qualifiedName: 'app.User' }); + table.get('Class')!('User', def); + expect(deps.types.lookupClassByName('User')).toHaveLength(1); + expect(deps.types.lookupClassByQualifiedName('app.User')).toHaveLength(1); + }); + + it('classLikeHook falls back to the simple name when qualifiedName is absent', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'class:User', type: 'Class' }); + table.get('Class')!('User', def); + expect(deps.types.lookupClassByQualifiedName('User')).toHaveLength(1); + }); + + it('methodHook writes to methods.register when ownerId is set', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'mtd:save', + type: 'Method', + ownerId: 'class:User', + }); + table.get('Method')!('save', def); + expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:save'); + }); + + it('methodHook silently skips registration when ownerId is missing', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'mtd:free', type: 'Method' }); + table.get('Method')!('free', def); + expect(deps.methods.lookupMethodByOwner('', 'free')).toBeUndefined(); + }); + + it('propertyHook writes to fields.register when ownerId is set', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ + nodeId: 'prop:name', + type: 'Property', + ownerId: 'class:User', + declaredType: 'string', + }); + table.get('Property')!('name', def); + expect(deps.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:name'); + }); + + it('propertyHook silently skips registration when ownerId is missing', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'prop:orphan', type: 'Property' }); + table.get('Property')!('orphan', def); + expect(deps.fields.lookupFieldByOwner('', 'orphan')).toBeUndefined(); + }); + + it('implHook writes to types.registerImpl, NOT types.registerClass', () => { + const deps = makeDeps(); + const table = createRegistrationTable(deps); + const def = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + table.get('Impl')!('User', def); + expect(deps.types.lookupImplByName('User')).toHaveLength(1); + // Critical: Impl must not pollute classByName — heritage resolution + // would otherwise treat an Impl as a parent type candidate. + expect(deps.types.lookupClassByName('User')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Factory-per-instance isolation +// --------------------------------------------------------------------------- + +describe('factory-per-instance isolation', () => { + it('two independent tables write to their own registries only', () => { + const depsA = makeDeps(); + const depsB = makeDeps(); + const tableA = createRegistrationTable(depsA); + const tableB = createRegistrationTable(depsB); + + tableA.get('Class')!('UserA', makeDef({ nodeId: 'class:UserA', type: 'Class' })); + tableB.get('Class')!('UserB', makeDef({ nodeId: 'class:UserB', type: 'Class' })); + + expect(depsA.types.lookupClassByName('UserA')).toHaveLength(1); + expect(depsA.types.lookupClassByName('UserB')).toHaveLength(0); + expect(depsB.types.lookupClassByName('UserB')).toHaveLength(1); + expect(depsB.types.lookupClassByName('UserA')).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/model/resolution-context.test.ts b/gitnexus/test/unit/model/resolution-context.test.ts new file mode 100644 index 0000000000..aae0a50739 --- /dev/null +++ b/gitnexus/test/unit/model/resolution-context.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for `ResolutionContext.resolve()` — the tiered name + * resolution that backs call-processor's Tier 1 / 2a-named / 2a / 2b / 3 + * pipeline. These tests pin invariants that TypeScript cannot prove at + * build time: tier precedence, cross-index dedup, and the + * walkBindingChain cycle/depth guards. + */ + +import { describe, it, expect } from 'vitest'; +import { createResolutionContext } from '../../../src/core/ingestion/model/resolution-context.js'; + +describe('ResolutionContext.resolve() — tier precedence', () => { + it('Tier 2a-named binding chain takes precedence over Tier 2a import-scoped', () => { + // Setup: A imports { User as U } from B. B defines both User (the + // real one) and U (an unrelated same-name symbol). A resolve('U') in + // file A must prefer the aliased binding chain (U → User in B), + // NOT the raw Tier 2a lookup that would find B's own 'U'. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'U', 'class:U_decoy', 'Class'); + + // Register the import A → B and the aliased binding A.U → B.User. + ctx.importMap.set('src/a.ts', new Set(['src/b.ts'])); + const aliasBindings = new Map(); + aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' }); + ctx.namedImportMap.set('src/a.ts', aliasBindings); + + const result = ctx.resolve('U', 'src/a.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + // The named-binding chain resolves U → User, not U → U_decoy. + expect(result!.candidates.map((c) => c.nodeId)).toEqual(['class:User']); + }); + + it('Tier 1 (same-file) beats Tier 2a even when an aliased import exists', () => { + // Belt-and-suspenders check: if the caller's own file has a matching + // symbol, it wins — aliased bindings only fire when Tier 1 misses. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/a.ts', 'U', 'fn:local:U', 'Function'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class'); + + const aliasBindings = new Map(); + aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' }); + ctx.namedImportMap.set('src/a.ts', aliasBindings); + + const result = ctx.resolve('U', 'src/a.ts'); + expect(result!.tier).toBe('same-file'); + expect(result!.candidates[0].nodeId).toBe('fn:local:U'); + }); +}); + +describe('ResolutionContext.resolve() — Tier 3 dedup for Function+ownerId', () => { + it('Python/Rust/Kotlin class methods emitted as Function+ownerId land in only one Tier 3 result', () => { + // Simulate the Python worker path: a class method is emitted with + // type='Function' and ownerId set. `rawSymbols.add` lands it in + // callableByName (via the Function callable-index gate) AND + // `wrappedAdd` normalizes the dispatch key to 'Method' so it also + // lands in methodRegistry. The same SymbolDefinition reference is + // reachable via two Tier 3 lookups. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.py', 'greet', 'fn:User.greet', 'Function', { + ownerId: 'class:User', + returnType: 'str', + }); + + // Sanity check the setup: the same def is in both indexes. + expect(ctx.model.symbols.lookupCallableByName('greet')).toHaveLength(1); + expect(ctx.model.methods.lookupMethodByName('greet')).toHaveLength(1); + expect(ctx.model.methods.hasFunctionMethods).toBe(true); + + // Resolve a free 'greet' call from an unrelated file — Tier 1 / 2a / + // 2b all miss, so Tier 3 fires. The dedup pass must collapse the + // two index hits into a single candidate. + const result = ctx.resolve('greet', 'src/caller.py'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates).toHaveLength(1); + expect(result!.candidates[0].nodeId).toBe('fn:User.greet'); + }); + + it('Tier 3 fast path fires when no Function+ownerId was ever registered', () => { + // Pure TypeScript-style: methods are emitted as strict Method labels, + // so callableByName and methodRegistry are disjoint and the dedup + // fast path can concat without a Set allocation. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + returnType: 'string', + }); + ctx.model.symbols.add('src/utils.ts', 'greet', 'fn:utils.greet', 'Function'); + + expect(ctx.model.methods.hasFunctionMethods).toBe(false); + + // Tier 3 for 'greet' from an unrelated file returns both the free + // function and the class method; neither overlaps so no dedup. + const result = ctx.resolve('greet', 'src/caller.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('global'); + expect(result!.candidates.map((c) => c.nodeId).sort()).toEqual([ + 'fn:utils.greet', + 'method:User.greet', + ]); + }); +}); + +describe('ResolutionContext.resolve() — walkBindingChain guards', () => { + it('circular re-export returns null (cycle detection fires)', () => { + // A imports { X } from B, B re-exports { X } from A. + // walkBindingChain must detect the cycle via the visited Set and + // return null instead of looping until depth exceeded. + const ctx = createResolutionContext(); + // Intentionally leave X undefined in both files — the walker only + // follows re-export edges, not definitions. + const aBindings = new Map(); + aBindings.set('X', { sourcePath: 'src/b.ts', exportedName: 'X' }); + ctx.namedImportMap.set('src/a.ts', aBindings); + const bBindings = new Map(); + bBindings.set('X', { sourcePath: 'src/a.ts', exportedName: 'X' }); + ctx.namedImportMap.set('src/b.ts', bBindings); + + const result = ctx.resolve('X', 'src/a.ts'); + // No definition anywhere in the chain → Tier 2a-named returns null, + // nothing else matches, overall result is null. + expect(result).toBeNull(); + }); + + it('chain deeper than MAX_BINDING_CHAIN_DEPTH drops the named-binding path', () => { + // Build a six-hop re-export chain where every hop just forwards the + // binding. walkBindingChain iterates 5 times and hits the depth cap + // before the sixth hop, returning null. No other tier can resolve + // 'X' either (no X is registered anywhere), so the overall + // `ctx.resolve` call returns null. + const ctx = createResolutionContext(); + const chain = [ + 'src/a.ts', + 'src/b.ts', + 'src/c.ts', + 'src/d.ts', + 'src/e.ts', + 'src/f.ts', + 'src/g.ts', + ]; + for (let i = 0; i < chain.length - 1; i++) { + const bindings = new Map(); + bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' }); + ctx.namedImportMap.set(chain[i], bindings); + } + // No symbol registered in any file — the chain walk is the only + // possible resolution path, and the depth cap silently kills it. + const result = ctx.resolve('X', 'src/a.ts'); + expect(result).toBeNull(); + }); + + it('chain of exactly five hops resolves successfully at the boundary', () => { + // Five hops from A is exactly MAX_BINDING_CHAIN_DEPTH — the final + // lookup on the fifth hop must succeed. + const ctx = createResolutionContext(); + ctx.model.symbols.add('src/e.ts', 'X', 'class:X', 'Class'); + const chain = ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts']; + for (let i = 0; i < chain.length - 1; i++) { + const bindings = new Map(); + bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' }); + ctx.namedImportMap.set(chain[i], bindings); + } + + const result = ctx.resolve('X', 'src/a.ts'); + expect(result).not.toBeNull(); + expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates[0].nodeId).toBe('class:X'); + }); +}); diff --git a/gitnexus/test/unit/model/semantic-model.test.ts b/gitnexus/test/unit/model/semantic-model.test.ts new file mode 100644 index 0000000000..1e86ee5d38 --- /dev/null +++ b/gitnexus/test/unit/model/semantic-model.test.ts @@ -0,0 +1,124 @@ +/** + * Unit tests for SemanticModel factory and lifecycle. + * + * Focused on behaviors that are NOT covered by the transitive + * ingestion-pipeline tests in symbol-table.test.ts: + * + * 1. model.clear() must cascade to all four stores (types, methods, + * fields, rawSymbols). Post-A2 (plan 006 Unit 7), this is the only + * path that resets the leaf AND the registries. External consumers + * hold a SymbolTableReader which has no `clear()` method, so the + * phantom-resolution failure mode is statically impossible. + * + * 2. createSemanticModel() must construct successfully against the + * real ALL_NODE_LABELS and current registration-table allowlists. + * A failure here means the dev-time exhaustiveness guard is + * flagging real drift that needs a registration-table fix. + */ + +import { describe, it, expect } from 'vitest'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; + +describe('createSemanticModel', () => { + it('constructs successfully — no drift between ALL_NODE_LABELS and the registration-table allowlists', () => { + expect(() => createSemanticModel()).not.toThrow(); + }); +}); + +describe('model.clear() cascade (A2 / Unit 7)', () => { + it('clears the type registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + + expect(model.types.lookupClassByName('User')).toHaveLength(1); + + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + }); + + it('clears the field registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeDefined(); + + model.clear(); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('clears the method registry', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeDefined(); + + model.clear(); + + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined(); + }); + + it('clears the file and callable indexes', () => { + const model = createSemanticModel(); + model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function'); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(1); + expect(Array.from(model.symbols.getFiles())).toContain('src/utils.ts'); + + model.clear(); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(0); + expect(Array.from(model.symbols.getFiles())).not.toContain('src/utils.ts'); + }); + + it('is idempotent — calling twice leaves every store empty', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + }); + + model.clear(); + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.symbols.lookupCallableByName('User')).toHaveLength(0); + }); + + it('post-A2: model.symbols exposes no clear() method', () => { + // Static guarantee enforced by the SymbolTableReader interface — this + // runtime assertion documents the contract. + const model = createSemanticModel(); + expect('clear' in model.symbols).toBe(false); + }); +}); + +describe('model.clear() cascade', () => { + it('clears every store — types, methods, fields, symbols', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + }); + model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + + model.clear(); + + expect(model.types.lookupClassByName('User')).toHaveLength(0); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined(); + expect(model.symbols.lookupCallableByName('User')).toHaveLength(0); + expect(Array.from(model.symbols.getFiles())).not.toContain('src/user.ts'); + }); +}); diff --git a/gitnexus/test/unit/model/type-registry.test.ts b/gitnexus/test/unit/model/type-registry.test.ts new file mode 100644 index 0000000000..8f0eee8380 --- /dev/null +++ b/gitnexus/test/unit/model/type-registry.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for TypeRegistry (SM-20). + * + * TypeRegistry owns three indexes: classByName (simple name → defs), + * classByQualifiedName (FQN → defs), and implByName (Rust impl blocks). + * All three use array values to support homonym classes across files + * (e.g. two `User` classes in different packages) and Rust's multiple + * impl blocks per type. + */ + +import { describe, it, expect } from 'vitest'; +import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js'; +import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js'; +import { makeDef as makeBaseDef } from './helpers.js'; + +const makeDef = (overrides: Partial = {}): SymbolDefinition => + makeBaseDef({ nodeId: 'class:test', type: 'Class', ...overrides }); + +describe('TypeRegistry — classByName lookup', () => { + it('returns an empty array when the class is not registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupClassByName('Nonexistent')).toEqual([]); + }); + + it('returns the def reference after register', () => { + const reg = createTypeRegistry(); + const def = makeDef({ nodeId: 'class:User' }); + + reg.registerClass('User', 'app.User', def); + + expect(reg.lookupClassByName('User')).toEqual([def]); + }); + + it('accumulates homonym classes across files — second register appends, does not clobber', () => { + const reg = createTypeRegistry(); + const userApp = makeDef({ nodeId: 'class:app.User', filePath: 'src/app/user.ts' }); + const userAdmin = makeDef({ nodeId: 'class:admin.User', filePath: 'src/admin/user.ts' }); + + reg.registerClass('User', 'app.User', userApp); + reg.registerClass('User', 'admin.User', userAdmin); + + const result = reg.lookupClassByName('User'); + expect(result).toHaveLength(2); + expect(result.map((d) => d.nodeId)).toEqual(['class:app.User', 'class:admin.User']); + }); +}); + +describe('TypeRegistry — classByQualifiedName lookup', () => { + it('returns empty when the FQN is not registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]); + }); + + it('returns the def after register', () => { + const reg = createTypeRegistry(); + const def = makeDef({ nodeId: 'class:app.User' }); + + reg.registerClass('User', 'app.User', def); + + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([def]); + }); + + it('disambiguates homonym classes — same simple name, different FQNs resolve independently', () => { + const reg = createTypeRegistry(); + const userApp = makeDef({ nodeId: 'class:app.User' }); + const userAdmin = makeDef({ nodeId: 'class:admin.User' }); + + reg.registerClass('User', 'app.User', userApp); + reg.registerClass('User', 'admin.User', userAdmin); + + // Simple name returns both; qualified lookups split cleanly. + expect(reg.lookupClassByName('User')).toHaveLength(2); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([userApp]); + expect(reg.lookupClassByQualifiedName('admin.User')).toEqual([userAdmin]); + }); + + it('partial classes — two defs with the same FQN accumulate in both indexes', () => { + // C#-style partial classes: same simple and qualified name in different + // files. Both classByName and classByQualifiedName should return both. + const reg = createTypeRegistry(); + const partialA = makeDef({ nodeId: 'class:User#a', filePath: 'src/User.Core.cs' }); + const partialB = makeDef({ nodeId: 'class:User#b', filePath: 'src/User.Api.cs' }); + + reg.registerClass('User', 'app.User', partialA); + reg.registerClass('User', 'app.User', partialB); + + expect(reg.lookupClassByName('User')).toHaveLength(2); + expect(reg.lookupClassByQualifiedName('app.User')).toHaveLength(2); + }); +}); + +describe('TypeRegistry — implByName (Rust impl blocks)', () => { + it('returns empty when no impls registered', () => { + const reg = createTypeRegistry(); + expect(reg.lookupImplByName('User')).toEqual([]); + }); + + it('registerImpl stores Rust impl blocks separately from classes', () => { + const reg = createTypeRegistry(); + const userClass = makeDef({ nodeId: 'class:User', type: 'Struct' }); + const userImpl = makeDef({ nodeId: 'impl:User', type: 'Impl' }); + + reg.registerClass('User', 'crate::User', userClass); + reg.registerImpl('User', userImpl); + + expect(reg.lookupClassByName('User')).toEqual([userClass]); + expect(reg.lookupImplByName('User')).toEqual([userImpl]); + }); + + it('accumulates multiple impl blocks for the same type (Rust allows several)', () => { + const reg = createTypeRegistry(); + const implA = makeDef({ nodeId: 'impl:User#inherent', type: 'Impl' }); + const implB = makeDef({ nodeId: 'impl:User#Display', type: 'Impl' }); + + reg.registerImpl('User', implA); + reg.registerImpl('User', implB); + + const impls = reg.lookupImplByName('User'); + expect(impls).toHaveLength(2); + expect(impls.map((d) => d.nodeId)).toEqual(['impl:User#inherent', 'impl:User#Display']); + }); +}); + +describe('TypeRegistry — clear()', () => { + it('empties all three indexes', () => { + const reg = createTypeRegistry(); + reg.registerClass('User', 'app.User', makeDef()); + reg.registerImpl('User', makeDef({ type: 'Impl' })); + + reg.clear(); + + expect(reg.lookupClassByName('User')).toEqual([]); + expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]); + expect(reg.lookupImplByName('User')).toEqual([]); + }); + + it('allows re-registration after clear', () => { + const reg = createTypeRegistry(); + reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:first' })); + reg.clear(); + reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:second' })); + + expect(reg.lookupClassByName('User')).toHaveLength(1); + expect(reg.lookupClassByName('User')[0].nodeId).toBe('class:second'); + }); +}); diff --git a/gitnexus/test/unit/sequential-language-availability.test.ts b/gitnexus/test/unit/sequential-language-availability.test.ts index 5b84f67021..3d804f93e2 100644 --- a/gitnexus/test/unit/sequential-language-availability.test.ts +++ b/gitnexus/test/unit/sequential-language-availability.test.ts @@ -14,7 +14,7 @@ import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; import { processImports } from '../../src/core/ingestion/import-processor.js'; import { processCalls } from '../../src/core/ingestion/call-processor.js'; import { processHeritage } from '../../src/core/ingestion/heritage-processor.js'; -import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; +import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js'; describe('sequential native parser availability', () => { diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index 67797e61ff..dee9cdeccf 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; -import { isFileInPackageDir } from '../../src/core/ingestion/import-processor.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js'; +import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js'; +import { isFileInPackageDir } from '../../src/core/ingestion/model/resolution-context.js'; /** Helper: resolve to single best definition (refuses ambiguous global) */ const resolveOne = (ctx: ResolutionContext, name: string, fromFile: string) => { @@ -35,7 +36,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 1: Same-file resolution', () => { it('resolves symbol defined in the same file', () => { - ctx.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); + ctx.model.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); const result = resolveOne(ctx, 'User', 'src/models/user.ts'); @@ -46,8 +47,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('prefers same-file over imported definition', () => { - ctx.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); - ctx.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); + ctx.model.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); + ctx.model.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); ctx.importMap.set('src/local.ts', new Set(['src/shared.ts'])); const result = resolveOne(ctx, 'Config', 'src/local.ts'); @@ -59,7 +60,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 2: Import-scoped resolution', () => { it('resolves symbol from an imported file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/auth.ts', 'AuthService', 'Class:src/services/auth.ts:AuthService', @@ -75,13 +76,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('prefers imported definition over non-imported with same name', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/mock-logger.ts', 'Logger', 'Class:src/testing/mock-logger.ts:Logger', @@ -96,7 +97,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('handles file with no imports — unique global falls through', () => { - ctx.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); + ctx.model.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); const result = resolveOne(ctx, 'Helper', 'src/app.ts'); @@ -107,7 +108,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('Tier 3: Global resolution', () => { it('resolves unique global when not in imports', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/external/base.ts', 'BaseModel', 'Class:src/external/base.ts:BaseModel', @@ -122,8 +123,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('refuses ambiguous global — returns null when multiple candidates exist', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = resolveOne(ctx, 'Config', 'src/other.ts'); @@ -131,8 +132,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('ctx.resolve returns all candidates at global tier (consumers decide)', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const tiered = ctx.resolve('Config', 'src/other.ts'); @@ -156,7 +157,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('type preservation', () => { it('preserves Interface type for heritage resolution', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/interfaces.ts', 'ILogger', 'Interface:src/interfaces.ts:ILogger', @@ -170,7 +171,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('preserves Class type for heritage resolution', () => { - ctx.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); + ctx.model.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/base.ts'])); const result = resolveOne(ctx, 'BaseService', 'src/app.ts'); @@ -181,13 +182,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { describe('heritage-specific scenarios', () => { it('resolves C# interface vs class ambiguity via imports', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/logging/ilogger.cs', 'ILogger', 'Interface:src/logging/ilogger.cs:ILogger', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/ilogger.cs', 'ILogger', 'Class:src/testing/ilogger.cs:ILogger', @@ -202,13 +203,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { }); it('resolves parent class from imported file for extends', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/api/controller.ts', 'UserController', 'Class:src/api/controller.ts:UserController', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/base/controller.ts', 'BaseController', 'Class:src/base/controller.ts:BaseController', @@ -231,7 +232,7 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns same-file tier for Tier 1 match', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const result = resolveInternal(ctx, 'Foo', 'src/a.ts'); @@ -242,8 +243,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns import-scoped tier for Tier 2 match', () => { - ctx.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); - ctx.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); + ctx.model.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); + ctx.model.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/logger.ts'])); const result = resolveInternal(ctx, 'Logger', 'src/app.ts'); @@ -253,7 +254,7 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns global tier for Tier 3 match', () => { - ctx.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); + ctx.model.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); const result = resolveInternal(ctx, 'Singleton', 'src/other.ts'); @@ -263,8 +264,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('returns null for ambiguous global — refuses to guess', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = resolveInternal(ctx, 'Config', 'src/other.ts'); @@ -277,8 +278,8 @@ describe('ResolutionContext.resolve — tier metadata', () => { }); it('Tier 1 wins over Tier 2 — same-file takes priority', () => { - ctx.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); - ctx.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); + ctx.model.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); + ctx.model.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/lib.ts'])); const result = resolveInternal(ctx, 'Util', 'src/app.ts'); @@ -296,13 +297,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('TS/JS: two Logger definitions with no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/logger.ts', 'Logger', 'Class:src/testing/logger.ts:Logger', @@ -314,13 +315,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('Java: same-named class in different packages, no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/dto/User.java', 'User', 'Class:com/example/dto/User.java:User', @@ -332,8 +333,8 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('C/C++: type defined in transitively-included header → returns null (not reachable via direct import)', () => { - ctx.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); - ctx.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); + ctx.model.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); + ctx.model.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); ctx.importMap.set('src/a.c', new Set(['src/b.h'])); const result = resolveOne(ctx, 'Widget', 'src/a.c'); @@ -341,13 +342,13 @@ describe('negative tests — ambiguous refusal per language family', () => { }); it('C#: two IService interfaces in different namespaces, no import → returns null', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', 'Interface', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/Testing/IService.cs', 'IService', 'Interface:src/Testing/IService.cs:IService', @@ -367,13 +368,13 @@ describe('heritage false-positive guard', () => { }); it('null from resolve prevents false edge — generateId fallback produces synthetic ID, not wrong match', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/api/base.ts', 'BaseController', 'Class:src/api/base.ts:BaseController', 'Class', ); - ctx.symbols.add( + ctx.model.symbols.add( 'src/testing/base.ts', 'BaseController', 'Class:src/testing/base.ts:BaseController', @@ -390,6 +391,14 @@ describe('heritage false-positive guard', () => { }); }); +// These two describe blocks (`lookupExactFull` and `SM-16: SymbolTable.getFiles()`) +// intentionally use `createSymbolTable()` directly instead of going through +// `createSemanticModel()`. The behaviors under test belong to the pure DAG +// leaf — file/callable indexes, getFiles iterator — and do not involve the +// owner-scoped registries. Testing them on the bare leaf keeps the unit +// isolated. Do not migrate these blocks to createSemanticModel() "for +// consistency" — that would add unused registry setup and weaken the +// isolation property. describe('lookupExactFull', () => { it('returns full SymbolDefinition for same-file lookup via O(1) direct storage', () => { const symbolTable = createSymbolTable(); @@ -476,7 +485,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('resolves symbol via PackageMap when not in ImportMap', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'HandleLogin', 'Function:internal/auth/handler.go:HandleLogin', @@ -492,7 +501,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('does not resolve symbol from wrong package', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/connection.go', 'Connect', 'Function:internal/db/connection.go:Connect', @@ -508,13 +517,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('Tier 2a (ImportMap) takes precedence over Tier 2b (PackageMap)', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', @@ -532,13 +541,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('resolves both symbols in same imported package', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Run', 'Function:internal/auth/handler.go:Run', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/worker.go', 'Run', 'Function:internal/auth/worker.go:Run', @@ -554,13 +563,18 @@ describe('Tier 2b: PackageMap resolution (Go)', () => { }); it('returns global without packageMap when ambiguous', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'X', 'Function:internal/auth/handler.go:X', 'Function', ); - ctx.symbols.add('internal/db/handler.go', 'X', 'Function:internal/db/handler.go:X', 'Function'); + ctx.model.symbols.add( + 'internal/db/handler.go', + 'X', + 'Function:internal/db/handler.go:X', + 'Function', + ); const result = resolveInternal(ctx, 'X', 'cmd/main.go'); @@ -577,7 +591,7 @@ describe('per-file cache', () => { }); it('caches results per file', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); ctx.enableCache('src/a.ts'); const r1 = ctx.resolve('Foo', 'src/a.ts'); @@ -591,7 +605,7 @@ describe('per-file cache', () => { }); it('resolve works without cache enabled', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const result = ctx.resolve('Foo', 'src/a.ts'); @@ -601,7 +615,7 @@ describe('per-file cache', () => { }); it('cache does not leak across files', () => { - ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); ctx.enableCache('src/a.ts'); ctx.resolve('Foo', 'src/a.ts'); // cached for a.ts @@ -627,8 +641,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('collects definitions from all imported files', () => { - ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); - ctx.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = ctx.resolve('Widget', 'src/app.ts'); @@ -640,8 +654,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('skips files with no matching symbol — no false positives', () => { - ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); - ctx.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = ctx.resolve('Widget', 'src/app.ts'); @@ -652,8 +666,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { it('returns all overloads from a single imported file', () => { // Same-name method overloads in one file - ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 }); - ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 }); + ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 }); + ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 }); ctx.importMap.set('src/app.ts', new Set(['src/math.ts'])); const result = ctx.resolve('add', 'src/app.ts'); @@ -663,7 +677,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('Java: resolves class from import via lookupExactAll per file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', @@ -681,7 +695,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('Python: resolves function from imported module file', () => { - ctx.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class'); + ctx.model.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class'); ctx.importMap.set('app.py', new Set(['models.py'])); const result = ctx.resolve('User', 'app.py'); @@ -691,7 +705,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { }); it('C#: resolves interface from imported file', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', @@ -707,7 +721,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => { it('TypeScript: resolves re-exported class via named binding chain', () => { // index.ts re-exports User from models.ts - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/index.ts', new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -733,13 +747,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('Go: resolves symbol in package dir via file iteration', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Authenticate', 'Function:internal/auth/handler.go:Authenticate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/repo.go', 'Authenticate', 'Function:internal/db/repo.go:Authenticate', @@ -755,8 +769,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('C#: resolves class from namespace directory', () => { - ctx.symbols.add('MyApp/Models/User.cs', 'User', 'Class:MyApp/Models/User.cs:User', 'Class'); - ctx.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class'); + ctx.model.symbols.add( + 'MyApp/Models/User.cs', + 'User', + 'Class:MyApp/Models/User.cs:User', + 'Class', + ); + ctx.model.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class'); ctx.packageMap.set('MyApp/Controllers/UserController.cs', new Set(['/MyApp/Models/'])); const result = ctx.resolve('User', 'MyApp/Controllers/UserController.cs'); @@ -767,13 +786,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); it('Tier 2a (ImportMap) still takes precedence over Tier 2b (PackageMap)', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function', ); - ctx.symbols.add( + ctx.model.symbols.add( 'internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', @@ -797,7 +816,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns class-like symbol (Class) at global tier', () => { - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); const result = ctx.resolve('User', 'src/app.ts'); @@ -806,7 +825,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns callable symbol (Function) at global tier', () => { - ctx.symbols.add('src/utils.ts', 'parseDate', 'Function:src/utils.ts:parseDate', 'Function'); + ctx.model.symbols.add( + 'src/utils.ts', + 'parseDate', + 'Function:src/utils.ts:parseDate', + 'Function', + ); const result = ctx.resolve('parseDate', 'src/app.ts'); @@ -815,8 +839,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('returns both Class and Function with the same name at global tier', () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function'); const result = ctx.resolve('User', 'src/app.ts'); @@ -827,8 +851,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Rust: returns Impl node at global tier (needed for method resolution)', () => { - ctx.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); const result = ctx.resolve('User', 'src/main.rs'); @@ -839,22 +863,24 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Rust: Impl is separate from Class-like types — does not affect heritage (lookupClassByName)', () => { - const table = createSymbolTable(); - table.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); - table.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); + // SM-23 DAG: registry lookups go through SemanticModel; SymbolTable + // is a pure leaf with no registry knowledge. + const model = createSemanticModel(); + model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct'); + model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl'); // lookupClassByName excludes Impl (preserves heritage resolution correctness) - const classDefs = table.lookupClassByName('User'); + const classDefs = model.types.lookupClassByName('User'); expect(classDefs.map((d) => d.type)).toEqual(['Struct']); // lookupImplByName returns only Impl nodes - const implDefs = table.lookupImplByName('User'); + const implDefs = model.types.lookupImplByName('User'); expect(implDefs.map((d) => d.type)).toEqual(['Impl']); }); it('ambiguous global returns all candidates (consumers decide)', () => { - ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); const result = ctx.resolve('Config', 'src/other.ts'); @@ -862,13 +888,30 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup expect(result!.candidates.length).toBe(2); }); + it('A4 intermediate: Method reachable via both callable and method indexes dedups to one Tier 3 candidate', () => { + // A method with an owner lands in callableByName (because Method is + // still in FREE_CALLABLE_TYPES during the Unit 3 intermediate state) AND in + // methodsByName (because A4 Unit 2 dual-indexes every method + // registration). Tier 3 must dedup by nodeId so consumers see each + // method exactly once. + ctx.model.symbols.add('src/user.ts', 'save', 'Method:src/user.ts:User.save', 'Method', { + ownerId: 'Class:src/user.ts:User', + }); + + const result = ctx.resolve('save', 'src/app.ts'); + + expect(result!.tier).toBe('global'); + const nodeIds = result!.candidates.map((c) => c.nodeId); + expect(nodeIds).toEqual(['Method:src/user.ts:User.save']); + }); + it('returns null when no symbol exists at any tier', () => { const result = ctx.resolve('NonExistent', 'src/app.ts'); expect(result).toBeNull(); }); it('TypeScript: resolves Enum at global tier', () => { - ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); const result = ctx.resolve('Status', 'src/app.ts'); @@ -877,7 +920,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Kotlin: resolves data class (Record) at global tier', () => { - ctx.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record'); + ctx.model.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record'); const result = ctx.resolve('User', 'src/Main.kt'); @@ -886,7 +929,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('PHP: resolves Trait at global tier', () => { - ctx.symbols.add('src/Loggable.php', 'Loggable', 'Trait:src/Loggable.php:Loggable', 'Trait'); + ctx.model.symbols.add( + 'src/Loggable.php', + 'Loggable', + 'Trait:src/Loggable.php:Loggable', + 'Trait', + ); const result = ctx.resolve('Loggable', 'src/App.php'); @@ -895,7 +943,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Java: resolves Interface at global tier', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'com/example/IService.java', 'IService', 'Interface:com/example/IService.java:IService', @@ -909,7 +957,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup }); it('Go: resolves Struct at global tier', () => { - ctx.symbols.add( + ctx.model.symbols.add( 'internal/model/user.go', 'User', 'Struct:internal/model/user.go:User', @@ -954,7 +1002,7 @@ describe('SM-16: SymbolTable.getFiles()', () => { describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('resolves non-aliased import via lookupExactAll at depth=0', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/app.ts', new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -968,7 +1016,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('resolves aliased import (U → User) via chain walk', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); ctx.namedImportMap.set( 'src/app.ts', new Map([['U', { sourcePath: 'src/models.ts', exportedName: 'User' }]]), @@ -982,7 +1030,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => { it('follows re-export chain A → B → C', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class'); + ctx.model.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class'); // B re-exports Widget from C ctx.namedImportMap.set( 'src/index.ts', @@ -1011,26 +1059,31 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('TypeAlias is not reachable at Tier 3', () => { - ctx.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias'); + ctx.model.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias'); const result = ctx.resolve('Handler', 'src/app.ts'); expect(result).toBeNull(); }); it('Const is not reachable at Tier 3', () => { - ctx.symbols.add('src/config.ts', 'MAX_RETRIES', 'Const:src/config.ts:MAX_RETRIES', 'Const'); + ctx.model.symbols.add( + 'src/config.ts', + 'MAX_RETRIES', + 'Const:src/config.ts:MAX_RETRIES', + 'Const', + ); const result = ctx.resolve('MAX_RETRIES', 'src/app.ts'); expect(result).toBeNull(); }); it('Variable is not reachable at Tier 3', () => { - ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); + ctx.model.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); const result = ctx.resolve('counter', 'src/app.ts'); expect(result).toBeNull(); }); it('Class-like and callable ARE reachable at Tier 3 (control)', () => { - ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function'); + ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function'); const classResult = ctx.resolve('User', 'src/app.ts'); expect(classResult).not.toBeNull(); @@ -1042,7 +1095,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('Macro (C/C++) is reachable at Tier 3 via callable index', () => { - ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro'); + ctx.model.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro'); const result = ctx.resolve('ASSERT', 'src/main.c'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); @@ -1050,7 +1103,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => }); it('Delegate (C#) is reachable at Tier 3 via callable index', () => { - ctx.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate'); + ctx.model.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate'); const result = ctx.resolve('OnClick', 'src/App.cs'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); @@ -1064,7 +1117,7 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( it('resolves newly added symbol after clear() resets the index', () => { const ctx = createResolutionContext(); // Initial setup: one symbol in package dir - ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); // Prime the packageDirIndex via a Tier 2b resolution @@ -1075,8 +1128,13 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( ctx.clear(); // Re-add symbols with a NEW file in the package dir - ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); - ctx.symbols.add('pkg/models/order.go', 'Order', 'Struct:pkg/models/order.go:Order', 'Struct'); + ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct'); + ctx.model.symbols.add( + 'pkg/models/order.go', + 'Order', + 'Struct:pkg/models/order.go:Order', + 'Struct', + ); ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/'])); // The new symbol must be visible — packageDirIndex was invalidated by clear() @@ -1092,8 +1150,8 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear( describe('SM-16: Tier 2b — Rust package-scoped resolution', () => { it('resolves struct in package dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct'); - ctx.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct'); + ctx.model.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct'); ctx.packageMap.set('src/main.rs', new Set(['/src/models/'])); const result = ctx.resolve('User', 'src/main.rs'); @@ -1106,8 +1164,18 @@ describe('SM-16: Tier 2b — Rust package-scoped resolution', () => { describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => { it('resolves class in package dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('com/app/models/User.kt', 'User', 'Class:com/app/models/User.kt:User', 'Class'); - ctx.symbols.add('com/app/other/User.kt', 'User', 'Class:com/app/other/User.kt:User', 'Class'); + ctx.model.symbols.add( + 'com/app/models/User.kt', + 'User', + 'Class:com/app/models/User.kt:User', + 'Class', + ); + ctx.model.symbols.add( + 'com/app/other/User.kt', + 'User', + 'Class:com/app/other/User.kt:User', + 'Class', + ); ctx.packageMap.set('com/app/Main.kt', new Set(['/com/app/models/'])); const result = ctx.resolve('User', 'com/app/Main.kt'); @@ -1120,8 +1188,8 @@ describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => { describe('SM-16: Tier 2b — PHP namespace directory resolution', () => { it('resolves class in namespace dir via Tier 2b', () => { const ctx = createResolutionContext(); - ctx.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class'); - ctx.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class'); + ctx.model.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class'); + ctx.model.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class'); ctx.packageMap.set('app/Controllers/UserController.php', new Set(['/app/Models/'])); const result = ctx.resolve('User', 'app/Controllers/UserController.php'); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 0f12851d6c..1b8490fcef 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -1,11 +1,24 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createSymbolTable, type SymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SymbolTableWriter } from '../../src/core/ingestion/model/symbol-table.js'; +import { + createSemanticModel, + type MutableSemanticModel, +} from '../../src/core/ingestion/model/semantic-model.js'; describe('SymbolTable', () => { - let table: SymbolTable; + // SM-23 DAG: SymbolTable is now a pure leaf with no registry knowledge. + // Tests that exercise owner-scoped lookups (lookupClassByName, + // lookupMethodByOwner, lookupFieldByOwner, lookupClassByQualifiedName, + // lookupImplByName) must go through SemanticModel which composes + // SymbolTable with the registries. We build a model and alias + // `table = model.symbols` so the 200+ file/callable test cases keep + // their existing call sites unchanged. + let model: MutableSemanticModel; + let table: SymbolTableWriter; beforeEach(() => { - table = createSymbolTable(); + model = createSemanticModel(); + table = model.symbols; }); describe('add', () => { @@ -151,7 +164,7 @@ describe('SymbolTable', () => { // No declaredType → still indexed in fieldByOwner (for write-access tracking // in dynamically-typed languages like Ruby/JS), but excluded from callable index expect(table.lookupCallableByName('name')).toEqual([]); - expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -159,9 +172,12 @@ describe('SymbolTable', () => { }); }); - it('non-Property callable types are in callable index', () => { + it('post-A4: Method with ownerId lands in methodsByName, not callableByName', () => { + // Plan 006 Unit 4 shrank FREE_CALLABLE_TYPES to free callables only. + // Method registrations now flow through the method registry. table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' }); - expect(table.lookupCallableByName('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); }); }); @@ -169,9 +185,9 @@ describe('SymbolTable', () => { it('adding a Function makes it available in callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' }); expect(table.lookupCallableByName('foo')).toHaveLength(1); - // Add another callable - table.add('src/a.ts', 'bar', 'func:bar', 'Method'); - expect(table.lookupCallableByName('bar')).toHaveLength(1); + // Free Macro is a callable (C/C++ preprocessor macro). + table.add('src/macros.h', 'BAR', 'macro:BAR', 'Macro'); + expect(table.lookupCallableByName('BAR')).toHaveLength(1); }); it('adding a Property does NOT add it to callable index', () => { @@ -204,6 +220,43 @@ describe('SymbolTable', () => { expect(table.lookupCallableByName('OnClick')).toHaveLength(1); expect(table.lookupCallableByName('OnClick')[0].type).toBe('Delegate'); }); + + it('Method WITHOUT ownerId falls back to the callable index', () => { + // Orphaned Method (extractor contract violation / degraded AST). + // The dispatch hook silently skips it because it has no owner to + // key under; the callable-index fallback keeps it reachable at + // Tier 3 global resolution. + table.add('src/a.ts', 'orphan', 'method:orphan', 'Method'); + expect(table.lookupCallableByName('orphan')).toHaveLength(1); + expect(table.lookupCallableByName('orphan')[0].type).toBe('Method'); + }); + + it('Constructor WITHOUT ownerId falls back to the callable index', () => { + table.add('src/a.ts', 'Orphan', 'ctor:Orphan', 'Constructor'); + expect(table.lookupCallableByName('Orphan')).toHaveLength(1); + expect(table.lookupCallableByName('Orphan')[0].type).toBe('Constructor'); + }); + + it('Method WITH ownerId does NOT land in the callable index (goes to MethodRegistry instead)', () => { + table.add('src/user.ts', 'greet', 'method:User.greet', 'Method', { + ownerId: 'class:User', + }); + expect(table.lookupCallableByName('greet')).toHaveLength(0); + }); + + it('Constructor WITH ownerId does NOT land in the callable index', () => { + table.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ownerId: 'class:User', + }); + expect(table.lookupCallableByName('User')).toHaveLength(0); + }); + + it('Property WITHOUT ownerId still does NOT fall back to the callable index', () => { + // Property fallback would pollute common names like `id` / `name` / + // `type` — kept disjoint from the Method/Constructor fallback. + table.add('src/a.ts', 'orphanField', 'prop:orphan', 'Property'); + expect(table.lookupCallableByName('orphanField')).toHaveLength(0); + }); }); describe('lookupFieldByOwner', () => { @@ -212,7 +265,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - const def = table.lookupFieldByOwner('class:User', 'address'); + const def = model.fields.lookupFieldByOwner('class:User', 'address'); expect(def).toBeDefined(); expect(def!.declaredType).toBe('Address'); expect(def!.nodeId).toBe('prop:address'); @@ -223,7 +276,7 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); }); it('returns undefined for unknown field name', () => { @@ -231,16 +284,16 @@ describe('SymbolTable', () => { declaredType: 'Address', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(table.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); }); it('indexes Property without declaredType (for dynamic language write-access)', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); - expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', type: 'Property', @@ -257,8 +310,8 @@ describe('SymbolTable', () => { declaredType: 'RepoName', ownerId: 'class:Repo', }); - expect(table.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); - expect(table.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); + expect(model.fields.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); }); }); @@ -268,7 +321,7 @@ describe('SymbolTable', () => { returnType: 'Address', ownerId: 'class:User', }); - const def = table.lookupMethodByOwner('class:User', 'getAddress'); + const def = model.methods.lookupMethodByOwner('class:User', 'getAddress'); expect(def).toBeDefined(); expect(def!.returnType).toBe('Address'); expect(def!.nodeId).toBe('method:getAddress'); @@ -283,8 +336,10 @@ describe('SymbolTable', () => { returnType: 'String', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe('Address'); - expect(table.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); + expect(model.methods.lookupMethodByOwner('class:User', 'getAddress')!.returnType).toBe( + 'Address', + ); + expect(model.methods.lookupMethodByOwner('class:User', 'getName')!.returnType).toBe('String'); }); it('distinguishes methods by owner', () => { @@ -296,8 +351,10 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:Address', }); - expect(table.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe('method:user:save'); - expect(table.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( + expect(model.methods.lookupMethodByOwner('class:User', 'save')!.nodeId).toBe( + 'method:user:save', + ); + expect(model.methods.lookupMethodByOwner('class:Address', 'save')!.nodeId).toBe( 'method:address:save', ); }); @@ -307,7 +364,7 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Unknown', 'save')).toBeUndefined(); }); it('returns undefined for unknown method name', () => { @@ -315,18 +372,24 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'delete')).toBeUndefined(); }); it('returns undefined for empty table', () => { - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); - it('does NOT index Method without ownerId', () => { + it('Method without ownerId is not in MethodRegistry but falls back to callable index', () => { + // methodHook silently skips Method-without-ownerId (methods.register + // requires an owner). The orphan-owner-scoped fallback in + // `SymbolTable.add()` routes such defs through `callableByName` so + // Tier 3 global resolution can still find them. table.add('src/utils.ts', 'helper', 'method:helper', 'Method'); - expect(table.lookupMethodByOwner('', 'helper')).toBeUndefined(); - // But it should still be in lookupCallableByName + expect(model.methods.lookupMethodByOwner('', 'helper')).toBeUndefined(); + expect(model.methods.lookupMethodByName('helper')).toHaveLength(0); expect(table.lookupCallableByName('helper')).toHaveLength(1); + expect(table.lookupCallableByName('helper')[0].type).toBe('Method'); + expect(table.lookupExact('src/utils.ts', 'helper')).toBe('method:helper'); }); it('returns first match for overloads with same returnType (unambiguous)', () => { @@ -340,7 +403,7 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'class:UserRepo', }); - const def = table.lookupMethodByOwner('class:UserRepo', 'find'); + const def = model.methods.lookupMethodByOwner('class:UserRepo', 'find'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('method:find:1'); expect(def!.returnType).toBe('User'); @@ -355,7 +418,7 @@ describe('SymbolTable', () => { parameterCount: 2, ownerId: 'class:Handler', }); - expect(table.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Handler', 'process')).toBeUndefined(); }); it('indexes Constructor in methodByOwner', () => { @@ -363,15 +426,17 @@ describe('SymbolTable', () => { parameterCount: 0, ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'User')).toEqual({ + expect(model.methods.lookupMethodByOwner('class:User', 'User')).toEqual({ nodeId: 'ctor:User', filePath: 'src/models.ts', type: 'Constructor', parameterCount: 0, ownerId: 'class:User', }); - // But it should be in lookupCallableByName - expect(table.lookupCallableByName('User')).toHaveLength(1); + // Post-A4 Unit 4: Constructor no longer lands in callableByName. + // It is reachable via methodsByName instead. + expect(table.lookupCallableByName('User')).toHaveLength(0); + expect(model.methods.lookupMethodByName('User')).toHaveLength(1); }); it('returns undefined for overloads with different returnTypes (ambiguous)', () => { @@ -385,15 +450,17 @@ describe('SymbolTable', () => { returnType: 'Number', ownerId: 'class:Converter', }); - expect(table.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); }); - it('Method with ownerId is still available via lookupCallableByName', () => { + it('post-A4: Method with ownerId is reachable via methodsByName, not callableByName', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupCallableByName('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); }); it('after clear(), lookupMethodByOwner returns undefined', () => { @@ -401,22 +468,26 @@ describe('SymbolTable', () => { returnType: 'void', ownerId: 'class:User', }); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeDefined(); - table.clear(); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeDefined(); + model.clear(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); }); }); describe('lookupCallableByName', () => { - it('returns only callable types (Function, Method, Constructor)', () => { + it('post-A4: returns only free callables (Function/Macro/Delegate)', () => { + // Post-Unit 4, FREE_CALLABLE_TYPES = {Function, Macro, Delegate}. + // Method and Constructor flow through the method registry instead. table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - table.add('src/a.ts', 'bar', 'method:bar', 'Method'); - table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor'); + table.add('src/a.ts', 'bar', 'method:bar', 'Method', { ownerId: 'class:X' }); + table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor', { ownerId: 'class:Baz' }); table.add('src/a.ts', 'User', 'class:User', 'Class'); table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface'); expect(table.lookupCallableByName('foo')).toHaveLength(1); - expect(table.lookupCallableByName('bar')).toHaveLength(1); - expect(table.lookupCallableByName('Baz')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toEqual([]); + expect(table.lookupCallableByName('Baz')).toEqual([]); + expect(model.methods.lookupMethodByName('bar')).toHaveLength(1); + expect(model.methods.lookupMethodByName('Baz')).toHaveLength(1); expect(table.lookupCallableByName('User')).toEqual([]); expect(table.lookupCallableByName('IUser')).toEqual([]); }); @@ -456,20 +527,20 @@ describe('SymbolTable', () => { ownerId: 'class:User', }); table.add('src/models.ts', 'User', 'class:User', 'Class'); - table.clear(); + model.clear(); expect(table.getStats()).toEqual({ fileCount: 0, }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); - expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); - expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); expect(table.lookupCallableByName('foo')).toEqual([]); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('allows re-adding after clear', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - table.clear(); + model.clear(); table.add('src/b.ts', 'bar', 'func:bar', 'Function'); expect(table.getStats()).toEqual({ fileCount: 1, @@ -480,7 +551,7 @@ describe('SymbolTable', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); // Verify callable is found expect(table.lookupCallableByName('foo')).toHaveLength(1); - table.clear(); + model.clear(); // After clear the callable index must be gone — empty table returns nothing expect(table.lookupCallableByName('foo')).toEqual([]); // Re-adding and looking up works correctly @@ -501,7 +572,7 @@ describe('SymbolTable', () => { expect(def!.ownerId).toBeUndefined(); }); - it('stores only ownerId on a Method (non-Property) — still in callable index', () => { + it('stores only ownerId on a Method — reachable via methodsByName (post-A4)', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' }); const def = table.lookupExactFull('src/models.ts', 'save'); expect(def).toBeDefined(); @@ -509,8 +580,10 @@ describe('SymbolTable', () => { expect(def!.parameterCount).toBeUndefined(); expect(def!.returnType).toBeUndefined(); expect(def!.declaredType).toBeUndefined(); - // Non-Property with ownerId must still appear in callable index - expect(table.lookupCallableByName('save')).toHaveLength(1); + // Post-A4 Unit 4: owner-scoped Method lives in methodsByName, + // not callableByName. + expect(table.lookupCallableByName('save')).toHaveLength(0); + expect(model.methods.lookupMethodByName('save')).toHaveLength(1); }); it('stores declaredType alone (no ownerId) — symbol in file index', () => { @@ -575,23 +648,27 @@ describe('SymbolTable', () => { expect(second[0].nodeId).toBe('func:fetch'); }); - it('includes newly added Method', () => { + it('post-A4: newly added Method is reachable via methodsByName, not callableByName', () => { table.add('src/a.ts', 'alpha', 'func:alpha', 'Function'); expect(table.lookupCallableByName('alpha')).toHaveLength(1); expect(table.lookupCallableByName('beta')).toEqual([]); - // Add a Method - table.add('src/a.ts', 'beta', 'method:beta', 'Method'); - const result = table.lookupCallableByName('beta'); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('Method'); + table.add('src/a.ts', 'beta', 'method:beta', 'Method', { ownerId: 'class:X' }); + expect(table.lookupCallableByName('beta')).toHaveLength(0); + const byName = model.methods.lookupMethodByName('beta'); + expect(byName).toHaveLength(1); + expect(byName[0].type).toBe('Method'); }); - it('includes newly added Constructor', () => { + it('post-A4: newly added Constructor is reachable via methodsByName, not callableByName', () => { table.add('src/a.ts', 'existing', 'func:existing', 'Function'); expect(table.lookupCallableByName('existing')).toHaveLength(1); - table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor'); - expect(table.lookupCallableByName('MyClass')).toHaveLength(1); - expect(table.lookupCallableByName('MyClass')[0].type).toBe('Constructor'); + table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor', { + ownerId: 'class:MyClass', + }); + expect(table.lookupCallableByName('MyClass')).toHaveLength(0); + const byName = model.methods.lookupMethodByName('MyClass'); + expect(byName).toHaveLength(1); + expect(byName[0].type).toBe('Constructor'); }); }); @@ -655,9 +732,9 @@ describe('SymbolTable', () => { declaredType: 'Date', ownerId: 'class:User', }); - expect(table.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number'); - expect(table.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string'); - expect(table.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date'); + expect(model.fields.lookupFieldByOwner('class:User', 'id')!.declaredType).toBe('number'); + expect(model.fields.lookupFieldByOwner('class:User', 'email')!.declaredType).toBe('string'); + expect(model.fields.lookupFieldByOwner('class:User', 'createdAt')!.declaredType).toBe('Date'); }); it('returns the full SymbolDefinition (nodeId + filePath + type) not just declaredType', () => { @@ -665,7 +742,7 @@ describe('SymbolTable', () => { declaredType: 'number', ownerId: 'class:Player', }); - const def = table.lookupFieldByOwner('class:Player', 'score'); + const def = model.fields.lookupFieldByOwner('class:Player', 'score'); expect(def).toBeDefined(); expect(def!.nodeId).toBe('prop:score'); expect(def!.filePath).toBe('src/models.ts'); @@ -682,17 +759,17 @@ describe('SymbolTable', () => { declaredType: 'UUID', ownerId: 'class:B', }); - expect(table.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); - expect(table.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id'); + expect(model.fields.lookupFieldByOwner('class:A', 'id')!.nodeId).toBe('prop:a:id'); + expect(model.fields.lookupFieldByOwner('class:B', 'id')!.nodeId).toBe('prop:b:id'); // An owner whose id is the concatenation of A's ownerId + fieldName must not match - expect(table.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:A\0id', '')).toBeUndefined(); }); }); describe('lookupClassByName', () => { it('returns Class definitions by name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0]).toEqual({ nodeId: 'class:User', @@ -704,28 +781,28 @@ describe('SymbolTable', () => { it('returns Struct definitions by name', () => { table.add('src/models.rs', 'Point', 'struct:Point', 'Struct'); - const results = table.lookupClassByName('Point'); + const results = model.types.lookupClassByName('Point'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Struct'); }); it('returns Interface definitions by name', () => { table.add('src/types.ts', 'Serializable', 'iface:Serializable', 'Interface'); - const results = table.lookupClassByName('Serializable'); + const results = model.types.lookupClassByName('Serializable'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Interface'); }); it('returns Enum definitions by name', () => { table.add('src/types.ts', 'Color', 'enum:Color', 'Enum'); - const results = table.lookupClassByName('Color'); + const results = model.types.lookupClassByName('Color'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Enum'); }); it('returns Record definitions by name', () => { table.add('src/models.java', 'Config', 'record:Config', 'Record'); - const results = table.lookupClassByName('Config'); + const results = model.types.lookupClassByName('Config'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Record'); }); @@ -733,7 +810,7 @@ describe('SymbolTable', () => { it('does NOT include Function with the same name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); table.add('src/utils.ts', 'User', 'func:User', 'Function'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].type).toBe('Class'); expect(results[0].nodeId).toBe('class:User'); @@ -744,10 +821,10 @@ describe('SymbolTable', () => { table.add('src/a.ts', 'Bar', 'var:Bar', 'Variable'); table.add('src/a.ts', 'Baz', 'prop:Baz', 'Property'); table.add('src/a.ts', 'Qux', 'ctor:Qux', 'Constructor'); - expect(table.lookupClassByName('Foo')).toEqual([]); - expect(table.lookupClassByName('Bar')).toEqual([]); - expect(table.lookupClassByName('Baz')).toEqual([]); - expect(table.lookupClassByName('Qux')).toEqual([]); + expect(model.types.lookupClassByName('Foo')).toEqual([]); + expect(model.types.lookupClassByName('Bar')).toEqual([]); + expect(model.types.lookupClassByName('Baz')).toEqual([]); + expect(model.types.lookupClassByName('Qux')).toEqual([]); }); it('includes Trait in the class set (PHP use, Rust impl, Scala traits)', () => { @@ -757,20 +834,20 @@ describe('SymbolTable', () => { // Struct` in Rust, etc. Added as part of PR #744 (SM-11 Codex review // fixes) after the PHP HasTimestamps trait walk gap was discovered. table.add('src/a.rs', 'Writer', 'trait:Writer', 'Trait'); - const results = table.lookupClassByName('Writer'); + const results = model.types.lookupClassByName('Writer'); expect(results).toHaveLength(1); expect(results[0].nodeId).toBe('trait:Writer'); }); it('does NOT include other type-like labels outside the allowed class set', () => { table.add('src/a.ts', 'User', 'type:User', 'Type'); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('returns multiple classes with the same name from different files', () => { table.add('src/models/user.ts', 'User', 'class:user:User', 'Class'); table.add('src/dto/user.ts', 'User', 'class:dto:User', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(2); expect(results[0].filePath).toBe('src/models/user.ts'); expect(results[1].filePath).toBe('src/dto/user.ts'); @@ -778,25 +855,25 @@ describe('SymbolTable', () => { it('returns empty array for unknown name', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByName('NonExistent')).toEqual([]); + expect(model.types.lookupClassByName('NonExistent')).toEqual([]); }); it('returns empty array for empty table', () => { - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByName('User')).toHaveLength(1); - table.clear(); - expect(table.lookupClassByName('User')).toEqual([]); + expect(model.types.lookupClassByName('User')).toHaveLength(1); + model.clear(); + expect(model.types.lookupClassByName('User')).toEqual([]); }); it('returns mixed class-like types with the same name', () => { // e.g. a Class and an Interface both named 'Comparable' in different files table.add('src/base.ts', 'Comparable', 'class:Comparable', 'Class'); table.add('src/types.ts', 'Comparable', 'iface:Comparable', 'Interface'); - const results = table.lookupClassByName('Comparable'); + const results = model.types.lookupClassByName('Comparable'); expect(results).toHaveLength(2); expect(results.map((r) => r.type)).toEqual(['Class', 'Interface']); }); @@ -806,7 +883,7 @@ describe('SymbolTable', () => { returnType: 'User', ownerId: 'module:models', }); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].ownerId).toBe('module:models'); }); @@ -814,14 +891,14 @@ describe('SymbolTable', () => { it('class-like symbols are available via lookupClassByName', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); // classByName is the dedicated index for class-like lookups - expect(table.lookupClassByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')).toHaveLength(1); }); it('allows re-adding after clear and returns correct results', () => { table.add('src/models.ts', 'User', 'class:User:v1', 'Class'); - table.clear(); + model.clear(); table.add('src/models.ts', 'User', 'class:User:v2', 'Class'); - const results = table.lookupClassByName('User'); + const results = model.types.lookupClassByName('User'); expect(results).toHaveLength(1); expect(results[0].nodeId).toBe('class:User:v2'); }); @@ -836,8 +913,8 @@ describe('SymbolTable', () => { qualifiedName: 'Data.User', }); - expect(table.lookupClassByName('User')).toHaveLength(2); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([ + expect(model.types.lookupClassByName('User')).toHaveLength(2); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([ { nodeId: 'class:services:User', filePath: 'src/services/user.cs', @@ -845,14 +922,14 @@ describe('SymbolTable', () => { qualifiedName: 'Services.User', }, ]); - const dataUserMatches = table.lookupClassByQualifiedName('Data.User'); + const dataUserMatches = model.types.lookupClassByQualifiedName('Data.User'); expect(dataUserMatches).toHaveLength(1); expect(dataUserMatches[0].qualifiedName).toBe('Data.User'); }); it('falls back to the simple name when no qualified metadata is provided', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - expect(table.lookupClassByQualifiedName('User')).toEqual([ + expect(model.types.lookupClassByQualifiedName('User')).toEqual([ { nodeId: 'class:User', filePath: 'src/models.ts', @@ -866,16 +943,258 @@ describe('SymbolTable', () => { table.add('src/utils.ts', 'User', 'func:User', 'Function', { qualifiedName: 'Services.User', }); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]); }); it('after clear(), returns empty array', () => { table.add('src/services/user.cs', 'User', 'class:User', 'Class', { qualifiedName: 'Services.User', }); - expect(table.lookupClassByQualifiedName('Services.User')).toHaveLength(1); - table.clear(); - expect(table.lookupClassByQualifiedName('Services.User')).toEqual([]); + expect(model.types.lookupClassByQualifiedName('Services.User')).toHaveLength(1); + model.clear(); + expect(model.types.lookupClassByQualifiedName('Services.User')).toEqual([]); + }); + }); + + describe('SemanticModel container (SM-21 inversion)', () => { + // Post-inversion, the SemanticModel is the top-level container and + // SymbolTable is a nested `symbols` subfield. These tests exercise the + // inverted access pattern directly via createSemanticModel() so the + // factory wiring is covered end-to-end: feeding the symbol table via + // its `add()` populates the parent registries (types/methods/fields). + const buildModel = (): MutableSemanticModel => createSemanticModel(); + + it('exposes types, methods, fields, and symbols subfields', () => { + const model = buildModel(); + expect(model.types).toBeDefined(); + expect(model.methods).toBeDefined(); + expect(model.fields).toBeDefined(); + expect(model.symbols).toBeDefined(); + }); + + it('feeding a Class via model.symbols.add populates model.types', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', { + qualifiedName: 'app.User', + }); + expect(model.types.lookupClassByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')[0]!.nodeId).toBe('class:User'); + expect(model.types.lookupClassByQualifiedName('app.User')).toHaveLength(1); + }); + + it('feeding a Method with ownerId populates model.methods', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', { + ownerId: 'class:User', + parameterCount: 0, + }); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save'); + }); + + it('feeding a Property with ownerId populates model.fields', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + }); + + it('feeding an Impl populates model.types.lookupImplByName', () => { + const model = buildModel(); + model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl'); + expect(model.types.lookupImplByName('User')).toHaveLength(1); + }); + + it('arity filtering disambiguates overloads via model.methods', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'greet', 'mtd:greet:0', 'Method', { + ownerId: 'class:User', + parameterCount: 0, + }); + model.symbols.add('src/user.ts', 'greet', 'mtd:greet:1', 'Method', { + ownerId: 'class:User', + parameterCount: 1, + }); + expect(model.methods.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe( + 'mtd:greet:0', + ); + expect(model.methods.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe( + 'mtd:greet:1', + ); + }); + + it('clear() cascades through all three registries and the nested symbol table', () => { + const model = buildModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'save', 'mtd:User.save', 'Method', { + ownerId: 'class:User', + }); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + // Pre-clear: every store is populated. + expect(model.types.lookupClassByName('User')).toHaveLength(1); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:User.save'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + expect(model.symbols.lookupExact('src/user.ts', 'User')).toBe('class:User'); + + model.clear(); + + // Post-clear: every store is empty — types, methods, fields, symbols. + expect(model.types.lookupClassByName('User')).toEqual([]); + expect(model.methods.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + expect(model.symbols.lookupExact('src/user.ts', 'User')).toBeUndefined(); + }); + + it('feeds Function-with-ownerId into model.methods (Python-style class method)', () => { + // Python/Rust/Kotlin extractors emit class methods as `Function` with + // ownerId. The add() branch must route these into the method registry + // so owner-scoped resolution works uniformly across languages. + const model = buildModel(); + model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', { + ownerId: 'class:User', + }); + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save'); + }); + + it('silently skips Property without ownerId (no model.fields registration)', () => { + // Properties without ownerId are kept in the file index but never + // reach the fields registry — documenting the intentional behavior. + const model = buildModel(); + model.symbols.add('src/user.ts', 'name', 'prop:orphan.name', 'Property', { + declaredType: 'string', + }); + expect(model.symbols.lookupExact('src/user.ts', 'name')).toBe('prop:orphan.name'); + expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // SM-22 — dispatch table routing invariants + // ------------------------------------------------------------------------- + + describe('registration dispatch table (SM-22)', () => { + it('registering a Class hits types.registerClass exactly once and touches no other registry', () => { + const model = createSemanticModel(); + const classSpy = vi.spyOn(model.types, 'registerClass'); + const implSpy = vi.spyOn(model.types, 'registerImpl'); + const methodsSpy = vi.spyOn(model.methods, 'register'); + const fieldsSpy = vi.spyOn(model.fields, 'register'); + + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class', { + qualifiedName: 'app.User', + }); + + expect(classSpy).toHaveBeenCalledTimes(1); + expect(implSpy).not.toHaveBeenCalled(); + expect(methodsSpy).not.toHaveBeenCalled(); + expect(fieldsSpy).not.toHaveBeenCalled(); + }); + + it('registering a Property populates fields.register and DOES NOT append to callableByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', { + ownerId: 'class:User', + declaredType: 'string', + }); + + expect(model.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name'); + // Property must NOT leak into callableByName — Property is not in + // FREE_CALLABLE_TYPES, so SymbolTable.add() never appends it. + expect(model.symbols.lookupCallableByName('name')).toHaveLength(0); + }); + + it('registering a free Function populates callableByName but not methods.register', () => { + const model = createSemanticModel(); + const methodsSpy = vi.spyOn(model.methods, 'register'); + + model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function'); + + expect(model.symbols.lookupCallableByName('format')).toHaveLength(1); + expect(methodsSpy).not.toHaveBeenCalled(); + }); + + it('registering a Function-with-ownerId routes to methods.register via pre-dispatch normalization AND appears in callableByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.py', 'User', 'class:User', 'Class'); + model.symbols.add('src/user.py', 'save', 'fn:User.save', 'Function', { + ownerId: 'class:User', + }); + + // Owner-scoped method lookup resolves it (Python-style class method). + expect(model.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('fn:User.save'); + // Function is in FREE_CALLABLE_TYPES, so it also appears in callableByName. + expect(model.symbols.lookupCallableByName('save')).toHaveLength(1); + }); + + it('registering an Impl populates lookupImplByName but NOT lookupClassByName', () => { + const model = createSemanticModel(); + model.symbols.add('src/user.rs', 'User', 'impl:User', 'Impl'); + // Impl is kept separate from class-like so heritage resolution + // does not treat it as a parent type candidate. + expect(model.types.lookupImplByName('User')).toHaveLength(1); + expect(model.types.lookupClassByName('User')).toHaveLength(0); + }); + + it('registering an inert NodeLabel only populates the file index', () => { + const model = createSemanticModel(); + const classSpy = vi.spyOn(model.types, 'registerClass'); + const implSpy = vi.spyOn(model.types, 'registerImpl'); + const methodsSpy = vi.spyOn(model.methods, 'register'); + const fieldsSpy = vi.spyOn(model.fields, 'register'); + + // `Variable` is in INERT_LABELS — no specialized registry, no + // callable index (it's not in FREE_CALLABLE_TYPES). + model.symbols.add('src/main.ts', 'CONFIG', 'var:CONFIG', 'Variable'); + + expect(model.symbols.lookupExact('src/main.ts', 'CONFIG')).toBe('var:CONFIG'); + expect(classSpy).not.toHaveBeenCalled(); + expect(implSpy).not.toHaveBeenCalled(); + expect(methodsSpy).not.toHaveBeenCalled(); + expect(fieldsSpy).not.toHaveBeenCalled(); + expect(model.symbols.lookupCallableByName('CONFIG')).toHaveLength(0); + }); + + it('Method-without-ownerId skips methods.register and falls back to the callable index', () => { + const model = createSemanticModel(); + const methodsSpy = vi.spyOn(model.methods, 'register'); + + model.symbols.add('src/orphan.ts', 'orphan', 'mtd:orphan', 'Method'); + + // File index still populated. + expect(model.symbols.lookupExact('src/orphan.ts', 'orphan')).toBe('mtd:orphan'); + // Method registry NOT populated (no ownerId to key under) — the + // dispatch hook silently skips. + expect(methodsSpy).not.toHaveBeenCalled(); + expect(model.methods.lookupMethodByName('orphan')).toHaveLength(0); + // Callable-index fallback: an orphaned Method/Constructor is an + // extractor contract violation (AST-degraded parse), but we keep + // it reachable at Tier 3 global resolution by routing it through + // `callableByName`. Matches pre-dispatch-table behavior. + expect(model.symbols.lookupCallableByName('orphan')).toHaveLength(1); + expect(model.symbols.lookupCallableByName('orphan')[0].type).toBe('Method'); + }); + + it('exhaustiveness guard does not fire for the current NodeLabel taxonomy', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Fresh SymbolTable — triggers the guard at construction. + createSemanticModel(); + // No warnings about missing NodeLabels — every label is accounted + // for in one of the three allowlists. + const mismatchWarnings = warnSpy.mock.calls.filter((args) => + String(args[0]).startsWith('[SymbolTable] NodeLabel '), + ); + expect(mismatchWarnings).toHaveLength(0); + warnSpy.mockRestore(); }); }); }); @@ -884,14 +1203,13 @@ describe('SymbolTable', () => { // lookupMethodByOwnerWithMRO — MRO-aware method resolution via HeritageMap // --------------------------------------------------------------------------- -import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js'; -import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/call-processor.js'; +import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js'; +import { lookupMethodByOwnerWithMRO } from '../../src/core/ingestion/model/index.js'; import { createResolutionContext, type ResolutionContext, -} from '../../src/core/ingestion/resolution-context.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; +} from '../../src/core/ingestion/model/resolution-context.js'; +import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js'; describe('lookupMethodByOwnerWithMRO', () => { let ctx: ResolutionContext; @@ -901,12 +1219,18 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child.parentMethod() resolves to Parent#parentMethod via MRO walk', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'parentMethod', 'method:Parent:parentMethod', 'Method', { - returnType: 'String', - ownerId: 'class:Parent', - }); + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add( + 'src/parent.java', + 'parentMethod', + 'method:Parent:parentMethod', + 'Method', + { + returnType: 'String', + ownerId: 'class:Parent', + }, + ); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -917,8 +1241,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'parentMethod', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Parent:parentMethod'); @@ -926,13 +1250,13 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('child override returns child version (direct hit, no walk)', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', { + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'save', 'method:Parent:save', 'Method', { returnType: 'void', ownerId: 'class:Parent', }); - ctx.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { + ctx.model.symbols.add('src/child.java', 'save', 'method:Child:save', 'Method', { returnType: 'void', ownerId: 'class:Child', }); @@ -946,18 +1270,18 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Child:save'); }); it('3-level inheritance: grandchild → child → parent, method on parent found', () => { - ctx.symbols.add('src/a.java', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.java', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', { + ctx.model.symbols.add('src/a.java', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.java', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.java', 'greet', 'method:A:greet', 'Method', { returnType: 'Greeting', ownerId: 'class:A', }); @@ -972,8 +1296,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'greet', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:greet'); @@ -981,15 +1305,15 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('diamond pattern: first-wins strategy returns first ancestor match in BFS order', () => { - ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', { + ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/b.ts', 'foo', 'method:B:foo', 'Method', { returnType: 'String', ownerId: 'class:B', }); - ctx.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { + ctx.model.symbols.add('src/c.ts', 'foo', 'method:C:foo', 'Method', { returnType: 'String', ownerId: 'class:C', }); @@ -1003,27 +1327,21 @@ describe('lookupMethodByOwnerWithMRO', () => { const map = buildHeritageMap(heritage, ctx); // TypeScript uses 'first-wins' — B is first parent, so B.foo wins - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'foo', - map, - ctx.symbols, - SupportedLanguages.TypeScript, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'first-wins'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:B:foo'); }); it('diamond pattern: c3 strategy uses C3 linearization order', () => { - ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.py', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.py', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { returnType: 'str', ownerId: 'class:B', }); - ctx.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { + ctx.model.symbols.add('src/c.py', 'foo', 'method:C:foo', 'Method', { returnType: 'str', ownerId: 'class:C', }); @@ -1037,22 +1355,41 @@ describe('lookupMethodByOwnerWithMRO', () => { const map = buildHeritageMap(heritage, ctx); // Python uses 'c3' — C3 linearization for D(B,C): [B, C, A] - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'foo', - map, - ctx.symbols, - SupportedLanguages.Python, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'foo', map, ctx.model, 'c3'); expect(result).toBeDefined(); // C3 linearization resolves to B before C in this hierarchy expect(result!.nodeId).toBe('method:B:foo'); }); + it('c3 (Python): cyclic hierarchy falls back to BFS ancestor order', () => { + // Build a legitimately cyclic heritage: A extends B, B extends A. + // c3Linearize returns null for this case (inconsistent linearization). + // The MRO walker must then fall back to heritageMap.getAncestors() + // (BFS order) instead of silently returning undefined. + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/b.py', 'foo', 'method:B:foo', 'Method', { + returnType: 'void', + ownerId: 'class:B', + }); + + const heritage: ExtractedHeritage[] = [ + { filePath: 'src/a.py', className: 'A', parentName: 'B', kind: 'extends' }, + { filePath: 'src/b.py', className: 'B', parentName: 'A', kind: 'extends' }, + ]; + const map = buildHeritageMap(heritage, ctx); + + // Even with a cyclic hierarchy, BFS via heritageMap.getAncestors() + // walks A → B and finds `foo` on B. The method lookup must succeed. + const result = lookupMethodByOwnerWithMRO('class:A', 'foo', map, ctx.model, 'c3'); + expect(result).toBeDefined(); + expect(result!.nodeId).toBe('method:B:foo'); + }); + it('qualified-syntax (Rust): returns undefined for inherited methods', () => { - ctx.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', { + ctx.model.symbols.add('src/parent.rs', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.rs', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.rs', 'process', 'method:Parent:process', 'Method', { returnType: 'void', ownerId: 'class:Parent', }); @@ -1066,16 +1403,16 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'process', map, - ctx.symbols, - SupportedLanguages.Rust, + ctx.model, + 'qualified-syntax', ); // Rust requires qualified syntax — no auto-resolution expect(result).toBeUndefined(); }); it('method not on any ancestor returns undefined', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/child.java', className: 'Child', parentName: 'Parent', kind: 'extends' }, @@ -1086,17 +1423,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'nonExistent', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeUndefined(); }); it('leftmost-base (C++): walks ancestors in BFS order', () => { - ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { returnType: 'void', ownerId: 'class:A', }); @@ -1107,22 +1444,16 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:C', - 'render', - map, - ctx.symbols, - SupportedLanguages.CPlusPlus, - ); + const result = lookupMethodByOwnerWithMRO('class:C', 'render', map, ctx.model, 'leftmost-base'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:A:render'); }); it('implements-split (Java): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', { + ctx.model.symbols.add('src/base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/iface.java', 'IRepo', 'iface:IRepo', 'Interface'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/base.java', 'save', 'method:Base:save', 'Method', { returnType: 'void', ownerId: 'class:Base', }); @@ -1142,8 +1473,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'save', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:save'); @@ -1156,14 +1487,14 @@ describe('lookupMethodByOwnerWithMRO', () => { // level. lookupMethodByOwnerWithMRO itself uses BFS order and returns // the first match — this test pins that contract so a future regression // that starts returning undefined (or flips the order) fails loudly. - ctx.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface'); - ctx.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface'); - ctx.symbols.add('src/C.java', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', { + ctx.model.symbols.add('src/I1.java', 'I1', 'iface:I1', 'Interface'); + ctx.model.symbols.add('src/I2.java', 'I2', 'iface:I2', 'Interface'); + ctx.model.symbols.add('src/C.java', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/I1.java', 'handle', 'method:I1:handle', 'Method', { returnType: 'void', ownerId: 'iface:I1', }); - ctx.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { + ctx.model.symbols.add('src/I2.java', 'handle', 'method:I2:handle', 'Method', { returnType: 'void', ownerId: 'iface:I2', }); @@ -1179,8 +1510,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:C', 'handle', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); // BFS first-wins — I1 was declared first, so it wins. @@ -1194,14 +1525,14 @@ describe('lookupMethodByOwnerWithMRO', () => { // Base before IFoo — class wins. Documents the current BFS-level // behavior; the strict Java "class always wins" rule is enforced at // the mro-processor graph pass. - ctx.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface'); - ctx.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', { + ctx.model.symbols.add('src/Base.java', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/IFoo.java', 'IFoo', 'iface:IFoo', 'Interface'); + ctx.model.symbols.add('src/Child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/Base.java', 'handle', 'method:Base:handle', 'Method', { returnType: 'void', ownerId: 'class:Base', }); - ctx.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { + ctx.model.symbols.add('src/IFoo.java', 'handle', 'method:IFoo:handle', 'Method', { returnType: 'void', ownerId: 'iface:IFoo', }); @@ -1216,17 +1547,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (Kotlin): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', { + ctx.model.symbols.add('src/base.kt', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/child.kt', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/base.kt', 'handle', 'method:Base:handle', 'Method', { returnType: 'Unit', ownerId: 'class:Base', }); @@ -1240,17 +1571,17 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'handle', map, - ctx.symbols, - SupportedLanguages.Kotlin, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:handle'); }); it('implements-split (C#): walks ancestors to find inherited method', () => { - ctx.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', { + ctx.model.symbols.add('src/Base.cs', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/Child.cs', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/Base.cs', 'Execute', 'method:Base:Execute', 'Method', { returnType: 'void', ownerId: 'class:Base', }); @@ -1264,8 +1595,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:Child', 'Execute', map, - ctx.symbols, - SupportedLanguages.CSharp, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Base:Execute'); @@ -1274,9 +1605,9 @@ describe('lookupMethodByOwnerWithMRO', () => { it('first-wins (JavaScript): walks ancestors to find inherited method', () => { // JavaScript provider is wired separately from TypeScript — this guards // the provider wiring independent of the TS path. - ctx.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class'); - ctx.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class'); - ctx.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', { + ctx.model.symbols.add('src/animal.js', 'Animal', 'class:Animal', 'Class'); + ctx.model.symbols.add('src/dog.js', 'Dog', 'class:Dog', 'Class'); + ctx.model.symbols.add('src/animal.js', 'speak', 'method:Animal:speak', 'Method', { returnType: 'string', ownerId: 'class:Animal', }); @@ -1286,13 +1617,7 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:Dog', - 'speak', - map, - ctx.symbols, - SupportedLanguages.JavaScript, - ); + const result = lookupMethodByOwnerWithMRO('class:Dog', 'speak', map, ctx.model, 'first-wins'); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:Animal:speak'); }); @@ -1301,19 +1626,19 @@ describe('lookupMethodByOwnerWithMRO', () => { // Diamond: D extends B, C; B extends A; C extends A. // Both B and C define render(). leftmost-base must return B#render (first // branch in declaration order), not A#render or C#render. - ctx.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/d.cpp', 'D', 'class:D', 'Class'); - ctx.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { + ctx.model.symbols.add('src/a.cpp', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.cpp', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.cpp', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/d.cpp', 'D', 'class:D', 'Class'); + ctx.model.symbols.add('src/a.cpp', 'render', 'method:A:render', 'Method', { returnType: 'void', ownerId: 'class:A', }); - ctx.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { + ctx.model.symbols.add('src/b.cpp', 'render', 'method:B:render', 'Method', { returnType: 'void', ownerId: 'class:B', }); - ctx.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { + ctx.model.symbols.add('src/c.cpp', 'render', 'method:C:render', 'Method', { returnType: 'void', ownerId: 'class:C', }); @@ -1327,13 +1652,7 @@ describe('lookupMethodByOwnerWithMRO', () => { ]; const map = buildHeritageMap(heritage, ctx); - const result = lookupMethodByOwnerWithMRO( - 'class:D', - 'render', - map, - ctx.symbols, - SupportedLanguages.CPlusPlus, - ); + const result = lookupMethodByOwnerWithMRO('class:D', 'render', map, ctx.model, 'leftmost-base'); expect(result).toBeDefined(); // BFS via HeritageMap visits B before C (insertion order), so leftmost // branch wins — matches C++ leftmost-base semantics for non-virtual base. @@ -1341,8 +1660,8 @@ describe('lookupMethodByOwnerWithMRO', () => { }); it('returns direct method on owner without walking (no heritage needed)', () => { - ctx.symbols.add('src/user.java', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { + ctx.model.symbols.add('src/user.java', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.java', 'getName', 'method:User:getName', 'Method', { returnType: 'String', ownerId: 'class:User', }); @@ -1353,8 +1672,8 @@ describe('lookupMethodByOwnerWithMRO', () => { 'class:User', 'getName', map, - ctx.symbols, - SupportedLanguages.Java, + ctx.model, + 'implements-split', ); expect(result).toBeDefined(); expect(result!.nodeId).toBe('method:User:getName'); @@ -1380,8 +1699,8 @@ describe('resolveMemberCall', () => { }); it('resolves direct method on owner type', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1396,9 +1715,9 @@ describe('resolveMemberCall', () => { }); it('resolves inherited method via MRO walk', () => { - ctx.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); - ctx.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); - ctx.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', { + ctx.model.symbols.add('src/parent.java', 'Parent', 'class:Parent', 'Class'); + ctx.model.symbols.add('src/child.java', 'Child', 'class:Child', 'Class'); + ctx.model.symbols.add('src/parent.java', 'validate', 'method:Parent:validate', 'Method', { returnType: 'boolean', ownerId: 'class:Parent', }); @@ -1422,7 +1741,7 @@ describe('resolveMemberCall', () => { }); it('returns null for unknown method on known owner', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveMemberCall('User', 'nonExistentMethod', 'src/app.ts', ctx); @@ -1430,8 +1749,8 @@ describe('resolveMemberCall', () => { }); it('returns result with correct confidence tier for same-file resolution', () => { - ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/app.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1444,8 +1763,8 @@ describe('resolveMemberCall', () => { }); it('returns result with import-scoped tier for cross-file resolution', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1459,10 +1778,10 @@ describe('resolveMemberCall', () => { }); it('resolves with heritage map across C3 MRO chain (Python)', () => { - ctx.symbols.add('src/a.py', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.py', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/c.py', 'C', 'class:C', 'Class'); - ctx.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', { + ctx.model.symbols.add('src/a.py', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.py', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/c.py', 'C', 'class:C', 'Class'); + ctx.model.symbols.add('src/a.py', 'foo', 'method:A:foo', 'Method', { returnType: 'str', ownerId: 'class:A', }); @@ -1491,8 +1810,8 @@ describe('resolveMemberCall', () => { // have used the tier of resolving "save" globally; new behaviour uses the // tier of resolving "User". Both happen to yield import-scoped here — // the test locks that the reported tier tracks the class lookup. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1513,9 +1832,9 @@ describe('resolveMemberCall', () => { it('Rust: returns null for trait-inherited method (qualified-syntax MRO)', () => { // Trait Writer defines `save`. Struct User has an impl_item but NO save // method of its own — save is only available via trait. - ctx.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait'); - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); - ctx.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', { + ctx.model.symbols.add('src/writer.rs', 'Writer', 'trait:Writer', 'Trait'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/writer.rs', 'save', 'method:Writer:save', 'Method', { returnType: 'bool', ownerId: 'trait:Writer', }); @@ -1537,8 +1856,8 @@ describe('resolveMemberCall', () => { // Positive control: a method defined directly on User (not via trait) // resolves normally — demonstrates the null in the previous test is // specifically due to the trait-inheritance path, not a broken fixture. - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', { + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'name', 'method:User:name', 'Method', { returnType: 'String', ownerId: 'struct:User', }); @@ -1562,13 +1881,13 @@ describe('resolveMemberCall', () => { it('disambiguates homonym classes: only one owns the method', () => { // Two classes both named `User` — one in auth.py (has `save`), one in // legacy.py (has `archive` but no `save`). Both are imported from app.py. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); - ctx.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', { + ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.model.symbols.add('src/legacy.py', 'archive', 'method:legacy:User:archive', 'Method', { returnType: 'None', ownerId: 'class:legacy:User', }); @@ -1589,13 +1908,13 @@ describe('resolveMemberCall', () => { // Both homonym Users define a `save` method — resolveMemberCall refuses // to pick one. The caller (resolveCallTarget) falls through to D1-D4 which // may or may not be able to narrow further. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); - ctx.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', { + ctx.model.symbols.add('src/legacy.py', 'User', 'class:legacy:User', 'Class'); + ctx.model.symbols.add('src/legacy.py', 'save', 'method:legacy:User:save', 'Method', { returnType: 'None', ownerId: 'class:legacy:User', }); @@ -1609,13 +1928,13 @@ describe('resolveMemberCall', () => { // Two homonym `User` classes in different files, both extending a common // `BaseUser` that owns `save`. Direct lookup on either User misses; MRO // walks both find BaseUser.save. Dedup by nodeId yields a single result. - ctx.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class'); - ctx.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', { + ctx.model.symbols.add('src/base.ts', 'BaseUser', 'class:BaseUser', 'Class'); + ctx.model.symbols.add('src/base.ts', 'save', 'method:BaseUser:save', 'Method', { returnType: 'void', ownerId: 'class:BaseUser', }); - ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); - ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); + ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/base.ts', 'src/a.ts', 'src/b.ts'])); const heritage: ExtractedHeritage[] = [ @@ -1639,11 +1958,11 @@ describe('resolveMemberCall', () => { // // Both A and B inherit `method` from Base. Derived extends (A, B). // Leftmost-base strategy walks A's chain first → finds Base::method. - ctx.symbols.add('src/base.h', 'Base', 'class:Base', 'Class'); - ctx.symbols.add('src/a.h', 'A', 'class:A', 'Class'); - ctx.symbols.add('src/b.h', 'B', 'class:B', 'Class'); - ctx.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class'); - ctx.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', { + ctx.model.symbols.add('src/base.h', 'Base', 'class:Base', 'Class'); + ctx.model.symbols.add('src/a.h', 'A', 'class:A', 'Class'); + ctx.model.symbols.add('src/b.h', 'B', 'class:B', 'Class'); + ctx.model.symbols.add('src/derived.h', 'Derived', 'class:Derived', 'Class'); + ctx.model.symbols.add('src/base.h', 'method', 'method:Base:method', 'Method', { returnType: 'int', ownerId: 'class:Base', }); @@ -1677,10 +1996,10 @@ describe('resolveMemberCall', () => { // C# uses implements-split MRO: class base chain walked first, then // interfaces. Here IService declares Save which is implemented by the // base class BaseService — MyService inherits Save through the class. - ctx.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface'); - ctx.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class'); - ctx.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class'); - ctx.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', { + ctx.model.symbols.add('src/iservice.cs', 'IService', 'interface:IService', 'Interface'); + ctx.model.symbols.add('src/base.cs', 'BaseService', 'class:BaseService', 'Class'); + ctx.model.symbols.add('src/my.cs', 'MyService', 'class:MyService', 'Class'); + ctx.model.symbols.add('src/base.cs', 'Save', 'method:BaseService:Save', 'Method', { returnType: 'void', ownerId: 'class:BaseService', }); @@ -1708,9 +2027,9 @@ describe('resolveMemberCall', () => { // Kotlin shares the implements-split MRO strategy with Java/C#. A class // inheriting from an interface that provides a default method should // resolve `obj.method()` to the interface's implementation. - ctx.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface'); - ctx.symbols.add('src/user.kt', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', { + ctx.model.symbols.add('src/validator.kt', 'Validator', 'interface:Validator', 'Interface'); + ctx.model.symbols.add('src/user.kt', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/validator.kt', 'validate', 'method:Validator:validate', 'Method', { returnType: 'Boolean', ownerId: 'interface:Validator', }); @@ -1765,13 +2084,13 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { // type-file verification guard requires the alias target file to be // among the receiver type's defining files before alias narrowing is // considered a valid signal. - ctx.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); - ctx.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { + ctx.model.symbols.add('src/auth.py', 'User', 'class:auth:User', 'Class'); + ctx.model.symbols.add('src/auth.py', 'save', 'method:auth:User:save', 'Method', { returnType: 'None', ownerId: 'class:auth:User', }); - ctx.symbols.add('src/other.py', 'User', 'class:other:User', 'Class'); - ctx.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', { + ctx.model.symbols.add('src/other.py', 'User', 'class:other:User', 'Class'); + ctx.model.symbols.add('src/other.py', 'save', 'method:other:User:save', 'Method', { returnType: 'None', ownerId: 'class:other:User', }); @@ -1797,8 +2116,8 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { it('overloadHints ignored for member calls — resolveMemberCall resolves directly', () => { // With the thin dispatcher, overloadHints are not passed to resolveMemberCall // (it does not accept them). Single-candidate member calls still resolve. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1825,8 +2144,8 @@ describe('resolveCallTarget thin dispatcher (SM-19)', () => { // Analogous to the overloadHints case: thin dispatcher delegates to // resolveMemberCall which resolves the single candidate without needing // argument-type disambiguation. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'save', 'method:User:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); @@ -1863,8 +2182,8 @@ describe('resolveStaticCall', () => { }); it('resolves constructor with ownerId via lookupMethodByOwner', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { returnType: 'User', ownerId: 'class:User', }); @@ -1877,7 +2196,7 @@ describe('resolveStaticCall', () => { }); it('returns class node when no constructor exists', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1887,7 +2206,7 @@ describe('resolveStaticCall', () => { }); it('returns null for non-class symbol', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = resolveStaticCall('helper', 'src/app.ts', ctx); @@ -1902,8 +2221,8 @@ describe('resolveStaticCall', () => { }); it('returns null when Constructor nodes lack ownerId', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { parameterCount: 1, }); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); @@ -1917,13 +2236,13 @@ describe('resolveStaticCall', () => { }); it('disambiguates constructor by arity', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { parameterCount: 0, returnType: 'User', ownerId: 'class:User', }); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { parameterCount: 2, returnType: 'User', ownerId: 'class:User', @@ -1937,7 +2256,7 @@ describe('resolveStaticCall', () => { }); it('returns correct confidence tier for import-scoped class', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1948,7 +2267,7 @@ describe('resolveStaticCall', () => { }); it('returns correct confidence tier for same-file class', () => { - ctx.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/app.ts', 'User', 'class:User', 'Class'); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1958,8 +2277,8 @@ describe('resolveStaticCall', () => { }); it('returns null for ambiguous homonym classes without constructor', () => { - ctx.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); - ctx.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); + ctx.model.symbols.add('src/a.ts', 'User', 'class:a:User', 'Class'); + ctx.model.symbols.add('src/b.ts', 'User', 'class:b:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = resolveStaticCall('User', 'src/app.ts', ctx); @@ -1969,7 +2288,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for constructor callForm', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); const result = _resolveCallTargetForTesting( @@ -1986,7 +2305,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for free-form call targeting a class (Swift/Kotlin)', () => { - ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.swift', new Set(['src/user.swift'])); const result = _resolveCallTargetForTesting( @@ -2003,8 +2322,8 @@ describe('resolveStaticCall', () => { }); it('reuses the pre-computed tiered result instead of calling ctx.resolve twice', () => { - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User', 'Constructor', { returnType: 'User', ownerId: 'class:User', }); @@ -2030,8 +2349,8 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Java constructor call (new User())', () => { - ctx.symbols.add('src/User.java', 'User', 'class:java:User', 'Class'); - ctx.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', { + ctx.model.symbols.add('src/User.java', 'User', 'class:java:User', 'Class'); + ctx.model.symbols.add('src/User.java', 'User', 'ctor:java:User', 'Constructor', { returnType: 'User', ownerId: 'class:java:User', }); @@ -2052,7 +2371,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Python free-form constructor (User())', () => { - ctx.symbols.add('models/user.py', 'User', 'class:py:User', 'Class'); + ctx.model.symbols.add('models/user.py', 'User', 'class:py:User', 'Class'); ctx.importMap.set('app.py', new Set(['models/user.py'])); const result = _resolveCallTargetForTesting( @@ -2069,7 +2388,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget for Kotlin free-form constructor (User())', () => { - ctx.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class'); + ctx.model.symbols.add('src/User.kt', 'User', 'class:kt:User', 'Class'); ctx.importMap.set('src/App.kt', new Set(['src/User.kt'])); const result = _resolveCallTargetForTesting( @@ -2093,7 +2412,7 @@ describe('resolveStaticCall', () => { // ------------------------------------------------------------------------- it('returns a Struct node when no constructor exists (positive regression guard)', () => { - ctx.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:User', 'Struct'); ctx.importMap.set('src/app.rs', new Set(['src/user.rs'])); const result = resolveStaticCall('User', 'src/app.rs', ctx); @@ -2103,7 +2422,7 @@ describe('resolveStaticCall', () => { }); it('returns a Record node when no constructor exists (positive regression guard)', () => { - ctx.symbols.add('src/User.cs', 'User', 'record:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = resolveStaticCall('User', 'src/App.cs', ctx); @@ -2115,7 +2434,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is an Interface (Java/C#/TS)', () => { // Constructor-shaped call on an interface name — not legal source, but // the resolver must refuse to emit a CALLS edge to a non-instantiable node. - ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); + ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); ctx.importMap.set('src/app.java', new Set(['src/validator.java'])); const result = resolveStaticCall('IValidator', 'src/app.java', ctx); @@ -2125,7 +2444,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is a Trait (PHP/Rust/Scala)', () => { // PHP `HasTimestamps` trait — not instantiable via constructor syntax. - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = resolveStaticCall('HasTimestamps', 'src/model.php', ctx); @@ -2134,7 +2453,7 @@ describe('resolveStaticCall', () => { }); it('null-routes when the sole candidate is a Rust Trait (Display)', () => { - ctx.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait'); + ctx.model.symbols.add('src/fmt.rs', 'Display', 'trait:rs:Display', 'Trait'); ctx.importMap.set('src/app.rs', new Set(['src/fmt.rs'])); const result = resolveStaticCall('Display', 'src/app.rs', ctx); @@ -2146,8 +2465,8 @@ describe('resolveStaticCall', () => { // Rust `impl User { ... }` alongside `struct User { ... }` in the same file. // Same-file tier returns both via lookupExactAll, both pass CLASS_LIKE_TYPES, // but the instantiability filter must strip the Impl so the Struct wins. - ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); const result = resolveStaticCall('User', 'src/user.rs', ctx); @@ -2158,7 +2477,7 @@ describe('resolveStaticCall', () => { it('null-routes when the sole candidate is a Rust Impl block (no Struct present)', () => { // Pathological extractor output: only the Impl survives tier resolution. // The instantiability filter must reject it rather than emit a wrong edge. - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); const result = resolveStaticCall('User', 'src/user.rs', ctx); @@ -2171,9 +2490,9 @@ describe('resolveStaticCall', () => { // extractor still resolves correctly. The Struct is also present so that // step-1's lookupClassByName pre-check succeeds (Impl alone isn't in the // classByName index). - ctx.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); - ctx.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); - ctx.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', { + ctx.model.symbols.add('src/user.rs', 'User', 'struct:rs:User', 'Struct'); + ctx.model.symbols.add('src/user.rs', 'User', 'impl:rs:User', 'Impl'); + ctx.model.symbols.add('src/user.rs', 'User', 'ctor:rs:User', 'Constructor', { returnType: 'User', ownerId: 'impl:rs:User', }); @@ -2186,7 +2505,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget and null-routes Interface constructor-shaped calls', () => { - ctx.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); + ctx.model.symbols.add('src/validator.java', 'IValidator', 'iface:IValidator', 'Interface'); ctx.importMap.set('src/app.java', new Set(['src/validator.java'])); const result = _resolveCallTargetForTesting( @@ -2204,7 +2523,7 @@ describe('resolveStaticCall', () => { }); it('routes through resolveCallTarget and null-routes Trait free-form calls', () => { - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = _resolveCallTargetForTesting( @@ -2225,7 +2544,7 @@ describe('resolveStaticCall', () => { // so S0 was bypassed and Record free-form calls fell through to the // constructor-form retry path. This test would have silently passed with // the old (wasteful) code path — with the fix, S0 resolves it directly. - ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = _resolveCallTargetForTesting( @@ -2245,13 +2564,13 @@ describe('resolveStaticCall', () => { // Regression guard: if call.argCount were ever dropped at the S0 call // site, the 2-arg constructor would resolve to the 0-arg overload (or // return null via ambiguity). This test fails in either case. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:0', 'Constructor', { parameterCount: 0, returnType: 'User', ownerId: 'class:User', }); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:2', 'Constructor', { parameterCount: 2, returnType: 'User', ownerId: 'class:User', @@ -2285,7 +2604,7 @@ describe('resolveFreeCall', () => { }); it('resolves a free function call via import-scoped resolution', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); @@ -2297,7 +2616,7 @@ describe('resolveFreeCall', () => { }); it('resolves a free function call via same-file resolution', () => { - ctx.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); + ctx.model.symbols.add('src/app.ts', 'helper', 'func:helper', 'Function'); const result = resolveFreeCall('helper', 'src/app.ts', ctx); @@ -2313,8 +2632,8 @@ describe('resolveFreeCall', () => { }); it('returns null for ambiguous free function calls (multiple candidates)', () => { - ctx.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function'); - ctx.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function'); + ctx.model.symbols.add('src/a.ts', 'doStuff', 'func:a:doStuff', 'Function'); + ctx.model.symbols.add('src/b.ts', 'doStuff', 'func:b:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts'])); const result = resolveFreeCall('doStuff', 'src/app.ts', ctx); @@ -2323,7 +2642,7 @@ describe('resolveFreeCall', () => { }); it('delegates to resolveStaticCall for free-form class targets (Swift/Kotlin)', () => { - ctx.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.swift', 'User', 'class:User', 'Class'); ctx.importMap.set('src/app.swift', new Set(['src/user.swift'])); const result = resolveFreeCall('User', 'src/app.swift', ctx); @@ -2333,7 +2652,7 @@ describe('resolveFreeCall', () => { }); it('delegates to resolveStaticCall for Record free-form targets (C#/Kotlin)', () => { - ctx.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); + ctx.model.symbols.add('src/User.cs', 'User', 'record:cs:User', 'Record'); ctx.importMap.set('src/App.cs', new Set(['src/User.cs'])); const result = resolveFreeCall('User', 'src/App.cs', ctx); @@ -2343,7 +2662,7 @@ describe('resolveFreeCall', () => { }); it('null-routes Trait free-form calls via resolveStaticCall', () => { - ctx.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); + ctx.model.symbols.add('src/timestamps.php', 'HasTimestamps', 'trait:HasTimestamps', 'Trait'); ctx.importMap.set('src/model.php', new Set(['src/timestamps.php'])); const result = resolveFreeCall('HasTimestamps', 'src/model.php', ctx); @@ -2352,7 +2671,7 @@ describe('resolveFreeCall', () => { }); it('uses tieredOverride when provided', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const tiered = ctx.resolve('doStuff', 'src/app.ts'); @@ -2374,7 +2693,7 @@ describe('resolveFreeCall', () => { }); it('routes through resolveCallTarget for free-form calls', () => { - ctx.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); + ctx.model.symbols.add('src/utils.ts', 'doStuff', 'func:doStuff', 'Function'); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); const result = _resolveCallTargetForTesting( @@ -2401,7 +2720,7 @@ describe('resolveFreeCall', () => { // file-extension branching; these guard the dispatch chain per language. it('resolves a Go free function (doStuff())', () => { - ctx.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function'); + ctx.model.symbols.add('src/helper.go', 'doStuff', 'func:go:doStuff', 'Function'); ctx.importMap.set('src/main.go', new Set(['src/helper.go'])); const result = _resolveCallTargetForTesting( @@ -2415,7 +2734,7 @@ describe('resolveFreeCall', () => { }); it('resolves a Python free function (def helper(): ... helper())', () => { - ctx.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); + ctx.model.symbols.add('helpers.py', 'helper', 'func:py:helper', 'Function'); ctx.importMap.set('app.py', new Set(['helpers.py'])); const result = _resolveCallTargetForTesting( @@ -2429,7 +2748,7 @@ describe('resolveFreeCall', () => { }); it('resolves a Rust free function outside any impl block (free_fn())', () => { - ctx.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function'); + ctx.model.symbols.add('src/helpers.rs', 'free_fn', 'func:rs:free_fn', 'Function'); ctx.importMap.set('src/main.rs', new Set(['src/helpers.rs'])); const result = _resolveCallTargetForTesting( @@ -2447,7 +2766,7 @@ describe('resolveFreeCall', () => { // indexing the function directly in its declaring file. The test guards // the dispatch chain for .java files, not the extractor's handling of // static imports specifically. - ctx.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function'); + ctx.model.symbols.add('src/Utils.java', 'doStuff', 'func:java:doStuff', 'Function'); ctx.importMap.set('src/App.java', new Set(['src/Utils.java'])); const result = _resolveCallTargetForTesting( @@ -2461,7 +2780,7 @@ describe('resolveFreeCall', () => { }); it('resolves a JavaScript module-level function (moduleFn())', () => { - ctx.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function'); + ctx.model.symbols.add('src/helpers.js', 'moduleFn', 'func:js:moduleFn', 'Function'); ctx.importMap.set('src/app.js', new Set(['src/helpers.js'])); const result = _resolveCallTargetForTesting( @@ -2478,10 +2797,10 @@ describe('resolveFreeCall', () => { // differing only in parameter count. it('narrows overloaded free functions by argCount (2-arg overload selected)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { parameterCount: 2, }); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); @@ -2497,10 +2816,10 @@ describe('resolveFreeCall', () => { }); it('narrows overloaded free functions by argCount (0-arg overload selected)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:0', 'Function', { parameterCount: 0, }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:2', 'Function', { parameterCount: 2, }); ctx.importMap.set('src/app.ts', new Set(['src/utils.ts'])); @@ -2520,7 +2839,7 @@ describe('resolveFreeCall', () => { // so a silent tier-table refactor surfaces here. it('resolves a globally-visible free function via Tier 3 with global confidence', () => { - ctx.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function'); + ctx.model.symbols.add('lib/global.ts', 'helper', 'func:global:helper', 'Function'); // No importMap entry — must fall through to Tier 3 (global). const result = _resolveCallTargetForTesting( @@ -2544,11 +2863,11 @@ describe('resolveFreeCall', () => { // preComputedArgTypes at the disambiguation site. it('disambiguates overloads via preComputedArgTypes (String overload matched)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { parameterCount: 1, parameterTypes: ['String'], }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { parameterCount: 1, parameterTypes: ['Int'], }); @@ -2566,11 +2885,11 @@ describe('resolveFreeCall', () => { }); it('disambiguates overloads via preComputedArgTypes (Int overload matched)', () => { - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:str', 'Function', { parameterCount: 1, parameterTypes: ['String'], }); - ctx.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { + ctx.model.symbols.add('src/utils.ts', 'helper', 'func:helper:int', 'Function', { parameterCount: 1, parameterTypes: ['Int'], }); @@ -2600,7 +2919,7 @@ describe('resolveFreeCall', () => { // will need to be updated alongside that work — that is the correct signal. it('null-routes Enum free-form calls (Color() — no instantiable fallback)', () => { - ctx.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); + ctx.model.symbols.add('src/color.ts', 'Color', 'enum:Color', 'Enum'); ctx.importMap.set('src/app.ts', new Set(['src/color.ts'])); const result = _resolveCallTargetForTesting( @@ -2631,8 +2950,13 @@ describe('resolveFreeCall', () => { it('dedupes Swift extension candidates by shortest file path (free-form retry path)', () => { // Two same-name Class entries, different path lengths. - ctx.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class'); - ctx.symbols.add('src/Extensions/UserExtensions.swift', 'User', 'class:User:extension', 'Class'); + ctx.model.symbols.add('src/User.swift', 'User', 'class:User:primary', 'Class'); + ctx.model.symbols.add( + 'src/Extensions/UserExtensions.swift', + 'User', + 'class:User:extension', + 'Class', + ); ctx.importMap.set( 'src/App.swift', new Set(['src/User.swift', 'src/Extensions/UserExtensions.swift']), @@ -2673,8 +2997,8 @@ describe('resolveFreeCall', () => { // 'constructor' form, which — per CONSTRUCTOR_TARGET_TYPES — prefers // the Constructor node over the Class node. // 4. Single survivor → returned as the call target. - ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); - ctx.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', { + ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class'); + ctx.model.symbols.add('src/user.ts', 'User', 'ctor:User:ownerless', 'Constructor', { parameterCount: 0, // No ownerId — this is the pathological extractor output the retry path // exists to handle. @@ -2699,7 +3023,7 @@ describe('resolveFreeCall', () => { // language coverage table in PR #756 review flagged this as uncovered; // this test exercises the `.php` dispatch path for free calls. Matches // the shape of the existing Go/Python/Rust/Java/JS language tests above. - ctx.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function'); + ctx.model.symbols.add('src/helpers.php', 'helper', 'func:php:helper', 'Function'); ctx.importMap.set('src/app.php', new Set(['src/helpers.php'])); const result = _resolveCallTargetForTesting( diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index ce771b857d..27f449f91f 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; +import { type SymbolDefinition } from '../../src/core/ingestion/model/symbol-table.js'; import { - createSymbolTable, - type SymbolDefinition, - type SymbolTable, -} from '../../src/core/ingestion/symbol-table.js'; + createSemanticModel, + type SemanticModel, +} from '../../src/core/ingestion/model/semantic-model.js'; import { stripNullable, extractSimpleTypeName, @@ -78,25 +78,6 @@ function flatSize(typeEnv: TypeEnvironment): number { return count; } -const createMockSymbolTable = (overrides: Partial = {}): SymbolTable => ({ - add: () => {}, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - lookupExactAll: () => [], - lookupCallableByName: () => [], - lookupFieldByOwner: () => undefined, - lookupMethodByOwner: () => undefined, - lookupClassByName: () => [], - lookupClassByQualifiedName: () => [], - lookupImplByName: () => [], - getFiles: () => [][Symbol.iterator](), - getStats: () => ({ - fileCount: 0, - }), - clear: () => {}, - ...overrides, -}); - const createClassDef = ( name: string, type: SymbolDefinition['type'] = 'Class', @@ -1191,29 +1172,44 @@ class RepoService { }); describe('destructured call results', () => { - // Minimal mock SymbolTable for call-result return type lookup + // Minimal mock SemanticModel for call-result return type lookup + // (SM-21 inversion — buildTypeEnv takes a SemanticModel via `model:`). const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - lookupClassByName: () => [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, + types: { + lookupClassByName: () => [], + lookupClassByQualifiedName: () => [], + lookupImplByName: () => [], + }, + methods: { + lookupMethodByOwner: () => undefined, + lookupMethodByName: () => [], + }, + fields: { + lookupFieldByOwner: () => undefined, + }, + symbols: { + add: () => {}, + lookupExact: () => undefined, + lookupExactFull: () => undefined, + lookupExactAll: () => [], + lookupCallableByName: (name: string) => + callables + .filter((c) => c.name === name) + .map((c) => ({ + nodeId: 'n1', + filePath: 'src.ts', + type: 'Function' as const, + returnType: c.returnType, + })), + getFiles: () => [][Symbol.iterator](), + getStats: () => ({ fileCount: 0 }), + }, }); it('emits callResult + fieldAccess items for const { x } = fn()', () => { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const { name } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); // callResult resolves __destr_getUser_N → User // fieldAccess resolves name via User's properties (no Property nodes in mock → undefined) // But the callResult itself IS emitted — verify constructorBindings is still empty @@ -1226,14 +1222,14 @@ class RepoService { 'async function f() { const { data } = await fetchData(); }', TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); expect(typeEnv.constructorBindings).toEqual([]); }); it('gracefully handles no return type (composable without annotation)', () => { const symbolTable = makeSymbolTable([{ name: 'useUserRole' }]); // no returnType const tree = parse('const { isMaker } = useUserRole();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); // No return type → callResult unresolved → fieldAccess unresolved expect(flatGet(typeEnv, 'isMaker')).toBeUndefined(); }); @@ -2045,17 +2041,10 @@ class RepoService { `, Kotlin, ); - // User is NOT defined in this file, but SymbolTable knows it's a Class - const mockSymbolTable = { - lookupClassByName: (name: string) => - name === 'User' ? [{ nodeId: 'n1', filePath: 'models.kt', type: 'Class' }] : [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }; - const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); + // User is NOT defined in this file, but SemanticModel knows it's a Class + const model = createSemanticModel(); + model.symbols.add('models.kt', 'User', 'n1', 'Class'); + const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2068,17 +2057,8 @@ class RepoService { `, Kotlin, ); - const mockSymbolTable = { - lookupClassByName: () => [], - lookupCallableByName: () => [], - lookupFieldByOwner: () => undefined, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }; - const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); + const model = createSemanticModel(); + const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2144,10 +2124,15 @@ def main(): }); describe('lookupClassByName regression coverage', () => { - const makeClassLookupTable = (classDefs: Record) => - createMockSymbolTable({ - lookupClassByName: (name: string) => classDefs[name] ?? [], - }); + const makeClassLookupTable = ( + classDefs: Record, + ): SemanticModel => { + const model = createSemanticModel(); + vi.spyOn(model.types, 'lookupClassByName').mockImplementation( + (name: string) => classDefs[name] ?? [], + ); + return model; + }; it('Python cross-file constructor inference uses lookupClassByName', () => { const tree = parse( @@ -2158,7 +2143,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), }); @@ -2174,7 +2159,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2188,7 +2173,7 @@ def main(): Python, ); const typeEnv = buildTypeEnv(tree, 'python', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), }); @@ -2205,7 +2190,7 @@ void run() { CPP, ); const typeEnv = buildTypeEnv(tree, 'cpp', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.h')], }), }); @@ -2222,7 +2207,7 @@ void run() { CPP, ); const typeEnv = buildTypeEnv(tree, 'cpp', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2237,7 +2222,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models/user.rb')], }), }); @@ -2254,7 +2239,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')], }), }); @@ -2271,7 +2256,7 @@ end Ruby, ); const typeEnv = buildTypeEnv(tree, 'ruby', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2286,7 +2271,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), }); @@ -2302,7 +2287,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), }); @@ -2318,7 +2303,7 @@ void run() { `, ); const typeEnv = buildTypeEnv(tree, 'dart', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2334,7 +2319,7 @@ fn run() { Rust, ); const typeEnv = buildTypeEnv(tree, 'rust', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Struct', 'models.rs')], }), }); @@ -2351,7 +2336,7 @@ fn run() { Rust, ); const typeEnv = buildTypeEnv(tree, 'rust', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'value')).toBeUndefined(); }); @@ -2366,7 +2351,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), }); @@ -2382,7 +2367,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({ + model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), }); @@ -2398,7 +2383,7 @@ func run() { `, ); const typeEnv = buildTypeEnv(tree, 'swift', { - symbolTable: makeClassLookupTable({}), + model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2413,20 +2398,13 @@ function process(user: User) { `, TypeScript.typescript, ); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'User' ? [createClassDef('User', 'Class', 'models.ts')] : [], - lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => - ownerNodeId === 'class:User' && fieldName === 'address' - ? { - nodeId: 'prop:User:address', - filePath: 'models.ts', - type: 'Property' as const, - declaredType: 'Address', - } - : undefined, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'User', 'class:User', 'Class'); + model.symbols.add('models.ts', 'address', 'prop:User:address', 'Property', { + ownerId: 'class:User', + declaredType: 'Address', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'addr')).toBe('Address'); }); @@ -2439,10 +2417,8 @@ function process(user: User) { `, TypeScript.typescript, ); - const symbolTable = createMockSymbolTable({ - lookupClassByName: () => [], - }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const model = createSemanticModel(); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'addr')).toBeUndefined(); }); @@ -2455,23 +2431,14 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - } - : undefined, - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2485,27 +2452,16 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2521,32 +2477,15 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' - ? [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { - ...createClassDef('Repo', 'Class', 'models-b.ts'), - nodeId: 'class:Repo:partial', - }, - ] - : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile', - filePath: 'models-b.ts', - type: 'Method', - ownerId: 'class:Repo:partial', - returnType: 'Profile', - } - : undefined, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile', 'Method', { + ownerId: 'class:Repo:partial', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2560,33 +2499,17 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') { - return [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' }, - ]; - } - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2602,44 +2525,19 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' - ? [ - createClassDef('Repo', 'Class', 'models-a.ts'), - { - ...createClassDef('Repo', 'Class', 'models-b.ts'), - nodeId: 'class:Repo:partial', - }, - ] - : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => { - if (methodName !== 'getProfile') return undefined; - if (ownerNodeId === 'class:Repo') { - return { - nodeId: 'method:Repo:getProfile#a', - filePath: 'models-a.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }; - } - if (ownerNodeId === 'class:Repo:partial') { - return { - nodeId: 'method:Repo:getProfile#b', - filePath: 'models-b.ts', - type: 'Method', - ownerId: 'class:Repo:partial', - returnType: 'Profile', - }; - } - return undefined; - }, - lookupExactAll: () => [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class'); + model.symbols.add('models-a.ts', 'getProfile', 'method:Repo:getProfile#a', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile#b', 'Method', { + ownerId: 'class:Repo:partial', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2653,42 +2551,18 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => - name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:Repo' && methodName === 'getProfile' - ? { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'models.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }, - { - nodeId: 'method:Repo:getProfile#2', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Profile', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', { + ownerId: 'class:Repo', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); + const typeEnv = buildTypeEnv(tree, 'typescript', { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2702,46 +2576,24 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'models.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:Repo:getProfile#1', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'User', - }, - { - nodeId: 'method:Repo:getProfile#2', - filePath: 'models.ts', - type: 'Method', - ownerId: 'class:Repo', - returnType: 'Admin', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', { + ownerId: 'class:Repo', + returnType: 'User', + }); + model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', { + ownerId: 'class:Repo', + returnType: 'Admin', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); @@ -2757,46 +2609,20 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupCallableByName = vi.fn(() => []); - const symbolTable = createMockSymbolTable({ - lookupClassByName: (name: string) => { - if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; - if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')]; - return []; - }, - lookupMethodByOwner: (ownerNodeId: string, methodName: string) => - ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile' - ? { - nodeId: 'method:BaseRepo:getProfile#1', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - } - : undefined, - lookupExactAll: (filePath: string, name: string) => - filePath === 'base.ts' && name === 'getProfile' - ? [ - { - nodeId: 'method:BaseRepo:getProfile#1', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - }, - { - nodeId: 'method:BaseRepo:getProfile#2', - filePath: 'base.ts', - type: 'Method', - ownerId: 'class:BaseRepo', - returnType: 'Profile', - }, - ] - : [], - lookupCallableByName, + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', }); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + ownerId: 'class:BaseRepo', + returnType: 'Profile', + }); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); @@ -2812,22 +2638,24 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const symbolTable = createSymbolTable(); - symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class'); - symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); - symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { + // SM-21: construct a real SemanticModel and feed it via + // model.symbols.add so the nested registries are populated. + const model = createSemanticModel(); + model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class'); + model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class'); + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', { ownerId: 'class:BaseRepo', parameterCount: 1, returnType: 'User', }); - symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { + model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', { ownerId: 'class:BaseRepo', parameterCount: 2, returnType: 'Admin', }); - const lookupCallableByName = vi.spyOn(symbolTable, 'lookupCallableByName'); + const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable, + model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); @@ -5740,24 +5568,20 @@ function process() { }); describe('importedReturnTypes (Phase 14 E3)', () => { - // Minimal mock SymbolTable that returns a known callable - const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - lookupClassByName: () => [], - lookupExact: () => undefined, - lookupExactFull: () => undefined, - add: () => {}, - getStats: () => ({ fileCount: 0 }), - clear: () => {}, - }); + // Minimal real SemanticModel populated via model.symbols.add so that + // lookupCallableByName returns Function symbols with the requested + // return types. + const makeSymbolTable = ( + callables: Array<{ name: string; returnType?: string }>, + ): SemanticModel => { + const model = createSemanticModel(); + callables.forEach((c, idx) => { + model.symbols.add('src.ts', c.name, `n${idx}`, 'Function', { + returnType: c.returnType, + }); + }); + return model; + }; it('SymbolTable has unambiguous match → uses it, ignores cross-file', () => { // SymbolTable knows getConfig() returns Config (SymbolType) @@ -5765,7 +5589,7 @@ function process() { const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]); const tree = parse('const c = getConfig();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getConfig', 'WrongType']]), }); // SymbolTable result (Config) wins over cross-file fallback (WrongType) @@ -5777,7 +5601,7 @@ function process() { const symbolTable = makeSymbolTable([]); const tree = parse('const c = getConfig();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getConfig', 'Config']]), }); // Cross-file fallback provides Config @@ -5792,7 +5616,7 @@ function process() { ]); const tree = parse('const r = process();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['process', 'User']]), }); // Ambiguous → conservative → no binding produced @@ -5812,7 +5636,7 @@ function process() { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const u = getUser();', TypeScript.typescript); const typeEnv = buildTypeEnv(tree, 'typescript', { - symbolTable: symbolTable as any, + model: symbolTable, importedReturnTypes: new Map([['getUser', 'CrossFileUser']]), }); // SymbolTable result (User) wins