feat(SM-18): Delete lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex (#769)

* 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 <gergomagyar@icloud.com>
This commit is contained in:
Copilot
2026-04-10 14:56:24 +01:00
committed by GitHub
co-authored by Copilot Gergo Magyar
parent e5dafce9f2
commit 100858f8c8
11 changed files with 279 additions and 365 deletions
+19 -25
View File
@@ -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<string, readonly SymbolDefinition[]>;
/** @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
-3
View File
@@ -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
@@ -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<string, TieredCandidates | null> | 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 {
+37 -63
View File
@@ -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<string, Map<string, SymbolDefinition[]>>();
// 2. Global Reverse Index (The "Backup")
// Structure: SymbolName -> [List of Definitions]
const globalIndex = new Map<string, SymbolDefinition[]>();
// 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<string, SymbolDefinition[]>();
// Only Function, Method, Constructor, Macro, Delegate symbols are indexed.
const callableByName = new Map<string, SymbolDefinition[]>();
// 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<string, SymbolDefinition>();
// 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<string, SymbolDefinition[]>();
// 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<string, SymbolDefinition[]>();
const classByQualifiedName = new Map<string, SymbolDefinition[]>();
// 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<string, SymbolDefinition[]>();
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,
+2 -2
View File
@@ -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;
@@ -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<User>').
+2 -2
View File
@@ -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');
});
+42 -1
View File
@@ -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<typeof createKnowledgeGraph>;
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');
});
});
+11 -63
View File
@@ -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');
+88 -137
View File
@@ -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<User>',
});
const results = table.lookupFuzzy('getUser');
expect(results).toHaveLength(1);
expect(results[0].returnType).toBe('Promise<User>');
const result = table.lookupExactFull('src/utils.ts', 'getUser');
expect(result).toBeDefined();
expect(result!.returnType).toBe('Promise<User>');
});
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);
});
+36 -42
View File
@@ -83,17 +83,15 @@ const createMockSymbolTable = (overrides: Partial<SymbolTable> = {}): 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: () => {},
});