* perf(index): ~34% faster fresh indexing, byte-identical graphs Profiling a fresh init on a medium TS repo (excalidraw, 657 files) showed the main thread as the critical path: per-row SQLite statement calls, repeated import-resolution walks, and per-row FTS trigger firings, with the parse workers ~75% idle behind it. This lands the semantics-preserving tranche of fixes: - Multi-row batched INSERTs (nodes/edges/unresolved refs/name segments) behind cached per-batch-size prepared statements; row order preserved, so rowid-based resolution determinism (#1015) is unchanged. - storeFileBundle: one transaction per file instead of four; nested transaction() calls now flatten (BEGIN-in-BEGIN previously threw, so no caller depended on nested rollback). - Dedicated store-writer thread for the fresh-DB bulk path (bundles applied in file order on a single writer connection; main thread does no DB work during the parse loop). Kill switch: CODEGRAPH_NO_STORE_WORKER=1. - Bulk FTS mode: drop the nodes_fts sync triggers during the bulk load, rebuild once at the end; crash inside the window self-heals on the next open. - Per-context memos for resolveImportPath/findExportedSymbol + a per-file exported-symbol index, invalidated exactly where clearCaches() already resets the resolver's own caches. - Fast-init on completely fresh DBs (journal in memory, no fsync until the index completes; interrupted init re-runs from scratch). Kill switch: CODEGRAPH_NO_FAST_INIT=1. - MaybeYield returns undefined on the not-due path so per-ref yield checks stop paying a promise + microtask hop each. - Parse pool prewarm for bulk indexing; compile-cache enabled at CLI and worker entry points. Excalidraw fresh init: 5.11s -> 3.36s median (n=5, warm cache, M-series). Graph dumps byte-identical across init, re-index, and sync paths; full suite green (2403 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(resolution): parallel reference resolution with canonical admission Fan resolution batches across a pool of read-only worker threads, each hosting a full ReferenceResolver over its own SQLite connection; results are admitted on the main thread in chunk order, so edge insertion order, row cleanup, failure parking, and deferred post-pass queues are exactly the sequence the single-threaded loop produces. Per-ref inputs match the baseline because the sequential path already resolves each batch against the state committed BEFORE that batch. Validated byte-identical on excalidraw (pool forced on) and apache/dubbo (4,048 Java files): dubbo full index 39s -> 19s (2.05x) with identical graph dumps (91,495 nodes / 223,953 edges). The pool only engages when total pending refs clear a threshold (default 150k, CODEGRAPH_PARALLEL_RESOLVE_MIN to tune, CODEGRAPH_NO_PARALLEL_RESOLVE=1 to disable): measured on a ~58k-ref repo the workers' boot CPU contends with resolution on the same cores and makes indexing slower, so small repos keep the sequential path. When fast-init left the DB in memory-journal mode, WAL is restored before resolution only when the pool will run (readers + rollback-journal writers don't mix). Also: sqlite adapter readOnly open support. TreeCursor spine rewrite of the body walker was built, measured neutral on real repos and equal in a 20k-child microbench (web-tree-sitter's namedChild(i) is not quadratic in this binding), and rejected — per-node JS<->WASM marshaling is the floor, which a traversal swap cannot remove. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2000 lines
84 KiB
TypeScript
2000 lines
84 KiB
TypeScript
/**
|
|
* Reference Resolution Orchestrator
|
|
*
|
|
* Coordinates all reference resolution strategies.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { Language, Node, UnresolvedReference, Edge } from '../types';
|
|
import { QueryBuilder } from '../db/queries';
|
|
import {
|
|
UnresolvedRef,
|
|
ResolvedRef,
|
|
ResolutionResult,
|
|
ResolutionContext,
|
|
FrameworkResolver,
|
|
ImportMapping,
|
|
} from './types';
|
|
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
|
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver';
|
|
import { ResolverPool, minRefsForPool } from './resolver-pool';
|
|
import { detectFrameworks } from './frameworks';
|
|
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
|
import { createYielder, type MaybeYield } from './cooperative-yield';
|
|
import { loadProjectAliases, type AliasMap } from './path-aliases';
|
|
import { loadGoModule, type GoModule } from './go-module';
|
|
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
|
|
import { logDebug } from '../errors';
|
|
import type { ReExport } from './types';
|
|
import { LRUCache } from './lru-cache';
|
|
|
|
/** Node kinds that can declare supertypes (extends/implements). */
|
|
const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
|
|
'class', 'struct', 'interface', 'trait', 'protocol', 'enum',
|
|
]);
|
|
|
|
/**
|
|
* Languages whose chained static-factory/fluent calls defer to the conformance
|
|
* second pass. Dotted-receiver languages resolve via matchDottedCallChain; the
|
|
* `::`-receiver ones (Rust) via matchScopedCallChain.
|
|
*/
|
|
const CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift', 'rust', 'go', 'scala', 'dart', 'objc', 'pascal']);
|
|
const SCOPED_CHAIN_LANGUAGES = new Set(['rust']);
|
|
|
|
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
|
|
const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
|
|
|
|
/** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */
|
|
const PHP_PROP_SHAPE = /^this->\w+\.\w+$/;
|
|
|
|
/**
|
|
* Cache size limits. Each per-resolver cache is bounded so memory
|
|
* stays flat on large codebases (20k+ files). Sizes were chosen to
|
|
* cover the working set for typical resolution batches without
|
|
* exceeding a few hundred MB worst-case. Override via the env var
|
|
* `CODEGRAPH_RESOLVER_CACHE_SIZE` (single integer applied to all
|
|
* caches) when tuning for very large or very small projects.
|
|
*/
|
|
const DEFAULT_CACHE_LIMIT = 5_000;
|
|
function resolveCacheLimit(): number {
|
|
const raw = process.env.CODEGRAPH_RESOLVER_CACHE_SIZE;
|
|
if (!raw) return DEFAULT_CACHE_LIMIT;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
return DEFAULT_CACHE_LIMIT;
|
|
}
|
|
|
|
// Re-export types
|
|
export * from './types';
|
|
|
|
// Pre-built Sets for O(1) built-in lookups (allocated once, shared across all instances)
|
|
const JS_BUILT_INS = new Set([
|
|
'console', 'window', 'document', 'global', 'process',
|
|
'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean',
|
|
'Date', 'Math', 'JSON', 'RegExp', 'Error', 'Map', 'Set',
|
|
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
|
'fetch', 'require', 'module', 'exports', '__dirname', '__filename',
|
|
]);
|
|
|
|
const REACT_HOOKS = new Set([
|
|
'useState', 'useEffect', 'useContext', 'useReducer', 'useCallback',
|
|
'useMemo', 'useRef', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
|
|
]);
|
|
|
|
const PYTHON_BUILT_INS = new Set([
|
|
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
|
|
'open', 'input', 'type', 'isinstance', 'hasattr', 'getattr', 'setattr',
|
|
'super', 'self', 'cls', 'None', 'True', 'False',
|
|
]);
|
|
|
|
const PYTHON_BUILT_IN_TYPES = new Set([
|
|
'list', 'dict', 'set', 'tuple', 'str', 'int', 'float', 'bool',
|
|
'bytes', 'bytearray', 'frozenset', 'object', 'super',
|
|
]);
|
|
|
|
const PYTHON_BUILT_IN_METHODS = new Set([
|
|
'append', 'extend', 'insert', 'remove', 'pop', 'clear', 'sort', 'reverse', 'copy',
|
|
'update', 'keys', 'values', 'items', 'get',
|
|
'add', 'discard', 'union', 'intersection', 'difference',
|
|
'split', 'join', 'strip', 'lstrip', 'rstrip', 'replace', 'lower', 'upper',
|
|
'startswith', 'endswith', 'find', 'index', 'count', 'encode', 'decode',
|
|
'format', 'isdigit', 'isalpha', 'isalnum',
|
|
'read', 'write', 'readline', 'readlines', 'close', 'flush', 'seek',
|
|
]);
|
|
|
|
const GO_STDLIB_PACKAGES = new Set([
|
|
'fmt', 'os', 'io', 'net', 'http', 'log', 'math', 'sort', 'sync',
|
|
'time', 'path', 'bytes', 'strings', 'strconv', 'errors', 'context',
|
|
'json', 'xml', 'csv', 'html', 'template', 'regexp', 'reflect',
|
|
'runtime', 'testing', 'flag', 'bufio', 'crypto', 'encoding',
|
|
'filepath', 'hash', 'mime', 'rand', 'signal', 'sql', 'syscall',
|
|
'unicode', 'unsafe', 'atomic', 'binary', 'debug', 'exec', 'heap',
|
|
'ring', 'scanner', 'tar', 'zip', 'gzip', 'zlib', 'tls', 'url',
|
|
'user', 'pprof', 'trace', 'ast', 'build', 'parser', 'printer',
|
|
'token', 'types', 'cgo', 'plugin', 'race', 'ioutil',
|
|
// Kubernetes-common stdlib aliases
|
|
'utilruntime', 'utilwait', 'utilnet',
|
|
]);
|
|
|
|
const GO_BUILT_INS = new Set([
|
|
'make', 'new', 'len', 'cap', 'append', 'copy', 'delete', 'close',
|
|
'panic', 'recover', 'print', 'println', 'complex', 'real', 'imag',
|
|
'error', 'nil', 'true', 'false', 'iota',
|
|
'int', 'int8', 'int16', 'int32', 'int64',
|
|
'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
|
|
'float32', 'float64', 'complex64', 'complex128',
|
|
'string', 'bool', 'byte', 'rune', 'any',
|
|
]);
|
|
|
|
const PASCAL_UNIT_PREFIXES = [
|
|
'System.', 'Winapi.', 'Vcl.', 'Fmx.', 'Data.', 'Datasnap.',
|
|
'Soap.', 'Xml.', 'Web.', 'REST.', 'FireDAC.', 'IBX.',
|
|
'IdHTTP', 'IdTCP', 'IdSSL',
|
|
];
|
|
|
|
const PASCAL_BUILT_INS = new Set([
|
|
'System', 'SysUtils', 'Classes', 'Types', 'Variants', 'StrUtils',
|
|
'Math', 'DateUtils', 'IOUtils', 'Generics.Collections', 'Generics.Defaults',
|
|
'Rtti', 'TypInfo', 'SyncObjs', 'RegularExpressions',
|
|
'SysInit', 'Windows', 'Messages', 'Graphics', 'Controls', 'Forms',
|
|
'Dialogs', 'StdCtrls', 'ExtCtrls', 'ComCtrls', 'Menus', 'ActnList',
|
|
'WriteLn', 'Write', 'ReadLn', 'Read', 'Inc', 'Dec', 'Ord', 'Chr',
|
|
'Length', 'SetLength', 'High', 'Low', 'Assigned', 'FreeAndNil',
|
|
'Format', 'IntToStr', 'StrToInt', 'FloatToStr', 'StrToFloat',
|
|
'Trim', 'UpperCase', 'LowerCase', 'Pos', 'Copy', 'Delete', 'Insert',
|
|
'Now', 'Date', 'Time', 'DateToStr', 'StrToDate',
|
|
'Raise', 'Exit', 'Break', 'Continue', 'Abort',
|
|
'True', 'False', 'nil', 'Self', 'Result',
|
|
'Create', 'Destroy', 'Free',
|
|
'TObject', 'TComponent', 'TPersistent', 'TInterfacedObject',
|
|
'TList', 'TStringList', 'TStrings', 'TStream', 'TMemoryStream', 'TFileStream',
|
|
'Exception', 'EAbort', 'EConvertError', 'EAccessViolation',
|
|
'IInterface', 'IUnknown',
|
|
]);
|
|
|
|
const C_BUILT_INS = new Set([
|
|
// Standard C library functions
|
|
'printf', 'fprintf', 'sprintf', 'snprintf', 'scanf', 'fscanf', 'sscanf',
|
|
'malloc', 'calloc', 'realloc', 'free',
|
|
'memcpy', 'memmove', 'memset', 'memcmp', 'memchr',
|
|
'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp',
|
|
'strstr', 'strchr', 'strrchr', 'strtok', 'strdup',
|
|
'fopen', 'fclose', 'fread', 'fwrite', 'fgets', 'fputs', 'fputc', 'fgetc',
|
|
'feof', 'ferror', 'fflush', 'fseek', 'ftell', 'rewind',
|
|
'exit', 'abort', 'atexit', 'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtod',
|
|
'qsort', 'bsearch',
|
|
'abs', 'labs', 'rand', 'srand',
|
|
'sin', 'cos', 'tan', 'sqrt', 'pow', 'log', 'log10', 'exp', 'ceil', 'floor', 'fabs',
|
|
'time', 'clock', 'difftime', 'mktime', 'localtime', 'gmtime', 'strftime', 'asctime',
|
|
'assert', 'errno',
|
|
'perror', 'remove', 'rename', 'tmpfile', 'tmpnam',
|
|
'getenv', 'system',
|
|
'signal', 'raise',
|
|
'setjmp', 'longjmp',
|
|
'va_start', 'va_end', 'va_arg', 'va_copy',
|
|
'NULL', 'EOF', 'BUFSIZ', 'FILENAME_MAX', 'RAND_MAX', 'EXIT_SUCCESS', 'EXIT_FAILURE',
|
|
'size_t', 'ptrdiff_t', 'wchar_t', 'intptr_t', 'uintptr_t',
|
|
'int8_t', 'int16_t', 'int32_t', 'int64_t',
|
|
'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t',
|
|
'FILE',
|
|
// POSIX additions commonly seen
|
|
'stat', 'lstat', 'fstat', 'open', 'close', 'read', 'write', 'pipe',
|
|
'fork', 'exec', 'waitpid', 'getpid', 'getppid', 'kill', 'sleep', 'usleep',
|
|
'pthread_create', 'pthread_join', 'pthread_mutex_lock', 'pthread_mutex_unlock',
|
|
'dlopen', 'dlsym', 'dlclose',
|
|
]);
|
|
|
|
const CPP_BUILT_INS = new Set([
|
|
// iostream objects (often used without std:: prefix via using)
|
|
'cout', 'cin', 'cerr', 'clog', 'endl', 'flush', 'ws',
|
|
'std', // the namespace itself when used as std::something
|
|
// Common C++ keywords that leak as references
|
|
'nullptr', 'true', 'false', 'this', 'sizeof', 'alignof', 'typeid',
|
|
'static_cast', 'dynamic_cast', 'reinterpret_cast', 'const_cast',
|
|
'make_unique', 'make_shared', 'make_pair',
|
|
'move', 'forward', 'swap',
|
|
]);
|
|
|
|
/**
|
|
* Reference Resolver
|
|
*
|
|
* Orchestrates reference resolution using multiple strategies.
|
|
*/
|
|
export class ReferenceResolver {
|
|
private projectRoot: string;
|
|
private queries: QueryBuilder;
|
|
private context: ResolutionContext;
|
|
private frameworks: FrameworkResolver[] = [];
|
|
// Chained static-factory/fluent call refs the first pass couldn't resolve,
|
|
// collected in-memory (the batched resolver deletes unresolved refs from the
|
|
// DB, so they can't be re-read). Drained by resolveChainedCallsViaConformance
|
|
// once implements/extends edges exist, to resolve methods on a supertype the
|
|
// receiver conforms to (#750).
|
|
private deferredChainRefs: UnresolvedRef[] = [];
|
|
// `this.<member>` function-as-value refs whose member is NOT on the
|
|
// enclosing class itself — possibly inherited. Collected in-memory for the
|
|
// same reason as deferredChainRefs and drained by
|
|
// resolveDeferredThisMemberRefs once implements/extends edges exist (#808).
|
|
private deferredThisMemberRefs: UnresolvedRef[] = [];
|
|
// Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder
|
|
// `_Imports.razor`, cascading to the project root). Used to disambiguate a
|
|
// markup type ref to the right C# namespace.
|
|
private razorUsingsCache = new Map<string, string[]>();
|
|
// All per-resolver caches are LRU-bounded. Previously these were
|
|
// unbounded Maps that grew with every distinct lookup and OOM'd on
|
|
// codebases with 20k+ files (see issue: unbounded cache growth).
|
|
private nodeCache: LRUCache<string, Node[]>; // per-file node cache
|
|
private fileCache: LRUCache<string, string | null>; // per-file content cache
|
|
private importMappingCache: LRUCache<string, ImportMapping[]>;
|
|
private reExportCache: LRUCache<string, ReExport[]>;
|
|
private nameCache: LRUCache<string, Node[]>; // name → nodes cache
|
|
private lowerNameCache: LRUCache<string, Node[]>; // lower(name) → nodes cache
|
|
private qualifiedNameCache: LRUCache<string, Node[]>; // qualified_name → nodes cache
|
|
private fileLinesCache: LRUCache<string, string[] | null>; // file → split lines cache
|
|
private methodMatchCache: LRUCache<string, Node[]>; // lang\0Type::method → matching method nodes
|
|
// Node kinds are a small fixed set (~24), so this is a plain Map, not an LRU.
|
|
// getNodesByKind returns the FULL node list for a kind; it was previously
|
|
// uncached — a per-ref `SELECT * FROM nodes WHERE kind=?` + row-mapping. Called
|
|
// for every dotted call ref by the Spring resolver (constants) and every
|
|
// `hook_` ref by the Drupal resolver (functions), that scan dominated
|
|
// resolution on large repos (#1180). The node set is stable within a
|
|
// resolution pass (same lifetime assumption as nameCache); clearCaches() resets
|
|
// it between passes. Callers must treat the returned array as read-only.
|
|
private nodesByKindCache = new Map<Node['kind'], Node[]>();
|
|
private knownNames: Set<string> | null = null; // all known symbol names for fast pre-filtering
|
|
private knownFiles: Set<string> | null = null;
|
|
private cachesWarmed = false;
|
|
// tsconfig/jsconfig path-alias map. `undefined` = not yet computed,
|
|
// `null` = computed and absent. Treated as immutable for the
|
|
// resolver's lifetime; callers re-create the resolver if config changes.
|
|
private projectAliases: AliasMap | null | undefined = undefined;
|
|
// go.mod module path. Same lazy/immutable convention as projectAliases.
|
|
private goModule: GoModule | null | undefined = undefined;
|
|
// Monorepo workspace member packages. Same lazy/immutable convention.
|
|
private workspacePackages: WorkspacePackages | null | undefined = undefined;
|
|
|
|
constructor(projectRoot: string, queries: QueryBuilder) {
|
|
this.projectRoot = projectRoot;
|
|
this.queries = queries;
|
|
|
|
const limit = resolveCacheLimit();
|
|
// The content cache is heavier (full file text), so we give it a
|
|
// smaller budget than the metadata caches.
|
|
const contentLimit = Math.max(64, Math.floor(limit / 5));
|
|
this.nodeCache = new LRUCache(limit);
|
|
this.fileCache = new LRUCache(contentLimit);
|
|
this.importMappingCache = new LRUCache(limit);
|
|
this.reExportCache = new LRUCache(limit);
|
|
this.nameCache = new LRUCache(limit);
|
|
this.lowerNameCache = new LRUCache(limit);
|
|
this.qualifiedNameCache = new LRUCache(limit);
|
|
// Split-lines arrays are heavier than content strings; refs arrive
|
|
// file-ordered, so a small cache still hits nearly always.
|
|
this.fileLinesCache = new LRUCache(contentLimit);
|
|
this.methodMatchCache = new LRUCache(limit);
|
|
|
|
this.context = this.createContext();
|
|
}
|
|
|
|
/**
|
|
* Initialize the resolver (detect frameworks, etc.)
|
|
*/
|
|
initialize(): void {
|
|
this.frameworks = detectFrameworks(this.context);
|
|
this.clearCaches();
|
|
}
|
|
|
|
/**
|
|
* Run each framework resolver's cross-file finalization pass and persist
|
|
* the returned node updates. Idempotent — safe to call after every indexAll
|
|
* and every incremental sync. Returns the number of nodes updated.
|
|
*
|
|
* Caches are cleared before/after so the post-extract pass sees fresh DB
|
|
* state and downstream queries see the updated names.
|
|
*/
|
|
runPostExtract(): number {
|
|
let updated = 0;
|
|
this.clearCaches();
|
|
for (const fw of this.frameworks) {
|
|
if (!fw.postExtract) continue;
|
|
try {
|
|
const nodes = fw.postExtract(this.context);
|
|
for (const node of nodes) {
|
|
this.queries.updateNode(node);
|
|
updated++;
|
|
}
|
|
} catch (err) {
|
|
logDebug(`Framework '${fw.name}' postExtract failed`, {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
if (updated > 0) this.clearCaches();
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Pre-build lightweight caches for resolution.
|
|
* Node lookups are now handled by indexed SQLite queries instead of
|
|
* loading all nodes into memory (which caused OOM on large codebases).
|
|
* We cache the set of known symbol names for fast pre-filtering.
|
|
*/
|
|
warmCaches(): void {
|
|
if (this.cachesWarmed) return;
|
|
|
|
// Only cache the set of known file paths (lightweight string set)
|
|
this.knownFiles = new Set(this.queries.getAllFilePaths());
|
|
|
|
// Cache all distinct symbol names for fast pre-filtering (just strings, not full nodes)
|
|
this.knownNames = new Set(this.queries.getAllNodeNames());
|
|
|
|
this.cachesWarmed = true;
|
|
}
|
|
|
|
/**
|
|
* warmCaches for the async resolution entry points: streams the distinct
|
|
* name set with periodic yields instead of one synchronous `.all()`. On a
|
|
* multi-million-node index the DISTINCT scan is a solid multi-second block
|
|
* (measured up to 28s inside `codegraph sync` on the Linux kernel index),
|
|
* long enough to matter to the #850 watchdog on slower hardware. Same
|
|
* result, same memory — only the event loop keeps turning.
|
|
*/
|
|
async warmCachesYielding(onYield: MaybeYield): Promise<void> {
|
|
if (this.cachesWarmed) return;
|
|
|
|
this.knownFiles = new Set(this.queries.getAllFilePaths());
|
|
|
|
const names = new Set<string>();
|
|
let scanned = 0;
|
|
for (const name of this.queries.iterateNodeNames()) {
|
|
names.add(name);
|
|
if ((++scanned & 8191) === 0) await onYield();
|
|
}
|
|
this.knownNames = names;
|
|
|
|
this.cachesWarmed = true;
|
|
}
|
|
|
|
/**
|
|
* Clear internal caches
|
|
*/
|
|
clearCaches(): void {
|
|
this.nodeCache.clear();
|
|
this.fileCache.clear();
|
|
this.importMappingCache.clear();
|
|
this.reExportCache.clear();
|
|
this.nameCache.clear();
|
|
this.lowerNameCache.clear();
|
|
this.qualifiedNameCache.clear();
|
|
this.fileLinesCache.clear();
|
|
this.methodMatchCache.clear();
|
|
this.nodesByKindCache.clear();
|
|
this.knownNames = null;
|
|
this.knownFiles = null;
|
|
this.cachesWarmed = false;
|
|
// The import-resolver's per-context memos assume the same stable window
|
|
// as the caches above — drop them together.
|
|
if (this.context) clearImportResolverMemos(this.context);
|
|
}
|
|
|
|
/** `readFile` through the LRU content cache (null = read failed, also cached). */
|
|
private readFileCached(filePath: string): string | null {
|
|
if (this.fileCache.has(filePath)) {
|
|
return this.fileCache.get(filePath)!;
|
|
}
|
|
const fullPath = path.join(this.projectRoot, filePath);
|
|
try {
|
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
this.fileCache.set(filePath, content);
|
|
return content;
|
|
} catch (error) {
|
|
logDebug('Failed to read file for resolution', { filePath, error: String(error) });
|
|
this.fileCache.set(filePath, null);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create the resolution context
|
|
*/
|
|
private createContext(): ResolutionContext {
|
|
return {
|
|
getNodesInFile: (filePath: string) => {
|
|
if (!this.nodeCache.has(filePath)) {
|
|
this.nodeCache.set(filePath, this.queries.getNodesByFile(filePath));
|
|
}
|
|
return this.nodeCache.get(filePath)!;
|
|
},
|
|
|
|
getNodesByName: (name: string) => {
|
|
const cached = this.nameCache.get(name);
|
|
if (cached !== undefined) return cached;
|
|
const result = this.queries.getNodesByName(name);
|
|
this.nameCache.set(name, result);
|
|
return result;
|
|
},
|
|
|
|
getMethodMatches: (typeName: string, methodName: string, language: Language) => {
|
|
const key = `${language} ${typeName}::${methodName}`;
|
|
const cached = this.methodMatchCache.get(key);
|
|
if (cached !== undefined) return cached;
|
|
let candidates = this.nameCache.get(methodName);
|
|
if (candidates === undefined) {
|
|
candidates = this.queries.getNodesByName(methodName);
|
|
this.nameCache.set(methodName, candidates);
|
|
}
|
|
const want = `${typeName}::${methodName}`;
|
|
const matches: Node[] = [];
|
|
for (const m of candidates) {
|
|
if (m.kind !== 'method') continue;
|
|
if (m.language !== language) continue;
|
|
const qn = m.qualifiedName;
|
|
if (qn === want || qn.endsWith(`::${want}`)) matches.push(m);
|
|
}
|
|
this.methodMatchCache.set(key, matches);
|
|
return matches;
|
|
},
|
|
|
|
getNodesByQualifiedName: (qualifiedName: string) => {
|
|
const cached = this.qualifiedNameCache.get(qualifiedName);
|
|
if (cached !== undefined) return cached;
|
|
const result = this.queries.getNodesByQualifiedNameExact(qualifiedName);
|
|
this.qualifiedNameCache.set(qualifiedName, result);
|
|
return result;
|
|
},
|
|
|
|
getNodesByKind: (kind: Node['kind']) => {
|
|
const cached = this.nodesByKindCache.get(kind);
|
|
if (cached !== undefined) return cached;
|
|
const result = this.queries.getNodesByKind(kind);
|
|
this.nodesByKindCache.set(kind, result);
|
|
return result;
|
|
},
|
|
|
|
// Streamed, uncached — synthesizers scan-and-filter whole kinds, and
|
|
// both the materialized array AND the per-kind cache retention are
|
|
// O(nodes) memory (#1212). Per-ref resolvers keep the cached array
|
|
// variant above.
|
|
iterateNodesByKind: (kind: Node['kind']) => this.queries.iterateNodesByKind(kind),
|
|
|
|
fileExists: (filePath: string) => {
|
|
// Check pre-built known files set first (O(1))
|
|
if (this.knownFiles) {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
if (this.knownFiles.has(filePath) || this.knownFiles.has(normalized)) {
|
|
return true;
|
|
}
|
|
}
|
|
// Fall back to filesystem for files not yet indexed
|
|
const fullPath = path.join(this.projectRoot, filePath);
|
|
try {
|
|
return fs.existsSync(fullPath);
|
|
} catch (error) {
|
|
logDebug('Error checking file existence', { filePath, error: String(error) });
|
|
return false;
|
|
}
|
|
},
|
|
|
|
readFile: (filePath: string) => this.readFileCached(filePath),
|
|
|
|
getFileLines: (filePath: string) => {
|
|
const cached = this.fileLinesCache.get(filePath);
|
|
if (cached !== undefined) return cached;
|
|
const source = this.readFileCached(filePath);
|
|
const lines = source === null ? null : source.split(/\r?\n/);
|
|
this.fileLinesCache.set(filePath, lines);
|
|
return lines;
|
|
},
|
|
|
|
getProjectRoot: () => this.projectRoot,
|
|
|
|
getAllFiles: () => {
|
|
return this.queries.getAllFilePaths();
|
|
},
|
|
|
|
listDirectories: (relativePath: string) => {
|
|
const target = relativePath === '.' || relativePath === ''
|
|
? this.projectRoot
|
|
: path.join(this.projectRoot, relativePath);
|
|
try {
|
|
return fs
|
|
.readdirSync(target, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name);
|
|
} catch (error) {
|
|
logDebug('Failed to list directory for resolution', {
|
|
relativePath,
|
|
error: String(error),
|
|
});
|
|
return [];
|
|
}
|
|
},
|
|
|
|
getNodesByLowerName: (lowerName: string) => {
|
|
const cached = this.lowerNameCache.get(lowerName);
|
|
if (cached !== undefined) return cached;
|
|
const result = this.queries.getNodesByLowerName(lowerName);
|
|
this.lowerNameCache.set(lowerName, result);
|
|
return result;
|
|
},
|
|
|
|
getNodeById: (id: string) => {
|
|
return this.queries.getNodeById(id);
|
|
},
|
|
|
|
getSupertypes: (typeName: string, language) => {
|
|
// Union the `implements`/`extends` targets of every same-named type node.
|
|
// Matching by simple name (not id) reconciles a type declared in one node
|
|
// (`KF::Builder`) with conformance declared in a separate extension node
|
|
// (`KF.Builder: KFOptionSetter`) — both have name `Builder`.
|
|
const typeNodes = this.context
|
|
.getNodesByName(typeName)
|
|
.filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language);
|
|
if (typeNodes.length === 0) return [];
|
|
const supertypes = new Set<string>();
|
|
for (const tn of typeNodes) {
|
|
for (const edge of this.queries.getOutgoingEdges(tn.id, ['implements', 'extends'])) {
|
|
const target = this.queries.getNodeById(edge.target);
|
|
if (target?.name && target.name !== typeName) supertypes.add(target.name);
|
|
}
|
|
}
|
|
return [...supertypes];
|
|
},
|
|
|
|
getImportMappings: (filePath: string, language) => {
|
|
const cacheKey = filePath;
|
|
const cached = this.importMappingCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const content = this.context.readFile(filePath);
|
|
if (!content) {
|
|
this.importMappingCache.set(cacheKey, []);
|
|
return [];
|
|
}
|
|
|
|
const mappings = extractImportMappings(filePath, content, language);
|
|
this.importMappingCache.set(cacheKey, mappings);
|
|
return mappings;
|
|
},
|
|
|
|
getProjectAliases: () => {
|
|
if (this.projectAliases === undefined) {
|
|
this.projectAliases = loadProjectAliases(this.projectRoot);
|
|
}
|
|
return this.projectAliases;
|
|
},
|
|
|
|
getGoModule: () => {
|
|
if (this.goModule === undefined) {
|
|
this.goModule = loadGoModule(this.projectRoot);
|
|
}
|
|
return this.goModule;
|
|
},
|
|
|
|
getWorkspacePackages: () => {
|
|
if (this.workspacePackages === undefined) {
|
|
this.workspacePackages = loadWorkspacePackages(this.projectRoot);
|
|
}
|
|
return this.workspacePackages;
|
|
},
|
|
|
|
getReExports: (filePath: string, language) => {
|
|
const cached = this.reExportCache.get(filePath);
|
|
if (cached) return cached;
|
|
const content = this.context.readFile(filePath);
|
|
if (!content) {
|
|
this.reExportCache.set(filePath, []);
|
|
return [];
|
|
}
|
|
// Re-exports are a JS/TS-only construct, and what matters is the
|
|
// BARREL file's own language — not the consuming reference's. A
|
|
// `.svelte`/`.vue` consumer threads its own language down the
|
|
// re-export chase, which would make extractReExports() bail on a
|
|
// `.ts` index barrel and silently break the chain (#629). Re-key
|
|
// the parse on the barrel's extension so the chase works no matter
|
|
// what kind of file imports through it.
|
|
const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?|ets)$/i.test(filePath);
|
|
const reExports = extractReExports(content, isJsFamily ? 'typescript' : language);
|
|
this.reExportCache.set(filePath, reExports);
|
|
return reExports;
|
|
},
|
|
|
|
getCppIncludeDirs: () => {
|
|
return loadCppIncludeDirs(this.projectRoot);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve all unresolved references
|
|
*/
|
|
resolveAll(
|
|
unresolvedRefs: UnresolvedReference[],
|
|
onProgress?: (current: number, total: number) => void
|
|
): ResolutionResult {
|
|
// Pre-load all nodes into memory for fast lookups
|
|
this.warmCaches();
|
|
|
|
const resolved: ResolvedRef[] = [];
|
|
const unresolved: UnresolvedRef[] = [];
|
|
const byMethod: Record<string, number> = {};
|
|
|
|
// Convert to our internal format, using denormalized fields when available
|
|
const refs: UnresolvedRef[] = unresolvedRefs.map((ref) => ({
|
|
fromNodeId: ref.fromNodeId,
|
|
referenceName: ref.referenceName,
|
|
referenceKind: ref.referenceKind,
|
|
line: ref.line,
|
|
column: ref.column,
|
|
filePath: ref.filePath || this.getFilePathFromNodeId(ref.fromNodeId),
|
|
language: ref.language || this.getLanguageFromNodeId(ref.fromNodeId),
|
|
rowId: ref.rowId,
|
|
}));
|
|
|
|
const total = refs.length;
|
|
let lastReportedPercent = -1;
|
|
|
|
for (let i = 0; i < refs.length; i++) {
|
|
const ref = refs[i]!; // Array index is guaranteed to be in bounds
|
|
const result = this.resolveOne(ref);
|
|
|
|
if (result) {
|
|
resolved.push(result);
|
|
byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
|
|
} else {
|
|
unresolved.push(ref);
|
|
}
|
|
|
|
// Report progress every 1% to avoid too many updates
|
|
if (onProgress) {
|
|
const currentPercent = Math.floor((i / total) * 100);
|
|
if (currentPercent > lastReportedPercent) {
|
|
lastReportedPercent = currentPercent;
|
|
onProgress(i + 1, total);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final progress report
|
|
if (onProgress && total > 0) {
|
|
onProgress(total, total);
|
|
}
|
|
|
|
return {
|
|
resolved,
|
|
unresolved,
|
|
stats: {
|
|
total: refs.length,
|
|
resolved: resolved.length,
|
|
unresolved: unresolved.length,
|
|
byMethod,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a reference name has any possible match in the codebase.
|
|
* Uses the pre-built knownNames set to skip expensive resolution
|
|
* for names that definitely don't exist as symbols.
|
|
*/
|
|
private hasAnyPossibleMatch(name: string): boolean {
|
|
if (!this.knownNames) return true; // no pre-filter available
|
|
|
|
// Direct name match
|
|
if (this.knownNames.has(name)) return true;
|
|
|
|
// For qualified names like "obj.method" or "Class::method", check the parts
|
|
const dotIdx = name.indexOf('.');
|
|
if (dotIdx > 0) {
|
|
const receiver = name.substring(0, dotIdx);
|
|
const member = name.substring(dotIdx + 1);
|
|
if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
|
|
// Also check capitalized receiver (instance-method resolution)
|
|
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
|
|
if (this.knownNames.has(capitalized)) return true;
|
|
// JVM FQN: `com.example.foo.Bar` — the only useful segment is the
|
|
// last one (`Bar`); the earlier check finds `example.foo.Bar` which
|
|
// never matches a node name.
|
|
const lastDot = name.lastIndexOf('.');
|
|
if (lastDot > dotIdx) {
|
|
const tail = name.substring(lastDot + 1);
|
|
if (tail && this.knownNames.has(tail)) return true;
|
|
}
|
|
}
|
|
const colonIdx = name.indexOf('::');
|
|
if (colonIdx > 0) {
|
|
const receiver = name.substring(0, colonIdx);
|
|
const member = name.substring(colonIdx + 2);
|
|
if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
|
|
// Multi-segment path `a::b::c` (a Rust/C++ module call like
|
|
// `database::profiles::find`) — the only segment that names a symbol is
|
|
// the last (`c`); `member` above is `b::c`, which never matches a node
|
|
// name, so without this the pre-filter drops the ref before the Rust path
|
|
// resolver ever sees it. Mirror the dotted-name leaf check above.
|
|
const lastColon = name.lastIndexOf('::');
|
|
if (lastColon > colonIdx) {
|
|
const tail = name.substring(lastColon + 2);
|
|
if (tail && this.knownNames.has(tail)) return true;
|
|
}
|
|
}
|
|
|
|
// Lua/Luau method calls use a single `:` (`lg:log`); R uses `$` (`lg$log`).
|
|
// Check the member (and receiver) around these separators too, so the ref
|
|
// isn't dropped here before the method-call resolver ever sees it. The `:`
|
|
// case is skipped when the name actually contains `::` (handled above).
|
|
for (const sep of [':', '$']) {
|
|
if (sep === ':' && name.includes('::')) continue;
|
|
const sepIdx = name.indexOf(sep);
|
|
if (sepIdx > 0) {
|
|
const receiver = name.substring(0, sepIdx);
|
|
const member = name.substring(sepIdx + 1);
|
|
if (this.knownNames.has(member) || this.knownNames.has(receiver)) return true;
|
|
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
|
|
if (this.knownNames.has(capitalized)) return true;
|
|
}
|
|
}
|
|
|
|
// For path-like references (e.g., "snippets/drawer-menu.liquid"), check the filename
|
|
const slashIdx = name.lastIndexOf('/');
|
|
if (slashIdx > 0) {
|
|
const fileName = name.substring(slashIdx + 1);
|
|
if (this.knownNames.has(fileName)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Does `ref.referenceName` match an import declared in its containing
|
|
* file? Used as a pre-filter escape so re-export chain resolution
|
|
* still gets a chance when the name has no project-wide declaration.
|
|
*/
|
|
private matchesAnyImport(ref: UnresolvedRef): boolean {
|
|
const imports = this.context.getImportMappings(ref.filePath, ref.language);
|
|
if (imports.length === 0) return false;
|
|
for (const imp of imports) {
|
|
if (
|
|
imp.localName === ref.referenceName ||
|
|
ref.referenceName.startsWith(imp.localName + '.')
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Resolve a single reference
|
|
*/
|
|
resolveOne(ref: UnresolvedRef): ResolvedRef | null {
|
|
// Skip built-in/external references
|
|
if (this.isBuiltInOrExternal(ref)) {
|
|
return null;
|
|
}
|
|
|
|
// CFML component paths in inheritance (#1152): `extends="coldbox.system.web.
|
|
// Controller"` names the supertype by its dot-separated path (or `extends=
|
|
// "../base"` by relative file path) — the graph indexes the class under its
|
|
// final segment only, so these die at the fast pre-filter below and never
|
|
// resolved. Handled by a dedicated path-corroborated matcher, gated to
|
|
// inheritance refs only (a dotted `calls` ref is a member-access chain, not
|
|
// a component path). No fallthrough on miss: the full path string can only
|
|
// ever mis-match downstream, and an unresolvable supertype usually lives in
|
|
// an out-of-repo library (mxunit, testbox) — silent beats wrong.
|
|
if (
|
|
(ref.language === 'cfml' || ref.language === 'cfscript') &&
|
|
(ref.referenceKind === 'extends' || ref.referenceKind === 'implements') &&
|
|
(ref.referenceName.includes('.') || ref.referenceName.includes('/'))
|
|
) {
|
|
return this.resolveCfmlComponentPath(ref);
|
|
}
|
|
|
|
// Fast pre-filter: skip if no symbol with this name exists anywhere
|
|
// AND the name doesn't match a local import. The import escape is
|
|
// necessary because re-export rename chains (`import { login }
|
|
// from './barrel'` where the barrel has `export { signIn as login }
|
|
// from './auth'`) intentionally call a name that has no
|
|
// declaration anywhere — only the renamed upstream symbol does.
|
|
// ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that
|
|
// routes them to the decorator-gated matcher; the symbol itself is
|
|
// indexed under the bare name, so the existence check strips the dot.
|
|
// Nix static path imports (`import ./x.nix`) name a FILE, not a symbol —
|
|
// they bypass the symbol-existence check and resolve via resolveViaImport.
|
|
const existenceName =
|
|
ref.language === 'arkts' && ref.referenceName.startsWith('.')
|
|
? ref.referenceName.slice(1)
|
|
: ref.referenceName;
|
|
if (
|
|
!isNixPathImportRef(ref) &&
|
|
!this.hasAnyPossibleMatch(existenceName) &&
|
|
!this.matchesAnyImport(ref) &&
|
|
!this.frameworks.some((f) => f.claimsReference?.(ref.referenceName))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Function-as-value refs (#756) get a dedicated, strictly-gated path:
|
|
// import-based resolution first (an imported callback resolves through its
|
|
// import, the most precise cross-file signal), then matchFunctionRef
|
|
// (same-file first, unique-only cross-file, function/method targets only).
|
|
// They never reach the framework or fuzzy strategies below.
|
|
if (ref.referenceKind === 'function_ref') {
|
|
// `this.<member>` values (TS/JS) resolve ONLY against the enclosing
|
|
// class's own members — never a same-named symbol elsewhere.
|
|
if (ref.referenceName.startsWith('this.')) {
|
|
return this.gateLanguage(this.resolveThisMemberFnRef(ref), ref);
|
|
}
|
|
const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
|
if (viaImport) {
|
|
const target = this.queries.getNodeById(viaImport.targetNodeId);
|
|
if (target && (target.kind === 'function' || target.kind === 'method')) {
|
|
return viaImport;
|
|
}
|
|
}
|
|
return this.gateLanguage(matchFunctionRef(ref, this.context), ref);
|
|
}
|
|
|
|
// JVM FQN imports skip framework/name-matcher: `import com.example.Bar`
|
|
// resolves directly through the qualifiedName index, which is unambiguous
|
|
// even when several `Bar` classes exist in different packages.
|
|
const jvmImport = resolveJvmImport(ref, this.context);
|
|
if (jvmImport) return jvmImport;
|
|
|
|
// Razor/Blazor: a markup or `@code` type ref resolves through the file's
|
|
// `@using` namespaces (incl. folder `_Imports.razor`). This precisely
|
|
// disambiguates a simple name that exists in several namespaces — e.g.
|
|
// `CatalogBrand` resolving to `BlazorShared.Models::CatalogBrand` (the DTO,
|
|
// which the `.razor` `@using`s) rather than the same-named domain entity.
|
|
if (ref.language === 'razor') {
|
|
const razorResult = this.resolveRazorUsing(ref);
|
|
if (razorResult) return razorResult;
|
|
}
|
|
|
|
const candidates: ResolvedRef[] = [];
|
|
|
|
// Strategy 1: Try framework-specific resolution. Cross-language bridges
|
|
// are deliberately preserved (Drupal `routing.yml` → PHP controller, RN
|
|
// JS → native `calls`) — `gateFrameworkLanguage` only drops a type/import
|
|
// edge between two KNOWN families (see its doc), never a `calls` bridge or
|
|
// a config↔code edge.
|
|
for (const framework of this.frameworks) {
|
|
const result = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref);
|
|
if (result) {
|
|
if (result.confidence >= 0.9) return result; // High confidence, return immediately
|
|
candidates.push(result);
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Try import-based resolution
|
|
const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
|
if (importResult) {
|
|
if (importResult.confidence >= 0.9) return importResult;
|
|
candidates.push(importResult);
|
|
}
|
|
|
|
// PHP include/require paths resolve to files via import resolution only.
|
|
// If that didn't find the file, do NOT fall back to the symbol
|
|
// name-matcher — it would mis-connect e.g. "inc/db.php" to an unrelated
|
|
// db.php elsewhere in the tree (a wrong edge is worse than none, #660).
|
|
// Terraform refs are directory-scoped by language semantics — the
|
|
// framework resolver IS the whole rulebook (`var.X` can never legally
|
|
// bind outside its module directory), so the name-matcher's
|
|
// qualified-name fallback would only ever add wrong cross-module edges.
|
|
// Nix static path imports are file references for the same reason —
|
|
// falling through would let "./x.nix" name-match an unrelated node.
|
|
if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || ref.language === 'terraform') {
|
|
return candidates.length > 0
|
|
? candidates.reduce((best, curr) =>
|
|
curr.confidence > best.confidence ? curr : best
|
|
)
|
|
: null;
|
|
}
|
|
|
|
// Strategy 3: Try name matching
|
|
let nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
|
|
// Nix has no ambient cross-file namespace — a callee binds lexically
|
|
// (same file) or through explicit import/callPackage wiring (the import
|
|
// path above). A cross-file name match is wrong by construction: every
|
|
// module `inherit (lib) mkOption`s the same nixpkgs helpers, so the
|
|
// matcher would link each `mkOption` call to whichever file's inherit
|
|
// binding it happened to pick. Same-file matches only.
|
|
if (nameResult) {
|
|
const target = this.queries.getNodeById(nameResult.targetNodeId);
|
|
if (ref.language === 'nix') {
|
|
if (!target || target.filePath !== ref.filePath) {
|
|
nameResult = null;
|
|
}
|
|
} else if (target && target.language === 'nix') {
|
|
// The reverse direction is just as impossible: no other language can
|
|
// symbolically call into a .nix binding (interop is eval/CLI, never a
|
|
// linkable symbol) — without this, a Python script's `split()` lands
|
|
// on some module's `split = ...` binding as a low-confidence match.
|
|
nameResult = null;
|
|
}
|
|
}
|
|
if (nameResult) {
|
|
candidates.push(nameResult);
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
// Defer a chained static-factory/fluent call the first pass couldn't
|
|
// resolve — its method may live on a supertype the receiver conforms to,
|
|
// resolvable once implements/extends edges exist (the conformance pass).
|
|
if (
|
|
ref.referenceKind === 'calls' &&
|
|
CHAIN_LANGUAGES.has(ref.language) &&
|
|
CHAIN_SHAPE.test(ref.referenceName)
|
|
) {
|
|
this.deferredChainRefs.push(ref);
|
|
} else if (
|
|
// PHP `$this->prop->method()` (encoded `this->prop.method`): its method
|
|
// may live on the property's declared supertype, resolvable only once
|
|
// implements/extends edges exist — defer to the same conformance pass.
|
|
ref.referenceKind === 'calls' &&
|
|
ref.language === 'php' &&
|
|
PHP_PROP_SHAPE.test(ref.referenceName)
|
|
) {
|
|
this.deferredChainRefs.push(ref);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Return highest confidence candidate
|
|
return candidates.reduce((best, curr) =>
|
|
curr.confidence > best.confidence ? curr : best
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create edges from resolved references
|
|
*/
|
|
createEdges(resolved: ResolvedRef[]): Edge[] {
|
|
return resolved.map((ref) => {
|
|
// `function_ref` (#756) is internal-only: it persists as a `references`
|
|
// edge (the registration site depends on the callback), distinguishable
|
|
// by metadata.resolvedBy === 'function-ref'. callers/impact already
|
|
// traverse `references`, so registration sites surface with no
|
|
// graph-layer changes.
|
|
let kind: Edge['kind'] =
|
|
ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind;
|
|
|
|
// Promote "extends" to "implements" when a class/struct targets an interface
|
|
if (kind === 'extends') {
|
|
const targetNode = this.queries.getNodeById(ref.targetNodeId);
|
|
if (targetNode && (targetNode.kind === 'interface' || targetNode.kind === 'protocol')) {
|
|
const sourceNode = this.queries.getNodeById(ref.original.fromNodeId);
|
|
if (sourceNode && sourceNode.kind !== 'interface' && sourceNode.kind !== 'protocol') {
|
|
kind = 'implements';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Promote "calls" to "instantiates" when the resolved target is a
|
|
// class/struct. Languages without a `new` keyword (Python, Ruby)
|
|
// express instantiation as `Foo()` — extraction can't tell that
|
|
// apart from a function call without symbol info, but resolution
|
|
// can: if `Foo` resolves to a class, the call IS an instantiation.
|
|
if (kind === 'calls') {
|
|
const targetNode = this.queries.getNodeById(ref.targetNodeId);
|
|
if (targetNode && (targetNode.kind === 'class' || targetNode.kind === 'struct')) {
|
|
kind = 'instantiates';
|
|
}
|
|
}
|
|
|
|
return {
|
|
source: ref.original.fromNodeId,
|
|
target: ref.targetNodeId,
|
|
kind,
|
|
line: ref.original.line,
|
|
column: ref.original.column,
|
|
metadata: {
|
|
confidence: ref.confidence,
|
|
resolvedBy: ref.resolvedBy,
|
|
// The ORIGINAL reference text (and kind, when edge-kind promotion
|
|
// rewrote it — calls→instantiates, extends→implements,
|
|
// function_ref→references). If this edge's target is later removed
|
|
// by a re-index, the edge is resurrected as exactly this ref and
|
|
// re-resolved (#1240 removal case) — a faithful resurrection, so
|
|
// re-resolution can never bind anywhere a full re-index wouldn't.
|
|
// Reconstruction from the target node's name instead would strip
|
|
// receiver/qualifier context (`h.greet` → `greet`) and risk a
|
|
// wrong rebind; edges without refName (pre-#1240, synthesized) are
|
|
// deliberately NOT resurrected for the same reason.
|
|
refName: ref.original.referenceName,
|
|
...(ref.original.referenceKind !== kind ? { refKind: ref.original.referenceKind } : {}),
|
|
// Uniform marker for function-as-value edges (#756), regardless of
|
|
// which strategy resolved them (import vs matchFunctionRef) — lets
|
|
// tooling label "callback registration" and lets validation diff
|
|
// exactly the edges this feature added.
|
|
...(ref.original.referenceKind === 'function_ref' ? { fnRef: true } : {}),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Split resolved refs into rows deletable by id and hand-built refs that
|
|
* must fall back to the key-tuple delete. Rows loaded from the database
|
|
* carry their row id and are deleted by exactly that id; the key tuple
|
|
* omits line/col, so it also removes SIBLING rows — the same caller calling
|
|
* the same callee at other lines — that a later batch hadn't attempted yet:
|
|
* when a batch boundary split a caller's same-named call sites, the later
|
|
* sites' edges were silently never created (#1269).
|
|
*/
|
|
private static partitionResolvedCleanup(resolved: ResolvedRef[]): {
|
|
rowIds: number[];
|
|
legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>;
|
|
} {
|
|
const rowIds: number[] = [];
|
|
const legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }> = [];
|
|
for (const r of resolved) {
|
|
if (r.original.rowId != null) rowIds.push(r.original.rowId);
|
|
else legacyKeys.push({
|
|
fromNodeId: r.original.fromNodeId,
|
|
referenceName: r.original.referenceName,
|
|
referenceKind: r.original.referenceKind,
|
|
});
|
|
}
|
|
return { rowIds, legacyKeys };
|
|
}
|
|
|
|
/**
|
|
* Same row-id precision for parking unresolvable refs as status='failed'
|
|
* (#1240): the key-tuple fallback would flip same-key sibling rows in later
|
|
* batches to 'failed' before they were ever attempted, and resolution
|
|
* outcome can differ per call site (receiver-type inference reads the
|
|
* ref's line), so a sibling must not inherit this row's failure (#1269).
|
|
*/
|
|
private static partitionFailedCleanup(unresolved: UnresolvedRef[]): {
|
|
byRowId: Array<{ rowId: number; referenceName: string }>;
|
|
legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>;
|
|
} {
|
|
const byRowId: Array<{ rowId: number; referenceName: string }> = [];
|
|
const legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }> = [];
|
|
for (const r of unresolved) {
|
|
if (r.rowId != null) byRowId.push({ rowId: r.rowId, referenceName: r.referenceName });
|
|
else legacyKeys.push({
|
|
fromNodeId: r.fromNodeId,
|
|
referenceName: r.referenceName,
|
|
referenceKind: r.referenceKind,
|
|
});
|
|
}
|
|
return { byRowId, legacyKeys };
|
|
}
|
|
|
|
/**
|
|
* Resolve and persist edges to database
|
|
*/
|
|
resolveAndPersist(
|
|
unresolvedRefs: UnresolvedReference[],
|
|
onProgress?: (current: number, total: number) => void
|
|
): ResolutionResult {
|
|
const result = this.resolveAll(unresolvedRefs, onProgress);
|
|
|
|
// Create edges from resolved references
|
|
const edges = this.createEdges(result.resolved);
|
|
|
|
// Insert edges into database
|
|
if (edges.length > 0) {
|
|
this.queries.insertEdges(edges);
|
|
}
|
|
|
|
// Clean up resolved refs from unresolved_refs table so metrics are accurate
|
|
if (result.resolved.length > 0) {
|
|
const { rowIds, legacyKeys } = ReferenceResolver.partitionResolvedCleanup(result.resolved);
|
|
this.queries.deleteReferencesByRowIds(rowIds);
|
|
this.queries.deleteSpecificResolvedReferences(legacyKeys);
|
|
}
|
|
|
|
// Park unresolvable refs as status='failed' — parity with
|
|
// resolveAndPersistBatched. Deleting them was wrong (#1240): a ref whose
|
|
// own file never changes is otherwise gone forever, so when a DIFFERENT
|
|
// file later gains the export/symbol that would satisfy it, no sync can
|
|
// recreate the edge — only a full re-index. Failed rows are excluded from
|
|
// the pending readers, which preserves the #1187 orphan sweep's
|
|
// invariant in status form: after a COMPLETED pass nothing it processed
|
|
// is still 'pending', so any pending row at rest belongs to an
|
|
// interrupted run and the sweep can key off the pending count.
|
|
if (result.unresolved.length > 0) {
|
|
const { byRowId, legacyKeys } = ReferenceResolver.partitionFailedCleanup(result.unresolved);
|
|
this.queries.markReferencesFailedByRowIds(byRowId);
|
|
this.queries.markReferencesFailed(legacyKeys);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Yielding counterpart of {@link resolveAndPersist} for a caller-supplied
|
|
* ref list — used by sync's failed-ref retry pass (#1240). Same persistence
|
|
* semantics: resolved refs become edges and their rows are deleted;
|
|
* still-unresolvable refs are (re-)marked failed (a no-op for rows already
|
|
* in that status). Yields per-ref because sync can run on the daemon's
|
|
* liveness-watchdog thread (#850/#1091) and a retry set is unbounded when
|
|
* a large edit lands many popular symbol names at once.
|
|
*/
|
|
async resolveAndPersistListYielding(refs: UnresolvedReference[]): Promise<ResolutionResult> {
|
|
const maybeYield = createYielder();
|
|
const result = await this.resolveBatchYielding(refs, maybeYield);
|
|
|
|
const PERSIST_CHUNK = 1000;
|
|
const edges = this.createEdges(result.resolved);
|
|
for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
|
|
this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
|
|
const resolvedCleanup = ReferenceResolver.partitionResolvedCleanup(result.resolved);
|
|
for (let i = 0; i < resolvedCleanup.rowIds.length; i += PERSIST_CHUNK) {
|
|
this.queries.deleteReferencesByRowIds(resolvedCleanup.rowIds.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
for (let i = 0; i < resolvedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
|
this.queries.deleteSpecificResolvedReferences(resolvedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
|
|
const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved);
|
|
for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) {
|
|
this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
|
this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Second resolution pass for chained static-factory / fluent calls whose
|
|
* chained method is defined on a SUPERTYPE the receiver's type conforms to —
|
|
* a protocol-extension / inherited / default-interface method (#750). The
|
|
* first pass can't resolve these because `implements`/`extends` edges aren't
|
|
* built yet; this runs AFTER edges are persisted, so `context.getSupertypes`
|
|
* (and the conformance fallback in resolveMethodOnType) can walk them.
|
|
*
|
|
* Operates only on the leftover unresolved refs that have the `inner().method`
|
|
* chain shape, for the dotted-chain languages — a small set — and is idempotent
|
|
* (re-resolving an already-resolved ref is a no-op since it's been deleted).
|
|
* Returns the number of newly-created edges.
|
|
*/
|
|
async resolveChainedCallsViaConformance(): Promise<number> {
|
|
const deferred = this.deferredChainRefs;
|
|
this.deferredChainRefs = [];
|
|
if (deferred.length === 0) return 0;
|
|
|
|
// Read fresh edges (the main pass built the implements/extends edges after
|
|
// these refs were deferred). matchDottedCallChain now resolves a method on a
|
|
// supertype via context.getSupertypes -> resolveMethodOnType's conformance walk.
|
|
this.clearCaches();
|
|
// This post-pass runs synchronously on the indexer's main thread; yield
|
|
// periodically so the #850 liveness watchdog heartbeat can fire on a repo
|
|
// with many deferred chained calls (#1091).
|
|
const maybeYield = createYielder();
|
|
const resolved: ResolvedRef[] = [];
|
|
for (const ref of deferred) {
|
|
// PHP `this->prop.method` resolves via matchMethodCall (declared-type
|
|
// inference + resolveMethodOnType conformance walk); `::`-receiver
|
|
// languages (Rust) split on `::` (matchScopedCallChain); other
|
|
// dotted-receiver languages on `.` (matchDottedCallChain).
|
|
const chainMatch = (ref.language === 'php' && PHP_PROP_SHAPE.test(ref.referenceName))
|
|
? matchMethodCall(ref, this.context)
|
|
: SCOPED_CHAIN_LANGUAGES.has(ref.language)
|
|
? matchScopedCallChain(ref, this.context)
|
|
: matchDottedCallChain(ref, this.context);
|
|
const match = this.gateLanguage(chainMatch, ref);
|
|
if (match) resolved.push(match);
|
|
await maybeYield();
|
|
}
|
|
if (resolved.length === 0) return 0;
|
|
|
|
const edges = this.createEdges(resolved);
|
|
if (edges.length > 0) {
|
|
this.queries.insertEdges(edges);
|
|
this.clearCaches();
|
|
}
|
|
return edges.length;
|
|
}
|
|
|
|
/**
|
|
* Resolve one batch with a yield checkpoint between EVERY ref so the #850
|
|
* liveness heartbeat can fire on a slow/dense batch (#1091). The checkpoint
|
|
* granularity is per-ref — not per-N-refs — because per-ref cost is unbounded
|
|
* in the worst case (a collision-heavy method name whose candidate set misses
|
|
* the LRU re-fetches tens of thousands of rows): any fixed N multiplies that
|
|
* worst case into the watchdog window, which is how v1.2.0 still got killed
|
|
* at "Resolving refs" on large Java monorepos (#1122). `maybeYield()` is a
|
|
* ~ns time check when under budget, so per-ref checkpoints cost nothing.
|
|
* Behaviourally identical to `resolveAll(batch)`: `warmCaches()` is
|
|
* idempotent (guarded) and `resolveOne` is independent per ref, so yielding
|
|
* between refs changes only timing, never which edges get created.
|
|
*/
|
|
private async resolveBatchYielding(
|
|
batch: UnresolvedReference[],
|
|
maybeYield: MaybeYield
|
|
): Promise<ResolutionResult> {
|
|
this.warmCaches();
|
|
|
|
const resolved: ResolvedRef[] = [];
|
|
const unresolved: UnresolvedRef[] = [];
|
|
const byMethod: Record<string, number> = {};
|
|
|
|
for (const raw of batch) {
|
|
const ref: UnresolvedRef = {
|
|
fromNodeId: raw.fromNodeId,
|
|
referenceName: raw.referenceName,
|
|
referenceKind: raw.referenceKind,
|
|
line: raw.line,
|
|
column: raw.column,
|
|
filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId),
|
|
language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId),
|
|
rowId: raw.rowId,
|
|
};
|
|
const result = this.resolveOne(ref);
|
|
if (result) {
|
|
resolved.push(result);
|
|
byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
|
|
} else {
|
|
unresolved.push(ref);
|
|
}
|
|
// Fast-path the per-ref yield check: awaiting the async no-op costs a
|
|
// microtask hop per ref, which dominates at ~10⁵ refs (see MaybeYield).
|
|
const y = maybeYield();
|
|
if (y) await y;
|
|
}
|
|
|
|
return {
|
|
resolved,
|
|
unresolved,
|
|
stats: {
|
|
total: batch.length,
|
|
resolved: resolved.length,
|
|
unresolved: unresolved.length,
|
|
byMethod,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a list of refs and return everything the ADMISSION side needs to
|
|
* persist the outcome: resolutions, failures, the deferred post-pass refs
|
|
* this run produced (drained, so the caller owns routing them), and stats.
|
|
* This is the resolver-worker entry point — it runs the exact per-ref loop
|
|
* of resolveBatchYielding, minus the main-thread yields (worker threads have
|
|
* no watchdog heartbeat to starve). Results are in input order.
|
|
*/
|
|
resolveListForAdmission(refs: UnresolvedReference[]): {
|
|
resolved: ResolvedRef[];
|
|
unresolved: UnresolvedRef[];
|
|
deferredChain: UnresolvedRef[];
|
|
deferredThisMember: UnresolvedRef[];
|
|
byMethod: Record<string, number>;
|
|
} {
|
|
this.warmCaches();
|
|
const resolved: ResolvedRef[] = [];
|
|
const unresolved: UnresolvedRef[] = [];
|
|
const byMethod: Record<string, number> = {};
|
|
for (const raw of refs) {
|
|
const ref: UnresolvedRef = {
|
|
fromNodeId: raw.fromNodeId,
|
|
referenceName: raw.referenceName,
|
|
referenceKind: raw.referenceKind,
|
|
line: raw.line,
|
|
column: raw.column,
|
|
filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId),
|
|
language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId),
|
|
rowId: raw.rowId,
|
|
};
|
|
const result = this.resolveOne(ref);
|
|
if (result) {
|
|
resolved.push(result);
|
|
byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
|
|
} else {
|
|
unresolved.push(ref);
|
|
}
|
|
}
|
|
return {
|
|
resolved,
|
|
unresolved,
|
|
deferredChain: this.deferredChainRefs.splice(0),
|
|
deferredThisMember: this.deferredThisMemberRefs.splice(0),
|
|
byMethod,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Re-queue deferred post-pass refs produced by resolver workers, preserving
|
|
* their admission order so resolveChainedCallsViaConformance /
|
|
* resolveDeferredThisMemberRefs process them exactly as the sequential path
|
|
* would have.
|
|
*/
|
|
appendDeferredFromWorkers(deferredChain: UnresolvedRef[], deferredThisMember: UnresolvedRef[]): void {
|
|
this.deferredChainRefs.push(...deferredChain);
|
|
this.deferredThisMemberRefs.push(...deferredThisMember);
|
|
}
|
|
|
|
/**
|
|
* Resolve and persist in batches to keep memory bounded.
|
|
* Processes unresolved references in chunks, persisting edges and cleaning
|
|
* up resolved refs after each batch to avoid accumulating large arrays.
|
|
*/
|
|
async resolveAndPersistBatched(
|
|
onProgress?: (current: number, total: number) => void,
|
|
batchSize: number = 5000,
|
|
onSynthesisProgress?: (done: number, total: number) => void,
|
|
// When provided, big batches fan out across a read-only resolver-worker
|
|
// pool with results admitted in canonical order (see resolver-pool.ts).
|
|
// Sequential fallback on any pool failure. CODEGRAPH_NO_PARALLEL_RESOLVE=1
|
|
// disables entirely.
|
|
parallel?: { dbPath: string }
|
|
): Promise<ResolutionResult> {
|
|
// Resolution runs on the indexer's MAIN thread, and the #850 liveness
|
|
// watchdog SIGKILLs a process whose event loop stalls past its window (60s
|
|
// by default). A single dense batch's resolveAll — or the synthesis pass
|
|
// below — can exceed that on a large repo, killing a VALID in-progress index
|
|
// (#1091). A shared yielder lets both give the watchdog heartbeat a regular
|
|
// window to fire; see ./cooperative-yield.
|
|
const maybeYield = createYielder();
|
|
|
|
await this.warmCachesYielding(maybeYield);
|
|
|
|
const total = this.queries.getUnresolvedReferencesCount();
|
|
let processed = 0;
|
|
const aggregateStats = {
|
|
total: 0,
|
|
resolved: 0,
|
|
unresolved: 0,
|
|
byMethod: {} as Record<string, number>,
|
|
};
|
|
|
|
// Parallel pool, started immediately but never awaited up front: early
|
|
// batches run sequentially while the workers boot (module load + readonly
|
|
// DB open + framework detect + cache warm ≈ hundreds of ms), and the loop
|
|
// switches to fan-out the moment the pool reports ready — so pool boot
|
|
// costs zero wall-clock. Any failure downgrades to sequential permanently.
|
|
let pool: ResolverPool | null = null;
|
|
let poolReady = false;
|
|
if (parallel && total >= minRefsForPool()) {
|
|
pool = ResolverPool.tryCreate(parallel.dbPath, this.projectRoot);
|
|
pool?.ready().then(
|
|
() => { poolReady = true; },
|
|
() => { void pool?.destroy().catch(() => undefined); pool = null; }
|
|
);
|
|
}
|
|
|
|
// Process in batches. We always read from offset 0 because every ref the
|
|
// batch processed leaves the pending set (resolved rows are deleted,
|
|
// unresolvable ones flip to status='failed'), shifting the remaining
|
|
// pending rows forward.
|
|
let prevRemaining = Number.POSITIVE_INFINITY;
|
|
try {
|
|
while (true) {
|
|
const batch = this.queries.getUnresolvedReferencesBatch(0, batchSize);
|
|
if (batch.length === 0) break;
|
|
|
|
let result: ResolutionResult;
|
|
if (pool && poolReady && ResolverPool.worthParallel(batch.length)) {
|
|
try {
|
|
const out = await pool.resolveBatch(batch);
|
|
// Deferred post-pass refs ride back from the workers; re-queue them
|
|
// in admission order so the post-passes see the sequential order.
|
|
this.appendDeferredFromWorkers(out.deferredChain, out.deferredThisMember);
|
|
result = {
|
|
resolved: out.resolved,
|
|
unresolved: out.unresolved,
|
|
stats: {
|
|
total: batch.length,
|
|
resolved: out.resolved.length,
|
|
unresolved: out.unresolved.length,
|
|
byMethod: out.byMethod,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
logDebug('Parallel resolution failed; falling back to sequential', {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
await pool.destroy().catch(() => undefined);
|
|
pool = null;
|
|
result = await this.resolveBatchYielding(batch, maybeYield);
|
|
}
|
|
} else {
|
|
result = await this.resolveBatchYielding(batch, maybeYield);
|
|
}
|
|
|
|
// Persist in bounded sub-transactions with yields between: a whole
|
|
// batch's edge insert / keyed deletes are otherwise one solid
|
|
// synchronous span each on a multi-GB index, sitting BETWEEN the
|
|
// per-ref yields — the last unyielded stretch of the resolution loop.
|
|
// Crash semantics are unchanged (already several transactions): edges
|
|
// land before their refs are deleted, so a kill mid-way re-resolves
|
|
// the remainder idempotently on the next run/sweep (#1187).
|
|
const PERSIST_CHUNK = 1000;
|
|
|
|
// Persist edges immediately
|
|
const edges = this.createEdges(result.resolved);
|
|
for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
|
|
this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
|
|
// Clean up resolved refs so they don't appear in the next batch —
|
|
// by row id, so a same-key sibling ref in a LATER batch (same caller
|
|
// calling the same callee at another line) is left pending for its own
|
|
// attempt instead of being swept out with this batch's rows (#1269).
|
|
const resolvedCleanup = ReferenceResolver.partitionResolvedCleanup(result.resolved);
|
|
for (let i = 0; i < resolvedCleanup.rowIds.length; i += PERSIST_CHUNK) {
|
|
this.queries.deleteReferencesByRowIds(resolvedCleanup.rowIds.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
for (let i = 0; i < resolvedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
|
this.queries.deleteSpecificResolvedReferences(resolvedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
|
|
// Park unresolvable refs from this batch as status='failed' so they
|
|
// leave the pending set (the batch reader and non-progress guard below
|
|
// only see pending rows) but stay retryable when a later sync adds a
|
|
// symbol that could satisfy them (#1240).
|
|
const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved);
|
|
for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) {
|
|
this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
|
this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
|
await maybeYield();
|
|
}
|
|
|
|
// Aggregate stats
|
|
aggregateStats.total += result.stats.total;
|
|
aggregateStats.resolved += result.stats.resolved;
|
|
aggregateStats.unresolved += result.stats.unresolved;
|
|
for (const [method, count] of Object.entries(result.stats.byMethod)) {
|
|
aggregateStats.byMethod[method] = (aggregateStats.byMethod[method] || 0) + count;
|
|
}
|
|
|
|
processed += batch.length;
|
|
onProgress?.(processed, total);
|
|
|
|
// Yield so progress UI can render between batches
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
// NOTE: there used to be an extra early break here when a batch resolved
|
|
// nothing (`result.unresolved.length === batch.length`). That was wrong:
|
|
// an all-unresolvable batch still DELETES its rows (progress), yet the
|
|
// break abandoned every batch after it in the same run — on a repo whose
|
|
// first 5000 refs are all external/stdlib calls, resolution stopped at
|
|
// batch one and left the rest of the table as permanent orphans (#1187).
|
|
// The count-based guard below catches the true no-progress case.
|
|
|
|
// Non-progress guard (defense-in-depth). Because we re-read from offset 0
|
|
// each pass, the PENDING population MUST shrink every iteration — resolved
|
|
// refs are deleted and unresolvable ones are marked failed above, and both
|
|
// leave the pending set the batch reader sees. If it didn't shrink, a
|
|
// resolver returned a match whose `original.referenceName` differs from the
|
|
// stored row, so the keyed delete/update no-ops, and we'd re-read +
|
|
// re-resolve + re-insert the same rows forever (the runaway that grew a
|
|
// 99-file repo to 5M edges / 1.4 GB before the Go-fallback fix). Stop
|
|
// rather than grow the graph without bound.
|
|
const remaining = this.queries.getUnresolvedReferencesCount();
|
|
if (remaining >= prevRemaining) break;
|
|
prevRemaining = remaining;
|
|
}
|
|
} finally {
|
|
if (pool) await pool.destroy().catch(() => undefined);
|
|
}
|
|
|
|
// Dynamic-edge synthesis: now that all base `calls` edges are persisted,
|
|
// synthesize observer/callback dispatch edges (dispatcher → registered
|
|
// callbacks) that static parsing leaves out. Best-effort — never fail the
|
|
// index on it. See docs/design/callback-edge-synthesis.md.
|
|
try {
|
|
aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
|
|
this.queries,
|
|
this.context,
|
|
onSynthesisProgress
|
|
);
|
|
} catch {
|
|
// synthesis is additive and optional; ignore failures
|
|
}
|
|
|
|
return {
|
|
resolved: [],
|
|
unresolved: [],
|
|
stats: aggregateStats,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get detected frameworks
|
|
*/
|
|
getDetectedFrameworks(): string[] {
|
|
return this.frameworks.map((f) => f.name);
|
|
}
|
|
|
|
/**
|
|
* Check if reference is to a built-in or external symbol
|
|
*/
|
|
private isBuiltInOrExternal(ref: UnresolvedRef): boolean {
|
|
const name = ref.referenceName;
|
|
const isJsTs = ref.language === 'typescript' || ref.language === 'javascript'
|
|
|| ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'arkts';
|
|
|
|
// JavaScript/TypeScript built-ins
|
|
if (isJsTs && JS_BUILT_INS.has(name)) {
|
|
return true;
|
|
}
|
|
|
|
// ArkTS resource-reference intrinsics — `$r('app.string.x')` /
|
|
// `$rawfile('x.png')` are framework-provided and appear dozens of times
|
|
// per UI file; without this they can resolve to a stray same-named
|
|
// symbol (e.g. a checked-in hvigor wrapper's `$r`).
|
|
if (ref.language === 'arkts' && (name === '$r' || name === '$rawfile')) {
|
|
return true;
|
|
}
|
|
|
|
// Common JS/TS library calls (console.log, Math.floor, JSON.parse)
|
|
if (isJsTs && (name.startsWith('console.') || name.startsWith('Math.') || name.startsWith('JSON.'))) {
|
|
return true;
|
|
}
|
|
|
|
// React hooks from React itself
|
|
if (isJsTs && REACT_HOOKS.has(name)) {
|
|
return true;
|
|
}
|
|
|
|
// Python built-ins (bare calls only — dotted calls like console.print are method calls)
|
|
if (ref.language === 'python' && PYTHON_BUILT_INS.has(name)) {
|
|
return true;
|
|
}
|
|
|
|
// Python built-in method calls (e.g., list.extend, dict.update)
|
|
if (ref.language === 'python') {
|
|
const dotIdx = name.indexOf('.');
|
|
if (dotIdx > 0) {
|
|
const receiver = name.substring(0, dotIdx);
|
|
const method = name.substring(dotIdx + 1);
|
|
// Filter calls on built-in types (list.append, dict.update, etc.)
|
|
if (PYTHON_BUILT_IN_TYPES.has(receiver)) {
|
|
return true;
|
|
}
|
|
// Filter built-in methods on non-class receivers
|
|
// (e.g., items.append where items is a local list variable)
|
|
// But allow if the capitalized receiver matches a known codebase class
|
|
if (PYTHON_BUILT_IN_METHODS.has(method)) {
|
|
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
|
|
if (!this.knownNames?.has(capitalized)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
// A bare name colliding with a builtin method (index, get, update, count…)
|
|
// is only a builtin when NOTHING in the codebase declares it. A declared
|
|
// symbol with that exact name — e.g. a Flask/FastAPI view `def index()` or
|
|
// `def get()` — is a real reference target. Mirrors the knownNames guard on
|
|
// the dotted branch above; without it, every handler named after a builtin
|
|
// method silently loses its route→handler edge.
|
|
if (PYTHON_BUILT_IN_METHODS.has(name) && !this.knownNames?.has(name)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Go standard library packages — refs like "fmt.Println", "http.ListenAndServe", etc.
|
|
if (ref.language === 'go') {
|
|
const dotIdx = name.indexOf('.');
|
|
if (dotIdx > 0) {
|
|
const pkg = name.substring(0, dotIdx);
|
|
if (GO_STDLIB_PACKAGES.has(pkg)) {
|
|
return true;
|
|
}
|
|
}
|
|
if (GO_BUILT_INS.has(name)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Pascal/Delphi built-ins and standard library units
|
|
if (ref.language === 'pascal') {
|
|
if (PASCAL_UNIT_PREFIXES.some((p) => name.startsWith(p))) {
|
|
return true;
|
|
}
|
|
if (PASCAL_BUILT_INS.has(name)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// C/C++ standard library symbols (printf, malloc, std::vector, etc.).
|
|
// Names that collide with user-defined symbols are NOT filtered —
|
|
// C and C++ projects routinely shadow stdlib names (custom allocators
|
|
// define `malloc`/`free`, stream wrappers define `read`/`write`/`open`,
|
|
// containers define `move`/`swap`, logging libs wrap `printf`). Killing
|
|
// those resolutions makes the graph wrong, not cleaner. We only filter
|
|
// when there's no user node with this name — then name-matching would
|
|
// produce zero edges anyway and the filter just short-circuits work.
|
|
if (ref.language === 'c' || ref.language === 'cpp') {
|
|
// C++ std:: namespace prefix — safe to filter unconditionally,
|
|
// since `std::foo` is never a user-defined qualified name in
|
|
// tree-sitter output.
|
|
if (name.startsWith('std::')) return true;
|
|
if (C_BUILT_INS.has(name) || CPP_BUILT_INS.has(name)) {
|
|
return !this.hasAnyPossibleMatch(name);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get file path from node ID
|
|
*/
|
|
private getFilePathFromNodeId(nodeId: string): string {
|
|
const node = this.queries.getNodeById(nodeId);
|
|
return node?.filePath || '';
|
|
}
|
|
|
|
/**
|
|
* Get language from node ID
|
|
*/
|
|
private getLanguageFromNodeId(nodeId: string): UnresolvedRef['language'] {
|
|
const node = this.queries.getNodeById(nodeId);
|
|
return node?.language || 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Drop an import/name-strategy resolution that crosses a language family.
|
|
* Two regimes (mirrors `applyLanguageGate`'s candidate filter):
|
|
* - `references` (type usage): STRICT — a `Type.member` static read names a
|
|
* same-family type, never a coincidentally same-named symbol in another
|
|
* language. Drops any non-same-family target.
|
|
* - `imports` (import binding / `#include`): both-known — a C++ `#include
|
|
* "X.h"` must not resolve to a same-named ObjC header on another platform
|
|
* (basename collision), but a singleton-family / SFC language (`vue` →
|
|
* `.ts`) importing across is left alone.
|
|
* Applies to the import (strategy 2) + name-match (strategy 3) results.
|
|
*/
|
|
/**
|
|
* Collect the `@using` namespaces in scope for a `.razor`/`.cshtml` file: its
|
|
* own `@using` directives plus every `_Imports.razor` from the file's folder up
|
|
* to the project root (Razor `_Imports` cascade). Cached per file.
|
|
*/
|
|
private getRazorUsings(filePath: string): string[] {
|
|
const cached = this.razorUsingsCache.get(filePath);
|
|
if (cached) return cached;
|
|
const usings = new Set<string>();
|
|
const addFrom = (src: string | null): void => {
|
|
if (!src) return;
|
|
for (const m of src.matchAll(/^\s*@using\s+(?:static\s+)?([A-Za-z_][\w.]*)/gm)) usings.add(m[1]!);
|
|
};
|
|
addFrom(this.context.readFile(filePath));
|
|
let dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
|
|
// Walk up to the project root, reading each level's _Imports.razor.
|
|
for (;;) {
|
|
addFrom(this.context.readFile(dir ? `${dir}/_Imports.razor` : '_Imports.razor'));
|
|
if (!dir) break;
|
|
const slash = dir.lastIndexOf('/');
|
|
dir = slash >= 0 ? dir.slice(0, slash) : '';
|
|
}
|
|
const arr = [...usings];
|
|
this.razorUsingsCache.set(filePath, arr);
|
|
return arr;
|
|
}
|
|
|
|
/**
|
|
* Resolve a Razor/Blazor simple type ref through the file's `@using`
|
|
* namespaces: `CatalogBrand` + `@using BlazorShared.Models` → the node whose
|
|
* qualified name is `BlazorShared.Models::CatalogBrand`. Only resolves when the
|
|
* `@using` set yields exactly ONE type (otherwise it stays ambiguous and falls
|
|
* through to name-matching).
|
|
*/
|
|
private resolveRazorUsing(ref: UnresolvedRef): ResolvedRef | null {
|
|
if (ref.referenceName.includes('.') || ref.referenceName.includes('::')) return null;
|
|
const usings = this.getRazorUsings(ref.filePath);
|
|
if (usings.length === 0) return null;
|
|
const found = new Map<string, Node>();
|
|
for (const ns of usings) {
|
|
for (const cand of this.context.getNodesByQualifiedName(`${ns}::${ref.referenceName}`)) {
|
|
found.set(cand.id, cand);
|
|
}
|
|
}
|
|
if (found.size !== 1) return null;
|
|
const target = found.values().next().value!;
|
|
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
|
}
|
|
|
|
/**
|
|
* Resolve a CFML inheritance reference written as a component path (#1152).
|
|
* Two forms exist in real code:
|
|
*
|
|
* - Dotted: `extends="coldbox.system.web.Controller"` — dots are directory
|
|
* separators from the webroot or a CFML mapping. Mappings live in server
|
|
* config / Application.cfc, so the leading segments may not exist in the
|
|
* repo at all (in the coldbox repo itself the path is `system/web/
|
|
* Controller.cfc` — the `coldbox.` root IS the repo). Matched by final
|
|
* segment (the class), corroborated right-to-left against the candidate's
|
|
* parent directories.
|
|
* - Relative: `extends="../base"` / `extends="./base"` (the FW/1 style) —
|
|
* resolved against the referencing file's own directory.
|
|
*
|
|
* Conservative by design: a candidate needs at least one corroborating
|
|
* directory segment (a dotted path whose only same-named class sits in an
|
|
* unrelated directory is almost always an out-of-repo library supertype —
|
|
* mxunit/testbox/coldbox-as-dependency), and a corroboration tie yields no
|
|
* edge. Directory comparison is case-insensitive (CFML path resolution is);
|
|
* the class segment itself is matched exactly, which real code satisfies —
|
|
* dotted paths are written to match the on-disk file name.
|
|
*/
|
|
private resolveCfmlComponentPath(ref: UnresolvedRef): ResolvedRef | null {
|
|
const cfmlCandidates = (name: string): Node[] =>
|
|
this.context
|
|
.getNodesByName(name)
|
|
.filter(
|
|
(n) =>
|
|
(n.kind === 'class' || n.kind === 'interface') &&
|
|
(n.language === 'cfml' || n.language === 'cfscript')
|
|
);
|
|
const norm = (p: string): string => p.replace(/\\/g, '/').toLowerCase();
|
|
|
|
// Relative-path form: `../base`, `./base`, `sub/thing` — resolve against
|
|
// the referencing file's directory and require an exact (case-insensitive)
|
|
// file match.
|
|
if (ref.referenceName.includes('/')) {
|
|
const rel = ref.referenceName.replace(/\.cfc$/i, '');
|
|
const fromDir = ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
|
|
const parts = [...fromDir];
|
|
for (const seg of rel.split('/')) {
|
|
if (seg === '' || seg === '.') continue;
|
|
if (seg === '..') {
|
|
if (parts.length === 0) return null; // escapes the project root
|
|
parts.pop();
|
|
} else {
|
|
parts.push(seg);
|
|
}
|
|
}
|
|
const wantPath = norm(parts.join('/') + '.cfc');
|
|
const className = parts[parts.length - 1];
|
|
if (!className) return null;
|
|
const target = cfmlCandidates(className).find((c) => norm(c.filePath) === wantPath);
|
|
return target
|
|
? { original: ref, targetNodeId: target.id, confidence: 0.95, resolvedBy: 'file-path' }
|
|
: null;
|
|
}
|
|
|
|
// Dotted form.
|
|
const segments = ref.referenceName.split('.').map((s) => s.trim()).filter(Boolean);
|
|
if (segments.length < 2) return null;
|
|
const className = segments[segments.length - 1]!;
|
|
const dirSegments = segments.slice(0, -1);
|
|
|
|
let best: Node | null = null;
|
|
let bestScore = 0;
|
|
let tie = false;
|
|
for (const cand of cfmlCandidates(className)) {
|
|
const dirs = cand.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
|
|
// Count matching directory segments right-to-left: for
|
|
// `coldbox.system.web.Controller` vs `system/web/Controller.cfc`,
|
|
// `web` and `system` match, then the repo root ends the run → score 2.
|
|
let score = 0;
|
|
while (
|
|
score < dirSegments.length &&
|
|
score < dirs.length &&
|
|
dirSegments[dirSegments.length - 1 - score]!.toLowerCase() ===
|
|
dirs[dirs.length - 1 - score]!.toLowerCase()
|
|
) {
|
|
score++;
|
|
}
|
|
if (score > bestScore) {
|
|
best = cand;
|
|
bestScore = score;
|
|
tie = false;
|
|
} else if (score === bestScore && score > 0) {
|
|
tie = true;
|
|
}
|
|
}
|
|
if (!best || bestScore === 0 || tie) return null;
|
|
return { original: ref, targetNodeId: best.id, confidence: 0.9, resolvedBy: 'qualified-name' };
|
|
}
|
|
|
|
/**
|
|
* Resolve a `this.<member>` function-as-value reference (#756/#808) to the
|
|
* ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The
|
|
* registration idiom (`btn.on('click', this.handleClick)`) names a member
|
|
* of the class being defined, so the only valid target shares the
|
|
* from-symbol's qualified-name scope. Function/method targets only — a
|
|
* property (a data field, post-#808 classification) yields no edge — same
|
|
* file required, no fallback of any kind.
|
|
*/
|
|
private resolveThisMemberFnRef(ref: UnresolvedRef): ResolvedRef | null {
|
|
const member = ref.referenceName.slice('this.'.length);
|
|
if (!member) return null;
|
|
const fromNode = this.queries.getNodeById(ref.fromNodeId);
|
|
if (!fromNode) return null;
|
|
// A hook declared at class-body level (Ruby `before_action :authenticate`)
|
|
// attributes to the CLASS node itself — its qualified name IS the scope.
|
|
// For members, strip the member segment.
|
|
let classPrefix: string;
|
|
if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
|
|
classPrefix = fromNode.qualifiedName;
|
|
} else {
|
|
const sep = fromNode.qualifiedName.lastIndexOf('::');
|
|
if (sep <= 0) return null; // not inside a class scope
|
|
classPrefix = fromNode.qualifiedName.slice(0, sep);
|
|
}
|
|
const candidates = this.context
|
|
.getNodesByQualifiedName(`${classPrefix}::${member}`)
|
|
.filter(
|
|
(n) =>
|
|
(n.kind === 'function' || n.kind === 'method') &&
|
|
n.filePath === ref.filePath &&
|
|
n.id !== ref.fromNodeId
|
|
);
|
|
if (candidates.length === 0) {
|
|
// Not on the class itself — possibly INHERITED. implements/extends
|
|
// edges don't exist yet in this pass, so retry in the supertype pass
|
|
// (resolveDeferredThisMemberRefs) instead of giving up.
|
|
this.deferredThisMemberRefs.push(ref);
|
|
return null;
|
|
}
|
|
const target = candidates.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
|
|
return {
|
|
original: ref,
|
|
targetNodeId: target.id,
|
|
confidence: 0.95,
|
|
resolvedBy: 'function-ref',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Second pass for `this.<member>` refs whose member wasn't on the enclosing
|
|
* class itself (#808): once implements/extends edges exist, walk the
|
|
* class's supertypes (transitively, depth-capped) and resolve the member on
|
|
* the nearest one that declares it — `this.handleSubmit` registered in a
|
|
* subclass resolves to `FormBase::handleSubmit`. Validated targets only
|
|
* (function/method kind, same language family); no match → no edge.
|
|
* Mirrors resolveChainedCallsViaConformance's lifecycle. Returns the number
|
|
* of newly-created edges.
|
|
*/
|
|
async resolveDeferredThisMemberRefs(): Promise<number> {
|
|
const deferred = this.deferredThisMemberRefs;
|
|
this.deferredThisMemberRefs = [];
|
|
if (deferred.length === 0) return 0;
|
|
|
|
this.clearCaches();
|
|
// Synchronous main-thread post-pass with a per-ref supertype BFS — yield
|
|
// periodically so the #850 liveness watchdog heartbeat can fire (#1091).
|
|
const maybeYield = createYielder();
|
|
const resolved: ResolvedRef[] = [];
|
|
for (const ref of deferred) {
|
|
await maybeYield();
|
|
const member = ref.referenceName.slice('this.'.length);
|
|
const fromNode = this.queries.getNodeById(ref.fromNodeId);
|
|
if (!fromNode || !member) continue;
|
|
// Class-body-level hooks (Ruby) attribute to the CLASS node itself.
|
|
let className: string;
|
|
if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
|
|
className = fromNode.name;
|
|
} else {
|
|
const sep = fromNode.qualifiedName.lastIndexOf('::');
|
|
if (sep <= 0) continue;
|
|
const classPrefix = fromNode.qualifiedName.slice(0, sep);
|
|
className = classPrefix.includes('::')
|
|
? classPrefix.slice(classPrefix.lastIndexOf('::') + 2)
|
|
: classPrefix;
|
|
}
|
|
|
|
// NODE-anchored BFS up the supertype graph: start from the class node
|
|
// in the ref's own file (never a same-named class elsewhere — rails has
|
|
// a dozen `Engine`s), follow implements/extends EDGES to supertype
|
|
// NODES, and look members up through `contains` edges. No name-based
|
|
// unions anywhere — a name-keyed getSupertypes('Engine') merged every
|
|
// Engine's parents and produced a cross-class wrong edge on rails.
|
|
let frontierNodes = this.context
|
|
.getNodesByName(className)
|
|
.filter(
|
|
(n) =>
|
|
SUPERTYPE_BEARING_KINDS.has(n.kind) &&
|
|
n.filePath === ref.filePath
|
|
);
|
|
if (frontierNodes.length === 0) {
|
|
// The class itself may be declared in another file (partial/reopened
|
|
// classes); fall back to same-family nodes of that name.
|
|
frontierNodes = this.context
|
|
.getNodesByName(className)
|
|
.filter(
|
|
(n) =>
|
|
SUPERTYPE_BEARING_KINDS.has(n.kind) &&
|
|
sameLanguageFamily(n.language, ref.language)
|
|
);
|
|
}
|
|
const seenNodes = new Set<string>(frontierNodes.map((n) => n.id));
|
|
let target: Node | null = null;
|
|
for (let depth = 0; depth < 5 && frontierNodes.length > 0 && !target; depth++) {
|
|
const next: Node[] = [];
|
|
for (const typeNode of frontierNodes) {
|
|
for (const edge of this.queries.getOutgoingEdges(typeNode.id, ['implements', 'extends'])) {
|
|
const superNode = this.queries.getNodeById(edge.target);
|
|
if (!superNode || seenNodes.has(superNode.id)) continue;
|
|
seenNodes.add(superNode.id);
|
|
if (!SUPERTYPE_BEARING_KINDS.has(superNode.kind)) continue;
|
|
// Member lookup anchored on the supertype's contains edges.
|
|
for (const c of this.queries.getOutgoingEdges(superNode.id, ['contains'])) {
|
|
const m = this.queries.getNodeById(c.target);
|
|
if (
|
|
m &&
|
|
m.name === member &&
|
|
(m.kind === 'function' || m.kind === 'method') &&
|
|
sameLanguageFamily(m.language, ref.language)
|
|
) {
|
|
target = m;
|
|
break;
|
|
}
|
|
}
|
|
if (target) break;
|
|
next.push(superNode);
|
|
}
|
|
if (target) break;
|
|
}
|
|
frontierNodes = next;
|
|
}
|
|
|
|
if (target) {
|
|
resolved.push({
|
|
original: ref,
|
|
targetNodeId: target.id,
|
|
confidence: 0.85,
|
|
resolvedBy: 'function-ref',
|
|
});
|
|
}
|
|
}
|
|
if (resolved.length === 0) return 0;
|
|
|
|
const edges = this.createEdges(resolved);
|
|
if (edges.length > 0) {
|
|
this.queries.insertEdges(edges);
|
|
this.clearCaches();
|
|
}
|
|
return edges.length;
|
|
}
|
|
|
|
private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
|
if (!result) return result;
|
|
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
|
if (!tgt || !ref.language) return result;
|
|
if ((ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') && !sameLanguageFamily(tgt, ref.language)) return null;
|
|
if (ref.referenceKind === 'imports' && crossesKnownFamily(tgt, ref.language)) return null;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Drop a FRAMEWORK-strategy resolution that crosses two *known* language
|
|
* families for a type-usage (`references`) or import-binding (`imports`)
|
|
* edge. The framework strategy is intentionally ungated for cross-language
|
|
* bridges, but those legitimate bridges are either `calls` edges (RN/Expo
|
|
* JS → native) or config↔code edges whose config side (`yaml`/`blade`/…) is
|
|
* not a known programming-language family. A `references`/`imports` edge
|
|
* between two *known* families is always a coincidental name collision — the
|
|
* React/Svelte/Vue PascalCase component resolvers name-match `getNodesByName`
|
|
* without a language check, so a TS `<TestRunner>` ref happily matched a
|
|
* Kotlin `class TestRunner`. Gating only the both-known-cross-family case
|
|
* lets config bridges and `calls` bridges through untouched.
|
|
*/
|
|
private gateFrameworkLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
|
if (!result) return result;
|
|
if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
|
|
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
|
if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a reference resolver instance
|
|
*/
|
|
export function createResolver(projectRoot: string, queries: QueryBuilder): ReferenceResolver {
|
|
const resolver = new ReferenceResolver(projectRoot, queries);
|
|
resolver.initialize();
|
|
return resolver;
|
|
}
|