index.ts 88 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081
  1. /**
  2. * Reference Resolution Orchestrator
  3. *
  4. * Coordinates all reference resolution strategies.
  5. */
  6. import * as fs from 'fs';
  7. import * as path from 'path';
  8. import { Language, Node, UnresolvedReference, Edge } from '../types';
  9. import { QueryBuilder } from '../db/queries';
  10. import {
  11. UnresolvedRef,
  12. ResolvedRef,
  13. ResolutionResult,
  14. ResolutionContext,
  15. FrameworkResolver,
  16. ImportMapping,
  17. } from './types';
  18. import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
  19. import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver';
  20. import { ResolverPool, minRefsForPool } from './resolver-pool';
  21. import { detectFrameworks } from './frameworks';
  22. import { synthesizeCallbackEdges } from './callback-synthesizer';
  23. import { createYielder, type MaybeYield } from './cooperative-yield';
  24. import { loadProjectAliases, type AliasMap } from './path-aliases';
  25. import { loadGoModule, type GoModule } from './go-module';
  26. import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
  27. import { logDebug } from '../errors';
  28. import type { ReExport } from './types';
  29. import { LRUCache } from './lru-cache';
  30. /** Node kinds that can declare supertypes (extends/implements). */
  31. const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
  32. 'class', 'struct', 'interface', 'trait', 'protocol', 'enum',
  33. ]);
  34. /**
  35. * Languages whose chained static-factory/fluent calls defer to the conformance
  36. * second pass. Dotted-receiver languages resolve via matchDottedCallChain; the
  37. * `::`-receiver ones (Rust) via matchScopedCallChain.
  38. */
  39. const CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift', 'rust', 'go', 'scala', 'dart', 'objc', 'pascal']);
  40. const SCOPED_CHAIN_LANGUAGES = new Set(['rust']);
  41. /** The extractor's chained-receiver encoding: `<inner>().<method>`. */
  42. const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
  43. /** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */
  44. const PHP_PROP_SHAPE = /^this->\w+\.\w+$/;
  45. /**
  46. * Cache size limits. Each per-resolver cache is bounded so memory
  47. * stays flat on large codebases (20k+ files). Sizes were chosen to
  48. * cover the working set for typical resolution batches without
  49. * exceeding a few hundred MB worst-case. Override via the env var
  50. * `CODEGRAPH_RESOLVER_CACHE_SIZE` (single integer applied to all
  51. * caches) when tuning for very large or very small projects.
  52. */
  53. const DEFAULT_CACHE_LIMIT = 5_000;
  54. function resolveCacheLimit(): number {
  55. const raw = process.env.CODEGRAPH_RESOLVER_CACHE_SIZE;
  56. if (!raw) return DEFAULT_CACHE_LIMIT;
  57. const parsed = Number.parseInt(raw, 10);
  58. if (Number.isFinite(parsed) && parsed > 0) return parsed;
  59. return DEFAULT_CACHE_LIMIT;
  60. }
  61. // Re-export types
  62. export * from './types';
  63. // Pre-built Sets for O(1) built-in lookups (allocated once, shared across all instances)
  64. const JS_BUILT_INS = new Set([
  65. 'console', 'window', 'document', 'global', 'process',
  66. 'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean',
  67. 'Date', 'Math', 'JSON', 'RegExp', 'Error', 'Map', 'Set',
  68. 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
  69. 'fetch', 'require', 'module', 'exports', '__dirname', '__filename',
  70. ]);
  71. const REACT_HOOKS = new Set([
  72. 'useState', 'useEffect', 'useContext', 'useReducer', 'useCallback',
  73. 'useMemo', 'useRef', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
  74. ]);
  75. const PYTHON_BUILT_INS = new Set([
  76. 'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
  77. 'open', 'input', 'type', 'isinstance', 'hasattr', 'getattr', 'setattr',
  78. 'super', 'self', 'cls', 'None', 'True', 'False',
  79. ]);
  80. const PYTHON_BUILT_IN_TYPES = new Set([
  81. 'list', 'dict', 'set', 'tuple', 'str', 'int', 'float', 'bool',
  82. 'bytes', 'bytearray', 'frozenset', 'object', 'super',
  83. ]);
  84. const PYTHON_BUILT_IN_METHODS = new Set([
  85. 'append', 'extend', 'insert', 'remove', 'pop', 'clear', 'sort', 'reverse', 'copy',
  86. 'update', 'keys', 'values', 'items', 'get',
  87. 'add', 'discard', 'union', 'intersection', 'difference',
  88. 'split', 'join', 'strip', 'lstrip', 'rstrip', 'replace', 'lower', 'upper',
  89. 'startswith', 'endswith', 'find', 'index', 'count', 'encode', 'decode',
  90. 'format', 'isdigit', 'isalpha', 'isalnum',
  91. 'read', 'write', 'readline', 'readlines', 'close', 'flush', 'seek',
  92. ]);
  93. const GO_STDLIB_PACKAGES = new Set([
  94. 'fmt', 'os', 'io', 'net', 'http', 'log', 'math', 'sort', 'sync',
  95. 'time', 'path', 'bytes', 'strings', 'strconv', 'errors', 'context',
  96. 'json', 'xml', 'csv', 'html', 'template', 'regexp', 'reflect',
  97. 'runtime', 'testing', 'flag', 'bufio', 'crypto', 'encoding',
  98. 'filepath', 'hash', 'mime', 'rand', 'signal', 'sql', 'syscall',
  99. 'unicode', 'unsafe', 'atomic', 'binary', 'debug', 'exec', 'heap',
  100. 'ring', 'scanner', 'tar', 'zip', 'gzip', 'zlib', 'tls', 'url',
  101. 'user', 'pprof', 'trace', 'ast', 'build', 'parser', 'printer',
  102. 'token', 'types', 'cgo', 'plugin', 'race', 'ioutil',
  103. // Kubernetes-common stdlib aliases
  104. 'utilruntime', 'utilwait', 'utilnet',
  105. ]);
  106. const GO_BUILT_INS = new Set([
  107. 'make', 'new', 'len', 'cap', 'append', 'copy', 'delete', 'close',
  108. 'panic', 'recover', 'print', 'println', 'complex', 'real', 'imag',
  109. 'error', 'nil', 'true', 'false', 'iota',
  110. 'int', 'int8', 'int16', 'int32', 'int64',
  111. 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
  112. 'float32', 'float64', 'complex64', 'complex128',
  113. 'string', 'bool', 'byte', 'rune', 'any',
  114. ]);
  115. const PASCAL_UNIT_PREFIXES = [
  116. 'System.', 'Winapi.', 'Vcl.', 'Fmx.', 'Data.', 'Datasnap.',
  117. 'Soap.', 'Xml.', 'Web.', 'REST.', 'FireDAC.', 'IBX.',
  118. 'IdHTTP', 'IdTCP', 'IdSSL',
  119. ];
  120. const PASCAL_BUILT_INS = new Set([
  121. 'System', 'SysUtils', 'Classes', 'Types', 'Variants', 'StrUtils',
  122. 'Math', 'DateUtils', 'IOUtils', 'Generics.Collections', 'Generics.Defaults',
  123. 'Rtti', 'TypInfo', 'SyncObjs', 'RegularExpressions',
  124. 'SysInit', 'Windows', 'Messages', 'Graphics', 'Controls', 'Forms',
  125. 'Dialogs', 'StdCtrls', 'ExtCtrls', 'ComCtrls', 'Menus', 'ActnList',
  126. 'WriteLn', 'Write', 'ReadLn', 'Read', 'Inc', 'Dec', 'Ord', 'Chr',
  127. 'Length', 'SetLength', 'High', 'Low', 'Assigned', 'FreeAndNil',
  128. 'Format', 'IntToStr', 'StrToInt', 'FloatToStr', 'StrToFloat',
  129. 'Trim', 'UpperCase', 'LowerCase', 'Pos', 'Copy', 'Delete', 'Insert',
  130. 'Now', 'Date', 'Time', 'DateToStr', 'StrToDate',
  131. 'Raise', 'Exit', 'Break', 'Continue', 'Abort',
  132. 'True', 'False', 'nil', 'Self', 'Result',
  133. 'Create', 'Destroy', 'Free',
  134. 'TObject', 'TComponent', 'TPersistent', 'TInterfacedObject',
  135. 'TList', 'TStringList', 'TStrings', 'TStream', 'TMemoryStream', 'TFileStream',
  136. 'Exception', 'EAbort', 'EConvertError', 'EAccessViolation',
  137. 'IInterface', 'IUnknown',
  138. ]);
  139. const C_BUILT_INS = new Set([
  140. // Standard C library functions
  141. 'printf', 'fprintf', 'sprintf', 'snprintf', 'scanf', 'fscanf', 'sscanf',
  142. 'malloc', 'calloc', 'realloc', 'free',
  143. 'memcpy', 'memmove', 'memset', 'memcmp', 'memchr',
  144. 'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp',
  145. 'strstr', 'strchr', 'strrchr', 'strtok', 'strdup',
  146. 'fopen', 'fclose', 'fread', 'fwrite', 'fgets', 'fputs', 'fputc', 'fgetc',
  147. 'feof', 'ferror', 'fflush', 'fseek', 'ftell', 'rewind',
  148. 'exit', 'abort', 'atexit', 'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtod',
  149. 'qsort', 'bsearch',
  150. 'abs', 'labs', 'rand', 'srand',
  151. 'sin', 'cos', 'tan', 'sqrt', 'pow', 'log', 'log10', 'exp', 'ceil', 'floor', 'fabs',
  152. 'time', 'clock', 'difftime', 'mktime', 'localtime', 'gmtime', 'strftime', 'asctime',
  153. 'assert', 'errno',
  154. 'perror', 'remove', 'rename', 'tmpfile', 'tmpnam',
  155. 'getenv', 'system',
  156. 'signal', 'raise',
  157. 'setjmp', 'longjmp',
  158. 'va_start', 'va_end', 'va_arg', 'va_copy',
  159. 'NULL', 'EOF', 'BUFSIZ', 'FILENAME_MAX', 'RAND_MAX', 'EXIT_SUCCESS', 'EXIT_FAILURE',
  160. 'size_t', 'ptrdiff_t', 'wchar_t', 'intptr_t', 'uintptr_t',
  161. 'int8_t', 'int16_t', 'int32_t', 'int64_t',
  162. 'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t',
  163. 'FILE',
  164. // POSIX additions commonly seen
  165. 'stat', 'lstat', 'fstat', 'open', 'close', 'read', 'write', 'pipe',
  166. 'fork', 'exec', 'waitpid', 'getpid', 'getppid', 'kill', 'sleep', 'usleep',
  167. 'pthread_create', 'pthread_join', 'pthread_mutex_lock', 'pthread_mutex_unlock',
  168. 'dlopen', 'dlsym', 'dlclose',
  169. ]);
  170. const CPP_BUILT_INS = new Set([
  171. // iostream objects (often used without std:: prefix via using)
  172. 'cout', 'cin', 'cerr', 'clog', 'endl', 'flush', 'ws',
  173. 'std', // the namespace itself when used as std::something
  174. // Common C++ keywords that leak as references
  175. 'nullptr', 'true', 'false', 'this', 'sizeof', 'alignof', 'typeid',
  176. 'static_cast', 'dynamic_cast', 'reinterpret_cast', 'const_cast',
  177. 'make_unique', 'make_shared', 'make_pair',
  178. 'move', 'forward', 'swap',
  179. ]);
  180. /**
  181. * Reference Resolver
  182. *
  183. * Orchestrates reference resolution using multiple strategies.
  184. */
  185. export class ReferenceResolver {
  186. private projectRoot: string;
  187. private queries: QueryBuilder;
  188. private context: ResolutionContext;
  189. private frameworks: FrameworkResolver[] = [];
  190. // Chained static-factory/fluent call refs the first pass couldn't resolve,
  191. // collected in-memory (the batched resolver deletes unresolved refs from the
  192. // DB, so they can't be re-read). Drained by resolveChainedCallsViaConformance
  193. // once implements/extends edges exist, to resolve methods on a supertype the
  194. // receiver conforms to (#750).
  195. private deferredChainRefs: UnresolvedRef[] = [];
  196. // `this.<member>` function-as-value refs whose member is NOT on the
  197. // enclosing class itself — possibly inherited. Collected in-memory for the
  198. // same reason as deferredChainRefs and drained by
  199. // resolveDeferredThisMemberRefs once implements/extends edges exist (#808).
  200. private deferredThisMemberRefs: UnresolvedRef[] = [];
  201. // Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder
  202. // `_Imports.razor`, cascading to the project root). Used to disambiguate a
  203. // markup type ref to the right C# namespace.
  204. private razorUsingsCache = new Map<string, string[]>();
  205. // All per-resolver caches are LRU-bounded. Previously these were
  206. // unbounded Maps that grew with every distinct lookup and OOM'd on
  207. // codebases with 20k+ files (see issue: unbounded cache growth).
  208. private nodeCache: LRUCache<string, Node[]>; // per-file node cache
  209. private fileCache: LRUCache<string, string | null>; // per-file content cache
  210. private importMappingCache: LRUCache<string, ImportMapping[]>;
  211. private reExportCache: LRUCache<string, ReExport[]>;
  212. private nameCache: LRUCache<string, Node[]>; // name → nodes cache
  213. private lowerNameCache: LRUCache<string, Node[]>; // lower(name) → nodes cache
  214. private qualifiedNameCache: LRUCache<string, Node[]>; // qualified_name → nodes cache
  215. private fileLinesCache: LRUCache<string, string[] | null>; // file → split lines cache
  216. private methodMatchCache: LRUCache<string, Node[]>; // lang\0Type::method → matching method nodes
  217. // Node kinds are a small fixed set (~24), so this is a plain Map, not an LRU.
  218. // getNodesByKind returns the FULL node list for a kind; it was previously
  219. // uncached — a per-ref `SELECT * FROM nodes WHERE kind=?` + row-mapping. Called
  220. // for every dotted call ref by the Spring resolver (constants) and every
  221. // `hook_` ref by the Drupal resolver (functions), that scan dominated
  222. // resolution on large repos (#1180). The node set is stable within a
  223. // resolution pass (same lifetime assumption as nameCache); clearCaches() resets
  224. // it between passes. Callers must treat the returned array as read-only.
  225. private nodesByKindCache = new Map<Node['kind'], Node[]>();
  226. private knownNames: Set<string> | null = null; // all known symbol names for fast pre-filtering
  227. private knownFiles: Set<string> | null = null;
  228. private cachesWarmed = false;
  229. // tsconfig/jsconfig path-alias map. `undefined` = not yet computed,
  230. // `null` = computed and absent. Treated as immutable for the
  231. // resolver's lifetime; callers re-create the resolver if config changes.
  232. private projectAliases: AliasMap | null | undefined = undefined;
  233. // go.mod module path. Same lazy/immutable convention as projectAliases.
  234. private goModule: GoModule | null | undefined = undefined;
  235. // Monorepo workspace member packages. Same lazy/immutable convention.
  236. private workspacePackages: WorkspacePackages | null | undefined = undefined;
  237. constructor(projectRoot: string, queries: QueryBuilder) {
  238. this.projectRoot = projectRoot;
  239. this.queries = queries;
  240. const limit = resolveCacheLimit();
  241. // The content cache is heavier (full file text), so we give it a
  242. // smaller budget than the metadata caches.
  243. const contentLimit = Math.max(64, Math.floor(limit / 5));
  244. this.nodeCache = new LRUCache(limit);
  245. this.fileCache = new LRUCache(contentLimit);
  246. this.importMappingCache = new LRUCache(limit);
  247. this.reExportCache = new LRUCache(limit);
  248. this.nameCache = new LRUCache(limit);
  249. this.lowerNameCache = new LRUCache(limit);
  250. this.qualifiedNameCache = new LRUCache(limit);
  251. // Split-lines arrays are heavier than content strings; refs arrive
  252. // file-ordered, so a small cache still hits nearly always.
  253. this.fileLinesCache = new LRUCache(contentLimit);
  254. this.methodMatchCache = new LRUCache(limit);
  255. this.context = this.createContext();
  256. }
  257. /**
  258. * Initialize the resolver (detect frameworks, etc.)
  259. */
  260. initialize(): void {
  261. this.frameworks = detectFrameworks(this.context);
  262. this.clearCaches();
  263. }
  264. /**
  265. * Run each framework resolver's cross-file finalization pass and persist
  266. * the returned node updates. Idempotent — safe to call after every indexAll
  267. * and every incremental sync. Returns the number of nodes updated.
  268. *
  269. * Caches are cleared before/after so the post-extract pass sees fresh DB
  270. * state and downstream queries see the updated names.
  271. */
  272. runPostExtract(): number {
  273. let updated = 0;
  274. this.clearCaches();
  275. for (const fw of this.frameworks) {
  276. if (!fw.postExtract) continue;
  277. try {
  278. const nodes = fw.postExtract(this.context);
  279. for (const node of nodes) {
  280. this.queries.updateNode(node);
  281. updated++;
  282. }
  283. } catch (err) {
  284. logDebug(`Framework '${fw.name}' postExtract failed`, {
  285. error: err instanceof Error ? err.message : String(err),
  286. });
  287. }
  288. }
  289. if (updated > 0) this.clearCaches();
  290. return updated;
  291. }
  292. /**
  293. * Pre-build lightweight caches for resolution.
  294. * Node lookups are now handled by indexed SQLite queries instead of
  295. * loading all nodes into memory (which caused OOM on large codebases).
  296. * We cache the set of known symbol names for fast pre-filtering.
  297. */
  298. warmCaches(): void {
  299. if (this.cachesWarmed) return;
  300. // Only cache the set of known file paths (lightweight string set)
  301. this.knownFiles = new Set(this.queries.getAllFilePaths());
  302. // Cache all distinct symbol names for fast pre-filtering (just strings, not full nodes)
  303. this.knownNames = new Set(this.queries.getAllNodeNames());
  304. this.cachesWarmed = true;
  305. }
  306. /**
  307. * warmCaches for the async resolution entry points: streams the distinct
  308. * name set with periodic yields instead of one synchronous `.all()`. On a
  309. * multi-million-node index the DISTINCT scan is a solid multi-second block
  310. * (measured up to 28s inside `codegraph sync` on the Linux kernel index),
  311. * long enough to matter to the #850 watchdog on slower hardware. Same
  312. * result, same memory — only the event loop keeps turning.
  313. */
  314. async warmCachesYielding(onYield: MaybeYield): Promise<void> {
  315. if (this.cachesWarmed) return;
  316. this.knownFiles = new Set(this.queries.getAllFilePaths());
  317. const names = new Set<string>();
  318. let scanned = 0;
  319. for (const name of this.queries.iterateNodeNames()) {
  320. names.add(name);
  321. if ((++scanned & 8191) === 0) await onYield();
  322. }
  323. this.knownNames = names;
  324. this.cachesWarmed = true;
  325. }
  326. /**
  327. * Clear internal caches
  328. */
  329. clearCaches(): void {
  330. this.nodeCache.clear();
  331. this.fileCache.clear();
  332. this.importMappingCache.clear();
  333. this.reExportCache.clear();
  334. this.nameCache.clear();
  335. this.lowerNameCache.clear();
  336. this.qualifiedNameCache.clear();
  337. this.fileLinesCache.clear();
  338. this.methodMatchCache.clear();
  339. this.nodesByKindCache.clear();
  340. this.knownNames = null;
  341. this.knownFiles = null;
  342. this.cachesWarmed = false;
  343. // The import-resolver's per-context memos assume the same stable window
  344. // as the caches above — drop them together.
  345. if (this.context) clearImportResolverMemos(this.context);
  346. }
  347. /** `readFile` through the LRU content cache (null = read failed, also cached). */
  348. private readFileCached(filePath: string): string | null {
  349. if (this.fileCache.has(filePath)) {
  350. return this.fileCache.get(filePath)!;
  351. }
  352. const fullPath = path.join(this.projectRoot, filePath);
  353. try {
  354. const content = fs.readFileSync(fullPath, 'utf-8');
  355. this.fileCache.set(filePath, content);
  356. return content;
  357. } catch (error) {
  358. logDebug('Failed to read file for resolution', { filePath, error: String(error) });
  359. this.fileCache.set(filePath, null);
  360. return null;
  361. }
  362. }
  363. /**
  364. * Create the resolution context
  365. */
  366. private createContext(): ResolutionContext {
  367. return {
  368. getNodesInFile: (filePath: string) => {
  369. if (!this.nodeCache.has(filePath)) {
  370. this.nodeCache.set(filePath, this.queries.getNodesByFile(filePath));
  371. }
  372. return this.nodeCache.get(filePath)!;
  373. },
  374. getNodesByName: (name: string) => {
  375. const cached = this.nameCache.get(name);
  376. if (cached !== undefined) return cached;
  377. const result = this.queries.getNodesByName(name);
  378. this.nameCache.set(name, result);
  379. return result;
  380. },
  381. getMethodMatches: (typeName: string, methodName: string, language: Language) => {
  382. const key = `${language} ${typeName}::${methodName}`;
  383. const cached = this.methodMatchCache.get(key);
  384. if (cached !== undefined) return cached;
  385. let candidates = this.nameCache.get(methodName);
  386. if (candidates === undefined) {
  387. candidates = this.queries.getNodesByName(methodName);
  388. this.nameCache.set(methodName, candidates);
  389. }
  390. const want = `${typeName}::${methodName}`;
  391. const matches: Node[] = [];
  392. for (const m of candidates) {
  393. if (m.kind !== 'method') continue;
  394. if (m.language !== language) continue;
  395. const qn = m.qualifiedName;
  396. if (qn === want || qn.endsWith(`::${want}`)) matches.push(m);
  397. }
  398. this.methodMatchCache.set(key, matches);
  399. return matches;
  400. },
  401. getNodesByQualifiedName: (qualifiedName: string) => {
  402. const cached = this.qualifiedNameCache.get(qualifiedName);
  403. if (cached !== undefined) return cached;
  404. const result = this.queries.getNodesByQualifiedNameExact(qualifiedName);
  405. this.qualifiedNameCache.set(qualifiedName, result);
  406. return result;
  407. },
  408. getNodesByKind: (kind: Node['kind']) => {
  409. const cached = this.nodesByKindCache.get(kind);
  410. if (cached !== undefined) return cached;
  411. const result = this.queries.getNodesByKind(kind);
  412. this.nodesByKindCache.set(kind, result);
  413. return result;
  414. },
  415. // Streamed, uncached — synthesizers scan-and-filter whole kinds, and
  416. // both the materialized array AND the per-kind cache retention are
  417. // O(nodes) memory (#1212). Per-ref resolvers keep the cached array
  418. // variant above.
  419. iterateNodesByKind: (kind: Node['kind']) => this.queries.iterateNodesByKind(kind),
  420. fileExists: (filePath: string) => {
  421. // Check pre-built known files set first (O(1))
  422. if (this.knownFiles) {
  423. const normalized = filePath.replace(/\\/g, '/');
  424. if (this.knownFiles.has(filePath) || this.knownFiles.has(normalized)) {
  425. return true;
  426. }
  427. }
  428. // Fall back to filesystem for files not yet indexed
  429. const fullPath = path.join(this.projectRoot, filePath);
  430. try {
  431. return fs.existsSync(fullPath);
  432. } catch (error) {
  433. logDebug('Error checking file existence', { filePath, error: String(error) });
  434. return false;
  435. }
  436. },
  437. readFile: (filePath: string) => this.readFileCached(filePath),
  438. getFileLines: (filePath: string) => {
  439. const cached = this.fileLinesCache.get(filePath);
  440. if (cached !== undefined) return cached;
  441. const source = this.readFileCached(filePath);
  442. const lines = source === null ? null : source.split(/\r?\n/);
  443. this.fileLinesCache.set(filePath, lines);
  444. return lines;
  445. },
  446. getProjectRoot: () => this.projectRoot,
  447. getAllFiles: () => {
  448. return this.queries.getAllFilePaths();
  449. },
  450. listDirectories: (relativePath: string) => {
  451. const target = relativePath === '.' || relativePath === ''
  452. ? this.projectRoot
  453. : path.join(this.projectRoot, relativePath);
  454. try {
  455. return fs
  456. .readdirSync(target, { withFileTypes: true })
  457. .filter((entry) => entry.isDirectory())
  458. .map((entry) => entry.name);
  459. } catch (error) {
  460. logDebug('Failed to list directory for resolution', {
  461. relativePath,
  462. error: String(error),
  463. });
  464. return [];
  465. }
  466. },
  467. getNodesByLowerName: (lowerName: string) => {
  468. const cached = this.lowerNameCache.get(lowerName);
  469. if (cached !== undefined) return cached;
  470. const result = this.queries.getNodesByLowerName(lowerName);
  471. this.lowerNameCache.set(lowerName, result);
  472. return result;
  473. },
  474. getNodeById: (id: string) => {
  475. return this.queries.getNodeById(id);
  476. },
  477. getSupertypes: (typeName: string, language) => {
  478. // Union the `implements`/`extends` targets of every same-named type node.
  479. // Matching by simple name (not id) reconciles a type declared in one node
  480. // (`KF::Builder`) with conformance declared in a separate extension node
  481. // (`KF.Builder: KFOptionSetter`) — both have name `Builder`.
  482. const typeNodes = this.context
  483. .getNodesByName(typeName)
  484. .filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language);
  485. if (typeNodes.length === 0) return [];
  486. const supertypes = new Set<string>();
  487. for (const tn of typeNodes) {
  488. for (const edge of this.queries.getOutgoingEdges(tn.id, ['implements', 'extends'])) {
  489. const target = this.queries.getNodeById(edge.target);
  490. if (target?.name && target.name !== typeName) supertypes.add(target.name);
  491. }
  492. }
  493. return [...supertypes];
  494. },
  495. getImportMappings: (filePath: string, language) => {
  496. const cacheKey = filePath;
  497. const cached = this.importMappingCache.get(cacheKey);
  498. if (cached) return cached;
  499. const content = this.context.readFile(filePath);
  500. if (!content) {
  501. this.importMappingCache.set(cacheKey, []);
  502. return [];
  503. }
  504. const mappings = extractImportMappings(filePath, content, language);
  505. this.importMappingCache.set(cacheKey, mappings);
  506. return mappings;
  507. },
  508. getProjectAliases: () => {
  509. if (this.projectAliases === undefined) {
  510. this.projectAliases = loadProjectAliases(this.projectRoot);
  511. }
  512. return this.projectAliases;
  513. },
  514. getGoModule: () => {
  515. if (this.goModule === undefined) {
  516. this.goModule = loadGoModule(this.projectRoot);
  517. }
  518. return this.goModule;
  519. },
  520. getWorkspacePackages: () => {
  521. if (this.workspacePackages === undefined) {
  522. this.workspacePackages = loadWorkspacePackages(this.projectRoot);
  523. }
  524. return this.workspacePackages;
  525. },
  526. getReExports: (filePath: string, language) => {
  527. const cached = this.reExportCache.get(filePath);
  528. if (cached) return cached;
  529. const content = this.context.readFile(filePath);
  530. if (!content) {
  531. this.reExportCache.set(filePath, []);
  532. return [];
  533. }
  534. // Re-exports are a JS/TS-only construct, and what matters is the
  535. // BARREL file's own language — not the consuming reference's. A
  536. // `.svelte`/`.vue` consumer threads its own language down the
  537. // re-export chase, which would make extractReExports() bail on a
  538. // `.ts` index barrel and silently break the chain (#629). Re-key
  539. // the parse on the barrel's extension so the chase works no matter
  540. // what kind of file imports through it.
  541. const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?|ets)$/i.test(filePath);
  542. const reExports = extractReExports(content, isJsFamily ? 'typescript' : language);
  543. this.reExportCache.set(filePath, reExports);
  544. return reExports;
  545. },
  546. getCppIncludeDirs: () => {
  547. return loadCppIncludeDirs(this.projectRoot);
  548. },
  549. };
  550. }
  551. /**
  552. * Resolve all unresolved references
  553. */
  554. resolveAll(
  555. unresolvedRefs: UnresolvedReference[],
  556. onProgress?: (current: number, total: number) => void
  557. ): ResolutionResult {
  558. // Pre-load all nodes into memory for fast lookups
  559. this.warmCaches();
  560. const resolved: ResolvedRef[] = [];
  561. const unresolved: UnresolvedRef[] = [];
  562. const byMethod: Record<string, number> = {};
  563. // Convert to our internal format, using denormalized fields when available
  564. const refs: UnresolvedRef[] = unresolvedRefs.map((ref) => ({
  565. fromNodeId: ref.fromNodeId,
  566. referenceName: ref.referenceName,
  567. referenceKind: ref.referenceKind,
  568. line: ref.line,
  569. column: ref.column,
  570. filePath: ref.filePath || this.getFilePathFromNodeId(ref.fromNodeId),
  571. language: ref.language || this.getLanguageFromNodeId(ref.fromNodeId),
  572. rowId: ref.rowId,
  573. }));
  574. const total = refs.length;
  575. let lastReportedPercent = -1;
  576. for (let i = 0; i < refs.length; i++) {
  577. const ref = refs[i]!; // Array index is guaranteed to be in bounds
  578. const result = this.resolveOne(ref);
  579. if (result) {
  580. resolved.push(result);
  581. byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
  582. } else {
  583. unresolved.push(ref);
  584. }
  585. // Report progress every 1% to avoid too many updates
  586. if (onProgress) {
  587. const currentPercent = Math.floor((i / total) * 100);
  588. if (currentPercent > lastReportedPercent) {
  589. lastReportedPercent = currentPercent;
  590. onProgress(i + 1, total);
  591. }
  592. }
  593. }
  594. // Final progress report
  595. if (onProgress && total > 0) {
  596. onProgress(total, total);
  597. }
  598. return {
  599. resolved,
  600. unresolved,
  601. stats: {
  602. total: refs.length,
  603. resolved: resolved.length,
  604. unresolved: unresolved.length,
  605. byMethod,
  606. },
  607. };
  608. }
  609. /**
  610. * Check if a reference name has any possible match in the codebase.
  611. * Uses the pre-built knownNames set to skip expensive resolution
  612. * for names that definitely don't exist as symbols.
  613. */
  614. private hasAnyPossibleMatch(name: string): boolean {
  615. if (!this.knownNames) return true; // no pre-filter available
  616. // Direct name match
  617. if (this.knownNames.has(name)) return true;
  618. // For qualified names like "obj.method" or "Class::method", check the parts
  619. const dotIdx = name.indexOf('.');
  620. if (dotIdx > 0) {
  621. const receiver = name.substring(0, dotIdx);
  622. const member = name.substring(dotIdx + 1);
  623. if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
  624. // Also check capitalized receiver (instance-method resolution)
  625. const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
  626. if (this.knownNames.has(capitalized)) return true;
  627. // JVM FQN: `com.example.foo.Bar` — the only useful segment is the
  628. // last one (`Bar`); the earlier check finds `example.foo.Bar` which
  629. // never matches a node name.
  630. const lastDot = name.lastIndexOf('.');
  631. if (lastDot > dotIdx) {
  632. const tail = name.substring(lastDot + 1);
  633. if (tail && this.knownNames.has(tail)) return true;
  634. }
  635. }
  636. const colonIdx = name.indexOf('::');
  637. if (colonIdx > 0) {
  638. const receiver = name.substring(0, colonIdx);
  639. const member = name.substring(colonIdx + 2);
  640. if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
  641. // Multi-segment path `a::b::c` (a Rust/C++ module call like
  642. // `database::profiles::find`) — the only segment that names a symbol is
  643. // the last (`c`); `member` above is `b::c`, which never matches a node
  644. // name, so without this the pre-filter drops the ref before the Rust path
  645. // resolver ever sees it. Mirror the dotted-name leaf check above.
  646. const lastColon = name.lastIndexOf('::');
  647. if (lastColon > colonIdx) {
  648. const tail = name.substring(lastColon + 2);
  649. if (tail && this.knownNames.has(tail)) return true;
  650. }
  651. }
  652. // Lua/Luau method calls use a single `:` (`lg:log`); R uses `$` (`lg$log`).
  653. // Check the member (and receiver) around these separators too, so the ref
  654. // isn't dropped here before the method-call resolver ever sees it. The `:`
  655. // case is skipped when the name actually contains `::` (handled above).
  656. for (const sep of [':', '$']) {
  657. if (sep === ':' && name.includes('::')) continue;
  658. const sepIdx = name.indexOf(sep);
  659. if (sepIdx > 0) {
  660. const receiver = name.substring(0, sepIdx);
  661. const member = name.substring(sepIdx + 1);
  662. if (this.knownNames.has(member) || this.knownNames.has(receiver)) return true;
  663. const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
  664. if (this.knownNames.has(capitalized)) return true;
  665. }
  666. }
  667. // For path-like references (e.g., "snippets/drawer-menu.liquid"), check the filename
  668. const slashIdx = name.lastIndexOf('/');
  669. if (slashIdx > 0) {
  670. const fileName = name.substring(slashIdx + 1);
  671. if (this.knownNames.has(fileName)) return true;
  672. }
  673. return false;
  674. }
  675. /**
  676. * Does `ref.referenceName` match an import declared in its containing
  677. * file? Used as a pre-filter escape so re-export chain resolution
  678. * still gets a chance when the name has no project-wide declaration.
  679. */
  680. private matchesAnyImport(ref: UnresolvedRef): boolean {
  681. const imports = this.context.getImportMappings(ref.filePath, ref.language);
  682. if (imports.length === 0) return false;
  683. for (const imp of imports) {
  684. if (
  685. imp.localName === ref.referenceName ||
  686. ref.referenceName.startsWith(imp.localName + '.')
  687. ) {
  688. return true;
  689. }
  690. }
  691. return false;
  692. }
  693. /**
  694. * Resolve a single reference
  695. */
  696. resolveOne(ref: UnresolvedRef): ResolvedRef | null {
  697. // Skip built-in/external references
  698. if (this.isBuiltInOrExternal(ref)) {
  699. return null;
  700. }
  701. // CFML component paths in inheritance (#1152): `extends="coldbox.system.web.
  702. // Controller"` names the supertype by its dot-separated path (or `extends=
  703. // "../base"` by relative file path) — the graph indexes the class under its
  704. // final segment only, so these die at the fast pre-filter below and never
  705. // resolved. Handled by a dedicated path-corroborated matcher, gated to
  706. // inheritance refs only (a dotted `calls` ref is a member-access chain, not
  707. // a component path). No fallthrough on miss: the full path string can only
  708. // ever mis-match downstream, and an unresolvable supertype usually lives in
  709. // an out-of-repo library (mxunit, testbox) — silent beats wrong.
  710. if (
  711. (ref.language === 'cfml' || ref.language === 'cfscript') &&
  712. (ref.referenceKind === 'extends' || ref.referenceKind === 'implements') &&
  713. (ref.referenceName.includes('.') || ref.referenceName.includes('/'))
  714. ) {
  715. return this.resolveCfmlComponentPath(ref);
  716. }
  717. // Fast pre-filter: skip if no symbol with this name exists anywhere
  718. // AND the name doesn't match a local import. The import escape is
  719. // necessary because re-export rename chains (`import { login }
  720. // from './barrel'` where the barrel has `export { signIn as login }
  721. // from './auth'`) intentionally call a name that has no
  722. // declaration anywhere — only the renamed upstream symbol does.
  723. // ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that
  724. // routes them to the decorator-gated matcher; the symbol itself is
  725. // indexed under the bare name, so the existence check strips the dot.
  726. // Nix static path imports (`import ./x.nix`) name a FILE, not a symbol —
  727. // they bypass the symbol-existence check and resolve via resolveViaImport.
  728. const existenceName =
  729. ref.language === 'arkts' && ref.referenceName.startsWith('.')
  730. ? ref.referenceName.slice(1)
  731. : ref.referenceName;
  732. if (
  733. !isNixPathImportRef(ref) &&
  734. !this.hasAnyPossibleMatch(existenceName) &&
  735. !this.matchesAnyImport(ref) &&
  736. !this.frameworks.some((f) => f.claimsReference?.(ref.referenceName))
  737. ) {
  738. return null;
  739. }
  740. // Function-as-value refs (#756) get a dedicated, strictly-gated path:
  741. // import-based resolution first (an imported callback resolves through its
  742. // import, the most precise cross-file signal), then matchFunctionRef
  743. // (same-file first, unique-only cross-file, function/method targets only).
  744. // They never reach the framework or fuzzy strategies below.
  745. if (ref.referenceKind === 'function_ref') {
  746. // `this.<member>` values (TS/JS) resolve ONLY against the enclosing
  747. // class's own members — never a same-named symbol elsewhere.
  748. if (ref.referenceName.startsWith('this.')) {
  749. return this.gateLanguage(this.resolveThisMemberFnRef(ref), ref);
  750. }
  751. const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref);
  752. if (viaImport) {
  753. const target = this.queries.getNodeById(viaImport.targetNodeId);
  754. if (target && (target.kind === 'function' || target.kind === 'method')) {
  755. return viaImport;
  756. }
  757. }
  758. return this.gateLanguage(matchFunctionRef(ref, this.context), ref);
  759. }
  760. // JVM FQN imports skip framework/name-matcher: `import com.example.Bar`
  761. // resolves directly through the qualifiedName index, which is unambiguous
  762. // even when several `Bar` classes exist in different packages.
  763. const jvmImport = resolveJvmImport(ref, this.context);
  764. if (jvmImport) return jvmImport;
  765. // Razor/Blazor: a markup or `@code` type ref resolves through the file's
  766. // `@using` namespaces (incl. folder `_Imports.razor`). This precisely
  767. // disambiguates a simple name that exists in several namespaces — e.g.
  768. // `CatalogBrand` resolving to `BlazorShared.Models::CatalogBrand` (the DTO,
  769. // which the `.razor` `@using`s) rather than the same-named domain entity.
  770. if (ref.language === 'razor') {
  771. const razorResult = this.resolveRazorUsing(ref);
  772. if (razorResult) return razorResult;
  773. }
  774. const candidates: ResolvedRef[] = [];
  775. // Strategy 1: Try framework-specific resolution. Cross-language bridges
  776. // are deliberately preserved (Drupal `routing.yml` → PHP controller, RN
  777. // JS → native `calls`) — `gateFrameworkLanguage` only drops a type/import
  778. // edge between two KNOWN families (see its doc), never a `calls` bridge or
  779. // a config↔code edge.
  780. for (const framework of this.frameworks) {
  781. const result = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref);
  782. if (result) {
  783. if (result.confidence >= 0.9) return result; // High confidence, return immediately
  784. candidates.push(result);
  785. }
  786. }
  787. // Strategy 2: Try import-based resolution
  788. const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
  789. if (importResult) {
  790. if (importResult.confidence >= 0.9) return importResult;
  791. candidates.push(importResult);
  792. }
  793. // PHP include/require paths resolve to files via import resolution only.
  794. // If that didn't find the file, do NOT fall back to the symbol
  795. // name-matcher — it would mis-connect e.g. "inc/db.php" to an unrelated
  796. // db.php elsewhere in the tree (a wrong edge is worse than none, #660).
  797. // Terraform refs are directory-scoped by language semantics — the
  798. // framework resolver IS the whole rulebook (`var.X` can never legally
  799. // bind outside its module directory), so the name-matcher's
  800. // qualified-name fallback would only ever add wrong cross-module edges.
  801. // Nix static path imports are file references for the same reason —
  802. // falling through would let "./x.nix" name-match an unrelated node.
  803. if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || ref.language === 'terraform') {
  804. return candidates.length > 0
  805. ? candidates.reduce((best, curr) =>
  806. curr.confidence > best.confidence ? curr : best
  807. )
  808. : null;
  809. }
  810. // Strategy 3: Try name matching
  811. let nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
  812. // Nix has no ambient cross-file namespace — a callee binds lexically
  813. // (same file) or through explicit import/callPackage wiring (the import
  814. // path above). A cross-file name match is wrong by construction: every
  815. // module `inherit (lib) mkOption`s the same nixpkgs helpers, so the
  816. // matcher would link each `mkOption` call to whichever file's inherit
  817. // binding it happened to pick. Same-file matches only.
  818. if (nameResult) {
  819. const target = this.queries.getNodeById(nameResult.targetNodeId);
  820. if (ref.language === 'nix') {
  821. if (!target || target.filePath !== ref.filePath) {
  822. nameResult = null;
  823. }
  824. } else if (target && target.language === 'nix') {
  825. // The reverse direction is just as impossible: no other language can
  826. // symbolically call into a .nix binding (interop is eval/CLI, never a
  827. // linkable symbol) — without this, a Python script's `split()` lands
  828. // on some module's `split = ...` binding as a low-confidence match.
  829. nameResult = null;
  830. }
  831. }
  832. if (nameResult) {
  833. candidates.push(nameResult);
  834. }
  835. if (candidates.length === 0) {
  836. // Defer a chained static-factory/fluent call the first pass couldn't
  837. // resolve — its method may live on a supertype the receiver conforms to,
  838. // resolvable once implements/extends edges exist (the conformance pass).
  839. if (
  840. ref.referenceKind === 'calls' &&
  841. CHAIN_LANGUAGES.has(ref.language) &&
  842. CHAIN_SHAPE.test(ref.referenceName)
  843. ) {
  844. this.deferredChainRefs.push(ref);
  845. } else if (
  846. // PHP `$this->prop->method()` (encoded `this->prop.method`): its method
  847. // may live on the property's declared supertype, resolvable only once
  848. // implements/extends edges exist — defer to the same conformance pass.
  849. ref.referenceKind === 'calls' &&
  850. ref.language === 'php' &&
  851. PHP_PROP_SHAPE.test(ref.referenceName)
  852. ) {
  853. this.deferredChainRefs.push(ref);
  854. }
  855. return null;
  856. }
  857. // Return highest confidence candidate
  858. return candidates.reduce((best, curr) =>
  859. curr.confidence > best.confidence ? curr : best
  860. );
  861. }
  862. /**
  863. * Create edges from resolved references
  864. */
  865. createEdges(resolved: ResolvedRef[]): Edge[] {
  866. return resolved.map((ref) => {
  867. // `function_ref` (#756) is internal-only: it persists as a `references`
  868. // edge (the registration site depends on the callback), distinguishable
  869. // by metadata.resolvedBy === 'function-ref'. callers/impact already
  870. // traverse `references`, so registration sites surface with no
  871. // graph-layer changes.
  872. let kind: Edge['kind'] =
  873. ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind;
  874. // Promote "extends" to "implements" when a class/struct targets an interface
  875. if (kind === 'extends') {
  876. const targetNode = this.queries.getNodeById(ref.targetNodeId);
  877. if (targetNode && (targetNode.kind === 'interface' || targetNode.kind === 'protocol')) {
  878. const sourceNode = this.queries.getNodeById(ref.original.fromNodeId);
  879. if (sourceNode && sourceNode.kind !== 'interface' && sourceNode.kind !== 'protocol') {
  880. kind = 'implements';
  881. }
  882. }
  883. }
  884. // Promote "calls" to "instantiates" when the resolved target is a
  885. // class/struct. Languages without a `new` keyword (Python, Ruby)
  886. // express instantiation as `Foo()` — extraction can't tell that
  887. // apart from a function call without symbol info, but resolution
  888. // can: if `Foo` resolves to a class, the call IS an instantiation.
  889. if (kind === 'calls') {
  890. const targetNode = this.queries.getNodeById(ref.targetNodeId);
  891. if (targetNode && (targetNode.kind === 'class' || targetNode.kind === 'struct')) {
  892. kind = 'instantiates';
  893. }
  894. }
  895. return {
  896. source: ref.original.fromNodeId,
  897. target: ref.targetNodeId,
  898. kind,
  899. line: ref.original.line,
  900. column: ref.original.column,
  901. metadata: {
  902. confidence: ref.confidence,
  903. resolvedBy: ref.resolvedBy,
  904. // The ORIGINAL reference text (and kind, when edge-kind promotion
  905. // rewrote it — calls→instantiates, extends→implements,
  906. // function_ref→references). If this edge's target is later removed
  907. // by a re-index, the edge is resurrected as exactly this ref and
  908. // re-resolved (#1240 removal case) — a faithful resurrection, so
  909. // re-resolution can never bind anywhere a full re-index wouldn't.
  910. // Reconstruction from the target node's name instead would strip
  911. // receiver/qualifier context (`h.greet` → `greet`) and risk a
  912. // wrong rebind; edges without refName (pre-#1240, synthesized) are
  913. // deliberately NOT resurrected for the same reason.
  914. refName: ref.original.referenceName,
  915. ...(ref.original.referenceKind !== kind ? { refKind: ref.original.referenceKind } : {}),
  916. // Uniform marker for function-as-value edges (#756), regardless of
  917. // which strategy resolved them (import vs matchFunctionRef) — lets
  918. // tooling label "callback registration" and lets validation diff
  919. // exactly the edges this feature added.
  920. ...(ref.original.referenceKind === 'function_ref' ? { fnRef: true } : {}),
  921. },
  922. };
  923. });
  924. }
  925. /**
  926. * Split resolved refs into rows deletable by id and hand-built refs that
  927. * must fall back to the key-tuple delete. Rows loaded from the database
  928. * carry their row id and are deleted by exactly that id; the key tuple
  929. * omits line/col, so it also removes SIBLING rows — the same caller calling
  930. * the same callee at other lines — that a later batch hadn't attempted yet:
  931. * when a batch boundary split a caller's same-named call sites, the later
  932. * sites' edges were silently never created (#1269).
  933. */
  934. private static partitionResolvedCleanup(resolved: ResolvedRef[]): {
  935. rowIds: number[];
  936. legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>;
  937. } {
  938. const rowIds: number[] = [];
  939. const legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }> = [];
  940. for (const r of resolved) {
  941. if (r.original.rowId != null) rowIds.push(r.original.rowId);
  942. else legacyKeys.push({
  943. fromNodeId: r.original.fromNodeId,
  944. referenceName: r.original.referenceName,
  945. referenceKind: r.original.referenceKind,
  946. });
  947. }
  948. return { rowIds, legacyKeys };
  949. }
  950. /**
  951. * Same row-id precision for parking unresolvable refs as status='failed'
  952. * (#1240): the key-tuple fallback would flip same-key sibling rows in later
  953. * batches to 'failed' before they were ever attempted, and resolution
  954. * outcome can differ per call site (receiver-type inference reads the
  955. * ref's line), so a sibling must not inherit this row's failure (#1269).
  956. */
  957. private static partitionFailedCleanup(unresolved: UnresolvedRef[]): {
  958. byRowId: Array<{ rowId: number; referenceName: string }>;
  959. legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>;
  960. } {
  961. const byRowId: Array<{ rowId: number; referenceName: string }> = [];
  962. const legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }> = [];
  963. for (const r of unresolved) {
  964. if (r.rowId != null) byRowId.push({ rowId: r.rowId, referenceName: r.referenceName });
  965. else legacyKeys.push({
  966. fromNodeId: r.fromNodeId,
  967. referenceName: r.referenceName,
  968. referenceKind: r.referenceKind,
  969. });
  970. }
  971. return { byRowId, legacyKeys };
  972. }
  973. /**
  974. * Resolve and persist edges to database
  975. */
  976. resolveAndPersist(
  977. unresolvedRefs: UnresolvedReference[],
  978. onProgress?: (current: number, total: number) => void
  979. ): ResolutionResult {
  980. const result = this.resolveAll(unresolvedRefs, onProgress);
  981. // Create edges from resolved references
  982. const edges = this.createEdges(result.resolved);
  983. // Insert edges into database
  984. if (edges.length > 0) {
  985. this.queries.insertEdges(edges);
  986. }
  987. // Clean up resolved refs from unresolved_refs table so metrics are accurate
  988. if (result.resolved.length > 0) {
  989. const { rowIds, legacyKeys } = ReferenceResolver.partitionResolvedCleanup(result.resolved);
  990. this.queries.deleteReferencesByRowIds(rowIds);
  991. this.queries.deleteSpecificResolvedReferences(legacyKeys);
  992. }
  993. // Park unresolvable refs as status='failed' — parity with
  994. // resolveAndPersistBatched. Deleting them was wrong (#1240): a ref whose
  995. // own file never changes is otherwise gone forever, so when a DIFFERENT
  996. // file later gains the export/symbol that would satisfy it, no sync can
  997. // recreate the edge — only a full re-index. Failed rows are excluded from
  998. // the pending readers, which preserves the #1187 orphan sweep's
  999. // invariant in status form: after a COMPLETED pass nothing it processed
  1000. // is still 'pending', so any pending row at rest belongs to an
  1001. // interrupted run and the sweep can key off the pending count.
  1002. if (result.unresolved.length > 0) {
  1003. const { byRowId, legacyKeys } = ReferenceResolver.partitionFailedCleanup(result.unresolved);
  1004. this.queries.markReferencesFailedByRowIds(byRowId);
  1005. this.queries.markReferencesFailed(legacyKeys);
  1006. }
  1007. return result;
  1008. }
  1009. /**
  1010. * Yielding counterpart of {@link resolveAndPersist} for a caller-supplied
  1011. * ref list — used by sync's failed-ref retry pass (#1240). Same persistence
  1012. * semantics: resolved refs become edges and their rows are deleted;
  1013. * still-unresolvable refs are (re-)marked failed (a no-op for rows already
  1014. * in that status). Yields per-ref because sync can run on the daemon's
  1015. * liveness-watchdog thread (#850/#1091) and a retry set is unbounded when
  1016. * a large edit lands many popular symbol names at once.
  1017. */
  1018. async resolveAndPersistListYielding(refs: UnresolvedReference[]): Promise<ResolutionResult> {
  1019. const maybeYield = createYielder();
  1020. const result = await this.resolveBatchYielding(refs, maybeYield);
  1021. const PERSIST_CHUNK = 1000;
  1022. const edges = this.createEdges(result.resolved);
  1023. for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
  1024. this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
  1025. await maybeYield();
  1026. }
  1027. const resolvedCleanup = ReferenceResolver.partitionResolvedCleanup(result.resolved);
  1028. for (let i = 0; i < resolvedCleanup.rowIds.length; i += PERSIST_CHUNK) {
  1029. this.queries.deleteReferencesByRowIds(resolvedCleanup.rowIds.slice(i, i + PERSIST_CHUNK));
  1030. await maybeYield();
  1031. }
  1032. for (let i = 0; i < resolvedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
  1033. this.queries.deleteSpecificResolvedReferences(resolvedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
  1034. await maybeYield();
  1035. }
  1036. const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved);
  1037. for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) {
  1038. this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK));
  1039. await maybeYield();
  1040. }
  1041. for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
  1042. this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
  1043. await maybeYield();
  1044. }
  1045. return result;
  1046. }
  1047. /**
  1048. * Second resolution pass for chained static-factory / fluent calls whose
  1049. * chained method is defined on a SUPERTYPE the receiver's type conforms to —
  1050. * a protocol-extension / inherited / default-interface method (#750). The
  1051. * first pass can't resolve these because `implements`/`extends` edges aren't
  1052. * built yet; this runs AFTER edges are persisted, so `context.getSupertypes`
  1053. * (and the conformance fallback in resolveMethodOnType) can walk them.
  1054. *
  1055. * Operates only on the leftover unresolved refs that have the `inner().method`
  1056. * chain shape, for the dotted-chain languages — a small set — and is idempotent
  1057. * (re-resolving an already-resolved ref is a no-op since it's been deleted).
  1058. * Returns the number of newly-created edges.
  1059. */
  1060. async resolveChainedCallsViaConformance(): Promise<number> {
  1061. const deferred = this.deferredChainRefs;
  1062. this.deferredChainRefs = [];
  1063. if (deferred.length === 0) return 0;
  1064. // Read fresh edges (the main pass built the implements/extends edges after
  1065. // these refs were deferred). matchDottedCallChain now resolves a method on a
  1066. // supertype via context.getSupertypes -> resolveMethodOnType's conformance walk.
  1067. this.clearCaches();
  1068. // This post-pass runs synchronously on the indexer's main thread; yield
  1069. // periodically so the #850 liveness watchdog heartbeat can fire on a repo
  1070. // with many deferred chained calls (#1091).
  1071. const maybeYield = createYielder();
  1072. const resolved: ResolvedRef[] = [];
  1073. for (const ref of deferred) {
  1074. // PHP `this->prop.method` resolves via matchMethodCall (declared-type
  1075. // inference + resolveMethodOnType conformance walk); `::`-receiver
  1076. // languages (Rust) split on `::` (matchScopedCallChain); other
  1077. // dotted-receiver languages on `.` (matchDottedCallChain).
  1078. const chainMatch = (ref.language === 'php' && PHP_PROP_SHAPE.test(ref.referenceName))
  1079. ? matchMethodCall(ref, this.context)
  1080. : SCOPED_CHAIN_LANGUAGES.has(ref.language)
  1081. ? matchScopedCallChain(ref, this.context)
  1082. : matchDottedCallChain(ref, this.context);
  1083. const match = this.gateLanguage(chainMatch, ref);
  1084. if (match) resolved.push(match);
  1085. await maybeYield();
  1086. }
  1087. if (resolved.length === 0) return 0;
  1088. const edges = this.createEdges(resolved);
  1089. if (edges.length > 0) {
  1090. this.queries.insertEdges(edges);
  1091. this.clearCaches();
  1092. }
  1093. return edges.length;
  1094. }
  1095. /**
  1096. * Resolve one batch with a yield checkpoint between EVERY ref so the #850
  1097. * liveness heartbeat can fire on a slow/dense batch (#1091). The checkpoint
  1098. * granularity is per-ref — not per-N-refs — because per-ref cost is unbounded
  1099. * in the worst case (a collision-heavy method name whose candidate set misses
  1100. * the LRU re-fetches tens of thousands of rows): any fixed N multiplies that
  1101. * worst case into the watchdog window, which is how v1.2.0 still got killed
  1102. * at "Resolving refs" on large Java monorepos (#1122). `maybeYield()` is a
  1103. * ~ns time check when under budget, so per-ref checkpoints cost nothing.
  1104. * Behaviourally identical to `resolveAll(batch)`: `warmCaches()` is
  1105. * idempotent (guarded) and `resolveOne` is independent per ref, so yielding
  1106. * between refs changes only timing, never which edges get created.
  1107. */
  1108. private async resolveBatchYielding(
  1109. batch: UnresolvedReference[],
  1110. maybeYield: MaybeYield
  1111. ): Promise<ResolutionResult> {
  1112. this.warmCaches();
  1113. const resolved: ResolvedRef[] = [];
  1114. const unresolved: UnresolvedRef[] = [];
  1115. const byMethod: Record<string, number> = {};
  1116. for (const raw of batch) {
  1117. const ref: UnresolvedRef = {
  1118. fromNodeId: raw.fromNodeId,
  1119. referenceName: raw.referenceName,
  1120. referenceKind: raw.referenceKind,
  1121. line: raw.line,
  1122. column: raw.column,
  1123. filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId),
  1124. language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId),
  1125. rowId: raw.rowId,
  1126. };
  1127. const result = this.resolveOne(ref);
  1128. if (result) {
  1129. resolved.push(result);
  1130. byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
  1131. } else {
  1132. unresolved.push(ref);
  1133. }
  1134. // Fast-path the per-ref yield check: awaiting the async no-op costs a
  1135. // microtask hop per ref, which dominates at ~10⁵ refs (see MaybeYield).
  1136. const y = maybeYield();
  1137. if (y) await y;
  1138. }
  1139. return {
  1140. resolved,
  1141. unresolved,
  1142. stats: {
  1143. total: batch.length,
  1144. resolved: resolved.length,
  1145. unresolved: unresolved.length,
  1146. byMethod,
  1147. },
  1148. };
  1149. }
  1150. /**
  1151. * Resolve a list of refs and return everything the ADMISSION side needs to
  1152. * persist the outcome: resolutions, failures, the deferred post-pass refs
  1153. * this run produced (drained, so the caller owns routing them), and stats.
  1154. * This is the resolver-worker entry point — it runs the exact per-ref loop
  1155. * of resolveBatchYielding, minus the main-thread yields (worker threads have
  1156. * no watchdog heartbeat to starve). Results are in input order.
  1157. */
  1158. resolveListForAdmission(refs: UnresolvedReference[]): {
  1159. resolved: ResolvedRef[];
  1160. unresolved: UnresolvedRef[];
  1161. deferredChain: UnresolvedRef[];
  1162. deferredThisMember: UnresolvedRef[];
  1163. byMethod: Record<string, number>;
  1164. } {
  1165. this.warmCaches();
  1166. const resolved: ResolvedRef[] = [];
  1167. const unresolved: UnresolvedRef[] = [];
  1168. const byMethod: Record<string, number> = {};
  1169. for (const raw of refs) {
  1170. const ref: UnresolvedRef = {
  1171. fromNodeId: raw.fromNodeId,
  1172. referenceName: raw.referenceName,
  1173. referenceKind: raw.referenceKind,
  1174. line: raw.line,
  1175. column: raw.column,
  1176. filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId),
  1177. language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId),
  1178. rowId: raw.rowId,
  1179. };
  1180. const result = this.resolveOne(ref);
  1181. if (result) {
  1182. resolved.push(result);
  1183. byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
  1184. } else {
  1185. unresolved.push(ref);
  1186. }
  1187. }
  1188. return {
  1189. resolved,
  1190. unresolved,
  1191. deferredChain: this.deferredChainRefs.splice(0),
  1192. deferredThisMember: this.deferredThisMemberRefs.splice(0),
  1193. byMethod,
  1194. };
  1195. }
  1196. /**
  1197. * The resolver's live ResolutionContext — resolver-pool workers use it to
  1198. * run synthesis passes against their own read-only connection.
  1199. */
  1200. getResolutionContext(): ResolutionContext {
  1201. return this.context;
  1202. }
  1203. /**
  1204. * Re-queue deferred post-pass refs produced by resolver workers, preserving
  1205. * their admission order so resolveChainedCallsViaConformance /
  1206. * resolveDeferredThisMemberRefs process them exactly as the sequential path
  1207. * would have.
  1208. */
  1209. appendDeferredFromWorkers(deferredChain: UnresolvedRef[], deferredThisMember: UnresolvedRef[]): void {
  1210. this.deferredChainRefs.push(...deferredChain);
  1211. this.deferredThisMemberRefs.push(...deferredThisMember);
  1212. }
  1213. /**
  1214. * Resolve and persist in batches to keep memory bounded.
  1215. * Processes unresolved references in chunks, persisting edges and cleaning
  1216. * up resolved refs after each batch to avoid accumulating large arrays.
  1217. */
  1218. async resolveAndPersistBatched(
  1219. onProgress?: (current: number, total: number) => void,
  1220. batchSize: number = 5000,
  1221. onSynthesisProgress?: (done: number, total: number) => void,
  1222. // When provided, big batches fan out across a read-only resolver-worker
  1223. // pool with results admitted in canonical order (see resolver-pool.ts).
  1224. // Sequential fallback on any pool failure. CODEGRAPH_NO_PARALLEL_RESOLVE=1
  1225. // disables entirely.
  1226. parallel?: { dbPath: string }
  1227. ): Promise<ResolutionResult> {
  1228. // Resolution runs on the indexer's MAIN thread, and the #850 liveness
  1229. // watchdog SIGKILLs a process whose event loop stalls past its window (60s
  1230. // by default). A single dense batch's resolveAll — or the synthesis pass
  1231. // below — can exceed that on a large repo, killing a VALID in-progress index
  1232. // (#1091). A shared yielder lets both give the watchdog heartbeat a regular
  1233. // window to fire; see ./cooperative-yield.
  1234. const maybeYield = createYielder();
  1235. await this.warmCachesYielding(maybeYield);
  1236. const total = this.queries.getUnresolvedReferencesCount();
  1237. let processed = 0;
  1238. const aggregateStats = {
  1239. total: 0,
  1240. resolved: 0,
  1241. unresolved: 0,
  1242. byMethod: {} as Record<string, number>,
  1243. };
  1244. // Parallel pool, started immediately but never awaited up front: early
  1245. // batches run sequentially while the workers boot (module load + readonly
  1246. // DB open + framework detect + cache warm ≈ hundreds of ms), and the loop
  1247. // switches to fan-out the moment the pool reports ready — so pool boot
  1248. // costs zero wall-clock. Any failure downgrades to sequential permanently.
  1249. let pool: ResolverPool | null = null;
  1250. let poolReady = false;
  1251. const tPoolStart = Date.now();
  1252. if (parallel && total >= minRefsForPool()) {
  1253. pool = ResolverPool.tryCreate(parallel.dbPath, this.projectRoot);
  1254. pool?.ready().then(
  1255. () => {
  1256. poolReady = true;
  1257. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] pool ready after ${Date.now() - tPoolStart}ms`);
  1258. },
  1259. () => { void pool?.destroy().catch(() => undefined); pool = null; }
  1260. );
  1261. }
  1262. // Process in PIPELINED batches (double-buffer). The enumeration is the
  1263. // head of the pending set in rowid order; every ref a persisted batch
  1264. // processed leaves the pending set (resolved rows are deleted,
  1265. // unresolvable ones flip to status='failed'), shifting the remaining
  1266. // pending rows forward.
  1267. let prevRemaining = Number.POSITIVE_INFINITY;
  1268. // Fan-out result of ResolverPool.resolveBatch, settled (never rejecting)
  1269. // so a fan-out begun before the previous batch's persist can't produce an
  1270. // unhandled rejection while it waits to be awaited.
  1271. type PoolSettled =
  1272. | { ok: true; out: Awaited<ReturnType<ResolverPool['resolveBatch']>> }
  1273. | { ok: false; err: unknown };
  1274. type InFlight = { mode: 'pool'; settled: Promise<PoolSettled> } | { mode: 'seq' };
  1275. // Begin one batch: fan out to the pool when it's ready and the batch is
  1276. // big enough — workers then resolve batch k+1 WHILE the main thread
  1277. // persists batch k (persist measured at ~58% of resolution wall on a
  1278. // 255k-ref repo, all of it previously spent with the pool idle).
  1279. // Sequential batches stay lazy: they run on the main thread at settle
  1280. // time, where an early start would only contend with the persist.
  1281. const beginBatch = (batch: UnresolvedReference[]): InFlight => {
  1282. if (pool && poolReady && ResolverPool.worthParallel(batch.length)) {
  1283. return {
  1284. mode: 'pool',
  1285. settled: pool.resolveBatch(batch).then(
  1286. (out) => ({ ok: true as const, out }),
  1287. (err: unknown) => ({ ok: false as const, err })
  1288. ),
  1289. };
  1290. }
  1291. return { mode: 'seq' };
  1292. };
  1293. // Settle an in-flight batch to a ResolutionResult. Deferred post-pass refs
  1294. // are appended HERE, in loop order — never inside the fan-out promise — so
  1295. // admission order stays exactly the sequential order even while a later
  1296. // batch resolves concurrently. A pool failure downgrades to sequential
  1297. // permanently and re-resolves this batch on the main thread.
  1298. const settleBatch = async (
  1299. inFlight: InFlight,
  1300. batch: UnresolvedReference[]
  1301. ): Promise<ResolutionResult> => {
  1302. if (inFlight.mode === 'pool') {
  1303. const settled = await inFlight.settled;
  1304. if (settled.ok) {
  1305. this.appendDeferredFromWorkers(settled.out.deferredChain, settled.out.deferredThisMember);
  1306. return {
  1307. resolved: settled.out.resolved,
  1308. unresolved: settled.out.unresolved,
  1309. stats: {
  1310. total: batch.length,
  1311. resolved: settled.out.resolved.length,
  1312. unresolved: settled.out.unresolved.length,
  1313. byMethod: settled.out.byMethod,
  1314. },
  1315. };
  1316. }
  1317. logDebug('Parallel resolution failed; falling back to sequential', {
  1318. error: settled.err instanceof Error ? settled.err.message : String(settled.err),
  1319. });
  1320. if (pool) await pool.destroy().catch(() => undefined);
  1321. pool = null;
  1322. }
  1323. return this.resolveBatchYielding(batch, maybeYield);
  1324. };
  1325. try {
  1326. let batch = this.queries.getUnresolvedReferencesBatch(0, batchSize);
  1327. let inFlight: InFlight | null = batch.length > 0 ? beginBatch(batch) : null;
  1328. while (batch.length > 0 && inFlight) {
  1329. // Prefetch the NEXT batch before this one persists: this batch's rows
  1330. // are still pending (nothing has mutated the table since they were
  1331. // read), so skipping exactly batch.length rows in the same rowid
  1332. // enumeration yields the following batch.
  1333. const nextBatch = this.queries.getUnresolvedReferencesBatch(batch.length, batchSize);
  1334. const tBatch = Date.now();
  1335. const result = await settleBatch(inFlight, batch);
  1336. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch ${inFlight.mode}: ${batch.length} refs in ${Date.now() - tBatch}ms`);
  1337. // Persist in bounded sub-transactions with yields between: a whole
  1338. // batch's edge insert / keyed deletes are otherwise one solid
  1339. // synchronous span each on a multi-GB index, sitting BETWEEN the
  1340. // per-ref yields — the last unyielded stretch of the resolution loop.
  1341. // Crash semantics are unchanged (already several transactions): edges
  1342. // land before their refs are deleted, so a kill mid-way re-resolves
  1343. // the remainder idempotently on the next run/sweep (#1187).
  1344. const PERSIST_CHUNK = 1000;
  1345. const tPersist = Date.now();
  1346. // Persist edges BEFORE fanning out the next batch: later batches read
  1347. // this batch's edges — resolveMethodOnType walks supertype chains over
  1348. // `extends`/`implements` edges that earlier batches resolved, so a
  1349. // receiver typed as a subclass only reaches a method declared on its
  1350. // base class if those edges are visible. (Validated on dubbo: fanning
  1351. // out first downgraded exactly those supertype-method resolutions from
  1352. // the 0.9 typed-receiver path to the 0.65 word-overlap fallback.)
  1353. const edges = this.createEdges(result.resolved);
  1354. for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
  1355. this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
  1356. await maybeYield();
  1357. }
  1358. // NOW fan the next batch out — workers see exactly the edge state the
  1359. // sequential baseline would (every batch ≤ this one committed), while
  1360. // the main thread spends the REST of the persist (ref deletes + failed
  1361. // parking below) overlapped with their resolution — the double-buffer.
  1362. const nextInFlight = nextBatch.length > 0 ? beginBatch(nextBatch) : null;
  1363. // Clean up resolved refs so they don't appear in the next batch —
  1364. // by row id, so a same-key sibling ref in a LATER batch (same caller
  1365. // calling the same callee at another line) is left pending for its own
  1366. // attempt instead of being swept out with this batch's rows (#1269).
  1367. const resolvedCleanup = ReferenceResolver.partitionResolvedCleanup(result.resolved);
  1368. for (let i = 0; i < resolvedCleanup.rowIds.length; i += PERSIST_CHUNK) {
  1369. this.queries.deleteReferencesByRowIds(resolvedCleanup.rowIds.slice(i, i + PERSIST_CHUNK));
  1370. await maybeYield();
  1371. }
  1372. for (let i = 0; i < resolvedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
  1373. this.queries.deleteSpecificResolvedReferences(resolvedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
  1374. await maybeYield();
  1375. }
  1376. // Park unresolvable refs from this batch as status='failed' so they
  1377. // leave the pending set (the batch reader and non-progress guard below
  1378. // only see pending rows) but stay retryable when a later sync adds a
  1379. // symbol that could satisfy them (#1240).
  1380. const failedCleanup = ReferenceResolver.partitionFailedCleanup(result.unresolved);
  1381. for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) {
  1382. this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK));
  1383. await maybeYield();
  1384. }
  1385. for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
  1386. this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
  1387. await maybeYield();
  1388. }
  1389. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch persist: ${Date.now() - tPersist}ms`);
  1390. // Aggregate stats
  1391. aggregateStats.total += result.stats.total;
  1392. aggregateStats.resolved += result.stats.resolved;
  1393. aggregateStats.unresolved += result.stats.unresolved;
  1394. for (const [method, count] of Object.entries(result.stats.byMethod)) {
  1395. aggregateStats.byMethod[method] = (aggregateStats.byMethod[method] || 0) + count;
  1396. }
  1397. processed += batch.length;
  1398. onProgress?.(processed, total);
  1399. // Yield so progress UI can render between batches
  1400. await new Promise(resolve => setImmediate(resolve));
  1401. // NOTE: there used to be an extra early break here when a batch resolved
  1402. // nothing (`result.unresolved.length === batch.length`). That was wrong:
  1403. // an all-unresolvable batch still DELETES its rows (progress), yet the
  1404. // break abandoned every batch after it in the same run — on a repo whose
  1405. // first 5000 refs are all external/stdlib calls, resolution stopped at
  1406. // batch one and left the rest of the table as permanent orphans (#1187).
  1407. // The count-based guard below catches the true no-progress case.
  1408. // Non-progress guard (defense-in-depth). Each iteration enumerates from
  1409. // the head of the pending set, so the PENDING population MUST shrink
  1410. // every iteration — resolved refs are deleted and unresolvable ones are
  1411. // marked failed above, and both leave the pending set the batch reader
  1412. // sees. If it didn't shrink, a resolver returned a match whose
  1413. // `original.referenceName` differs from the stored row, so the keyed
  1414. // delete/update no-ops, and we'd re-read + re-resolve + re-insert the
  1415. // same rows forever (the runaway that grew a 99-file repo to 5M edges /
  1416. // 1.4 GB before the Go-fallback fix). Stop rather than grow the graph
  1417. // without bound. (An in-flight prefetched batch is abandoned unsettled —
  1418. // fan-out has no side effects until settleBatch appends its results.)
  1419. const remaining = this.queries.getUnresolvedReferencesCount();
  1420. if (remaining >= prevRemaining) break;
  1421. prevRemaining = remaining;
  1422. // Advance the pipeline: the prefetched batch (already fanned out when
  1423. // the pool is on) becomes the current one.
  1424. batch = nextBatch;
  1425. inFlight = nextInFlight;
  1426. }
  1427. // Dynamic-edge synthesis: now that all base `calls` edges are persisted,
  1428. // synthesize observer/callback dispatch edges (dispatcher → registered
  1429. // callbacks) that static parsing leaves out. Best-effort — never fail the
  1430. // index on it. The pool (when it survived resolution) is REUSED to fan the
  1431. // independent passes across its read-only workers — that's why its destroy
  1432. // lives in the finally below, after synthesis, not at the end of the batch
  1433. // loop. See docs/design/callback-edge-synthesis.md.
  1434. const tSynth = Date.now();
  1435. try {
  1436. aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
  1437. this.queries,
  1438. this.context,
  1439. onSynthesisProgress,
  1440. pool
  1441. );
  1442. } catch {
  1443. // synthesis is additive and optional; ignore failures
  1444. }
  1445. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] callback-synthesis: ${Date.now() - tSynth}ms`);
  1446. } finally {
  1447. if (pool) await pool.destroy().catch(() => undefined);
  1448. }
  1449. return {
  1450. resolved: [],
  1451. unresolved: [],
  1452. stats: aggregateStats,
  1453. };
  1454. }
  1455. /**
  1456. * Get detected frameworks
  1457. */
  1458. getDetectedFrameworks(): string[] {
  1459. return this.frameworks.map((f) => f.name);
  1460. }
  1461. /**
  1462. * Check if reference is to a built-in or external symbol
  1463. */
  1464. private isBuiltInOrExternal(ref: UnresolvedRef): boolean {
  1465. const name = ref.referenceName;
  1466. const isJsTs = ref.language === 'typescript' || ref.language === 'javascript'
  1467. || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'arkts';
  1468. // JavaScript/TypeScript built-ins
  1469. if (isJsTs && JS_BUILT_INS.has(name)) {
  1470. return true;
  1471. }
  1472. // ArkTS resource-reference intrinsics — `$r('app.string.x')` /
  1473. // `$rawfile('x.png')` are framework-provided and appear dozens of times
  1474. // per UI file; without this they can resolve to a stray same-named
  1475. // symbol (e.g. a checked-in hvigor wrapper's `$r`).
  1476. if (ref.language === 'arkts' && (name === '$r' || name === '$rawfile')) {
  1477. return true;
  1478. }
  1479. // Common JS/TS library calls (console.log, Math.floor, JSON.parse)
  1480. if (isJsTs && (name.startsWith('console.') || name.startsWith('Math.') || name.startsWith('JSON.'))) {
  1481. return true;
  1482. }
  1483. // React hooks from React itself
  1484. if (isJsTs && REACT_HOOKS.has(name)) {
  1485. return true;
  1486. }
  1487. // Python built-ins (bare calls only — dotted calls like console.print are method calls)
  1488. if (ref.language === 'python' && PYTHON_BUILT_INS.has(name)) {
  1489. return true;
  1490. }
  1491. // Python built-in method calls (e.g., list.extend, dict.update)
  1492. if (ref.language === 'python') {
  1493. const dotIdx = name.indexOf('.');
  1494. if (dotIdx > 0) {
  1495. const receiver = name.substring(0, dotIdx);
  1496. const method = name.substring(dotIdx + 1);
  1497. // Filter calls on built-in types (list.append, dict.update, etc.)
  1498. if (PYTHON_BUILT_IN_TYPES.has(receiver)) {
  1499. return true;
  1500. }
  1501. // Filter built-in methods on non-class receivers
  1502. // (e.g., items.append where items is a local list variable)
  1503. // But allow if the capitalized receiver matches a known codebase class
  1504. if (PYTHON_BUILT_IN_METHODS.has(method)) {
  1505. const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
  1506. if (!this.knownNames?.has(capitalized)) {
  1507. return true;
  1508. }
  1509. }
  1510. }
  1511. // A bare name colliding with a builtin method (index, get, update, count…)
  1512. // is only a builtin when NOTHING in the codebase declares it. A declared
  1513. // symbol with that exact name — e.g. a Flask/FastAPI view `def index()` or
  1514. // `def get()` — is a real reference target. Mirrors the knownNames guard on
  1515. // the dotted branch above; without it, every handler named after a builtin
  1516. // method silently loses its route→handler edge.
  1517. if (PYTHON_BUILT_IN_METHODS.has(name) && !this.knownNames?.has(name)) {
  1518. return true;
  1519. }
  1520. }
  1521. // Go standard library packages — refs like "fmt.Println", "http.ListenAndServe", etc.
  1522. if (ref.language === 'go') {
  1523. const dotIdx = name.indexOf('.');
  1524. if (dotIdx > 0) {
  1525. const pkg = name.substring(0, dotIdx);
  1526. if (GO_STDLIB_PACKAGES.has(pkg)) {
  1527. return true;
  1528. }
  1529. }
  1530. if (GO_BUILT_INS.has(name)) {
  1531. return true;
  1532. }
  1533. }
  1534. // Pascal/Delphi built-ins and standard library units
  1535. if (ref.language === 'pascal') {
  1536. if (PASCAL_UNIT_PREFIXES.some((p) => name.startsWith(p))) {
  1537. return true;
  1538. }
  1539. if (PASCAL_BUILT_INS.has(name)) {
  1540. return true;
  1541. }
  1542. }
  1543. // C/C++ standard library symbols (printf, malloc, std::vector, etc.).
  1544. // Names that collide with user-defined symbols are NOT filtered —
  1545. // C and C++ projects routinely shadow stdlib names (custom allocators
  1546. // define `malloc`/`free`, stream wrappers define `read`/`write`/`open`,
  1547. // containers define `move`/`swap`, logging libs wrap `printf`). Killing
  1548. // those resolutions makes the graph wrong, not cleaner. We only filter
  1549. // when there's no user node with this name — then name-matching would
  1550. // produce zero edges anyway and the filter just short-circuits work.
  1551. if (ref.language === 'c' || ref.language === 'cpp') {
  1552. // C++ std:: namespace prefix — safe to filter unconditionally,
  1553. // since `std::foo` is never a user-defined qualified name in
  1554. // tree-sitter output.
  1555. if (name.startsWith('std::')) return true;
  1556. if (C_BUILT_INS.has(name) || CPP_BUILT_INS.has(name)) {
  1557. return !this.hasAnyPossibleMatch(name);
  1558. }
  1559. }
  1560. return false;
  1561. }
  1562. /**
  1563. * Get file path from node ID
  1564. */
  1565. private getFilePathFromNodeId(nodeId: string): string {
  1566. const node = this.queries.getNodeById(nodeId);
  1567. return node?.filePath || '';
  1568. }
  1569. /**
  1570. * Get language from node ID
  1571. */
  1572. private getLanguageFromNodeId(nodeId: string): UnresolvedRef['language'] {
  1573. const node = this.queries.getNodeById(nodeId);
  1574. return node?.language || 'unknown';
  1575. }
  1576. /**
  1577. * Drop an import/name-strategy resolution that crosses a language family.
  1578. * Two regimes (mirrors `applyLanguageGate`'s candidate filter):
  1579. * - `references` (type usage): STRICT — a `Type.member` static read names a
  1580. * same-family type, never a coincidentally same-named symbol in another
  1581. * language. Drops any non-same-family target.
  1582. * - `imports` (import binding / `#include`): both-known — a C++ `#include
  1583. * "X.h"` must not resolve to a same-named ObjC header on another platform
  1584. * (basename collision), but a singleton-family / SFC language (`vue` →
  1585. * `.ts`) importing across is left alone.
  1586. * Applies to the import (strategy 2) + name-match (strategy 3) results.
  1587. */
  1588. /**
  1589. * Collect the `@using` namespaces in scope for a `.razor`/`.cshtml` file: its
  1590. * own `@using` directives plus every `_Imports.razor` from the file's folder up
  1591. * to the project root (Razor `_Imports` cascade). Cached per file.
  1592. */
  1593. private getRazorUsings(filePath: string): string[] {
  1594. const cached = this.razorUsingsCache.get(filePath);
  1595. if (cached) return cached;
  1596. const usings = new Set<string>();
  1597. const addFrom = (src: string | null): void => {
  1598. if (!src) return;
  1599. for (const m of src.matchAll(/^\s*@using\s+(?:static\s+)?([A-Za-z_][\w.]*)/gm)) usings.add(m[1]!);
  1600. };
  1601. addFrom(this.context.readFile(filePath));
  1602. let dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
  1603. // Walk up to the project root, reading each level's _Imports.razor.
  1604. for (;;) {
  1605. addFrom(this.context.readFile(dir ? `${dir}/_Imports.razor` : '_Imports.razor'));
  1606. if (!dir) break;
  1607. const slash = dir.lastIndexOf('/');
  1608. dir = slash >= 0 ? dir.slice(0, slash) : '';
  1609. }
  1610. const arr = [...usings];
  1611. this.razorUsingsCache.set(filePath, arr);
  1612. return arr;
  1613. }
  1614. /**
  1615. * Resolve a Razor/Blazor simple type ref through the file's `@using`
  1616. * namespaces: `CatalogBrand` + `@using BlazorShared.Models` → the node whose
  1617. * qualified name is `BlazorShared.Models::CatalogBrand`. Only resolves when the
  1618. * `@using` set yields exactly ONE type (otherwise it stays ambiguous and falls
  1619. * through to name-matching).
  1620. */
  1621. private resolveRazorUsing(ref: UnresolvedRef): ResolvedRef | null {
  1622. if (ref.referenceName.includes('.') || ref.referenceName.includes('::')) return null;
  1623. const usings = this.getRazorUsings(ref.filePath);
  1624. if (usings.length === 0) return null;
  1625. const found = new Map<string, Node>();
  1626. for (const ns of usings) {
  1627. for (const cand of this.context.getNodesByQualifiedName(`${ns}::${ref.referenceName}`)) {
  1628. found.set(cand.id, cand);
  1629. }
  1630. }
  1631. if (found.size !== 1) return null;
  1632. const target = found.values().next().value!;
  1633. return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
  1634. }
  1635. /**
  1636. * Resolve a CFML inheritance reference written as a component path (#1152).
  1637. * Two forms exist in real code:
  1638. *
  1639. * - Dotted: `extends="coldbox.system.web.Controller"` — dots are directory
  1640. * separators from the webroot or a CFML mapping. Mappings live in server
  1641. * config / Application.cfc, so the leading segments may not exist in the
  1642. * repo at all (in the coldbox repo itself the path is `system/web/
  1643. * Controller.cfc` — the `coldbox.` root IS the repo). Matched by final
  1644. * segment (the class), corroborated right-to-left against the candidate's
  1645. * parent directories.
  1646. * - Relative: `extends="../base"` / `extends="./base"` (the FW/1 style) —
  1647. * resolved against the referencing file's own directory.
  1648. *
  1649. * Conservative by design: a candidate needs at least one corroborating
  1650. * directory segment (a dotted path whose only same-named class sits in an
  1651. * unrelated directory is almost always an out-of-repo library supertype —
  1652. * mxunit/testbox/coldbox-as-dependency), and a corroboration tie yields no
  1653. * edge. Directory comparison is case-insensitive (CFML path resolution is);
  1654. * the class segment itself is matched exactly, which real code satisfies —
  1655. * dotted paths are written to match the on-disk file name.
  1656. */
  1657. private resolveCfmlComponentPath(ref: UnresolvedRef): ResolvedRef | null {
  1658. const cfmlCandidates = (name: string): Node[] =>
  1659. this.context
  1660. .getNodesByName(name)
  1661. .filter(
  1662. (n) =>
  1663. (n.kind === 'class' || n.kind === 'interface') &&
  1664. (n.language === 'cfml' || n.language === 'cfscript')
  1665. );
  1666. const norm = (p: string): string => p.replace(/\\/g, '/').toLowerCase();
  1667. // Relative-path form: `../base`, `./base`, `sub/thing` — resolve against
  1668. // the referencing file's directory and require an exact (case-insensitive)
  1669. // file match.
  1670. if (ref.referenceName.includes('/')) {
  1671. const rel = ref.referenceName.replace(/\.cfc$/i, '');
  1672. const fromDir = ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
  1673. const parts = [...fromDir];
  1674. for (const seg of rel.split('/')) {
  1675. if (seg === '' || seg === '.') continue;
  1676. if (seg === '..') {
  1677. if (parts.length === 0) return null; // escapes the project root
  1678. parts.pop();
  1679. } else {
  1680. parts.push(seg);
  1681. }
  1682. }
  1683. const wantPath = norm(parts.join('/') + '.cfc');
  1684. const className = parts[parts.length - 1];
  1685. if (!className) return null;
  1686. const target = cfmlCandidates(className).find((c) => norm(c.filePath) === wantPath);
  1687. return target
  1688. ? { original: ref, targetNodeId: target.id, confidence: 0.95, resolvedBy: 'file-path' }
  1689. : null;
  1690. }
  1691. // Dotted form.
  1692. const segments = ref.referenceName.split('.').map((s) => s.trim()).filter(Boolean);
  1693. if (segments.length < 2) return null;
  1694. const className = segments[segments.length - 1]!;
  1695. const dirSegments = segments.slice(0, -1);
  1696. let best: Node | null = null;
  1697. let bestScore = 0;
  1698. let tie = false;
  1699. for (const cand of cfmlCandidates(className)) {
  1700. const dirs = cand.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
  1701. // Count matching directory segments right-to-left: for
  1702. // `coldbox.system.web.Controller` vs `system/web/Controller.cfc`,
  1703. // `web` and `system` match, then the repo root ends the run → score 2.
  1704. let score = 0;
  1705. while (
  1706. score < dirSegments.length &&
  1707. score < dirs.length &&
  1708. dirSegments[dirSegments.length - 1 - score]!.toLowerCase() ===
  1709. dirs[dirs.length - 1 - score]!.toLowerCase()
  1710. ) {
  1711. score++;
  1712. }
  1713. if (score > bestScore) {
  1714. best = cand;
  1715. bestScore = score;
  1716. tie = false;
  1717. } else if (score === bestScore && score > 0) {
  1718. tie = true;
  1719. }
  1720. }
  1721. if (!best || bestScore === 0 || tie) return null;
  1722. return { original: ref, targetNodeId: best.id, confidence: 0.9, resolvedBy: 'qualified-name' };
  1723. }
  1724. /**
  1725. * Resolve a `this.<member>` function-as-value reference (#756/#808) to the
  1726. * ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The
  1727. * registration idiom (`btn.on('click', this.handleClick)`) names a member
  1728. * of the class being defined, so the only valid target shares the
  1729. * from-symbol's qualified-name scope. Function/method targets only — a
  1730. * property (a data field, post-#808 classification) yields no edge — same
  1731. * file required, no fallback of any kind.
  1732. */
  1733. private resolveThisMemberFnRef(ref: UnresolvedRef): ResolvedRef | null {
  1734. const member = ref.referenceName.slice('this.'.length);
  1735. if (!member) return null;
  1736. const fromNode = this.queries.getNodeById(ref.fromNodeId);
  1737. if (!fromNode) return null;
  1738. // A hook declared at class-body level (Ruby `before_action :authenticate`)
  1739. // attributes to the CLASS node itself — its qualified name IS the scope.
  1740. // For members, strip the member segment.
  1741. let classPrefix: string;
  1742. if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
  1743. classPrefix = fromNode.qualifiedName;
  1744. } else {
  1745. const sep = fromNode.qualifiedName.lastIndexOf('::');
  1746. if (sep <= 0) return null; // not inside a class scope
  1747. classPrefix = fromNode.qualifiedName.slice(0, sep);
  1748. }
  1749. const candidates = this.context
  1750. .getNodesByQualifiedName(`${classPrefix}::${member}`)
  1751. .filter(
  1752. (n) =>
  1753. (n.kind === 'function' || n.kind === 'method') &&
  1754. n.filePath === ref.filePath &&
  1755. n.id !== ref.fromNodeId
  1756. );
  1757. if (candidates.length === 0) {
  1758. // Not on the class itself — possibly INHERITED. implements/extends
  1759. // edges don't exist yet in this pass, so retry in the supertype pass
  1760. // (resolveDeferredThisMemberRefs) instead of giving up.
  1761. this.deferredThisMemberRefs.push(ref);
  1762. return null;
  1763. }
  1764. const target = candidates.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
  1765. return {
  1766. original: ref,
  1767. targetNodeId: target.id,
  1768. confidence: 0.95,
  1769. resolvedBy: 'function-ref',
  1770. };
  1771. }
  1772. /**
  1773. * Second pass for `this.<member>` refs whose member wasn't on the enclosing
  1774. * class itself (#808): once implements/extends edges exist, walk the
  1775. * class's supertypes (transitively, depth-capped) and resolve the member on
  1776. * the nearest one that declares it — `this.handleSubmit` registered in a
  1777. * subclass resolves to `FormBase::handleSubmit`. Validated targets only
  1778. * (function/method kind, same language family); no match → no edge.
  1779. * Mirrors resolveChainedCallsViaConformance's lifecycle. Returns the number
  1780. * of newly-created edges.
  1781. */
  1782. async resolveDeferredThisMemberRefs(): Promise<number> {
  1783. const deferred = this.deferredThisMemberRefs;
  1784. this.deferredThisMemberRefs = [];
  1785. if (deferred.length === 0) return 0;
  1786. this.clearCaches();
  1787. // Synchronous main-thread post-pass with a per-ref supertype BFS — yield
  1788. // periodically so the #850 liveness watchdog heartbeat can fire (#1091).
  1789. const maybeYield = createYielder();
  1790. const resolved: ResolvedRef[] = [];
  1791. for (const ref of deferred) {
  1792. await maybeYield();
  1793. const member = ref.referenceName.slice('this.'.length);
  1794. const fromNode = this.queries.getNodeById(ref.fromNodeId);
  1795. if (!fromNode || !member) continue;
  1796. // Class-body-level hooks (Ruby) attribute to the CLASS node itself.
  1797. let className: string;
  1798. if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
  1799. className = fromNode.name;
  1800. } else {
  1801. const sep = fromNode.qualifiedName.lastIndexOf('::');
  1802. if (sep <= 0) continue;
  1803. const classPrefix = fromNode.qualifiedName.slice(0, sep);
  1804. className = classPrefix.includes('::')
  1805. ? classPrefix.slice(classPrefix.lastIndexOf('::') + 2)
  1806. : classPrefix;
  1807. }
  1808. // NODE-anchored BFS up the supertype graph: start from the class node
  1809. // in the ref's own file (never a same-named class elsewhere — rails has
  1810. // a dozen `Engine`s), follow implements/extends EDGES to supertype
  1811. // NODES, and look members up through `contains` edges. No name-based
  1812. // unions anywhere — a name-keyed getSupertypes('Engine') merged every
  1813. // Engine's parents and produced a cross-class wrong edge on rails.
  1814. let frontierNodes = this.context
  1815. .getNodesByName(className)
  1816. .filter(
  1817. (n) =>
  1818. SUPERTYPE_BEARING_KINDS.has(n.kind) &&
  1819. n.filePath === ref.filePath
  1820. );
  1821. if (frontierNodes.length === 0) {
  1822. // The class itself may be declared in another file (partial/reopened
  1823. // classes); fall back to same-family nodes of that name.
  1824. frontierNodes = this.context
  1825. .getNodesByName(className)
  1826. .filter(
  1827. (n) =>
  1828. SUPERTYPE_BEARING_KINDS.has(n.kind) &&
  1829. sameLanguageFamily(n.language, ref.language)
  1830. );
  1831. }
  1832. const seenNodes = new Set<string>(frontierNodes.map((n) => n.id));
  1833. let target: Node | null = null;
  1834. for (let depth = 0; depth < 5 && frontierNodes.length > 0 && !target; depth++) {
  1835. const next: Node[] = [];
  1836. for (const typeNode of frontierNodes) {
  1837. for (const edge of this.queries.getOutgoingEdges(typeNode.id, ['implements', 'extends'])) {
  1838. const superNode = this.queries.getNodeById(edge.target);
  1839. if (!superNode || seenNodes.has(superNode.id)) continue;
  1840. seenNodes.add(superNode.id);
  1841. if (!SUPERTYPE_BEARING_KINDS.has(superNode.kind)) continue;
  1842. // Member lookup anchored on the supertype's contains edges.
  1843. for (const c of this.queries.getOutgoingEdges(superNode.id, ['contains'])) {
  1844. const m = this.queries.getNodeById(c.target);
  1845. if (
  1846. m &&
  1847. m.name === member &&
  1848. (m.kind === 'function' || m.kind === 'method') &&
  1849. sameLanguageFamily(m.language, ref.language)
  1850. ) {
  1851. target = m;
  1852. break;
  1853. }
  1854. }
  1855. if (target) break;
  1856. next.push(superNode);
  1857. }
  1858. if (target) break;
  1859. }
  1860. frontierNodes = next;
  1861. }
  1862. if (target) {
  1863. resolved.push({
  1864. original: ref,
  1865. targetNodeId: target.id,
  1866. confidence: 0.85,
  1867. resolvedBy: 'function-ref',
  1868. });
  1869. }
  1870. }
  1871. if (resolved.length === 0) return 0;
  1872. const edges = this.createEdges(resolved);
  1873. if (edges.length > 0) {
  1874. this.queries.insertEdges(edges);
  1875. this.clearCaches();
  1876. }
  1877. return edges.length;
  1878. }
  1879. private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
  1880. if (!result) return result;
  1881. const tgt = this.getLanguageFromNodeId(result.targetNodeId);
  1882. if (!tgt || !ref.language) return result;
  1883. if ((ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') && !sameLanguageFamily(tgt, ref.language)) return null;
  1884. if (ref.referenceKind === 'imports' && crossesKnownFamily(tgt, ref.language)) return null;
  1885. return result;
  1886. }
  1887. /**
  1888. * Drop a FRAMEWORK-strategy resolution that crosses two *known* language
  1889. * families for a type-usage (`references`) or import-binding (`imports`)
  1890. * edge. The framework strategy is intentionally ungated for cross-language
  1891. * bridges, but those legitimate bridges are either `calls` edges (RN/Expo
  1892. * JS → native) or config↔code edges whose config side (`yaml`/`blade`/…) is
  1893. * not a known programming-language family. A `references`/`imports` edge
  1894. * between two *known* families is always a coincidental name collision — the
  1895. * React/Svelte/Vue PascalCase component resolvers name-match `getNodesByName`
  1896. * without a language check, so a TS `<TestRunner>` ref happily matched a
  1897. * Kotlin `class TestRunner`. Gating only the both-known-cross-family case
  1898. * lets config bridges and `calls` bridges through untouched.
  1899. */
  1900. private gateFrameworkLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
  1901. if (!result) return result;
  1902. if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
  1903. const tgt = this.getLanguageFromNodeId(result.targetNodeId);
  1904. if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
  1905. return result;
  1906. }
  1907. }
  1908. /**
  1909. * Create a reference resolver instance
  1910. */
  1911. export function createResolver(projectRoot: string, queries: QueryBuilder): ReferenceResolver {
  1912. const resolver = new ReferenceResolver(projectRoot, queries);
  1913. resolver.initialize();
  1914. return resolver;
  1915. }