| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314 |
- /**
- * 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, dumpNameMatcherProfile } 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.resolveOneTimed(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;
- const tPre = this.profileStages ? process.hrtime.bigint() : 0n;
- const preFilterPass =
- isNixPathImportRef(ref) ||
- this.hasAnyPossibleMatch(existenceName) ||
- this.matchesAnyImport(ref) ||
- this.frameworks.some((f) => f.claimsReference?.(ref.referenceName));
- if (this.profileStages) this.stageAdd('preFilter', ref, preFilterPass, tPre);
- if (!preFilterPass) {
- 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 tJvm = this.profileStages ? process.hrtime.bigint() : 0n;
- const jvmImport = resolveJvmImport(ref, this.context);
- if (this.profileStages) this.stageAdd('jvmImport', ref, !!jvmImport, tJvm);
- 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.
- const tFw = this.profileStages ? process.hrtime.bigint() : 0n;
- let fwEarly: ResolvedRef | null = null;
- for (const framework of this.frameworks) {
- const result = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref);
- if (result) {
- if (result.confidence >= 0.9) {
- fwEarly = result; // High confidence, return immediately (below)
- break;
- }
- candidates.push(result);
- }
- }
- if (this.profileStages) this.stageAdd('frameworks', ref, fwEarly !== null, tFw);
- if (fwEarly) return fwEarly;
- // Strategy 2: Try import-based resolution
- const tImp = this.profileStages ? process.hrtime.bigint() : 0n;
- const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
- if (this.profileStages) this.stageAdd('viaImport', ref, !!importResult, tImp);
- 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
- const tName = this.profileStages ? process.hrtime.bigint() : 0n;
- let nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
- if (this.profileStages) this.stageAdd('nameMatch', ref, !!nameResult, tName);
- // 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.resolveOneTimed(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.
- */
- /**
- * CODEGRAPH_RESOLVE_PROFILE=1: per-outcome wall-clock histogram of
- * resolveOne, keyed by the winning strategy (`resolvedBy`) or
- * `fail:<referenceKind>` — the §7a.2 "profile the per-ref path" probe. The
- * kernel-scale batch loop is ~430s and CORE-INVARIANT (835.9s pooled-4-on-8
- * ≈ 812.5s sequential-on-2 for the whole superphase), so the next lever is
- * which CLASS of ref the time belongs to, not more parallelism. Off by
- * default: the hrtime pair costs ~100ns/ref only when the env is set.
- */
- private resolveProfile: Map<string, { n: number; ns: bigint }> | null =
- process.env.CODEGRAPH_RESOLVE_PROFILE ? new Map() : null;
- /**
- * CODEGRAPH_RESOLVE_PROFILE=2 additionally attributes time to the
- * STRATEGIES inside resolveOne (`stage:<name>|<refKind>|hit/miss` rows in
- * the same histogram) — i.e. WHICH machinery a failing class of refs pays
- * for, not just that it fails. =1 keeps the per-outcome rows only.
- */
- private profileStages: boolean = process.env.CODEGRAPH_RESOLVE_PROFILE === '2';
- private stageAdd(stage: string, ref: UnresolvedRef, hit: boolean, t0: bigint): void {
- if (!this.resolveProfile) return;
- const dt = process.hrtime.bigint() - t0;
- const key = `stage:${stage}|${ref.referenceKind}|${hit ? 'hit' : 'miss'}`;
- const slot = this.resolveProfile.get(key);
- if (slot) {
- slot.n++;
- slot.ns += dt;
- } else {
- this.resolveProfile.set(key, { n: 1, ns: dt });
- }
- }
- private resolveOneTimed(ref: UnresolvedRef): ResolvedRef | null {
- if (!this.resolveProfile) return this.resolveOne(ref);
- const t0 = process.hrtime.bigint();
- const result = this.resolveOne(ref);
- const dt = process.hrtime.bigint() - t0;
- const key = result ? result.resolvedBy : `fail:${ref.referenceKind}`;
- const slot = this.resolveProfile.get(key);
- if (slot) {
- slot.n++;
- slot.ns += dt;
- } else {
- this.resolveProfile.set(key, { n: 1, ns: dt });
- }
- return result;
- }
- /** Dump the CODEGRAPH_RESOLVE_PROFILE histogram to stderr (no-op when off). */
- dumpResolveProfile(label: string): void {
- if (!this.resolveProfile || this.resolveProfile.size === 0) return;
- const rows = [...this.resolveProfile.entries()]
- .map(([k, v]) => ({ k, n: v.n, ms: Number(v.ns / 1_000_000n) }))
- .sort((a, b) => b.ms - a.ms);
- for (const r of rows) {
- console.error(
- `[resolve-profile] ${label} ${r.k}: n=${r.n} total=${(r.ms / 1000).toFixed(1)}s avg=${((r.ms * 1000) / Math.max(1, r.n)).toFixed(0)}µs`
- );
- }
- // =2 only: this thread's matchReference sub-stage table rides along.
- dumpNameMatcherProfile(label);
- }
- 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.resolveOneTimed(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,
- };
- }
- /**
- * The resolver's live ResolutionContext — resolver-pool workers use it to
- * run synthesis passes against their own read-only connection.
- */
- getResolutionContext(): ResolutionContext {
- return this.context;
- }
- /**
- * 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. bulkEdgeLoad hooks (when provided) bracket the batch
- // loop with drop/recreate of the non-unique edge indexes on big runs —
- // see DatabaseConnection.beginBulkEdgeLoad. backpressure (when provided)
- // is the WAL valve's writer-side backstop (WalCheckpointValve.backpressure):
- // called at pool-idle boundaries so a full backfill can actually complete —
- // the valve's timer-driven passive passes stay perpetually partial against
- // the pool's continuous reads, which is how a kernel-scale resolution grew
- // a 22GB WAL on a 4.6GB DB (migration plan §7a.1).
- parallel?: {
- dbPath: string;
- bulkEdgeLoad?: { begin: () => void; end: () => void | Promise<void> };
- /** unresolved_refs index window for the batched loop — the loop only
- * reads the status index + PK; dropping the sync-path ref indexes cuts
- * each per-batch DELETE's B-tree work (DatabaseConnection.beginBulkRefLoad). */
- refIndexLoad?: { begin: () => void; end: () => void | Promise<void> };
- backpressure?: () => Promise<void> | null;
- }
- ): 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();
- if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
- console.error(`[pool-timing] backpressure hook: ${parallel?.backpressure ? 'present' : 'absent'}`);
- }
- // CODEGRAPH_RESOLVE_PROFILE loop-stage attribution: the §7a.2 kernel-scale
- // histogram showed resolveOne owns only ~93s of the ~436s batch loop —
- // these counters name where the other ~340s goes (reads, edge build+insert,
- // deletes/marks, the per-batch count guard).
- const loopProf: Record<string, number> | null = process.env.CODEGRAPH_RESOLVE_PROFILE
- ? { read: 0, settle: 0, backpressure: 0, recycle: 0, createEdges: 0, insertEdges: 0, deletes: 0, marks: 0, countGuard: 0 }
- : null;
- const lp = (k: string, t0: number): void => { if (loopProf) loopProf[k] = (loopProf[k] ?? 0) + (Date.now() - t0); };
- let tLp = 0;
- 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;
- const tPoolStart = Date.now();
- if (parallel && total >= minRefsForPool()) {
- pool = ResolverPool.tryCreate(parallel.dbPath, this.projectRoot);
- pool?.ready().then(
- () => {
- poolReady = true;
- if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] pool ready after ${Date.now() - tPoolStart}ms`);
- },
- () => { void pool?.destroy().catch(() => undefined); pool = null; }
- );
- }
- // Process in PIPELINED batches (double-buffer). The enumeration is the
- // head of the pending set in rowid order; every ref a persisted 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;
- // Cadence for the worker connection recycling below — ~8 batches
- // ≈ 40k refs between recycles keeps the WAL shallow at kernel scale
- // while a small sync never recycles at all. (25 recovered only half
- // the write tax — the WAL re-deepened between recycles; reopens are
- // sub-millisecond so the shorter cadence is ~free.)
- const RECYCLE_EVERY_BATCHES = 8;
- let batchesSinceRecycle = 0;
- // Fan-out result of ResolverPool.resolveBatch, settled (never rejecting)
- // so a fan-out begun before the previous batch's persist can't produce an
- // unhandled rejection while it waits to be awaited.
- type PoolSettled =
- | { ok: true; out: Awaited<ReturnType<ResolverPool['resolveBatch']>> }
- | { ok: false; err: unknown };
- type InFlight = { mode: 'pool'; settled: Promise<PoolSettled> } | { mode: 'seq' };
- // Begin one batch: fan out to the pool when it's ready and the batch is
- // big enough — workers then resolve batch k+1 WHILE the main thread
- // persists batch k (persist measured at ~58% of resolution wall on a
- // 255k-ref repo, all of it previously spent with the pool idle).
- // Sequential batches stay lazy: they run on the main thread at settle
- // time, where an early start would only contend with the persist.
- const beginBatch = (batch: UnresolvedReference[]): InFlight => {
- if (pool && poolReady && ResolverPool.worthParallel(batch.length)) {
- return {
- mode: 'pool',
- settled: pool.resolveBatch(batch).then(
- (out) => ({ ok: true as const, out }),
- (err: unknown) => ({ ok: false as const, err })
- ),
- };
- }
- return { mode: 'seq' };
- };
- // Settle an in-flight batch to a ResolutionResult. Deferred post-pass refs
- // are appended HERE, in loop order — never inside the fan-out promise — so
- // admission order stays exactly the sequential order even while a later
- // batch resolves concurrently. A pool failure downgrades to sequential
- // permanently and re-resolves this batch on the main thread.
- const settleBatch = async (
- inFlight: InFlight,
- batch: UnresolvedReference[]
- ): Promise<ResolutionResult> => {
- if (inFlight.mode === 'pool') {
- const settled = await inFlight.settled;
- if (settled.ok) {
- this.appendDeferredFromWorkers(settled.out.deferredChain, settled.out.deferredThisMember);
- return {
- resolved: settled.out.resolved,
- unresolved: settled.out.unresolved,
- stats: {
- total: batch.length,
- resolved: settled.out.resolved.length,
- unresolved: settled.out.unresolved.length,
- byMethod: settled.out.byMethod,
- },
- };
- }
- logDebug('Parallel resolution failed; falling back to sequential', {
- error: settled.err instanceof Error ? settled.err.message : String(settled.err),
- });
- if (pool) await pool.destroy().catch(() => undefined);
- pool = null;
- }
- return this.resolveBatchYielding(batch, maybeYield);
- };
- // Bulk edge load: on big runs, drop the non-unique edge indexes for the
- // duration of the batch loop (the identity index stays — OR IGNORE dedup
- // and the source-keyed supertype-walk reads both live on it). Recreated in
- // the inner finally BEFORE synthesis, whose passes read kind-keyed.
- // Measured on a 224k-edge resolution set: insert 2.8s → 1.1s + 0.3s
- // recreate. Same ref-count gate as the pool so small syncs never pay the
- // recreate cost.
- let bulkEdgesActive = false;
- if (parallel?.bulkEdgeLoad && total >= minRefsForPool()) {
- try {
- parallel.bulkEdgeLoad.begin();
- bulkEdgesActive = true;
- } catch { /* keep the indexes; inserts just pay the per-row maintenance */ }
- }
- // Same gate for the ref-index window: the loop's deletes stop maintaining
- // the five sync-path unresolved_refs indexes, and the end-of-loop rebuild
- // is near-free (only failed refs survive the loop).
- let bulkRefsActive = false;
- if (parallel?.refIndexLoad && total >= minRefsForPool()) {
- try {
- parallel.refIndexLoad.begin();
- bulkRefsActive = true;
- } catch { /* keep the indexes; deletes just pay the per-row maintenance */ }
- }
- try {
- try {
- tLp = Date.now();
- let batch = this.queries.getUnresolvedReferencesBatchAfter(0, batchSize);
- lp('read', tLp);
- let inFlight: InFlight | null = batch.length > 0 ? beginBatch(batch) : null;
- while (batch.length > 0 && inFlight) {
- // Prefetch the NEXT batch before this one persists: this batch's rows
- // are still pending (nothing has mutated the table since they were
- // read), so seeking past this batch's last row id in the same rowid
- // enumeration yields the following batch (keyset — OFFSET re-walked the
- // accumulated failed prefix every read, 54.6s at kernel scale, §7a.2).
- tLp = Date.now();
- const nextBatch = this.queries.getUnresolvedReferencesBatchAfter(batch[batch.length - 1]!.rowId!, batchSize);
- lp('read', tLp);
- const tBatch = Date.now();
- const result = await settleBatch(inFlight, batch);
- if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch ${inFlight.mode}: ${batch.length} refs in ${Date.now() - tBatch}ms`);
- lp('settle', tBatch);
- // WAL-valve backstop at the ONE pool-idle boundary of the double-buffer
- // (this batch settled, the next not yet fanned out): past the hard cap
- // the writer parks for a full backfill here, where the pool's readers
- // are all between statements — so the backfill completes, readers
- // re-enter at SQLite's backfilled mark, and the next persist commit
- // WRAPS the WAL instead of growing it. No-op (one fstat) under the cap.
- tLp = Date.now();
- const bp = parallel?.backpressure?.();
- if (bp) await bp;
- lp('backpressure', tLp);
- // Recycle the workers' read connections periodically at this same
- // worker-idle boundary (batch k settled, batch k+1 not yet fanned
- // out): a long-lived reader pins WAL checkpoint progress, and the
- // deep WAL that accumulates behind it taxes the writer's OWN page
- // operations — the §7a.6 writes-under-readers finding (deletes
- // 42.6s → 118.8s from 0 to 4 attached readers; an aggressive valve
- // recovered the writes but paid +129s in full-park folds). Releasing
- // the read marks every ~25 batches lets the existing checkpoints
- // advance instead, at ~milliseconds of reopen cost. A failed recycle
- // downgrades to sequential permanently, same as a failed fan-out.
- if (pool && poolReady && ++batchesSinceRecycle >= RECYCLE_EVERY_BATCHES) {
- batchesSinceRecycle = 0;
- tLp = Date.now();
- try {
- await pool.recycleWorkers();
- } catch (err) {
- logDebug('Worker connection recycle failed; falling back to sequential', {
- error: err instanceof Error ? err.message : String(err),
- });
- await pool.destroy().catch(() => undefined);
- pool = null;
- }
- lp('recycle', tLp);
- }
- // 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;
- const tPersist = Date.now();
- // Persist edges BEFORE fanning out the next batch: later batches read
- // this batch's edges — resolveMethodOnType walks supertype chains over
- // `extends`/`implements` edges that earlier batches resolved, so a
- // receiver typed as a subclass only reaches a method declared on its
- // base class if those edges are visible. (Validated on dubbo: fanning
- // out first downgraded exactly those supertype-method resolutions from
- // the 0.9 typed-receiver path to the 0.65 word-overlap fallback.)
- tLp = Date.now();
- const edges = this.createEdges(result.resolved);
- lp('createEdges', tLp);
- tLp = Date.now();
- for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
- this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
- await maybeYield();
- }
- lp('insertEdges', tLp);
- // NOW fan the next batch out — workers see exactly the edge state the
- // sequential baseline would (every batch ≤ this one committed), while
- // the main thread spends the REST of the persist (ref deletes + failed
- // parking below) overlapped with their resolution — the double-buffer.
- const nextInFlight = nextBatch.length > 0 ? beginBatch(nextBatch) : null;
- // 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).
- tLp = Date.now();
- let removedThisBatch = 0;
- const resolvedCleanup = ReferenceResolver.partitionResolvedCleanup(result.resolved);
- for (let i = 0; i < resolvedCleanup.rowIds.length; i += PERSIST_CHUNK) {
- removedThisBatch += this.queries.deleteReferencesByRowIds(resolvedCleanup.rowIds.slice(i, i + PERSIST_CHUNK));
- await maybeYield();
- }
- for (let i = 0; i < resolvedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
- removedThisBatch += this.queries.deleteSpecificResolvedReferences(resolvedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
- await maybeYield();
- }
- lp('deletes', tLp);
- // 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).
- tLp = Date.now();
- const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved);
- for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) {
- removedThisBatch += this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK));
- await maybeYield();
- }
- for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
- removedThisBatch += this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
- await maybeYield();
- }
- lp('marks', tLp);
- if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch persist: ${Date.now() - tPersist}ms`);
- // 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). Each iteration enumerates from
- // the head of the pending set, so 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. (An in-flight prefetched batch is abandoned unsettled —
- // fan-out has no side effects until settleBatch appends its results.)
- // Non-progress signal, now O(1): `changes` summed across this batch's
- // deletes + failed-parks is the DIRECT evidence the guard's old count
- // diff inferred — a resolver returning a mismatched name makes the keyed
- // cleanup no-op, which shows up here as zero removals. The per-batch
- // COUNT(*) it replaces walked every remaining pending row — O(N²/batch)
- // over a run, 93.9s of the kernel-scale batch loop (§7a.2). A REAL count
- // runs only on the suspicious path (claimed-work batch removed nothing —
- // e.g. every row was a sibling a legacy-key sweep already consumed),
- // where it arbitrates stop-vs-continue exactly as before.
- if (removedThisBatch <= 0 && batch.length > 0) {
- tLp = Date.now();
- const remaining = this.queries.getUnresolvedReferencesCount();
- lp('countGuard', tLp);
- if (remaining >= prevRemaining) break;
- prevRemaining = remaining;
- }
- // Advance the pipeline: the prefetched batch (already fanned out when
- // the pool is on) becomes the current one.
- batch = nextBatch;
- inFlight = nextInFlight;
- }
- } finally {
- // Recreate the edge indexes BEFORE synthesis (kind-keyed reads) and on
- // any error path. A crash before this line is healed by the next
- // DatabaseConnection open (schema.sql re-applies IF NOT EXISTS).
- if (bulkRefsActive) {
- const tRef = Date.now();
- await parallel!.refIndexLoad!.end();
- if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] ref-index-recreate: ${Date.now() - tRef}ms`);
- }
- if (bulkEdgesActive) {
- const tIdx = Date.now();
- await parallel!.bulkEdgeLoad!.end();
- if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] edge-index-recreate: ${Date.now() - tIdx}ms`);
- // The recreate just wrote every non-unique edge index into the WAL
- // (multi-GB at kernel scale) with the pool idle — fold before the
- // synthesis passes pin readers against it for minutes.
- const bp = parallel?.backpressure?.();
- if (bp) await bp;
- }
- }
- // 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. The pool (when it survived resolution) is REUSED to fan the
- // independent passes across its read-only workers — that's why its destroy
- // lives in the finally below, after synthesis, not at the end of the batch
- // loop. See docs/design/callback-edge-synthesis.md.
- const tSynth = Date.now();
- try {
- aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
- this.queries,
- this.context,
- onSynthesisProgress,
- pool,
- parallel?.backpressure
- );
- } catch {
- // synthesis is additive and optional; ignore failures
- }
- if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] callback-synthesis: ${Date.now() - tSynth}ms`);
- } finally {
- if (pool) await pool.destroy().catch(() => undefined);
- }
- if (loopProf) {
- const parts = Object.entries(loopProf).map(([k, v]) => `${k}=${(v / 1000).toFixed(1)}s`).join(' ');
- console.error(`[resolve-profile] loop-stages ${parts}`);
- }
- this.dumpResolveProfile('main');
- 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;
- }
|