name-matcher.ts 77 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845
  1. /**
  2. * Name Matcher
  3. *
  4. * Handles symbol name matching for reference resolution.
  5. */
  6. import { Language, Node } from '../types';
  7. import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
  8. /**
  9. * Ceiling on how many same-named definitions a FUZZY name-match strategy will
  10. * score. A name defined more times than this is "ubiquitous" — a method/symbol
  11. * re-declared across a vendored theme or SDK (e.g. `init`/`update`/`render` on
  12. * every widget of a committed Metronic theme — #999). No directory-proximity or
  13. * receiver-word-overlap score can reliably pick THE one true target among
  14. * thousands, so the fuzzy strategies (matchByExactName's findBestMatch, and
  15. * matchMethodCall Strategy 3) decline above the ceiling instead of emitting a
  16. * low-confidence, almost-certainly-wrong edge. This also caps their per-ref cost
  17. * at O(ceiling): without it, K same-named refs each scored K candidates — the
  18. * O(K²) blow-up that pinned a core for 15-28 min at "Resolving refs … 94%" on a
  19. * repo vendoring a large JS/TS theme (#999). The PRECISE strategies are
  20. * unaffected: qualified-name, import-based, and class-name (Strategy 1/2)
  21. * resolution all still run and resolve a ubiquitous name when the context names
  22. * its exact target. Real repos top out near ~40 same-named methods, so a normal
  23. * codebase never reaches this; only bulk-vendored code does. Tune via
  24. * `CODEGRAPH_AMBIGUOUS_NAME_CEILING`.
  25. */
  26. const DEFAULT_AMBIGUOUS_NAME_CEILING = 500;
  27. function resolveAmbiguousNameCeiling(): number {
  28. const raw = process.env.CODEGRAPH_AMBIGUOUS_NAME_CEILING;
  29. if (!raw) return DEFAULT_AMBIGUOUS_NAME_CEILING;
  30. const parsed = Number.parseInt(raw, 10);
  31. return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AMBIGUOUS_NAME_CEILING;
  32. }
  33. const AMBIGUOUS_NAME_CEILING = resolveAmbiguousNameCeiling();
  34. /**
  35. * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid")
  36. * by matching the filename against file nodes.
  37. */
  38. export function matchByFilePath(
  39. ref: UnresolvedRef,
  40. context: ResolutionContext
  41. ): ResolvedRef | null {
  42. // Path-like (`a/b.liquid`) OR a bare filename ending in a short extension
  43. // (`Foo.h` — an Objective-C `#import "Foo.h"`, resolved to the header by
  44. // basename). A bare ref WITHOUT an extension is a symbol name, not a file, so
  45. // leave it to the symbol-matching strategies.
  46. if (!ref.referenceName.includes('/') && !/\.[A-Za-z][A-Za-z0-9]{0,3}$/.test(ref.referenceName)) {
  47. return null;
  48. }
  49. // Extract the filename from the path
  50. const fileName = ref.referenceName.split('/').pop();
  51. if (!fileName) return null;
  52. // Search for file nodes with this name
  53. const candidates = context.getNodesByName(fileName);
  54. const fileNodes = candidates.filter(n => n.kind === 'file');
  55. if (fileNodes.length === 0) return null;
  56. // Prefer exact path match on qualified_name
  57. const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName);
  58. if (exactMatch) {
  59. return {
  60. original: ref,
  61. targetNodeId: exactMatch.id,
  62. confidence: 0.95,
  63. resolvedBy: 'file-path',
  64. };
  65. }
  66. // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches
  67. // "src/snippets/foo.liquid"). When several files share the basename — a
  68. // `#include "RNCAsyncStorage.h"` with a same-named header on another platform
  69. // (windows/code/ vs apple/) — prefer the one in the includer's own directory,
  70. // then by directory proximity / same language family. A C/C++ include (and any
  71. // bare-filename import) resolves relative to the including file, not to an
  72. // arbitrary same-named header elsewhere in the tree.
  73. const suffixMatches = fileNodes.filter(
  74. n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName)
  75. );
  76. if (suffixMatches.length > 0) {
  77. return {
  78. original: ref,
  79. targetNodeId: pickClosestFileNode(suffixMatches, ref).id,
  80. confidence: 0.85,
  81. resolvedBy: 'file-path',
  82. };
  83. }
  84. // If only one file node with this name, use it with lower confidence
  85. if (fileNodes.length === 1) {
  86. return {
  87. original: ref,
  88. targetNodeId: fileNodes[0]!.id,
  89. confidence: 0.7,
  90. resolvedBy: 'file-path',
  91. };
  92. }
  93. return null;
  94. }
  95. /**
  96. * Among several file nodes that all match a bare include/import by basename,
  97. * pick the one closest to the referencing file: same directory first, then by
  98. * directory-tree proximity, with the same language family as a tiebreak. A
  99. * C/C++ `#include "X.h"` (and any bare-filename import) resolves relative to the
  100. * including file — not to an arbitrary same-named header on another platform.
  101. */
  102. function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
  103. const dirOf = (p: string): string => {
  104. const i = p.lastIndexOf('/');
  105. return i >= 0 ? p.slice(0, i) : '';
  106. };
  107. const refDir = dirOf(ref.filePath);
  108. const sameDir = candidates.filter((c) => dirOf(c.filePath) === refDir);
  109. const pool = sameDir.length > 0 ? sameDir : candidates;
  110. let best = pool[0]!;
  111. let bestScore = -Infinity;
  112. for (const c of pool) {
  113. const score =
  114. computePathProximity(ref.filePath, c.filePath) +
  115. (sameLanguageFamily(c.language, ref.language) ? 5 : 0);
  116. if (score > bestScore) {
  117. bestScore = score;
  118. best = c;
  119. }
  120. }
  121. return best;
  122. }
  123. /**
  124. * Language families that share a type system / runtime, so a same-language-only
  125. * reference may still resolve across them (a Kotlin `Foo.BAR` can name a Java
  126. * `Foo`). Anything not listed forms its own singleton family.
  127. */
  128. const LANGUAGE_FAMILY: Record<string, string> = {
  129. java: 'jvm', kotlin: 'jvm', scala: 'jvm',
  130. swift: 'apple', objc: 'apple',
  131. typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web',
  132. c: 'c', cpp: 'c',
  133. // Razor/Blazor markup names C# types — same family so `@model Foo` /
  134. // `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
  135. csharp: 'dotnet', razor: 'dotnet',
  136. };
  137. export function sameLanguageFamily(a: string, b: string): boolean {
  138. if (a === b) return true;
  139. const fa = LANGUAGE_FAMILY[a];
  140. return fa !== undefined && fa === LANGUAGE_FAMILY[b];
  141. }
  142. /**
  143. * True when `lang` belongs to a known multi-language family (jvm/apple/web/c).
  144. * Languages not listed (php, python, go, ruby, rust, dart, …) and config
  145. * formats (yaml/xml/blade) form their own singleton families and return
  146. * `false` — used to leave config↔code framework bridges (whose config side is
  147. * never a known programming-language family) out of the cross-family gate.
  148. */
  149. export function isKnownLanguageFamily(lang: string): boolean {
  150. return LANGUAGE_FAMILY[lang] !== undefined;
  151. }
  152. /**
  153. * True when `a` and `b` are two DIFFERENT *known* language families — the
  154. * signature of a coincidental cross-language name collision (a TS `import
  155. * React` matching a Swift `import React`, a C++ `#include "X.h"` matching a
  156. * same-named ObjC header on another platform). The both-*known* test is
  157. * deliberately weaker than {@link sameLanguageFamily}'s negation: a
  158. * single-file-component language that carries its own tag (`vue`/`svelte`)
  159. * importing a `.ts` module, or any singleton-family language (php/go/ruby/…),
  160. * returns `false` here and is left alone.
  161. */
  162. export function crossesKnownFamily(a: string, b: string): boolean {
  163. return isKnownLanguageFamily(a) && isKnownLanguageFamily(b) && !sameLanguageFamily(a, b);
  164. }
  165. /**
  166. * Drop cross-language candidates from a name lookup. Two regimes:
  167. * - `references` (type-usage): a type named in language X resolves to a
  168. * SAME-family type, never a coincidentally same-named symbol in another
  169. * language (the Android `BatteryManager` system class vs a JS one). Strict
  170. * same-family filter — cross-language communication is `calls`, not refs.
  171. * - `imports` (import binding): an `import`/`#include` never crosses two
  172. * KNOWN families (TS `import React` ↮ Swift `import React`). Weaker
  173. * both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives.
  174. */
  175. function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
  176. if (ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') {
  177. return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
  178. }
  179. if (ref.referenceKind === 'imports') {
  180. return candidates.filter((c) => !crossesKnownFamily(c.language, ref.language));
  181. }
  182. return candidates;
  183. }
  184. /**
  185. * Resolve a function-as-value reference (#756) — a function name used as a
  186. * callback/function-pointer value (`register(handler)`, `o->cb = handler`,
  187. * `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed
  188. * for `function_ref` refs: exact name, function/method targets only, same
  189. * language family, same-file first, and cross-file only when the match is
  190. * UNIQUE. No fuzzy fallback, no qualified-name walking — a wrong callback
  191. * edge is worse than none.
  192. */
  193. export function matchFunctionRef(
  194. ref: UnresolvedRef,
  195. context: ResolutionContext
  196. ): ResolvedRef | null {
  197. // `this.<member>` refs are resolved ONLY by the class-scoped resolver in
  198. // resolveOne (resolveThisMemberFnRef) — never by name matching here.
  199. if (ref.referenceName.startsWith('this.')) return null;
  200. // In JS/TS/Python a bare identifier can never be a method value (methods
  201. // are only reachable through a receiver — `this.m` / `self.m` /
  202. // `Cls.m`), so bare fn-refs match FUNCTIONS only. This also sidesteps the
  203. // pre-existing TS quirk of class fields extracting as method-kind nodes,
  204. // which otherwise soaked up local names passed as arguments (excalidraw
  205. // A/B finding; same pattern in vendored docopt.py). Python's `self.m`
  206. // form keeps method targets via its own capture shape. C++ likewise: a
  207. // bare identifier can only be a FREE function (member values need
  208. // `&Cls::method`). PHP string callables name global FUNCTIONS (methods
  209. // need the `[$obj, 'm']` array form, which carries its own shape). Other
  210. // languages keep method targets: C# method groups, Swift/Dart
  211. // implicit-self, Java/Kotlin method references.
  212. const bareFnOnly =
  213. ref.language === 'typescript' || ref.language === 'tsx' ||
  214. ref.language === 'javascript' || ref.language === 'jsx' ||
  215. ref.language === 'cpp' || ref.language === 'python' ||
  216. ref.language === 'php';
  217. // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"):
  218. // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m`
  219. // shape is an explicit member reference). Unique-or-drop like everything else.
  220. if (ref.referenceName.includes('::')) {
  221. const memberName = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2);
  222. const scoped = context
  223. .getNodesByName(memberName)
  224. .filter(
  225. (n) =>
  226. (n.kind === 'function' || n.kind === 'method') &&
  227. sameLanguageFamily(n.language, ref.language) &&
  228. n.id !== ref.fromNodeId &&
  229. (n.qualifiedName === ref.referenceName ||
  230. n.qualifiedName.endsWith(`::${ref.referenceName}`))
  231. );
  232. if (scoped.length === 0) return null;
  233. const sameFileScoped = scoped.filter((n) => n.filePath === ref.filePath);
  234. const pool = sameFileScoped.length > 0 ? sameFileScoped : scoped;
  235. if (sameFileScoped.length === 0 && scoped.length > 1) return null;
  236. const target = pool.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
  237. return {
  238. original: ref,
  239. targetNodeId: target.id,
  240. confidence: 0.9,
  241. resolvedBy: 'function-ref',
  242. };
  243. }
  244. let candidates = context
  245. .getNodesByName(ref.referenceName)
  246. .filter(
  247. (n) =>
  248. (n.kind === 'function' || (!bareFnOnly && n.kind === 'method')) &&
  249. sameLanguageFamily(n.language, ref.language) &&
  250. n.id !== ref.fromNodeId // a function registering itself is not a dependency edge
  251. );
  252. if (candidates.length === 0) return null;
  253. // Swift implicit-self: a bare identifier can name a METHOD only of the
  254. // ENCLOSING type (`Button(action: handleTap)` written inside that type) —
  255. // a same-named method on any OTHER class is a parameter collision
  256. // (Alamofire: a `request` parameter resolving to EventMonitor::request).
  257. // Scope method candidates to the from-symbol's type; top-level code has no
  258. // implicit self, so method targets are excluded there entirely. Free
  259. // functions are unaffected.
  260. if (ref.language === 'swift' && candidates.some((n) => n.kind === 'method')) {
  261. const fromNode = context.getNodeById?.(ref.fromNodeId);
  262. const sep = fromNode ? fromNode.qualifiedName.lastIndexOf('::') : -1;
  263. const classPrefix = fromNode && sep > 0 ? fromNode.qualifiedName.slice(0, sep) : null;
  264. candidates = candidates.filter((n) => {
  265. if (n.kind !== 'method') return true;
  266. if (!classPrefix) return false;
  267. const mSep = n.qualifiedName.lastIndexOf('::');
  268. if (mSep <= 0) return false;
  269. const methodPrefix = n.qualifiedName.slice(0, mSep);
  270. // Accept exact-scope matches plus suffix relationships either way, so
  271. // extension-declared members (`Holder::m`) still match a nested
  272. // from-scope (`Module::Holder::wire`) and vice versa.
  273. return (
  274. methodPrefix === classPrefix ||
  275. methodPrefix.endsWith(`::${classPrefix}`) ||
  276. classPrefix.endsWith(`::${methodPrefix}`)
  277. );
  278. });
  279. if (candidates.length === 0) return null;
  280. }
  281. // Same-file definition wins — the extraction gate guarantees most survivors
  282. // have one, and it's the dominant C pattern (static callback registered in
  283. // a same-file ops struct).
  284. const sameFile = candidates.filter((n) => n.filePath === ref.filePath);
  285. if (sameFile.length > 0) {
  286. // Swift: several same-named METHODS in one file is an API overload family
  287. // (`Session.request(...)` × N), and a bare identifier hitting it is almost
  288. // always a same-named parameter, not a method value (Alamofire A/B
  289. // finding) — refuse rather than guess. A single method (SwiftUI's
  290. // `action: handleTap`) still resolves.
  291. if (
  292. ref.language === 'swift' &&
  293. sameFile.length > 1 &&
  294. sameFile.every((n) => n.kind === 'method')
  295. ) {
  296. return null;
  297. }
  298. // Same-name overloads in one file are the same conceptual symbol; pick
  299. // the first by position for determinism.
  300. const target = sameFile.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
  301. return {
  302. original: ref,
  303. targetNodeId: target.id,
  304. confidence: sameFile.length === 1 ? 0.95 : 0.9,
  305. resolvedBy: 'function-ref',
  306. };
  307. }
  308. // Cross-file (imported names the import resolver didn't already claim):
  309. // only an unambiguous match resolves.
  310. if (candidates.length === 1) {
  311. return {
  312. original: ref,
  313. targetNodeId: candidates[0]!.id,
  314. confidence: 0.8,
  315. resolvedBy: 'function-ref',
  316. };
  317. }
  318. return null;
  319. }
  320. /**
  321. * Try to resolve a reference by exact name match
  322. */
  323. export function matchByExactName(
  324. ref: UnresolvedRef,
  325. context: ResolutionContext
  326. ): ResolvedRef | null {
  327. // `import`-kind nodes are import STATEMENTS, not definitions, so a reference
  328. // resolving to a sibling file's `import` is a meaningless edge — the real
  329. // import→definition resolution is the import resolver's job (resolveViaImport),
  330. // never name-matching here. Excluding them also removes a quadratic blow-up:
  331. // a ubiquitous package (`react`, `@superset-ui/core`, Python `logging`/`typing`)
  332. // is re-declared as an `import` node in every file that imports it, so K
  333. // unresolved import refs each scored K same-named import candidates through
  334. // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on
  335. // large import-heavy (front-end + back-end) repos (#915).
  336. const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
  337. .filter((n) => n.kind !== 'import');
  338. if (candidates.length === 0) {
  339. return null;
  340. }
  341. // If only one match, use it — but penalize cross-language matches
  342. if (candidates.length === 1) {
  343. const isCrossLanguage = candidates[0]!.language !== ref.language;
  344. return {
  345. original: ref,
  346. targetNodeId: candidates[0]!.id,
  347. confidence: isCrossLanguage ? 0.5 : 0.9,
  348. resolvedBy: 'exact-match',
  349. };
  350. }
  351. // Ubiquitous-name ceiling (#999): above it, picking one target among K
  352. // same-named defs by directory proximity is unreliable AND O(K) per ref — the
  353. // quadratic behind the "Resolving refs" wedge on theme/SDK-vendoring repos.
  354. // Decline; the precise strategies (qualified-name, import, class-name) already
  355. // ran. Falls through to fuzzy, which itself only resolves a UNIQUE candidate.
  356. if (candidates.length > AMBIGUOUS_NAME_CEILING) {
  357. return null;
  358. }
  359. // Multiple matches - try to narrow down
  360. const bestMatch = findBestMatch(ref, candidates, context);
  361. if (bestMatch) {
  362. // Lower confidence when the match is from a distant/unrelated module
  363. const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
  364. const confidence = proximity >= 30 ? 0.7 : 0.4;
  365. return {
  366. original: ref,
  367. targetNodeId: bestMatch.id,
  368. confidence,
  369. resolvedBy: 'exact-match',
  370. };
  371. }
  372. return null;
  373. }
  374. /**
  375. * Try to resolve by qualified name
  376. */
  377. export function matchByQualifiedName(
  378. ref: UnresolvedRef,
  379. context: ResolutionContext
  380. ): ResolvedRef | null {
  381. // Check if the reference name looks qualified (contains :: or .)
  382. if (!ref.referenceName.includes('::') && !ref.referenceName.includes('.')) {
  383. return null;
  384. }
  385. const candidates = context.getNodesByQualifiedName(ref.referenceName);
  386. if (candidates.length === 1) {
  387. return {
  388. original: ref,
  389. targetNodeId: candidates[0]!.id,
  390. confidence: 0.95,
  391. resolvedBy: 'qualified-name',
  392. };
  393. }
  394. // Several symbols share this exact qualified name (e.g. `Logger::log` declared
  395. // in two files — an ODR clash or separate translation units): prefer the one
  396. // in the call site's own file before the partial-match fallback below, else
  397. // the first-indexed def wins and a call in `b/svc` targets `a/svc` (#1079).
  398. if (candidates.length > 1) {
  399. const ordered = preferCallSiteFile(candidates, ref.filePath);
  400. if (ordered[0]!.filePath === ref.filePath) {
  401. return {
  402. original: ref,
  403. targetNodeId: ordered[0]!.id,
  404. confidence: 0.95,
  405. resolvedBy: 'qualified-name',
  406. };
  407. }
  408. }
  409. // Try partial qualified name match — again preferring the call site's own
  410. // file when more than one symbol's qualifiedName ends with the reference.
  411. const parts = ref.referenceName.split(/[:.]/);
  412. const lastName = parts[parts.length - 1];
  413. if (lastName) {
  414. const partialCandidates = context
  415. .getNodesByName(lastName)
  416. .filter((candidate) => candidate.qualifiedName.endsWith(ref.referenceName));
  417. const chosen = preferCallSiteFile(partialCandidates, ref.filePath)[0];
  418. if (chosen) {
  419. return {
  420. original: ref,
  421. targetNodeId: chosen.id,
  422. confidence: 0.85,
  423. resolvedBy: 'qualified-name',
  424. };
  425. }
  426. }
  427. return null;
  428. }
  429. /**
  430. * When a symbol name is ambiguous across files, prefer the candidate(s) declared
  431. * in the call site's own file, keeping the rest in their original order (#1079).
  432. * A same-file definition is the strongest language-agnostic signal for which of
  433. * several same-named symbols a call means; without it, resolution collapses onto
  434. * whichever was indexed first, so a call in `b/svc` wrongly targets `a/svc`.
  435. * No-op when there are <2 candidates or none share the call site's file.
  436. */
  437. export function preferCallSiteFile(nodes: Node[], callSiteFile: string): Node[] {
  438. if (nodes.length < 2) return nodes;
  439. const same: Node[] = [];
  440. const other: Node[] = [];
  441. for (const n of nodes) {
  442. if (n.filePath === callSiteFile) same.push(n);
  443. else other.push(n);
  444. }
  445. return same.length ? [...same, ...other] : nodes;
  446. }
  447. // Exported for the precedence unit tests (#1079): they assert the
  448. // preferredFqn → same-file → matches[0] ordering directly.
  449. export function resolveMethodOnType(
  450. typeName: string,
  451. methodName: string,
  452. ref: UnresolvedRef,
  453. context: ResolutionContext,
  454. confidence: number,
  455. resolvedBy: ResolvedRef['resolvedBy'],
  456. /**
  457. * Optional FQN that identifies WHICH class declaration `typeName`
  458. * refers to in the caller's file. When multiple candidates share
  459. * the same qualifiedName (`FooConverter::convert` in both
  460. * `dao/converter/` and `service/converter/`), the FQN's
  461. * file-path-suffix picks the right one — the disambiguation
  462. * signal Java imports carry but the call site doesn't (#314).
  463. */
  464. preferredFqn?: string,
  465. /** Recursion guard for the supertype/conformance walk. */
  466. depth = 0,
  467. ): ResolvedRef | null {
  468. // Look up methods by name and match by qualifiedName ending in
  469. // `<typeName>::<methodName>`. This works whether the method is defined
  470. // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate
  471. // file (`int Foo::bar() { ... }` in foo.cpp while class Foo is in foo.hpp).
  472. // The previous same-file approach missed the latter — the typical C++ layout.
  473. // Prefer the context's per-(type, method) memo: the raw name lookup fetches
  474. // EVERY node sharing the method name — tens of thousands of rows for a
  475. // collision-heavy Java name like `execute` — and re-filtering that per ref
  476. // was a dominant term in the #1122 watchdog kill on large repos. Only the
  477. // ref-independent filter is memoized; per-ref disambiguation stays below.
  478. let matches: Node[];
  479. if (context.getMethodMatches) {
  480. matches = context.getMethodMatches(typeName, methodName, ref.language);
  481. } else {
  482. const methodCandidates = context.getNodesByName(methodName);
  483. const want = `${typeName}::${methodName}`;
  484. matches = [];
  485. for (const m of methodCandidates) {
  486. if (m.kind !== 'method') continue;
  487. if (m.language !== ref.language) continue;
  488. const qn = m.qualifiedName;
  489. if (qn === want || qn.endsWith(`::${want}`)) {
  490. matches.push(m);
  491. }
  492. }
  493. }
  494. if (matches.length === 0) {
  495. // Conformance fallback: the method may be defined on a supertype `typeName`
  496. // extends, or on a protocol / trait it conforms to (e.g. a Swift protocol-
  497. // extension method, a C# default-interface or extension method, a Kotlin
  498. // extension on a supertype). Walk supertypes transitively (depth-capped) via
  499. // the resolved implements/extends edges — empty in the first resolution pass,
  500. // populated in the conformance pass. Still VALIDATED (the method must exist on
  501. // a supertype), so a wrong inference produces no edge.
  502. if (depth < 4 && context.getSupertypes) {
  503. for (const supertype of context.getSupertypes(typeName, ref.language)) {
  504. const via = resolveMethodOnType(
  505. supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1,
  506. );
  507. if (via) return via;
  508. }
  509. }
  510. return null;
  511. }
  512. if (matches.length > 1 && preferredFqn) {
  513. const ext = ref.language === 'kotlin' ? '.kt' : '.java';
  514. const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
  515. const chosen = matches.find((m) => {
  516. const fp = m.filePath.replace(/\\/g, '/');
  517. return fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath);
  518. });
  519. if (chosen) {
  520. return {
  521. original: ref,
  522. targetNodeId: chosen.id,
  523. confidence,
  524. resolvedBy,
  525. };
  526. }
  527. }
  528. // Language-agnostic disambiguation: when several same-named methods survive
  529. // (e.g. two files each declaring `class Logger { void log(); }` — an ODR
  530. // clash, an anonymous-namespace type, or separate translation units), prefer
  531. // the definition in the CALL SITE's own file. Without this, every ambiguous
  532. // call collapses onto the first-indexed definition, so a call in `b/svc.cpp`
  533. // wrongly points at `a/svc.cpp` (#1079). This runs AFTER the `preferredFqn`
  534. // block, so Java/Kotlin import disambiguation — whose target is intentionally
  535. // in ANOTHER file (#314) — is unaffected: that block returns early whenever
  536. // an import FQN pins the class.
  537. const ordered = preferCallSiteFile(matches, ref.filePath);
  538. return {
  539. original: ref,
  540. targetNodeId: ordered[0]!.id,
  541. confidence,
  542. resolvedBy,
  543. };
  544. }
  545. // C++ keywords/control-flow tokens that can appear right before a receiver
  546. // (e.g. `return ptr->m()`) and must NOT be treated as a type.
  547. const CPP_NON_TYPE_TOKENS = new Set([
  548. 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
  549. 'break', 'continue', 'goto', 'throw', 'new', 'delete', 'co_await', 'co_yield',
  550. 'co_return', 'static_cast', 'const_cast', 'dynamic_cast', 'reinterpret_cast',
  551. 'sizeof', 'alignof', 'typeid', 'and', 'or', 'not', 'xor',
  552. ]);
  553. function normalizeCppTypeName(typeName: string): string | null {
  554. const normalized = typeName
  555. .replace(/\b(const|volatile|mutable|typename|class|struct)\b/g, ' ')
  556. .replace(/[&*]+/g, ' ')
  557. .replace(/<[^>]*>/g, ' ')
  558. .replace(/\s+/g, ' ')
  559. .trim();
  560. if (!normalized) return null;
  561. const parts = normalized.split(/::/).filter(Boolean);
  562. const last = parts[parts.length - 1];
  563. if (!last) return null;
  564. if (CPP_NON_TYPE_TOKENS.has(last)) return null;
  565. return last;
  566. }
  567. // Declarator regex: matches `Type receiver`, `Type* receiver`, `Type *receiver`,
  568. // `Type*receiver`, `Type<X> receiver`, etc., REQUIRING a declarator terminator
  569. // (`;`, `=`, `,`, `)`, `[`, `{`, `(`, or end-of-line) after the receiver. The
  570. // terminator rules out uses like `return receiver->m()` where the preceding
  571. // token is a keyword, not a type.
  572. function buildDeclaratorRegex(escapedReceiver: string): RegExp {
  573. return new RegExp(
  574. `([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`,
  575. );
  576. }
  577. function inferCppReceiverType(
  578. receiverName: string,
  579. ref: UnresolvedRef,
  580. context: ResolutionContext,
  581. depth = 0,
  582. ): string | null {
  583. // Per-file lines cache when available — this runs per `receiver->method()`
  584. // ref and re-splitting the file each time is the same quadratic as the
  585. // shared inferrer's (#1122).
  586. const lines = context.getFileLines
  587. ? context.getFileLines(ref.filePath)
  588. : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null);
  589. if (!lines || lines.length === 0) return null;
  590. const callLineIndex = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
  591. const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  592. const receiverPattern = new RegExp(`\\b${escapedReceiver}\\b`);
  593. const declaratorRegex = buildDeclaratorRegex(escapedReceiver);
  594. for (let i = callLineIndex; i >= 0; i--) {
  595. const line = lines[i];
  596. if (!line || !receiverPattern.test(line)) continue;
  597. const declaratorMatch = line.match(declaratorRegex);
  598. if (declaratorMatch) {
  599. const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
  600. if (normalized === 'auto') {
  601. // `auto x = Foo::instance();` — the declared type is deduced; recover it
  602. // from the initializer (call return type / construction) (#645).
  603. const initType = inferCppAutoInitializerType(line, receiverName, ref, context, depth);
  604. if (initType) return initType;
  605. // No usable initializer on this line — keep scanning earlier ones.
  606. } else if (normalized) {
  607. return normalized;
  608. }
  609. }
  610. }
  611. const headerCandidates = [
  612. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.h'),
  613. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hpp'),
  614. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hxx'),
  615. ].filter((candidate, index, arr) => arr.indexOf(candidate) === index && candidate !== ref.filePath);
  616. for (const headerPath of headerCandidates) {
  617. if (!context.fileExists(headerPath)) continue;
  618. const headerLines = context.getFileLines
  619. ? context.getFileLines(headerPath)
  620. : (context.readFile(headerPath)?.split(/\r?\n/) ?? null);
  621. if (!headerLines) continue;
  622. for (const line of headerLines) {
  623. if (!receiverPattern.test(line)) continue;
  624. const declaratorMatch = line.match(declaratorRegex);
  625. if (!declaratorMatch) continue;
  626. const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
  627. if (normalized && normalized !== 'auto') return normalized;
  628. }
  629. }
  630. return null;
  631. }
  632. /**
  633. * Last `::`-separated segment of a (possibly namespace-qualified) C++ name.
  634. */
  635. function cppLastSegment(name: string): string {
  636. const parts = name.split('::').filter(Boolean);
  637. return parts[parts.length - 1] ?? name;
  638. }
  639. /**
  640. * Return type captured at extraction for `Class::method` (or a free function),
  641. * read off the indexed node's `returnType` — used by the C++ (#645) and PHP
  642. * (#608) chained-call resolvers. Language-filtered. Null when not indexed or no
  643. * return type was recorded (a `void`/primitive return).
  644. */
  645. function lookupCalleeReturnType(
  646. callee: string,
  647. ref: UnresolvedRef,
  648. context: ResolutionContext,
  649. ): string | null {
  650. let method = callee;
  651. let cls: string | null = null;
  652. if (callee.includes('::')) {
  653. const parts = callee.split('::').filter(Boolean);
  654. method = parts[parts.length - 1] ?? callee;
  655. cls = parts.slice(0, -1).join('::');
  656. }
  657. const candidates = context.getNodesByName(method).filter(
  658. (n) =>
  659. (n.kind === 'method' || n.kind === 'function') &&
  660. n.language === ref.language &&
  661. !!n.returnType,
  662. );
  663. if (cls) {
  664. const want = `${cls}::${method}`;
  665. // The call site may name the class with MORE namespace qualification than
  666. // the stored node (`details::registry::instance` at the call vs
  667. // `registry::instance` on the node — the receiver type only carries the
  668. // immediate class), or LESS. Accept an exact match or either being a
  669. // namespace-suffix of the other; the shared `::<class>::<method>` tail keeps
  670. // it specific.
  671. const m = candidates.find(
  672. (n) =>
  673. n.qualifiedName === want ||
  674. n.qualifiedName.endsWith(`::${want}`) ||
  675. want.endsWith(`::${n.qualifiedName}`),
  676. );
  677. return m?.returnType ?? null;
  678. }
  679. return candidates.find((n) => n.kind === 'function')?.returnType ?? null;
  680. }
  681. /** Does the graph contain a class/struct named `name`'s last segment? */
  682. function cppClassExists(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
  683. const last = cppLastSegment(name);
  684. return context
  685. .getNodesByName(last)
  686. .some((n) => (n.kind === 'class' || n.kind === 'struct') && n.language === ref.language);
  687. }
  688. /**
  689. * Infer the class produced by a C++ call/construction expression, using return
  690. * types captured at extraction (#645). Handles, in order:
  691. * - `make_unique<T>()` / `make_shared<T>()` → T
  692. * - single-level member call `recv.method()` → recv's type, then method's return
  693. * - `Class::method()` / free `func()` → the callee's recorded return type
  694. * - direct construction `Type()` / `ns::Type()` → Type
  695. * Returns null when undeterminable. Callers MUST still validate the outer method
  696. * exists on the result before creating an edge, so a wrong guess stays silent.
  697. */
  698. function resolveCppCallResultType(
  699. inner: string,
  700. ref: UnresolvedRef,
  701. context: ResolutionContext,
  702. depth = 0,
  703. ): string | null {
  704. if (depth > 3) return null; // guard against pathological mutual recursion
  705. const expr = inner.trim();
  706. const make = expr.match(/(?:^|::)(?:make_unique|make_shared)\s*<\s*([A-Za-z_]\w*)/);
  707. if (make) return make[1] ?? null;
  708. // Single-level member call `recv.method` (the `manager.view().render()` shape).
  709. const dotIdx = expr.lastIndexOf('.');
  710. if (dotIdx > 0) {
  711. const recv = expr.slice(0, dotIdx);
  712. const method = expr.slice(dotIdx + 1);
  713. if (recv.includes('.') || recv.includes('(') || recv.includes('::')) return null; // single level only
  714. const recvType = inferCppReceiverType(recv, ref, context, depth + 1);
  715. if (!recvType) return null;
  716. return lookupCalleeReturnType(`${recvType}::${method}`, ref, context);
  717. }
  718. const ret = lookupCalleeReturnType(expr, ref, context);
  719. if (ret) return ret;
  720. // Direct construction — the callee itself names a class/struct.
  721. if (cppClassExists(expr, ref, context)) return cppLastSegment(expr);
  722. return null;
  723. }
  724. /**
  725. * Recover the type of an `auto`-declared local from its initializer on the
  726. * declaration line — `auto x = Foo::instance();`, `auto w = make_unique<W>();`,
  727. * `auto p = new W();`, `auto w = Widget();` (#645).
  728. */
  729. function inferCppAutoInitializerType(
  730. line: string,
  731. receiverName: string,
  732. ref: UnresolvedRef,
  733. context: ResolutionContext,
  734. depth: number,
  735. ): string | null {
  736. const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  737. const m = line.match(new RegExp(`\\b${escaped}\\b\\s*=\\s*([^;]+)`));
  738. if (!m || !m[1]) return null;
  739. const init = m[1].trim();
  740. const neu = init.match(/^new\s+([A-Za-z_][\w:]*)/);
  741. if (neu && neu[1]) return cppLastSegment(neu[1]);
  742. // A call or construction: `Foo(...)`, `A::b(...)`, `make_unique<T>(...)`.
  743. const call = init.match(/^([A-Za-z_][\w:]*(?:\s*<[^>;]*>)?)\s*\(/);
  744. if (call && call[1]) return resolveCppCallResultType(call[1].replace(/\s+/g, ''), ref, context, depth + 1);
  745. return null;
  746. }
  747. /**
  748. * Resolve a C++ chained call whose receiver is itself a call — encoded by the
  749. * extractor as `<innerCallee>().<method>` (#645). The receiver's type is what
  750. * the inner call returns; the outer method is then resolved and VALIDATED on it
  751. * (resolveMethodOnType requires `cls::method` to exist), so a wrong inference
  752. * produces no edge rather than a wrong one.
  753. */
  754. export function matchCppCallChain(
  755. ref: UnresolvedRef,
  756. context: ResolutionContext,
  757. ): ResolvedRef | null {
  758. const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
  759. if (!m || !m[1] || !m[2]) return null;
  760. const cls = resolveCppCallResultType(m[1], ref, context);
  761. if (!cls) return null;
  762. return resolveMethodOnType(cls, m[2], ref, context, 0.85, 'instance-method');
  763. }
  764. /**
  765. * Resolve a `::`-scoped factory chain whose receiver is a scoped/static call —
  766. * PHP `Cls::for($x)->method()` (#608, the per-credential Laravel client idiom) or
  767. * Rust `Foo::new().bar()` (an associated-function call) — both encoded by the
  768. * extractor as `Cls::factory().method`. The receiver's type is what `Cls::factory`
  769. * returns: a `self` marker (PHP `: self`/`: static`, Rust `-> Self`) resolves to
  770. * the factory's own type, a concrete return type to that type. The outer method is
  771. * then resolved and VALIDATED on it (resolveMethodOnType requires the method to
  772. * exist on the type or a supertype it conforms to), so a wrong inference yields no
  773. * edge rather than a wrong one. Shared by the `::`-receiver languages (PHP, Rust).
  774. */
  775. export function matchScopedCallChain(
  776. ref: UnresolvedRef,
  777. context: ResolutionContext,
  778. ): ResolvedRef | null {
  779. const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
  780. if (!m || !m[1] || !m[2]) return null;
  781. const inner = m[1];
  782. const method = m[2];
  783. if (!inner.includes('::')) return null; // only static-factory (`Cls::method`) chains
  784. const factoryClass = inner.slice(0, inner.lastIndexOf('::'));
  785. const ret = lookupCalleeReturnType(inner, ref, context);
  786. if (!ret) return null;
  787. // `self` (the extractor's marker for self/static/$this) → the factory's class.
  788. const resolvedClass = ret === 'self' ? factoryClass : ret;
  789. return resolveMethodOnType(resolvedClass, method, ref, context, 0.85, 'instance-method');
  790. }
  791. /**
  792. * Languages where an unprefixed capitalized call `Foo(args)` constructs the
  793. * class (so a `Foo(args).method()` receiver's type is `Foo`). Java/C# need `new`,
  794. * so a bare `Foo()` there is a method call, not construction — excluded. Scala's
  795. * `Foo(args)` is a case-class / companion `apply`, which conventionally returns
  796. * `Foo` — and resolveMethodOnType validates, so a non-conventional `apply` that
  797. * returns another type simply yields no edge rather than a wrong one. Pascal/Delphi:
  798. * a `TFoo(x)` is a TYPECAST whose result is a `TFoo`, so `TFoo(x).method()` resolves
  799. * the method on `TFoo` — same shape, same validation.
  800. */
  801. const CONSTRUCTS_VIA_BARE_CALL = new Set(['kotlin', 'swift', 'scala', 'dart', 'pascal']);
  802. /**
  803. * Resolve a dotted chained call whose receiver is a static factory / fluent call —
  804. * `Foo.getInstance().bar()`, encoded by the extractor as `Foo.getInstance().bar`
  805. * (#645/#608 mechanism). The receiver's type is what `Foo.getInstance` returns
  806. * (its declared return type); the outer method is then resolved and VALIDATED on
  807. * it (resolveMethodOnType requires `Type::method` to exist), so a wrong inference
  808. * yields no edge rather than a wrong one (e.g. a same-named `bar()` on an
  809. * unrelated class is never matched). Shared by the dot-notation languages
  810. * (Java, Kotlin, C#, Swift) — same receiver shape, same `Class::method` qualified names.
  811. */
  812. export function matchDottedCallChain(
  813. ref: UnresolvedRef,
  814. context: ResolutionContext,
  815. ): ResolvedRef | null {
  816. const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
  817. if (!m || !m[1] || !m[2]) return null;
  818. const inner = m[1]; // `Foo.getInstance`
  819. const method = m[2]; // `bar`
  820. const lastDot = inner.lastIndexOf('.');
  821. if (lastDot <= 0) {
  822. // Go: bare package-level factory FUNCTION `New().method()` — the receiver's
  823. // type is what `New` returns; resolve the method on that.
  824. if (ref.language === 'go') {
  825. const ret = lookupCalleeReturnType(inner, ref, context);
  826. if (ret) {
  827. return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
  828. }
  829. // `inner` isn't a function with a captured return type — typically a
  830. // package-level VARIABLE holding a function value (e.g. gin's `engine()`),
  831. // whose type we can't recover. Fall back to bare-name resolution of the
  832. // method so we don't DROP an edge the un-re-encoded bare path would have
  833. // found. (When `inner` IS a real factory function but the method doesn't
  834. // exist on its return type, `ret` is truthy and we returned no edge above —
  835. // the absent-method safety guarantee is preserved.)
  836. //
  837. // CRITICAL: resolve the TARGET via a synthetic bare-name ref, but return the
  838. // match tied to the ORIGINAL `ref` (referenceName `inner().method`). The
  839. // batched resolver (resolveAndPersistBatched) reads unresolved rows from
  840. // offset 0 every pass and relies on deleteSpecificResolvedReferences —
  841. // keyed on referenceName — to clear each resolved row so the batch empties.
  842. // If we propagated the synthetic ref's bare `method` as `.original`, the
  843. // delete would never match the stored `inner().method` row, the batch would
  844. // never drain, and the loop would re-resolve + re-insert forever (a runaway
  845. // that grew gin's graph to 5M edges / 1.4 GB before this fix).
  846. const bareRef = { ...ref, referenceName: method };
  847. const bareMatch = matchByExactName(bareRef, context) ?? matchFuzzy(bareRef, context);
  848. return bareMatch ? { ...bareMatch, original: ref } : null;
  849. }
  850. // Constructor receiver `Foo(args).method()` (encoded `Foo().method`): a bare,
  851. // capitalized inner is a class construction, so the receiver's type is the
  852. // class itself — resolve the method on it. Only in languages where an
  853. // unprefixed capitalized call constructs the class (Kotlin, Swift); in Java/C#
  854. // a bare `Foo()` is a method call (constructors need `new`), so we must not
  855. // assume construction. A lowercase bare inner is a top-level `factory().method()`
  856. // whose type we can't recover — bail.
  857. if (!CONSTRUCTS_VIA_BARE_CALL.has(ref.language) || !/^[A-Z]/.test(inner)) return null;
  858. return resolveMethodOnType(inner, method, ref, context, 0.85, 'instance-method', importedFqnOf(inner, ref, context));
  859. }
  860. // Factory/fluent receiver `Receiver.factory(args).method()`: the receiver's
  861. // type is what `Receiver.factory` returns (its declared return type).
  862. const factoryClass = inner.slice(0, lastDot).split('.').pop(); // simple class name
  863. const factoryMethod = inner.slice(lastDot + 1);
  864. if (!factoryClass || !factoryMethod) return null;
  865. const ret = lookupCalleeReturnType(`${factoryClass}::${factoryMethod}`, ref, context);
  866. if (!ret) {
  867. // Objective-C: a class-message factory — `[X alloc]`, `[X new]`,
  868. // `[X sharedFoo]` — returns an instance of the RECEIVER class `X` by
  869. // convention (`instancetype`). So when the factory's own return type isn't
  870. // recoverable (its selector returns `instancetype`, or `alloc`/`new` aren't
  871. // user-defined nodes at all), the receiver's type is the class `X` itself.
  872. // This resolves the ubiquitous `[[X alloc] init]` and singleton chains.
  873. // resolveMethodOnType validates against X (and its supertypes), so a class
  874. // whose method actually lives elsewhere yields NO edge, not a wrong one — and
  875. // crucially this does NOT fire when a concrete return type WAS captured but
  876. // simply lacks the method (that already returned null above: absent-method
  877. // safety, so a same-named decoy is still never matched).
  878. if (ref.language === 'objc' && /^[A-Z]/.test(factoryClass)) {
  879. return resolveMethodOnType(factoryClass, method, ref, context, 0.8, 'instance-method', importedFqnOf(factoryClass, ref, context));
  880. }
  881. // Pascal/Delphi: the extractor only re-encodes a `TFoo`/`IFoo`-prefixed chain
  882. // (the type-naming convention), so `factoryClass` is always a real class here.
  883. // A factory whose return type wasn't captured is a CONSTRUCTOR
  884. // (`TFileMem.Create().SetCachePerformance` — `constructor Create` has no `:
  885. // TBar` annotation but returns its own class) or an unannotated function. In
  886. // both cases the receiver's type is the class itself, so resolve the method on
  887. // `factoryClass`. resolveMethodOnType validates against it (and its
  888. // supertypes), so a wrong inference yields no edge — and this never fires when
  889. // a return type WAS captured but lacks the method (absent-method safety above).
  890. if (ref.language === 'pascal' && /^[TI]/.test(factoryClass)) {
  891. return resolveMethodOnType(factoryClass, method, ref, context, 0.8, 'instance-method', importedFqnOf(factoryClass, ref, context));
  892. }
  893. return null;
  894. }
  895. return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
  896. }
  897. /**
  898. * When several classes share a simple type name, the caller file's import of
  899. * that type is the only signal that names WHICH one (#314). Returns the imported
  900. * FQN for `typeName` in the ref's file, or undefined.
  901. */
  902. function importedFqnOf(
  903. typeName: string,
  904. ref: UnresolvedRef,
  905. context: ResolutionContext,
  906. ): string | undefined {
  907. const imports = context.getImportMappings(ref.filePath, ref.language);
  908. return imports.find((i) => i.localName === typeName)?.source;
  909. }
  910. /**
  911. * Java/Kotlin: infer a receiver's declared type by walking field declarations
  912. * in the class enclosing the call site. The field's `signature` is already in
  913. * the form "<TypeName> <fieldName>" (set by tree-sitter.ts extractField), so we
  914. * pull the type from there. Handles Spring `@Resource UserBO userbo;` /
  915. * `@Autowired private UserService userService;` where the receiver field name
  916. * doesn't match the class name by Java naming convention.
  917. *
  918. * Returns the bare type name (generics stripped, dotted package stripped) or
  919. * null when no matching field is in the enclosing class.
  920. */
  921. function inferJavaFieldReceiverType(
  922. receiverName: string,
  923. ref: UnresolvedRef,
  924. context: ResolutionContext,
  925. ): string | null {
  926. const inFile = context.getNodesInFile(ref.filePath);
  927. if (inFile.length === 0) return null;
  928. // Find the class enclosing the call line (tightest match by latest start).
  929. let enclosing: Node | null = null;
  930. for (const n of inFile) {
  931. if (n.kind !== 'class' && n.kind !== 'interface') continue;
  932. if (n.language !== ref.language) continue;
  933. const end = n.endLine ?? n.startLine;
  934. if (n.startLine <= ref.line && end >= ref.line) {
  935. if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n;
  936. }
  937. }
  938. if (!enclosing) return null;
  939. const enclosingEnd = enclosing.endLine ?? enclosing.startLine;
  940. const field = inFile.find(
  941. (n) =>
  942. n.kind === 'field' &&
  943. n.name === receiverName &&
  944. n.language === ref.language &&
  945. n.startLine >= enclosing.startLine &&
  946. (n.endLine ?? n.startLine) <= enclosingEnd,
  947. );
  948. if (!field || !field.signature) return null;
  949. // Signature shape: "<TypeName> <fieldName>" (extractField). Pull the type,
  950. // strip generics + dotted package, drop array/varargs markers.
  951. const beforeName = field.signature.slice(
  952. 0,
  953. field.signature.lastIndexOf(field.name),
  954. );
  955. const typeRaw = beforeName.trim();
  956. if (!typeRaw) return null;
  957. const typeNoGenerics = typeRaw.replace(/<[^>]*>/g, '').trim();
  958. const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').trim();
  959. const parts = typeNoArray.split(/[.\s]+/).filter(Boolean);
  960. const lastPart = parts[parts.length - 1];
  961. if (!lastPart) return null;
  962. if (!/^[A-Z]/.test(lastPart)) return null; // primitives / lowercase → skip
  963. return lastPart;
  964. }
  965. // ── Local-variable receiver-type inference (#1108) ──────────────────────────
  966. //
  967. // Instance calls through a local variable (`const lg = new Logger(); lg.log()`)
  968. // only resolved in C++ before this — no other language could learn the
  969. // receiver's type. Local variables are not indexed as nodes (node-explosion),
  970. // so, like the C++ inferrer above, we read the enclosing function's source and
  971. // match the receiver's declaration/initializer to recover its type. The type is
  972. // then handed to resolveMethodOnType, which VALIDATES that the type actually
  973. // declares the method, so a mis-inference produces NO edge — the safety net
  974. // that lets the patterns below stay simple. C++ keeps its dedicated inferrer
  975. // (header scan + `auto`); this covers every other language.
  976. // Tokens a loose pattern might capture that are never a user-defined type.
  977. const NON_TYPE_RECEIVER_TOKENS = new Set([
  978. 'this', 'self', 'super', 'new', 'return', 'await', 'yield', 'typeof',
  979. 'null', 'nil', 'None', 'true', 'false', 'True', 'False', 'undefined',
  980. ]);
  981. /**
  982. * Normalize a captured type expression to a simple type name: drop generic
  983. * args and pointer/ref markers, take the last `.`/`::`-qualified segment, and
  984. * reject obvious non-types.
  985. */
  986. function normalizeInferredTypeName(raw: string): string | null {
  987. const cleaned = raw.replace(/<[^>]*>/g, '').replace(/[&*]/g, '').trim();
  988. const seg = cleaned.split(/[.:]+/).filter(Boolean).pop();
  989. if (!seg) return null;
  990. if (NON_TYPE_RECEIVER_TOKENS.has(seg)) return null;
  991. return seg;
  992. }
  993. /**
  994. * Per-language patterns that recover a local variable's (or typed parameter's)
  995. * type from its declaration/initializer. Each regex captures the type in group
  996. * 1; `r` is the already-escaped receiver name. Ordered most-specific first.
  997. * PascalCase is required in the capture where the language convention allows,
  998. * as a cheap false-positive guard on top of resolveMethodOnType's validation.
  999. */
  1000. function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
  1001. switch (language) {
  1002. case 'typescript':
  1003. case 'javascript':
  1004. case 'tsx':
  1005. case 'jsx':
  1006. return [
  1007. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
  1008. // No keyword requirement, so this matches BOTH a local annotation
  1009. // (`const lg: Logger`) and a typed parameter (`function use(lg: Logger)`
  1010. // / `(lg: Logger) =>`) — the parameter case the old `const|let|var`
  1011. // prefix excluded (#1125). Mirrors Kotlin/Swift/Scala; the capture stops
  1012. // at `<` so a generic-typed param (`repo: Repository<User>`) still yields
  1013. // `Repository`. resolveMethodOnType validates the type actually declares
  1014. // the method, so the looser match produces no edge on a mis-inference.
  1015. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger (annotation or typed param)
  1016. ];
  1017. case 'python':
  1018. return [
  1019. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // lg = Logger(...)
  1020. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // lg: Logger (PEP 526)
  1021. ];
  1022. case 'java':
  1023. return [
  1024. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger()
  1025. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param
  1026. ];
  1027. case 'kotlin':
  1028. return [
  1029. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // val lg = Logger(...)
  1030. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // val lg: Logger / param
  1031. ];
  1032. case 'csharp':
  1033. return [
  1034. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger()
  1035. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param
  1036. ];
  1037. case 'swift':
  1038. return [
  1039. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // let lg = Logger(...)
  1040. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // let lg: Logger / param
  1041. ];
  1042. case 'rust':
  1043. return [
  1044. new RegExp(`\\blet\\s+(?:mut\\s+)?${r}\\b(?:\\s*:[^=]+)?=\\s*&?(?:mut\\s+)?([A-Z][\\w]*)`), // let lg = Logger::new()/Logger{}/Logger
  1045. // No `let`, so this covers a `let lg: Logger` binding AND a typed
  1046. // parameter (`fn use(lg: &Logger)`, a closure `|lg: Logger|`) — the
  1047. // parameter case the old `let`-anchored pattern excluded (#1125).
  1048. new RegExp(`\\b${r}\\s*:\\s*&?(?:mut\\s+)?([A-Z][\\w]*)`), // lg: Logger (binding or typed param)
  1049. ];
  1050. case 'go':
  1051. return [
  1052. new RegExp(`\\b${r}\\b\\s*:=\\s*&?([A-Za-z_][\\w.]*)\\s*{`), // lg := Logger{} / &Logger{}
  1053. new RegExp(`\\bvar\\s+${r}\\s+\\*?([A-Za-z_][\\w.]*)`), // var lg Logger / *Logger
  1054. // A typed parameter / method receiver (`func use(lg Logger)`,
  1055. // `func (l Logger) M()`) — name-before-type with no `var`/`:=` (#1125).
  1056. // PascalCase-guarded (unlike the anchored patterns above) to keep the
  1057. // keyword-free `ident Type` shape from matching unrelated pairs; the
  1058. // enclosing-scope bound already excludes package-level struct fields.
  1059. new RegExp(`\\b${r}\\s+\\*?([A-Z][\\w.]*)`), // func use(lg Logger) / (l Logger)
  1060. ];
  1061. case 'ruby':
  1062. return [
  1063. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w:]*)\\.new\\b`), // lg = Logger.new
  1064. ];
  1065. case 'scala':
  1066. return [
  1067. new RegExp(`\\b${r}\\b\\s*=\\s*(?:new\\s+)?([A-Z][\\w.]*)`), // val lg = new Logger / Logger(...)
  1068. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // val lg: Logger / param
  1069. ];
  1070. case 'dart':
  1071. return [
  1072. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // var lg = Logger(...)
  1073. // Trailing `[=;,)]` (not just `[=;]`) so a typed parameter — `Logger lg)`
  1074. // / `Logger lg,` — matches too, not only `Logger lg = ...` / `Logger lg;`
  1075. // (#1125). Mirrors Java/C#.
  1076. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg = ... / param
  1077. ];
  1078. case 'php':
  1079. return [
  1080. new RegExp(`\\$?${r}\\b\\s*=\\s*new\\s+([A-Za-z_\\\\][\\w\\\\]*)`), // $lg = new Logger()
  1081. // A typed parameter (`function use(Logger $lg)`, `?Logger $lg`,
  1082. // `\\App\\Logger $lg`, `&$lg` by-ref) and a typed `catch (E $e)` — the
  1083. // type sits before the `$`-variable (#1125). Namespace `\\` allowed.
  1084. new RegExp(`\\b([A-Za-z_\\\\][\\w\\\\]*)\\s+&?\\$${r}\\b`), // Logger $lg (typed param)
  1085. ];
  1086. case 'lua':
  1087. case 'luau':
  1088. return [
  1089. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\.new\\b`), // local lg = Logger.new()
  1090. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\s*\\(`), // local lg = Logger(...) (callable table)
  1091. // Luau annotation (`local lg: Logger`) / typed param — but Lua's
  1092. // method-call syntax is the IDENTICAL `receiver:Name` shape, and the
  1093. // backward scan starts on the call's own line, so without a gate any
  1094. // PascalCase method call (`lg:Log()`, the Roblox convention)
  1095. // self-matches as "type = Log" before the scan reaches the real
  1096. // declaration (#1124). The lookahead rejects a capture followed by
  1097. // any of Lua's three call forms — `(args)`, `"s"`/`'s'`/`[[s]]`,
  1098. // `{t}` — and its leading `[\w.]` alternative stops backtracking from
  1099. // shrinking the capture to dodge the gate (`lg:Log()` would otherwise
  1100. // still match, as `Lo`).
  1101. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)(?![\\w.]|\\s*[({"'\\[])`), // local lg: Logger / typed param
  1102. ];
  1103. case 'r':
  1104. return [
  1105. new RegExp(`\\b${r}\\b\\s*(?:<-|<<-|=)\\s*([A-Z][\\w.]*)\\$new\\b`), // lg <- Logger$new() (R6)
  1106. ];
  1107. case 'pascal':
  1108. return [
  1109. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w]*)`), // var lg: TLogger / param lg: TLogger
  1110. new RegExp(`\\b${r}\\b\\s*:=\\s*([A-Z][\\w.]*)\\.Create\\b`), // lg := TLogger.Create
  1111. ];
  1112. case 'cfml':
  1113. case 'cfscript':
  1114. return [
  1115. // svc = new UserService() / new path.to.UserService() — dotted component
  1116. // paths reduce to their final segment via normalizeInferredTypeName.
  1117. // Also matches inside tag markup (`<cfset svc = new UserService()>`)
  1118. // since the scan reads raw source lines.
  1119. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`),
  1120. // The classic form: svc = createObject("component", "path.to.UserService")
  1121. // (casing of createObject varies in the wild), plus the modern
  1122. // single-argument form createObject("path.to.UserService").
  1123. new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']component["']\\s*,\\s*["']([\\w.]+)["']`),
  1124. new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']([\\w.]+)["']\\s*\\)`),
  1125. // Typed cfscript parameter: `function save(UserService svc)` /
  1126. // `required UserService svc` — CFML's built-in types (string, numeric,
  1127. // any, struct…) are lowercase by convention, so the PascalCase guard
  1128. // excludes them.
  1129. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`),
  1130. // Tag-form typed argument, either attribute order:
  1131. // <cfargument name="svc" type="path.to.UserService">
  1132. new RegExp(`\\bcfargument[^>\\n]*\\bname\\s*=\\s*["']${r}["'][^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["']`, 'i'),
  1133. new RegExp(`\\bcfargument[^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["'][^>\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
  1134. // Component property (incl. WireBox DI): `property name="svc"
  1135. // inject="UserService";` / `<cfproperty name="svc" type="UserService">`,
  1136. // either attribute order. An inject DSL value with a namespace
  1137. // (`inject="svc@core"`) captures only the leading name and simply
  1138. // fails type-validation — no edge, never a wrong one.
  1139. new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\bname\\s*=\\s*["']${r}["'][^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["']`, 'i'),
  1140. new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["'][^;\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
  1141. ];
  1142. default:
  1143. return [];
  1144. }
  1145. }
  1146. /** 1-based start line of the tightest function/method enclosing the call. */
  1147. function enclosingScopeStartLine(ref: UnresolvedRef, context: ResolutionContext): number {
  1148. let start = 1;
  1149. for (const n of context.getNodesInFile(ref.filePath)) {
  1150. if (n.kind !== 'function' && n.kind !== 'method') continue;
  1151. if (n.language !== ref.language) continue;
  1152. const end = n.endLine ?? n.startLine;
  1153. if (n.startLine <= ref.line && end >= ref.line && n.startLine >= start) {
  1154. start = n.startLine;
  1155. }
  1156. }
  1157. return start;
  1158. }
  1159. /**
  1160. * Infer a receiver's type from its local declaration/initializer in the
  1161. * enclosing function body. Language-dispatched; returns null for languages
  1162. * without patterns or when no declaration is found. Bounded to the enclosing
  1163. * scope so a same-named variable in another function can't leak in.
  1164. */
  1165. function inferLocalReceiverType(
  1166. receiverName: string,
  1167. ref: UnresolvedRef,
  1168. context: ResolutionContext,
  1169. ): string | null {
  1170. // CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped
  1171. // field whose assignment or `property` declaration usually lives outside the
  1172. // calling function (the init-pseudoconstructor / WireBox-injection pattern),
  1173. // and `local.svc` is an explicit function-local. Strip the prefix so the
  1174. // declaration patterns match (`variables.svc = new X()`, `property
  1175. // name="svc" …`, `var svc = …` all bind the bare name), and widen the scan
  1176. // to the whole file for the component-scoped forms — nearest-declaration-
  1177. // backward still wins, so a function-local shadowing the field is preferred.
  1178. let scanReceiver = receiverName;
  1179. let componentScoped = false;
  1180. if (ref.language === 'cfml' || ref.language === 'cfscript') {
  1181. const scoped = receiverName.match(/^(variables|this|local|arguments)\.(.+)$/i);
  1182. if (scoped) {
  1183. scanReceiver = scoped[2]!;
  1184. const scope = scoped[1]!.toLowerCase();
  1185. componentScoped = scope === 'variables' || scope === 'this';
  1186. }
  1187. }
  1188. const patterns = localReceiverTypePatterns(
  1189. ref.language,
  1190. scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
  1191. );
  1192. if (patterns.length === 0) return null;
  1193. // Split through the context's per-file lines cache when available: this runs
  1194. // for EVERY `receiver.method()` ref, and re-splitting the whole file per ref
  1195. // was ~20% of total index CPU on Java-heavy repos (#1122).
  1196. const lines = context.getFileLines
  1197. ? context.getFileLines(ref.filePath)
  1198. : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null);
  1199. if (!lines || lines.length === 0) return null;
  1200. const callIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
  1201. const startIdx = componentScoped
  1202. ? 0
  1203. : Math.max(0, enclosingScopeStartLine(ref, context) - 1);
  1204. const matchLine = (i: number): string | null => {
  1205. const line = lines[i];
  1206. if (!line) return null;
  1207. // A generated/minified line (one multi-KB statement) is not something a
  1208. // human-written local declaration lives on, and regexing it per ref is
  1209. // pure waste — skip it rather than scan it.
  1210. if (line.length > 10_000) return null;
  1211. for (const re of patterns) {
  1212. const m = line.match(re);
  1213. if (m && m[1]) {
  1214. const type = normalizeInferredTypeName(m[1]);
  1215. if (type) return type;
  1216. }
  1217. }
  1218. return null;
  1219. };
  1220. // Nearest declaration wins: scan backward from the call to the scope start.
  1221. for (let i = callIdx; i >= startIdx; i--) {
  1222. const type = matchLine(i);
  1223. if (type) return type;
  1224. }
  1225. // A component-scoped field's declaration is position-independent — the
  1226. // `variables.svc = new X()` pseudoconstructor assignment or `property`
  1227. // declaration may sit BELOW the calling function in the file — so when the
  1228. // backward pass finds nothing, sweep the remainder of the file too.
  1229. if (componentScoped) {
  1230. for (let i = callIdx + 1; i < lines.length; i++) {
  1231. const type = matchLine(i);
  1232. if (type) return type;
  1233. }
  1234. }
  1235. return null;
  1236. }
  1237. /**
  1238. * Try to resolve by method name on a class/object
  1239. */
  1240. export function matchMethodCall(
  1241. ref: UnresolvedRef,
  1242. context: ResolutionContext
  1243. ): ResolvedRef | null {
  1244. // Parse method call patterns like "obj.method" or "Class::method". The method
  1245. // part allows trailing `:` keywords so Objective-C selectors resolve
  1246. // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other
  1247. // languages' method refs, so this is a no-op for them.
  1248. // The receiver allows dots (`builder.Services.AddCoreServices`) so a CHAINED
  1249. // call resolves by its last segment — Strategy 3 below name-matches the method
  1250. // (with its existing single-candidate / receiver-overlap guards). Without this
  1251. // a multi-dot extension-method call (C# DI `builder.Services.AddCoreServices()`,
  1252. // `Guard.Against.X()`) matched no pattern and never resolved.
  1253. const dotMatch = ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/);
  1254. const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/);
  1255. // Lua/Luau method calls use a single colon (`lg:log`); R uses `$` (`lg$log`).
  1256. // Recognize these receiver/method separators so local-variable receiver-type
  1257. // inference (#1108) applies to them too — extraction already emits the ref in
  1258. // this shape, but the resolver otherwise only understood `.` and `::`.
  1259. const luaColonMatch = (ref.language === 'lua' || ref.language === 'luau')
  1260. ? ref.referenceName.match(/^([\w.]+):(\w+)$/)
  1261. : null;
  1262. const rDollarMatch = ref.language === 'r'
  1263. ? ref.referenceName.match(/^([\w.]+)\$(\w+)$/)
  1264. : null;
  1265. const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch;
  1266. if (!match) {
  1267. return null;
  1268. }
  1269. const [, objectOrClass, methodName] = match;
  1270. // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose
  1271. // receiver type we can try to infer from its local declaration.
  1272. const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch;
  1273. // Infer the receiver's type from its local declaration/initializer in the
  1274. // enclosing scope, then resolve the method on that type (#1108). C++ keeps its
  1275. // dedicated inferrer (header scan + `auto`); every other language uses the
  1276. // shared source-based inferrer. resolveMethodOnType validates the method
  1277. // exists on the inferred type, so a mis-inference produces no edge.
  1278. if (inferableReceiver) {
  1279. const inferredType =
  1280. ref.language === 'cpp'
  1281. ? inferCppReceiverType(objectOrClass!, ref, context)
  1282. : inferLocalReceiverType(objectOrClass!, ref, context);
  1283. if (inferredType) {
  1284. // Java/Kotlin: when two classes share the simple name, the file's import
  1285. // pins WHICH one (#314). Other languages disambiguate by call-site file.
  1286. const importedFqn =
  1287. ref.language === 'java' || ref.language === 'kotlin'
  1288. ? context
  1289. .getImportMappings(ref.filePath, ref.language)
  1290. .find((i) => i.localName === inferredType)?.source
  1291. : undefined;
  1292. const typedMatch = resolveMethodOnType(
  1293. inferredType,
  1294. methodName!,
  1295. ref,
  1296. context,
  1297. 0.9,
  1298. 'instance-method',
  1299. importedFqn,
  1300. );
  1301. if (typedMatch) {
  1302. return typedMatch;
  1303. }
  1304. }
  1305. }
  1306. // Java/Kotlin: receiver may be a field whose name doesn't match the type by
  1307. // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
  1308. // the field in the enclosing class to get its declared type, then resolve
  1309. // the method on that type. Covers Spring `@Resource`/`@Autowired` field
  1310. // injection where the field type is the concrete bean class.
  1311. if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) {
  1312. const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context);
  1313. if (inferredType) {
  1314. // When two classes share the same simple name, the caller file's
  1315. // import is the only signal that names WHICH one — pass the
  1316. // imported FQN so resolveMethodOnType can disambiguate (#314).
  1317. const imports = context.getImportMappings(ref.filePath, ref.language);
  1318. const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
  1319. const typedMatch = resolveMethodOnType(
  1320. inferredType,
  1321. methodName!,
  1322. ref,
  1323. context,
  1324. 0.9,
  1325. 'instance-method',
  1326. importedFqn,
  1327. );
  1328. if (typedMatch) {
  1329. return typedMatch;
  1330. }
  1331. }
  1332. }
  1333. // Strategy 1: Direct class name match (existing logic). When the receiver
  1334. // names a class that exists in several files (`Logger.log()` / `Logger::log()`
  1335. // with a `Logger` in both `a/` and `b/`), try the class in the call site's
  1336. // own file first — otherwise the first-indexed class wins and a call in `b/`
  1337. // resolves to `a/`'s method (#1079).
  1338. const classCandidates = preferCallSiteFile(
  1339. context.getNodesByName(objectOrClass!),
  1340. ref.filePath,
  1341. );
  1342. for (const classNode of classCandidates) {
  1343. if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
  1344. // Skip cross-language class matches
  1345. if (classNode.language !== ref.language) continue;
  1346. const nodesInFile = context.getNodesInFile(classNode.filePath);
  1347. const methodNode = nodesInFile.find(
  1348. (n) =>
  1349. n.kind === 'method' &&
  1350. n.name === methodName &&
  1351. n.qualifiedName.includes(classNode.name)
  1352. );
  1353. if (methodNode) {
  1354. return {
  1355. original: ref,
  1356. targetNodeId: methodNode.id,
  1357. confidence: 0.85,
  1358. resolvedBy: 'qualified-name',
  1359. };
  1360. }
  1361. }
  1362. }
  1363. // Strategy 2: Instance variable receiver - try capitalized form to find class
  1364. // e.g., "permissionEngine" → look for classes containing "PermissionEngine"
  1365. const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1);
  1366. if (capitalizedReceiver !== objectOrClass) {
  1367. const fuzzyClassCandidates = preferCallSiteFile(
  1368. context.getNodesByName(capitalizedReceiver),
  1369. ref.filePath,
  1370. );
  1371. for (const classNode of fuzzyClassCandidates) {
  1372. if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
  1373. // Skip cross-language class matches
  1374. if (classNode.language !== ref.language) continue;
  1375. const nodesInFile = context.getNodesInFile(classNode.filePath);
  1376. const methodNode = nodesInFile.find(
  1377. (n) =>
  1378. n.kind === 'method' &&
  1379. n.name === methodName &&
  1380. n.qualifiedName.includes(classNode.name)
  1381. );
  1382. if (methodNode) {
  1383. return {
  1384. original: ref,
  1385. targetNodeId: methodNode.id,
  1386. confidence: 0.8,
  1387. resolvedBy: 'instance-method',
  1388. };
  1389. }
  1390. }
  1391. }
  1392. }
  1393. // Strategy 3: Find methods by name across the codebase, match by receiver
  1394. // name similarity with the containing class. Handles abbreviated variable
  1395. // names like permissionEngine → PermissionRuleEngine.
  1396. if (methodName) {
  1397. const methodCandidates = context.getNodesByName(methodName!);
  1398. // Ubiquitous-method ceiling (#999): a method name re-declared across a
  1399. // vendored theme/SDK (Metronic's `init`/`update`/… on every widget) yields
  1400. // K candidates that receiver-word overlap can't reliably disambiguate —
  1401. // and filtering + scoring all K per call is the O(K²) cost that wedged
  1402. // "Resolving refs" for 15-28 min. Bail before the O(K) work; Strategy 1/2
  1403. // (class-name match) already had their precise shot above.
  1404. if (methodCandidates.length > AMBIGUOUS_NAME_CEILING) {
  1405. return null;
  1406. }
  1407. const methods = methodCandidates.filter(
  1408. (n) => n.kind === 'method' && n.name === methodName
  1409. );
  1410. // Filter to same-language candidates first
  1411. const sameLanguageMethods = methods.filter(m => m.language === ref.language);
  1412. const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods;
  1413. // If only one same-language method with this name exists, use it
  1414. if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) {
  1415. return {
  1416. original: ref,
  1417. targetNodeId: targetMethods[0]!.id,
  1418. confidence: 0.7,
  1419. resolvedBy: 'instance-method',
  1420. };
  1421. }
  1422. // Multiple methods: score by receiver name word overlap with class name
  1423. if (targetMethods.length > 1) {
  1424. const receiverWords = splitCamelCase(objectOrClass!);
  1425. let bestMatch: typeof targetMethods[0] | undefined;
  1426. let bestScore = 0;
  1427. // Same-file candidates first, so a score tie (`score > bestScore` keeps
  1428. // the first seen) resolves to the call site's own file rather than the
  1429. // first-indexed duplicate (#1079).
  1430. for (const method of preferCallSiteFile(targetMethods, ref.filePath)) {
  1431. const classWords = splitCamelCase(method.qualifiedName);
  1432. let score = receiverWords.filter(w =>
  1433. classWords.some(cw => cw.toLowerCase() === w.toLowerCase())
  1434. ).length;
  1435. // Bonus for same language
  1436. if (method.language === ref.language) score += 1;
  1437. if (score > bestScore) {
  1438. bestScore = score;
  1439. bestMatch = method;
  1440. }
  1441. }
  1442. if (bestMatch && bestScore >= 2) {
  1443. return {
  1444. original: ref,
  1445. targetNodeId: bestMatch.id,
  1446. confidence: 0.65,
  1447. resolvedBy: 'instance-method',
  1448. };
  1449. }
  1450. }
  1451. }
  1452. return null;
  1453. }
  1454. /**
  1455. * Split a camelCase or PascalCase string into words.
  1456. */
  1457. function splitCamelCase(str: string): string[] {
  1458. return str.replace(/([a-z])([A-Z])/g, '$1 $2')
  1459. .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
  1460. .split(/[\s._:\/\\]+/)
  1461. .filter(w => w.length > 1);
  1462. }
  1463. /**
  1464. * Compute directory proximity from a pre-split list of directory segments
  1465. * (`filePath1` minus its filename) and a second file path.
  1466. * Returns a score based on the number of shared leading directory segments.
  1467. * Higher score = closer in directory tree.
  1468. *
  1469. * Split into a pre-split variant because findBestMatch scores every candidate
  1470. * against the SAME `ref.filePath`; re-splitting it per candidate was a hot spot
  1471. * on large repos (#915), so the caller splits it once and passes the segments.
  1472. */
  1473. function pathProximityFromDirs(dir1: string[], filePath2: string): number {
  1474. const dir2 = filePath2.split('/');
  1475. dir2.pop(); // drop filename — matches the original slice(0, -1) on both paths
  1476. let shared = 0;
  1477. const limit = Math.min(dir1.length, dir2.length);
  1478. for (let i = 0; i < limit; i++) {
  1479. if (dir1[i] === dir2[i]) {
  1480. shared++;
  1481. } else {
  1482. break;
  1483. }
  1484. }
  1485. // Each shared directory segment contributes 15 points, capped at 80
  1486. return Math.min(shared * 15, 80);
  1487. }
  1488. /**
  1489. * Compute directory proximity between two file paths.
  1490. * Returns a score based on the number of shared directory segments.
  1491. */
  1492. function computePathProximity(filePath1: string, filePath2: string): number {
  1493. const dir1 = filePath1.split('/');
  1494. dir1.pop();
  1495. return pathProximityFromDirs(dir1, filePath2);
  1496. }
  1497. /**
  1498. * Find the best matching node when there are multiple candidates
  1499. */
  1500. function findBestMatch(
  1501. ref: UnresolvedRef,
  1502. candidates: Node[],
  1503. _context: ResolutionContext
  1504. ): Node | null {
  1505. // Prioritization rules:
  1506. // 1. Same file > different file
  1507. // 2. Directory proximity (same module/package > different module)
  1508. // 3. Same language > different language
  1509. // 4. Functions/methods > classes/types (for call references)
  1510. // 5. Exported > non-exported
  1511. let bestScore = -1;
  1512. let bestNode: Node | null = null;
  1513. // Split the ref's path once (it's the same across every candidate) instead of
  1514. // re-splitting it inside computePathProximity per candidate (#915 hot spot).
  1515. const refDirs = ref.filePath.split('/');
  1516. refDirs.pop();
  1517. // A same-language candidate ALWAYS outscores a cross-language one: same-language
  1518. // scores at least +50 (language bonus), while a cross-language candidate maxes
  1519. // out at +35 (−80 language, +80 proximity, +25 kind, +10 exported; it can never
  1520. // be in the same file). So when any same-language candidate exists, skip the
  1521. // cross-language ones — provably the same winner, without paying the per-candidate
  1522. // scoring. Cuts the candidate set to same-language size on mixed front-end +
  1523. // back-end repos (#915). When ALL candidates are cross-language (a legitimate
  1524. // cross-language `calls` bridge), none are skipped and behavior is unchanged.
  1525. const hasSameLanguage = candidates.some((c) => c.language === ref.language);
  1526. for (const candidate of candidates) {
  1527. if (hasSameLanguage && candidate.language !== ref.language) continue;
  1528. let score = 0;
  1529. // Same file bonus
  1530. if (candidate.filePath === ref.filePath) {
  1531. score += 100;
  1532. }
  1533. // Directory proximity bonus — strongly prefer same module/package
  1534. score += pathProximityFromDirs(refDirs, candidate.filePath);
  1535. // Language matching: strongly prefer same language, penalize cross-language
  1536. if (candidate.language === ref.language) {
  1537. score += 50;
  1538. } else {
  1539. score -= 80;
  1540. }
  1541. // For call references, prefer functions/methods
  1542. if (ref.referenceKind === 'calls') {
  1543. if (candidate.kind === 'function' || candidate.kind === 'method') {
  1544. score += 25;
  1545. }
  1546. }
  1547. // For instantiation references (`new Foo()`), prefer class-like
  1548. // targets — without this, a function named `Foo` in another module
  1549. // could outscore the actual class.
  1550. if (ref.referenceKind === 'instantiates') {
  1551. if (
  1552. candidate.kind === 'class' ||
  1553. candidate.kind === 'struct' ||
  1554. candidate.kind === 'interface'
  1555. ) {
  1556. score += 25;
  1557. }
  1558. }
  1559. // For decorator references (`@Foo`), prefer functions. Class
  1560. // decorators (Python `@SomeClass`, Java annotation interfaces)
  1561. // also resolve here, hence the smaller class bonus.
  1562. if (ref.referenceKind === 'decorates') {
  1563. if (candidate.kind === 'function' || candidate.kind === 'method') {
  1564. score += 25;
  1565. } else if (candidate.kind === 'class' || candidate.kind === 'interface') {
  1566. score += 15;
  1567. }
  1568. }
  1569. // Exported bonus
  1570. if (candidate.isExported) {
  1571. score += 10;
  1572. }
  1573. // Closer line number (within same file)
  1574. if (candidate.filePath === ref.filePath && candidate.startLine) {
  1575. const distance = Math.abs(candidate.startLine - ref.line);
  1576. score += Math.max(0, 20 - distance / 10);
  1577. }
  1578. if (score > bestScore) {
  1579. bestScore = score;
  1580. bestNode = candidate;
  1581. }
  1582. }
  1583. return bestNode;
  1584. }
  1585. /**
  1586. * Fuzzy match - last resort with lower confidence
  1587. */
  1588. export function matchFuzzy(
  1589. ref: UnresolvedRef,
  1590. context: ResolutionContext
  1591. ): ResolvedRef | null {
  1592. const lowerName = ref.referenceName.toLowerCase();
  1593. // Use pre-built lowercase index for O(1) lookup instead of scanning all nodes
  1594. const candidates = context.getNodesByLowerName(lowerName);
  1595. // Filter to callable kinds only (function, method, class)
  1596. const callableKinds = new Set(['function', 'method', 'class']);
  1597. const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref);
  1598. // Prefer same-language matches
  1599. const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
  1600. const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates;
  1601. if (finalCandidates.length === 1) {
  1602. const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
  1603. return {
  1604. original: ref,
  1605. targetNodeId: finalCandidates[0]!.id,
  1606. confidence: isCrossLanguage ? 0.3 : 0.5,
  1607. resolvedBy: 'fuzzy',
  1608. };
  1609. }
  1610. return null;
  1611. }
  1612. /**
  1613. * Match all strategies in order of confidence
  1614. */
  1615. export function matchReference(
  1616. ref: UnresolvedRef,
  1617. context: ResolutionContext
  1618. ): ResolvedRef | null {
  1619. // Function-as-value refs (#756) resolve ONLY through the dedicated matcher —
  1620. // never the fuzzy/qualified fallthrough below (a wrong callback edge is
  1621. // worse than none).
  1622. if (ref.referenceKind === 'function_ref') {
  1623. return matchFunctionRef(ref, context);
  1624. }
  1625. // Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
  1626. // bare-name matching grabs any same-named symbol — on emqx,
  1627. // `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro
  1628. // constant in an unrelated app. Resolve only to the behaviour module's
  1629. // namespace; an out-of-repo behaviour (OTP's gen_server/supervisor) stays
  1630. // unresolved rather than guessed. The same module-only rule applies to every
  1631. // ref an `.app`/`.app.src` resource file emits — its `{mod, …}` callback and
  1632. // `{applications, …}` dependency names can only mean modules, and on emqx
  1633. // the `ssl` OTP app otherwise resolved to a test helper FUNCTION named ssl.
  1634. if (
  1635. ref.language === 'erlang' &&
  1636. (ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath))
  1637. ) {
  1638. const modules = context
  1639. .getNodesByName(ref.referenceName)
  1640. .filter((n) => n.language === 'erlang' && n.kind === 'namespace');
  1641. const chosen = preferCallSiteFile(modules, ref.filePath)[0];
  1642. if (!chosen) return null;
  1643. return {
  1644. original: ref,
  1645. targetNodeId: chosen.id,
  1646. confidence: 0.9,
  1647. resolvedBy: 'exact-match',
  1648. };
  1649. }
  1650. // Try strategies in order of confidence
  1651. let result: ResolvedRef | null;
  1652. // 0. File path match (e.g., "snippets/drawer-menu.liquid" → file node)
  1653. result = matchByFilePath(ref, context);
  1654. if (result) return result;
  1655. // 1. Qualified name match (highest confidence)
  1656. result = matchByQualifiedName(ref, context);
  1657. if (result) return result;
  1658. // 1b. C++ chained call whose receiver is another call — `Foo::instance().bar()`
  1659. // encoded as `Foo::instance().bar` by the extractor (#645). Resolve the
  1660. // receiver's type from what the inner call returns, then the method on it.
  1661. if (ref.language === 'cpp' || ref.language === 'c') {
  1662. result = matchCppCallChain(ref, context);
  1663. if (result) return result;
  1664. }
  1665. // 1c. `::`-scoped factory chain — PHP `Cls::for($x)->method()` (#608) or Rust
  1666. // `Foo::new().bar()`, both encoded as `Cls::factory().method`. The receiver's
  1667. // type is the factory's `self` (PHP `: self`/`: static`, Rust `-> Self`) or
  1668. // concrete return type.
  1669. if (ref.language === 'php' || ref.language === 'rust') {
  1670. result = matchScopedCallChain(ref, context);
  1671. if (result) return result;
  1672. }
  1673. // 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift /
  1674. // Go / Scala / Dart / Objective-C) — `Foo.getInstance().bar()` encoded as
  1675. // `Foo.getInstance().bar`, Go's bare-factory `New().Method()` as `New().Method`,
  1676. // Scala's companion factory, Dart's static factory / factory-constructor, or
  1677. // ObjC's chained message send `[[Foo create] doIt]` encoded as `Foo.create().doIt`
  1678. // (#645/#608 mechanism). Resolve the method's class from the inner call's
  1679. // declared return type, then validate it.
  1680. if (
  1681. ref.language === 'java' ||
  1682. ref.language === 'kotlin' ||
  1683. ref.language === 'csharp' ||
  1684. ref.language === 'swift' ||
  1685. ref.language === 'go' ||
  1686. ref.language === 'scala' ||
  1687. ref.language === 'dart' ||
  1688. ref.language === 'objc' ||
  1689. ref.language === 'pascal'
  1690. ) {
  1691. result = matchDottedCallChain(ref, context);
  1692. if (result) return result;
  1693. }
  1694. // 2. Method call pattern
  1695. result = matchMethodCall(ref, context);
  1696. if (result) return result;
  1697. // 3. Exact name match
  1698. result = matchByExactName(ref, context);
  1699. if (result) return result;
  1700. // 4. Fuzzy match (lowest confidence)
  1701. result = matchFuzzy(ref, context);
  1702. if (result) return result;
  1703. return null;
  1704. }