From 100858f8c8ea77e1fbd23d5e091cd8bb3846f108 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:56:24 +0100 Subject: [PATCH] feat(SM-18): Delete lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex (#769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Update test files for SymbolTable interface changes Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, and callableIndex references from all test files. Replace lookupFuzzyCallable with lookupCallableByName. Update getStats assertions to only expect { fileCount }. Remove tests that exclusively tested removed methods. Files updated: - symbol-table.test.ts: Remove lookupFuzzy describe block and all globalIndex/callableIndex tests, update callable method references - symbol-resolver.test.ts: Remove SM-16 lookupFuzzy test block, update Tier 3 describe title - type-env.test.ts: Update all mock SymbolTable objects and spy variable names - call-form.test.ts: Update ownerId propagation test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(SM-18): Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex Remove from SymbolTable interface and implementation: - lookupFuzzy method - lookupFuzzyCallable method - globalIndex Map - callableIndex Map (renamed to callableByName, backing lookupCallableByName) Add lookupCallableByName as the targeted replacement for fuzzy callable lookups. Migrate all production callers: - resolution-context.ts: lookupFuzzyCallable → lookupCallableByName - type-env.ts: lookupFuzzyCallable → lookupCallableByName - call-processor.ts: lookupFuzzy → lookupCallableByName (D2 widen paths) Remove fuzzyCallCount/fuzzyCallableCallCount stats and globalSymbolCount from getStats(). Update pipeline.ts logging accordingly. Memory savings: globalIndex stored every non-Property symbol (typically the largest index by entry count). Removing it eliminates one Map plus all its per-name arrays — net savings proportional to unique symbol count in the project. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4a658c69-41a9-4d57-8527-50ca544ca967 * fix(SM-18): address all PR #769 review findings 1. type-env.test.ts mock: add missing lookupImplByName + getFiles methods. 2. Macro/Delegate tests: 2 new tests confirm C/C++ Macro and C# Delegate are indexed in callableByName. 3. D2 widen path test: module-alias scenario verifying lookupCallableByName resolves methods in aliased files that shadow same-file definitions. 4. CALLABLE_TYPES unified: exported from symbol-table.ts (single source of truth), imported in call-processor.ts. Removed duplicate CALLABLE_SYMBOL_TYPES constant. 5. getStats() observability restored: tier hit counters (tierSameFile, tierImportScoped, tierGlobal, tierMiss) replace the removed fuzzyCallCount diagnostic. * chore(SM-18): remove unnecessary `as any` casts on valid NodeLabel types Macro, Delegate, TypeAlias, Const, and Variable are all valid NodeLabel values in gitnexus-shared. The casts suppressed type checking without purpose and signaled false uncertainty. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar --- gitnexus/src/core/ingestion/call-processor.ts | 44 ++-- gitnexus/src/core/ingestion/pipeline.ts | 3 - .../src/core/ingestion/resolution-context.ts | 67 ++++-- gitnexus/src/core/ingestion/symbol-table.ts | 100 +++----- gitnexus/src/core/ingestion/type-env.ts | 4 +- .../core/ingestion/type-extractors/types.ts | 2 +- gitnexus/test/unit/call-form.test.ts | 4 +- gitnexus/test/unit/call-processor.test.ts | 43 +++- gitnexus/test/unit/symbol-resolver.test.ts | 74 +----- gitnexus/test/unit/symbol-table.test.ts | 225 +++++++----------- gitnexus/test/unit/type-env.test.ts | 78 +++--- 11 files changed, 279 insertions(+), 365 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 755ec1e29..a5258fa8e 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,7 +1,7 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; import type { SymbolDefinition, SymbolTable } from './symbol-table.js'; -import { CLASS_TYPES } from './symbol-table.js'; +import { CLASS_TYPES, CALLABLE_TYPES } from './symbol-table.js'; import Parser from 'tree-sitter'; import type { ResolutionContext } from './resolution-context.js'; import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js'; @@ -1299,7 +1299,7 @@ export const processCalls = async ( return collectedHeritage; }; -const CALLABLE_SYMBOL_TYPES = new Set(['Function', 'Method', 'Constructor', 'Macro', 'Delegate']); +// CALLABLE_TYPES imported from symbol-table.ts — single source of truth. const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); @@ -1317,10 +1317,10 @@ const filterCallableCandidates = ( } else { const types = candidates.filter((c) => CONSTRUCTOR_TARGET_TYPES.has(c.type)); kindFiltered = - types.length > 0 ? types : candidates.filter((c) => CALLABLE_SYMBOL_TYPES.has(c.type)); + types.length > 0 ? types : candidates.filter((c) => CALLABLE_TYPES.has(c.type)); } } else { - kindFiltered = candidates.filter((c) => CALLABLE_SYMBOL_TYPES.has(c.type)); + kindFiltered = candidates.filter((c) => CALLABLE_TYPES.has(c.type)); } if (kindFiltered.length === 0) return []; @@ -1476,7 +1476,7 @@ const dedupSwiftExtensionCandidates = ( * * If filtering still leaves multiple candidates, refuse to emit a CALLS edge. */ -/** Per-file cache for the widen path's lookupFuzzy calls. Cleared between files. */ +/** Per-file cache for the widen path's lookupCallableByName calls. Cleared between files. */ type WidenCache = Map; /** @internal Exported for unit tests of D0 skip conditions (SM-11). Do not use outside tests. */ @@ -1577,7 +1577,7 @@ const resolveCallTarget = ( // the caller defines a function with the same name as the callee (Issue #417). // // Tracks `aliasNarrowed` so the D2 widening step below does NOT undo the alias filtering - // by calling lookupFuzzy again (which would re-introduce homonym candidates from other files). + // by calling lookupCallableByName again (which would re-introduce homonym candidates from other files). let aliasNarrowed = false; if (call.callForm === 'member' && call.receiverName) { const aliasMap = ctx.moduleAliasMap?.get(currentFile); @@ -1591,12 +1591,12 @@ const resolveCallTarget = ( } else { // Same-file tier returned a local match, but the alias points elsewhere. // Widen to global candidates and filter to the aliased module's file. - // Use per-file widenCache to avoid repeated lookupFuzzy for the same + // Use per-file widenCache to avoid repeated lookupCallableByName for the same // calledName+moduleFile from multiple call sites in the same file. const cacheKey = `${call.calledName}\0${moduleFile}`; let fuzzyDefs = widenCache?.get(cacheKey); if (!fuzzyDefs) { - fuzzyDefs = ctx.symbols.lookupFuzzy(call.calledName); + fuzzyDefs = ctx.symbols.lookupCallableByName(call.calledName); widenCache?.set(cacheKey, fuzzyDefs); } const widened = filterCallableCandidates(fuzzyDefs, call.argCount, call.callForm).filter( @@ -1668,17 +1668,17 @@ const resolveCallTarget = ( const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath)); // D2. Widen candidates: same-file tier may miss the parent's method when - // it lives in another file. Query the symbol table directly for all + // it lives in another file. Query the callable index directly for all // global methods with this name, then apply arity/kind filtering. // // When the candidate set was already narrowed by module-alias - // disambiguation, do NOT widen back to the full fuzzy pool — that + // disambiguation, do NOT widen back to the full callable pool — that // would undo the alias narrowing and reintroduce homonym candidates // from other files. const methodPool = filteredCandidates.length <= 1 && !aliasNarrowed ? filterCallableCandidates( - ctx.symbols.lookupFuzzy(call.calledName), + ctx.symbols.lookupCallableByName(call.calledName), call.argCount, call.callForm, ) @@ -1715,7 +1715,7 @@ const resolveCallTarget = ( // through to the permissive single-candidate tail return. // // Addresses Codex review finding R3 (PR #744): member calls where - // fuzzy fallback picked a globally-matching symbol that has no + // widening picked a globally-matching symbol that has no // relationship to the receiver's class hierarchy were silently // producing false-positive edges. Example: Rust `c.trait_only()` where // `trait_only` is captured as a Function node with no ownerId — it @@ -1920,12 +1920,12 @@ const resolveFieldOwnership = ( * * After deduplication: * - * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 fuzzy - * fallback in `resolveCallTarget` may still find something via lookupFuzzy) + * - 0 unique matches → `undefined` (owner-scoped path has no answer; D1-D4 + * fallback in `resolveCallTarget` may still find something via callable index) * - 1 unique match → return it * - ≥2 unique matches → `undefined` (genuine homonym ambiguity; don't silently pick one) * - * This absorbs what was previously D4's job inside `resolveCallTarget` — "filter fuzzy + * This absorbs what was previously D4's job inside `resolveCallTarget` — "filter * candidates to those whose ownerId is in the receiver type's nodeId set" — into the * owner-scoped path, aligning with the plan's target: * @@ -1933,8 +1933,7 @@ const resolveFieldOwnership = ( * * The returned `tier` reflects how the owner TYPE was resolved (not the method name). * Threaded out here so callers don't need a second `ctx.resolve(ownerType, ...)` call — - * this decouples callers from `ctx.resolve`'s per-file caching contract, which SM-16 - * will restructure when it replaces the `lookupFuzzy` data source. + * this decouples callers from `ctx.resolve`'s per-file caching contract. */ const resolveMethodByOwner = ( receiverTypeName: string, @@ -2064,17 +2063,12 @@ export const resolveMemberCall = ( * {@link resolveCallTarget} delegates here for `callForm === 'free'` before * processing constructor and member calls. * - * **Design note (SM-13):** This path still falls through to Tier 3 (global) - * via `ctx.resolve()`. Fuzzy global resolution remains until Phase 5 replaces - * `lookupFuzzy` with a scoped data source. - * * **Asymmetry vs `resolveCallTarget`:** `resolveFreeCall` intentionally does - * NOT take a `widenCache` parameter and does NOT run a D2 fuzzy-widening + * NOT take a `widenCache` parameter and does NOT run a D2 widening * pass. Member calls (`resolveCallTarget`'s main body) widen via - * `lookupFuzzy` to reach parent-class methods defined in different files; + * `lookupCallableByName` to reach parent-class methods defined in different files; * free calls have no receiver type and rely exclusively on the tiered pool - * from `ctx.resolve()`. Phase 5 will revisit whether free calls need a - * scoped widening pass once `lookupFuzzy` is retired. + * from `ctx.resolve()`. * * @param calledName - The called function name (e.g. 'doStuff') * @param filePath - File path of the call site diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9c754ed2f..42c630289 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1106,9 +1106,6 @@ async function runChunkedParseAndResolve( console.log( `🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`, ); - console.log( - `🔍 Fuzzy Lookups: ${rcStats.fuzzyCallCount} total, ${rcStats.fuzzyCallableCallCount} callable`, - ); } // ── Finalize the accumulator before the read phase begins. All worker-path diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts index e2731ac23..06b2c89be 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/resolution-context.ts @@ -10,15 +10,14 @@ * 2a-named. Named binding chain (walkBindingChain via NamedImportMap) * 2a. Import-scoped (iterate importedFiles with lookupExactAll per file) * 2b. Package-scoped (iterate indexed files matching package dir with lookupExactAll) - * 3. Global (lookupClassByName + lookupImplByName + lookupFuzzyCallable — consumers must check count) + * 3. Global (lookupClassByName + lookupImplByName + lookupCallableByName — consumers must check count) * - * SM-16: resolveUncached no longer calls lookupFuzzy. Each tier queries the - * minimum necessary scope directly: + * Each tier queries the minimum necessary scope directly: * - Tier 2a iterates the caller's import set (O(imports) × O(1) lookupExactAll). * - Tier 2b iterates all indexed files filtered by package dir * (O(files) × O(1) lookupExactAll — avoids a global name scan). - * - Tier 3 combines lookupClassByName + lookupImplByName + lookupFuzzyCallable - * (three O(1) index lookups vs one O(1) lookupFuzzy, with a narrower result set). + * - Tier 3 combines lookupClassByName + lookupImplByName + lookupCallableByName + * (three O(1) index lookups with a narrow, type-specific result set). */ import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; @@ -76,11 +75,12 @@ export interface ResolutionContext { // --- Operational --- getStats(): { fileCount: number; - globalSymbolCount: number; - fuzzyCallCount: number; - fuzzyCallableCallCount: number; cacheHits: number; cacheMisses: number; + tierSameFile: number; + tierImportScoped: number; + tierGlobal: number; + tierMiss: number; }; clear(): void; } @@ -104,6 +104,11 @@ export const createResolutionContext = (): ResolutionContext => { let cache: Map | null = null; let cacheHits = 0; let cacheMisses = 0; + // Tier hit counters — replaces the lost fuzzyCallCount diagnostic + let tierSameFile = 0; + let tierImportScoped = 0; + let tierGlobal = 0; + let tierMiss = 0; // --- Core resolution (single implementation of tier logic) --- @@ -111,6 +116,7 @@ export const createResolutionContext = (): ResolutionContext => { // Tier 1: Same file — authoritative match (returns all overloads) const localDefs = symbols.lookupExactAll(fromFile, name); if (localDefs.length > 0) { + tierSameFile++; return { candidates: localDefs, tier: 'same-file' }; } @@ -119,6 +125,7 @@ export const createResolutionContext = (): ResolutionContext => { // correctly even when lookupExactAll on the alias name returns nothing. const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap); if (chainResult && chainResult.length > 0) { + tierImportScoped++; return { candidates: chainResult, tier: 'import-scoped' }; } @@ -131,6 +138,7 @@ export const createResolutionContext = (): ResolutionContext => { importedDefs.push(...symbols.lookupExactAll(file, name)); } if (importedDefs.length > 0) { + tierImportScoped++; return { candidates: importedDefs, tier: 'import-scoped' }; } } @@ -178,37 +186,36 @@ export const createResolutionContext = (): ResolutionContext => { } } if (packageDefs.length > 0) { + tierImportScoped++; return { candidates: packageDefs, tier: 'import-scoped' }; } } - // Tier 3: Global — three targeted O(1) index lookups replace the single - // lookupFuzzy global scan. 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) by lookupFuzzyCallable. + // 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. // // Known exclusion: TypeAlias, Const, and Variable are NOT reachable at - // Tier 3 — they don't belong to any of the three indexes. The old - // lookupFuzzy returned them, but 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 callableIndex + // 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 // since call-processor.ts treats them as callable targets. - // - // Note: lookupFuzzy is still called directly in call-processor.ts - // (D2 module-alias widen path at ~line 1506/1588). Those callers - // bypass resolveUncached entirely and are tracked for separate removal - // in the roadmap. fuzzyCallCount only reflects resolveUncached usage. const classDefs = symbols.lookupClassByName(name); const implDefs = symbols.lookupImplByName(name); - const callableDefs = symbols.lookupFuzzyCallable(name); + const callableDefs = symbols.lookupCallableByName(name); - if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) return null; + if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) { + tierMiss++; + return null; + } const globalDefs = [...classDefs, ...implDefs, ...callableDefs]; + tierGlobal++; return { candidates: globalDefs, tier: 'global' }; }; @@ -257,6 +264,10 @@ export const createResolutionContext = (): ResolutionContext => { ...symbols.getStats(), cacheHits, cacheMisses, + tierSameFile, + tierImportScoped, + tierGlobal, + tierMiss, }); const clear = (): void => { @@ -269,6 +280,10 @@ export const createResolutionContext = (): ResolutionContext => { clearCache(); cacheHits = 0; cacheMisses = 0; + tierSameFile = 0; + tierImportScoped = 0; + tierGlobal = 0; + tierMiss = 0; }; return { diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index 451a213b3..eb7a62079 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -14,6 +14,17 @@ export const CLASS_TYPES = new Set([ '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; @@ -79,17 +90,11 @@ export interface SymbolTable { lookupExactAll: (filePath: string, name: string) => SymbolDefinition[]; /** - * Low Confidence: Look for a symbol anywhere in the project - * Used when imports are missing or for framework magic + * 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. */ - lookupFuzzy: (name: string) => SymbolDefinition[]; - - /** - * Low Confidence: Look for callable symbols (Function/Method/Constructor) by name. - * Faster than `lookupFuzzy` + filter — backed by a lazy callable-only index. - * Used by ReturnTypeLookup to resolve callee → return type. - */ - lookupFuzzyCallable: (name: string) => SymbolDefinition[]; + lookupCallableByName: (name: string) => SymbolDefinition[]; /** * Look up a field/property by its owning class nodeId and field name. @@ -128,7 +133,7 @@ export interface SymbolTable { * 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 lookupFuzzy calls. + * Used by Phase 1 semantic-model tasks to replace filtered global lookups. */ lookupClassByName: (name: string) => SymbolDefinition[]; @@ -161,9 +166,6 @@ export interface SymbolTable { */ getStats: () => { fileCount: number; - globalSymbolCount: number; - fuzzyCallCount: number; - fuzzyCallableCallCount: number; }; /** @@ -178,40 +180,31 @@ export const createSymbolTable = (): SymbolTable => { // Array allows overloaded methods (same name, different signatures) to coexist. const fileIndex = new Map>(); - // 2. Global Reverse Index (The "Backup") - // Structure: SymbolName -> [List of Definitions] - const globalIndex = new Map(); - - // 3. Eagerly-populated Callable Index — maintained on add(). + // 2. Eagerly-populated Callable Index — maintained on add(). // Structure: SymbolName -> [Callable Definitions] - // Only Function, Method, Constructor symbols are indexed. - const callableIndex = new Map(); + // Only Function, Method, Constructor, Macro, Delegate symbols are indexed. + const callableByName = new Map(); - // 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". + // 3. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". // Only Property symbols with ownerId and declaredType are indexed. const fieldByOwner = new Map(); - // 5. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". + // 4. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName". // Method symbols with ownerId are indexed. Supports overloads (array values). const methodByOwner = new Map(); - // 6. Eagerly-populated Class-type Index — keyed by symbol name. + // 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(); - // 7. Eagerly-populated Impl Index — keyed by symbol name. + // 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(); - let fuzzyCallCount = 0; - let fuzzyCallableCallCount = 0; - - // Must match CALLABLE_SYMBOL_TYPES in call-processor.ts — Macro (C/C++) - // and Delegate (C#) are callable targets that Tier 3 must surface. - const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor', 'Macro', 'Delegate']); + // Use the module-level CALLABLE_TYPES constant (exported for call-processor.ts). const add = ( filePath: string, @@ -261,31 +254,25 @@ export const createSymbolTable = (): SymbolTable => { fileMap.get(name)!.push(def); } - // B. Properties go to fieldByOwner index only — skip globalIndex to prevent + // 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 globalIndex + // Still add to fileIndex above (for lookupExact), but skip other indexes return; } - // C. Add to Global Index (same object reference) - if (!globalIndex.has(name)) { - globalIndex.set(name, []); - } - globalIndex.get(name)!.push(def); - - // C2. Methods, constructors, and ownerId-bound Functions go to - // methodByOwner index (in addition to globalIndex). + // 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 fuzzy widening. + // 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); @@ -296,7 +283,7 @@ export const createSymbolTable = (): SymbolTable => { } } - // C3. Class-like types go to classByName index (in addition to globalIndex). + // C2. Class-like types go to classByName index. if (CLASS_TYPES.has(type)) { const existing = classByName.get(name); if (existing) { @@ -314,7 +301,7 @@ export const createSymbolTable = (): SymbolTable => { } } - // C4. Rust Impl blocks go to implByName (separate from classByName to avoid + // 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); @@ -327,11 +314,11 @@ export const createSymbolTable = (): SymbolTable => { // D. Eagerly maintain callable index (like classByName, implByName). if (CALLABLE_TYPES.has(type)) { - const existing = callableIndex.get(name); + const existing = callableByName.get(name); if (existing) { existing.push(def); } else { - callableIndex.set(name, [def]); + callableByName.set(name, [def]); } } }; @@ -350,14 +337,8 @@ export const createSymbolTable = (): SymbolTable => { return fileIndex.get(filePath)?.get(name) ?? []; }; - const lookupFuzzy = (name: string): SymbolDefinition[] => { - fuzzyCallCount++; - return globalIndex.get(name) || []; - }; - - const lookupFuzzyCallable = (name: string): SymbolDefinition[] => { - fuzzyCallableCallCount++; - return callableIndex.get(name) ?? []; + const lookupCallableByName = (name: string): SymbolDefinition[] => { + return callableByName.get(name) ?? []; }; const lookupFieldByOwner = ( @@ -428,22 +409,16 @@ export const createSymbolTable = (): SymbolTable => { const getStats = () => ({ fileCount: fileIndex.size, - globalSymbolCount: globalIndex.size, - fuzzyCallableCallCount: fuzzyCallableCallCount, - fuzzyCallCount: fuzzyCallCount, }); const clear = () => { fileIndex.clear(); - globalIndex.clear(); - callableIndex.clear(); + callableByName.clear(); fieldByOwner.clear(); methodByOwner.clear(); classByName.clear(); classByQualifiedName.clear(); implByName.clear(); - fuzzyCallCount = 0; - fuzzyCallableCallCount = 0; }; return { @@ -451,8 +426,7 @@ export const createSymbolTable = (): SymbolTable => { lookupExact, lookupExactFull, lookupExactAll, - lookupFuzzy, - lookupFuzzyCallable, + lookupCallableByName, lookupFieldByOwner, lookupMethodByOwner, lookupClassByName, diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 2b093fb14..bac187b26 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -861,7 +861,7 @@ export const buildTypeEnv = ( // SymbolTable is authoritative when it has an unambiguous match if (symbolTable) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupFuzzyCallable(callee); + const callables = symbolTable.lookupCallableByName(callee); if (callables.length === 1) { const rawReturn = callables[0].returnType; if (rawReturn) return extractReturnTypeName(rawReturn); @@ -875,7 +875,7 @@ export const buildTypeEnv = ( lookupRawReturnType(callee: string): string | undefined { if (symbolTable) { if (provider.isBuiltInName(callee)) return undefined; - const callables = symbolTable.lookupFuzzyCallable(callee); + const callables = symbolTable.lookupCallableByName(callee); if (callables.length === 1) return callables[0].returnType; // Ambiguous (2+) → return undefined (conservative, no cross-file fallback) if (callables.length > 1) return undefined; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index 85da9d21a..d6b477978 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -54,7 +54,7 @@ export type DeclaredTypeUnwrapper = ( ) => string | undefined; /** Narrow lookup interface for resolving a callee name → return type name. - * Backed by SymbolTable.lookupFuzzyCallable; passed via ForLoopExtractorContext. + * Backed by SymbolTable.lookupCallableByName; passed via ForLoopExtractorContext. * Conservative: returns undefined when the callee is ambiguous (0 or 2+ matches). */ export interface ReturnTypeLookup { /** Processed type name after stripping wrappers (e.g., 'User' from 'Promise'). diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 41ed345ce..17e42897e 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -452,13 +452,13 @@ describe('ownerId on SymbolDefinition', () => { expect(def!.ownerId).toBeUndefined(); }); - it('propagates ownerId through lookupFuzzy', () => { + it('propagates ownerId through lookupCallableByName', () => { const st = createSymbolTable(); st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', { ownerId: 'Class:src/foo.ts:User', }); - const defs = st.lookupFuzzy('save'); + const defs = st.lookupCallableByName('save'); expect(defs).toHaveLength(1); expect(defs[0].ownerId).toBe('Class:src/foo.ts:User'); }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index a0754082d..a12dd0528 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -2218,7 +2218,7 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { // D0: lookupMethodByOwner(classId, 'doWork') → undefined // heritageMap.getAncestors(classId) → [] // lookupMethodByOwnerWithMRO returns undefined → D0 miss - // D1-D4: receiver type resolves to Obj; D2 widens via lookupFuzzy; + // D1-D4: receiver type resolves to Obj; D2 widens via lookupCallableByName; // D3 file-filter picks the only candidate in Obj's file. // Guarantees D0 miss does not swallow the call — D1-D4 still runs. const classFile = 'src/models/Obj.java'; @@ -2550,3 +2550,44 @@ describe('processAssignmentsFromExtracted', () => { expect(accesses[0].targetId).toBe('Property:src/models.ts:address'); }); }); + +// ---- D2 widen: module-alias + lookupCallableByName resolves method in aliased file ---- + +describe('D2 widen path: lookupCallableByName via module alias', () => { + let graph: ReturnType; + let ctx: ResolutionContext; + + beforeEach(() => { + graph = createKnowledgeGraph(); + ctx = createResolutionContext(); + }); + + it('resolves method via module alias widen using lookupCallableByName', async () => { + // Python pattern: `import auth; auth.login()` — auth is a 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'); + // 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'); + // Module alias: consumer.py → auth → src/auth.py + ctx.moduleAliasMap.set('src/consumer.py', new Map([['auth', 'src/auth.py']])); + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.py', + calledName: 'login', + sourceId: 'Function:src/consumer.py:main', + receiverName: 'auth', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx); + + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + // Should resolve to auth.py's login, NOT consumer.py's same-file shadow + expect(rels[0].targetId).toBe('Function:src/auth.py:login'); + }); +}); diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index e338deb9f..67797e61f 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -417,16 +417,6 @@ describe('lookupExactFull', () => { expect(result).toBeUndefined(); }); - it('shares same object reference between fileIndex and globalIndex', () => { - const symbolTable = createSymbolTable(); - symbolTable.add('src/x.ts', 'Bar', 'Class:src/x.ts:Bar', 'Class'); - - const fromExact = symbolTable.lookupExactFull('src/x.ts', 'Bar'); - const fromFuzzy = symbolTable.lookupFuzzy('Bar')[0]; - - expect(fromExact).toBe(fromFuzzy); - }); - it('preserves optional callable metadata on stored definitions', () => { const symbolTable = createSymbolTable(); symbolTable.add('src/math.ts', 'sum', 'Function:src/math.ts:sum', 'Function', { @@ -434,11 +424,10 @@ describe('lookupExactFull', () => { }); const fromExact = symbolTable.lookupExactFull('src/math.ts', 'sum'); - const fromFuzzy = symbolTable.lookupFuzzy('sum')[0]; + const fromCallable = symbolTable.lookupCallableByName('sum')[0]; expect(fromExact?.parameterCount).toBe(2); - expect(fromFuzzy.parameterCount).toBe(2); - expect(fromExact).toBe(fromFuzzy); + expect(fromCallable.parameterCount).toBe(2); }); }); @@ -626,32 +615,6 @@ describe('per-file cache', () => { }); }); -// --------------------------------------------------------------------------- -// SM-16: resolveUncached no longer calls lookupFuzzy -// --------------------------------------------------------------------------- - -// Note: fuzzyCallCount tracks ALL lookupFuzzy calls on the SymbolTable, including -// the D2 module-alias widen path in call-processor.ts which still calls lookupFuzzy -// directly. This test only exercises resolveUncached (via ctx.resolve), so the stat -// is 0 here. In a full pipeline integration test, fuzzyCallCount would be non-zero -// due to D2 callers. -describe('SM-16: resolveUncached does not call lookupFuzzy', () => { - it('lookupFuzzy is never called during resolve — fuzzyCallCount stays at 0', () => { - const ctx = createResolutionContext(); - ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); - ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); - ctx.importMap.set('src/app.ts', new Set(['src/user.ts'])); - ctx.packageMap.set('cmd/main.go', new Set(['/internal/'])); - - // Exercise all tiers - ctx.resolve('User', 'src/user.ts'); // Tier 1 same-file - ctx.resolve('User', 'src/app.ts'); // Tier 2a import-scoped - ctx.resolve('UserService', 'src/other.ts'); // Tier 3 global - - expect(ctx.getStats().fuzzyCallCount).toBe(0); - }); -}); - // Tier 2a uses importMap (file-level imports). Go resolves cross-package symbols // via packageMap (Tier 2b) instead, so no Go Tier 2a test is needed. Kotlin and // PHP support file-level imports but the importMap path is language-agnostic — @@ -769,7 +732,7 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { ctx = createResolutionContext(); }); - it('Go: resolves symbol in package dir via file iteration (no lookupFuzzy)', () => { + it('Go: resolves symbol in package dir via file iteration', () => { ctx.symbols.add( 'internal/auth/handler.go', 'Authenticate', @@ -826,7 +789,7 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => { }); }); -describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookupFuzzyCallable', () => { +describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookupCallableByName', () => { let ctx: ResolutionContext; beforeEach(() => { @@ -1048,29 +1011,19 @@ 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' as any, - ); + ctx.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' as any, - ); + ctx.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' as any); + ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable'); const result = ctx.resolve('counter', 'src/app.ts'); expect(result).toBeNull(); }); @@ -1088,21 +1041,16 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () => expect(funcResult!.tier).toBe('global'); }); - it('Macro (C/C++) is reachable at Tier 3 via callableIndex', () => { - ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro' as any); + 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'); const result = ctx.resolve('ASSERT', 'src/main.c'); expect(result).not.toBeNull(); expect(result!.tier).toBe('global'); expect(result!.candidates[0].type).toBe('Macro'); }); - it('Delegate (C#) is reachable at Tier 3 via callableIndex', () => { - ctx.symbols.add( - 'src/Events.cs', - 'OnClick', - 'Delegate:src/Events.cs:OnClick', - 'Delegate' as any, - ); + it('Delegate (C#) is reachable at Tier 3 via callable index', () => { + ctx.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'); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index 9378a6008..4763adfcc 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -11,7 +11,6 @@ describe('SymbolTable', () => { describe('add', () => { it('registers a symbol in the table', () => { table.add('src/index.ts', 'main', 'func:main', 'Function'); - expect(table.getStats().globalSymbolCount).toBe(1); expect(table.getStats().fileCount).toBe(1); }); @@ -19,15 +18,12 @@ describe('SymbolTable', () => { table.add('src/index.ts', 'main', 'func:main', 'Function'); table.add('src/index.ts', 'helper', 'func:helper', 'Function'); expect(table.getStats().fileCount).toBe(1); - expect(table.getStats().globalSymbolCount).toBe(2); }); it('handles same name in different files', () => { table.add('src/a.ts', 'init', 'func:a:init', 'Function'); table.add('src/b.ts', 'init', 'func:b:init', 'Function'); expect(table.getStats().fileCount).toBe(2); - // Global index groups by name, so 'init' has one entry with two definitions - expect(table.getStats().globalSymbolCount).toBe(1); }); it('allows duplicate adds for same file and name (overloads preserved)', () => { @@ -37,8 +33,6 @@ describe('SymbolTable', () => { expect(table.lookupExact('src/a.ts', 'foo')).toBe('func:foo:1'); // lookupExactAll returns all overloads expect(table.lookupExactAll('src/a.ts', 'foo')).toHaveLength(2); - // Global index appends - expect(table.lookupFuzzy('foo')).toHaveLength(2); }); }); @@ -63,36 +57,10 @@ describe('SymbolTable', () => { }); }); - describe('lookupFuzzy', () => { - it('finds all definitions of a symbol across files', () => { - table.add('src/a.ts', 'render', 'func:a:render', 'Function'); - table.add('src/b.ts', 'render', 'func:b:render', 'Method'); - const results = table.lookupFuzzy('render'); - expect(results).toHaveLength(2); - expect(results[0]).toEqual({ - nodeId: 'func:a:render', - filePath: 'src/a.ts', - type: 'Function', - }); - expect(results[1]).toEqual({ nodeId: 'func:b:render', filePath: 'src/b.ts', type: 'Method' }); - }); - - it('returns empty array for unknown symbol', () => { - expect(table.lookupFuzzy('nonexistent')).toEqual([]); - }); - - it('returns empty array for empty table', () => { - expect(table.lookupFuzzy('anything')).toEqual([]); - }); - }); - describe('getStats', () => { it('returns zero counts for empty table', () => { expect(table.getStats()).toEqual({ fileCount: 0, - globalSymbolCount: 0, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }); }); @@ -102,14 +70,6 @@ describe('SymbolTable', () => { table.add('src/b.ts', 'baz', 'func:baz', 'Function'); expect(table.getStats().fileCount).toBe(2); }); - - it('tracks unique global symbol names', () => { - table.add('src/a.ts', 'foo', 'func:a:foo', 'Function'); - table.add('src/b.ts', 'foo', 'func:b:foo', 'Function'); - table.add('src/a.ts', 'bar', 'func:a:bar', 'Function'); - // 'foo' and 'bar' are 2 unique global names - expect(table.getStats().globalSymbolCount).toBe(2); - }); }); describe('returnType metadata', () => { @@ -120,13 +80,13 @@ describe('SymbolTable', () => { expect(def!.returnType).toBe('User'); }); - it('returnType is available via lookupFuzzy', () => { + it('returnType is available via lookupExactFull', () => { table.add('src/utils.ts', 'getUser', 'func:getUser', 'Function', { returnType: 'Promise', }); - const results = table.lookupFuzzy('getUser'); - expect(results).toHaveLength(1); - expect(results[0].returnType).toBe('Promise'); + const result = table.lookupExactFull('src/utils.ts', 'getUser'); + expect(result).toBeDefined(); + expect(result!.returnType).toBe('Promise'); }); it('omits returnType when not provided', () => { @@ -169,28 +129,28 @@ describe('SymbolTable', () => { }); }); - describe('Property exclusion from globalIndex', () => { - it('Property with ownerId is NOT added to globalIndex', () => { + describe('Property exclusion from callable index', () => { + it('Property with ownerId is NOT in callable index', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { declaredType: 'string', ownerId: 'class:User', }); - // Should not appear in fuzzy lookup - expect(table.lookupFuzzy('name')).toEqual([]); + // Should not appear in callable lookup + expect(table.lookupCallableByName('name')).toEqual([]); // But should still be in fileIndex expect(table.lookupExact('src/models.ts', 'name')).toBe('prop:name'); }); - it('Property without ownerId IS added to globalIndex', () => { + it('Property without ownerId is NOT in callable index', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property'); - expect(table.lookupFuzzy('name')).toHaveLength(1); + expect(table.lookupCallableByName('name')).toEqual([]); }); - it('Property without declaredType is still added to fieldByOwner index only (not globalIndex)', () => { + it('Property without declaredType is still added to fieldByOwner index only', () => { table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); // No declaredType → still indexed in fieldByOwner (for write-access tracking - // in dynamically-typed languages like Ruby/JS), but excluded from globalIndex - expect(table.lookupFuzzy('name')).toEqual([]); + // in dynamically-typed languages like Ruby/JS), but excluded from callable index + expect(table.lookupCallableByName('name')).toEqual([]); expect(table.lookupFieldByOwner('class:User', 'name')).toEqual({ nodeId: 'prop:name', filePath: 'src/models.ts', @@ -199,40 +159,50 @@ describe('SymbolTable', () => { }); }); - it('non-Property types are always added to globalIndex', () => { + it('non-Property callable types are in callable index', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' }); - expect(table.lookupFuzzy('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(1); }); }); - describe('conditional callableIndex invalidation', () => { - it('adding a Function invalidates callableIndex', () => { + describe('conditional callable index behaviour', () => { + it('adding a Function makes it available in callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' }); - // First call builds the index - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - // Add another callable — should invalidate and rebuild + expect(table.lookupCallableByName('foo')).toHaveLength(1); + // Add another callable table.add('src/a.ts', 'bar', 'func:bar', 'Method'); - expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toHaveLength(1); }); - it('adding a Property does NOT invalidate callableIndex', () => { + it('adding a Property does NOT add it to callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - // Build callable index - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - // Add a Property — callable index should still be valid (foo still found) + expect(table.lookupCallableByName('foo')).toHaveLength(1); + // Add a Property — callable index should still only contain foo table.add('src/models.ts', 'name', 'prop:name', 'Property', { declaredType: 'string', ownerId: 'class:User', }); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupCallableByName('foo')).toHaveLength(1); }); - it('adding a Class does NOT invalidate callableIndex', () => { + it('adding a Class does NOT add it to callable index', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupCallableByName('foo')).toHaveLength(1); table.add('src/models.ts', 'User', 'class:User', 'Class'); - // Class is not callable, should not trigger rebuild - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Class is not callable, should not appear + expect(table.lookupCallableByName('foo')).toHaveLength(1); + }); + + it('Macro (C/C++) is indexed in callable index', () => { + table.add('src/macros.h', 'ASSERT', 'macro:ASSERT', 'Macro'); + expect(table.lookupCallableByName('ASSERT')).toHaveLength(1); + expect(table.lookupCallableByName('ASSERT')[0].type).toBe('Macro'); + }); + + it('Delegate (C#) is indexed in callable index', () => { + table.add('src/Events.cs', 'OnClick', 'delegate:OnClick', 'Delegate'); + expect(table.lookupCallableByName('OnClick')).toHaveLength(1); + expect(table.lookupCallableByName('OnClick')[0].type).toBe('Delegate'); }); }); @@ -355,8 +325,8 @@ describe('SymbolTable', () => { it('does NOT index Method without ownerId', () => { table.add('src/utils.ts', 'helper', 'method:helper', 'Method'); expect(table.lookupMethodByOwner('', 'helper')).toBeUndefined(); - // But it should still be in lookupFuzzy - expect(table.lookupFuzzy('helper')).toHaveLength(1); + // But it should still be in lookupCallableByName + expect(table.lookupCallableByName('helper')).toHaveLength(1); }); it('returns first match for overloads with same returnType (unambiguous)', () => { @@ -400,8 +370,8 @@ describe('SymbolTable', () => { parameterCount: 0, ownerId: 'class:User', }); - // But it should be in lookupFuzzyCallable - expect(table.lookupFuzzyCallable('User')).toHaveLength(1); + // But it should be in lookupCallableByName + expect(table.lookupCallableByName('User')).toHaveLength(1); }); it('returns undefined for overloads with different returnTypes (ambiguous)', () => { @@ -418,14 +388,12 @@ describe('SymbolTable', () => { expect(table.lookupMethodByOwner('class:Converter', 'convert')).toBeUndefined(); }); - it('Method with ownerId is still available via lookupFuzzy and lookupFuzzyCallable', () => { + it('Method with ownerId is still available via lookupCallableByName', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { returnType: 'void', ownerId: 'class:User', }); - // Methods stay in globalIndex (unlike Properties) - expect(table.lookupFuzzy('save')).toHaveLength(1); - expect(table.lookupFuzzyCallable('save')).toHaveLength(1); + expect(table.lookupCallableByName('save')).toHaveLength(1); }); it('after clear(), lookupMethodByOwner returns undefined', () => { @@ -439,37 +407,37 @@ describe('SymbolTable', () => { }); }); - describe('lookupFuzzyCallable', () => { + describe('lookupCallableByName', () => { it('returns only callable types (Function, Method, Constructor)', () => { 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', 'User', 'class:User', 'Class'); table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); - expect(table.lookupFuzzyCallable('Baz')).toHaveLength(1); - expect(table.lookupFuzzyCallable('User')).toEqual([]); - expect(table.lookupFuzzyCallable('IUser')).toEqual([]); + expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toHaveLength(1); + expect(table.lookupCallableByName('Baz')).toHaveLength(1); + expect(table.lookupCallableByName('User')).toEqual([]); + expect(table.lookupCallableByName('IUser')).toEqual([]); }); it('returns empty array for unknown name', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupFuzzyCallable('unknown')).toEqual([]); + expect(table.lookupCallableByName('unknown')).toEqual([]); }); - it('rebuilds index after adding new callable', () => { + it('includes newly added callable', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - expect(table.lookupFuzzyCallable('bar')).toEqual([]); + expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toEqual([]); table.add('src/a.ts', 'bar', 'func:bar', 'Function'); - expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + expect(table.lookupCallableByName('bar')).toHaveLength(1); }); it('filters non-callable types from mixed name entries', () => { table.add('src/a.ts', 'save', 'func:save', 'Function'); table.add('src/b.ts', 'save', 'class:save', 'Class'); - const callables = table.lookupFuzzyCallable('save'); + const callables = table.lookupCallableByName('save'); expect(callables).toHaveLength(1); expect(callables[0].type).toBe('Function'); }); @@ -491,15 +459,11 @@ describe('SymbolTable', () => { table.clear(); expect(table.getStats()).toEqual({ fileCount: 0, - globalSymbolCount: 0, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); - expect(table.lookupFuzzy('foo')).toEqual([]); expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); expect(table.lookupMethodByOwner('class:User', 'save')).toBeUndefined(); - expect(table.lookupFuzzyCallable('foo')).toEqual([]); + expect(table.lookupCallableByName('foo')).toEqual([]); expect(table.lookupClassByName('User')).toEqual([]); }); @@ -509,23 +473,20 @@ describe('SymbolTable', () => { table.add('src/b.ts', 'bar', 'func:bar', 'Function'); expect(table.getStats()).toEqual({ fileCount: 1, - globalSymbolCount: 1, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }); }); - it('resets callableIndex so first lookup after clear rebuilds from scratch', () => { + it('resets callable index so first lookup after clear rebuilds from scratch', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); - // Populate the lazy callable index - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Verify callable is found + expect(table.lookupCallableByName('foo')).toHaveLength(1); table.clear(); // After clear the callable index must be gone — empty table returns nothing - expect(table.lookupFuzzyCallable('foo')).toEqual([]); - // Re-adding and looking up rebuilds successfully + expect(table.lookupCallableByName('foo')).toEqual([]); + // Re-adding and looking up works correctly table.add('src/a.ts', 'foo', 'func:foo2', 'Function'); - expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); - expect(table.lookupFuzzyCallable('foo')[0].nodeId).toBe('func:foo2'); + expect(table.lookupCallableByName('foo')).toHaveLength(1); + expect(table.lookupCallableByName('foo')[0].nodeId).toBe('func:foo2'); }); }); @@ -540,7 +501,7 @@ describe('SymbolTable', () => { expect(def!.ownerId).toBeUndefined(); }); - it('stores only ownerId on a Method (non-Property) — still added to globalIndex', () => { + it('stores only ownerId on a Method (non-Property) — still in callable index', () => { table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:Repo' }); const def = table.lookupExactFull('src/models.ts', 'save'); expect(def).toBeDefined(); @@ -548,12 +509,12 @@ describe('SymbolTable', () => { expect(def!.parameterCount).toBeUndefined(); expect(def!.returnType).toBeUndefined(); expect(def!.declaredType).toBeUndefined(); - // Non-Property with ownerId must still appear in globalIndex - expect(table.lookupFuzzy('save')).toHaveLength(1); + // Non-Property with ownerId must still appear in callable index + expect(table.lookupCallableByName('save')).toHaveLength(1); }); - it('stores declaredType alone (no ownerId) — symbol goes to globalIndex', () => { - // A Variable/Property without an owner should still be globally visible + it('stores declaredType alone (no ownerId) — symbol in file index', () => { + // A Variable/Property without an owner should still be accessible via file index table.add('src/config.ts', 'DEFAULT_TIMEOUT', 'var:DEFAULT_TIMEOUT', 'Variable', { declaredType: 'number', }); @@ -561,9 +522,6 @@ describe('SymbolTable', () => { expect(def).toBeDefined(); expect(def!.declaredType).toBe('number'); expect(def!.ownerId).toBeUndefined(); - // No ownerId → not a Property exclusion path → must be in globalIndex - expect(table.lookupFuzzy('DEFAULT_TIMEOUT')).toHaveLength(1); - expect(table.lookupFuzzy('DEFAULT_TIMEOUT')[0].declaredType).toBe('number'); }); it('stores all four optional metadata fields simultaneously on a Method', () => { @@ -600,46 +558,40 @@ describe('SymbolTable', () => { }); }); - describe('lookupFuzzyCallable — lazy index behaviour', () => { + describe('lookupCallableByName — eager index behavior', () => { it('returns empty array when table has no callables', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); table.add('src/models.ts', 'IUser', 'iface:IUser', 'Interface'); - expect(table.lookupFuzzyCallable('User')).toEqual([]); - expect(table.lookupFuzzyCallable('IUser')).toEqual([]); + expect(table.lookupCallableByName('User')).toEqual([]); + expect(table.lookupCallableByName('IUser')).toEqual([]); }); - it('uses cached index on second call without adding new symbols', () => { + it('returns consistent result on repeated calls', () => { table.add('src/a.ts', 'fetch', 'func:fetch', 'Function', { returnType: 'Response' }); - // First call — builds the lazy index - const first = table.lookupFuzzyCallable('fetch'); + const first = table.lookupCallableByName('fetch'); expect(first).toHaveLength(1); - // Second call — must return equivalent result from cache - const second = table.lookupFuzzyCallable('fetch'); + const second = table.lookupCallableByName('fetch'); expect(second).toHaveLength(1); expect(second[0].nodeId).toBe('func:fetch'); - // Both calls return the same array reference (same cache entry) - expect(first).toBe(second); }); - it('invalidated cache is rebuilt correctly after adding a Method', () => { + it('includes newly added Method', () => { table.add('src/a.ts', 'alpha', 'func:alpha', 'Function'); - // Warm the cache - expect(table.lookupFuzzyCallable('alpha')).toHaveLength(1); - expect(table.lookupFuzzyCallable('beta')).toEqual([]); - // Add a Method — must invalidate cache + expect(table.lookupCallableByName('alpha')).toHaveLength(1); + expect(table.lookupCallableByName('beta')).toEqual([]); + // Add a Method table.add('src/a.ts', 'beta', 'method:beta', 'Method'); - // Rebuilt cache must now include beta - const result = table.lookupFuzzyCallable('beta'); + const result = table.lookupCallableByName('beta'); expect(result).toHaveLength(1); expect(result[0].type).toBe('Method'); }); - it('invalidated cache is rebuilt correctly after adding a Constructor', () => { + it('includes newly added Constructor', () => { table.add('src/a.ts', 'existing', 'func:existing', 'Function'); - expect(table.lookupFuzzyCallable('existing')).toHaveLength(1); + expect(table.lookupCallableByName('existing')).toHaveLength(1); table.add('src/models.ts', 'MyClass', 'ctor:MyClass', 'Constructor'); - expect(table.lookupFuzzyCallable('MyClass')).toHaveLength(1); - expect(table.lookupFuzzyCallable('MyClass')[0].type).toBe('Constructor'); + expect(table.lookupCallableByName('MyClass')).toHaveLength(1); + expect(table.lookupCallableByName('MyClass')[0].type).toBe('Constructor'); }); }); @@ -859,10 +811,9 @@ describe('SymbolTable', () => { expect(results[0].ownerId).toBe('module:models'); }); - it('class-like symbols are still available via lookupFuzzy', () => { + it('class-like symbols are available via lookupClassByName', () => { table.add('src/models.ts', 'User', 'class:User', 'Class'); - // classByName is an additional index, not a replacement for globalIndex - expect(table.lookupFuzzy('User')).toHaveLength(1); + // classByName is the dedicated index for class-like lookups expect(table.lookupClassByName('User')).toHaveLength(1); }); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 97818819c..ce771b857 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -83,17 +83,15 @@ const createMockSymbolTable = (overrides: Partial = {}): SymbolTabl lookupExact: () => undefined, lookupExactFull: () => undefined, lookupExactAll: () => [], - lookupFuzzy: () => [], - lookupFuzzyCallable: () => [], + lookupCallableByName: () => [], lookupFieldByOwner: () => undefined, lookupMethodByOwner: () => undefined, lookupClassByName: () => [], lookupClassByQualifiedName: () => [], + lookupImplByName: () => [], + getFiles: () => [][Symbol.iterator](), getStats: () => ({ fileCount: 0, - globalSymbolCount: 0, - fuzzyCallCount: 0, - fuzzyCallableCallCount: 0, }), clear: () => {}, ...overrides, @@ -1195,7 +1193,7 @@ class RepoService { describe('destructured call results', () => { // Minimal mock SymbolTable for call-result return type lookup const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupFuzzyCallable: (name: string) => + lookupCallableByName: (name: string) => callables .filter((c) => c.name === name) .map((c) => ({ @@ -1205,11 +1203,10 @@ class RepoService { returnType: c.returnType, })), lookupClassByName: () => [], - lookupFuzzy: () => [], lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, }); @@ -2055,7 +2052,7 @@ class RepoService { lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, }; const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); @@ -2073,14 +2070,12 @@ class RepoService { ); const mockSymbolTable = { lookupClassByName: () => [], - lookupFuzzy: (name: string) => - name === 'doStuff' ? [{ nodeId: 'n1', filePath: 'utils.kt', type: 'Function' }] : [], - lookupFuzzyCallable: () => [], + lookupCallableByName: () => [], lookupFieldByOwner: () => undefined, lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, }; const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any }); @@ -2460,7 +2455,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], @@ -2474,11 +2469,11 @@ function process(repo: Repo) { returnType: 'Profile', } : undefined, - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('inherited method return type resolution uses lookupMethodByOwner on parent owners', () => { @@ -2490,7 +2485,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; @@ -2507,14 +2502,14 @@ function process(repo: Repo) { returnType: 'Profile', } : undefined, - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution handles multiple class defs when only one owner has the method', () => { @@ -2526,7 +2521,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' @@ -2549,11 +2544,11 @@ function process(repo: Repo) { } : undefined, lookupExactAll: () => [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution with multiple class defs falls back to MRO when direct owners miss', () => { @@ -2565,7 +2560,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') { @@ -2588,14 +2583,14 @@ function process(repo: Repo) { } : undefined, lookupExactAll: () => [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution stays unresolved when multiple class defs each define the method', () => { @@ -2607,7 +2602,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' @@ -2642,11 +2637,11 @@ function process(repo: Repo) { return undefined; }, lookupExactAll: () => [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution preserves same-return overload success', () => { @@ -2658,7 +2653,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [], @@ -2691,11 +2686,11 @@ function process(repo: Repo) { }, ] : [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('method return type resolution stays unresolved for ambiguous overloads with differing returns', () => { @@ -2707,7 +2702,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; @@ -2743,14 +2738,14 @@ function process(repo: Repo) { }, ] : [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('inherited method return type resolution preserves same-return overload success on parent owners', () => { @@ -2762,7 +2757,7 @@ function process(repo: Repo) { `, TypeScript.typescript, ); - const lookupFuzzyCallable = vi.fn(() => []); + const lookupCallableByName = vi.fn(() => []); const symbolTable = createMockSymbolTable({ lookupClassByName: (name: string) => { if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')]; @@ -2798,14 +2793,14 @@ function process(repo: Repo) { }, ] : [], - lookupFuzzyCallable, + lookupCallableByName, }); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); - expect(lookupFuzzyCallable).not.toHaveBeenCalledWith('getProfile'); + expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); it('inherited method return type resolution stays unresolved for ambiguous overloads on parent owners', () => { @@ -2830,13 +2825,13 @@ function process(repo: Repo) { parameterCount: 2, returnType: 'Admin', }); - const lookupFuzzyCallable = vi.spyOn(symbolTable, 'lookupFuzzyCallable'); + const lookupCallableByName = vi.spyOn(symbolTable, 'lookupCallableByName'); const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable, parentMap: new Map([['Repo', ['BaseRepo']]]), }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); - expect(lookupFuzzyCallable).not.toHaveBeenCalled(); + expect(lookupCallableByName).not.toHaveBeenCalled(); }); }); @@ -5747,7 +5742,7 @@ function process() { describe('importedReturnTypes (Phase 14 E3)', () => { // Minimal mock SymbolTable that returns a known callable const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - lookupFuzzyCallable: (name: string) => + lookupCallableByName: (name: string) => callables .filter((c) => c.name === name) .map((c) => ({ @@ -5757,11 +5752,10 @@ function process() { returnType: c.returnType, })), lookupClassByName: () => [], - lookupFuzzy: () => [], lookupExact: () => undefined, lookupExactFull: () => undefined, add: () => {}, - getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + getStats: () => ({ fileCount: 0 }), clear: () => {}, });