name-matcher.ts 115 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660
  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. // ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with
  132. // `.ts` logic modules, so refs must cross freely between them.
  133. typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web',
  134. c: 'c', cpp: 'c',
  135. // Razor/Blazor markup names C# types — same family so `@model Foo` /
  136. // `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
  137. csharp: 'dotnet', razor: 'dotnet',
  138. };
  139. export function sameLanguageFamily(a: string, b: string): boolean {
  140. if (a === b) return true;
  141. const fa = LANGUAGE_FAMILY[a];
  142. return fa !== undefined && fa === LANGUAGE_FAMILY[b];
  143. }
  144. /**
  145. * True when `lang` belongs to a known multi-language family (jvm/apple/web/c).
  146. * Languages not listed (php, python, go, ruby, rust, dart, …) and config
  147. * formats (yaml/xml/blade) form their own singleton families and return
  148. * `false` — used to leave config↔code framework bridges (whose config side is
  149. * never a known programming-language family) out of the cross-family gate.
  150. */
  151. export function isKnownLanguageFamily(lang: string): boolean {
  152. return LANGUAGE_FAMILY[lang] !== undefined;
  153. }
  154. /**
  155. * True when `a` and `b` are two DIFFERENT *known* language families — the
  156. * signature of a coincidental cross-language name collision (a TS `import
  157. * React` matching a Swift `import React`, a C++ `#include "X.h"` matching a
  158. * same-named ObjC header on another platform). The both-*known* test is
  159. * deliberately weaker than {@link sameLanguageFamily}'s negation: a
  160. * single-file-component language that carries its own tag (`vue`/`svelte`)
  161. * importing a `.ts` module, or any singleton-family language (php/go/ruby/…),
  162. * returns `false` here and is left alone.
  163. */
  164. export function crossesKnownFamily(a: string, b: string): boolean {
  165. return isKnownLanguageFamily(a) && isKnownLanguageFamily(b) && !sameLanguageFamily(a, b);
  166. }
  167. /**
  168. * Drop cross-language candidates from a name lookup. Two regimes:
  169. * - `references` (type-usage): a type named in language X resolves to a
  170. * SAME-family type, never a coincidentally same-named symbol in another
  171. * language (the Android `BatteryManager` system class vs a JS one). Strict
  172. * same-family filter — cross-language communication is `calls`, not refs.
  173. * - `imports` (import binding): an `import`/`#include` never crosses two
  174. * KNOWN families (TS `import React` ↮ Swift `import React`). Weaker
  175. * both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives.
  176. */
  177. function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
  178. if (ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') {
  179. return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
  180. }
  181. if (ref.referenceKind === 'imports') {
  182. return candidates.filter((c) => !crossesKnownFamily(c.language, ref.language));
  183. }
  184. return candidates;
  185. }
  186. /**
  187. * Resolve a function-as-value reference (#756) — a function name used as a
  188. * callback/function-pointer value (`register(handler)`, `o->cb = handler`,
  189. * `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed
  190. * for `function_ref` refs: exact name, function/method targets only, same
  191. * language family, same-file first, and cross-file only when the match is
  192. * UNIQUE. No fuzzy fallback, no qualified-name walking — a wrong callback
  193. * edge is worse than none.
  194. */
  195. export function matchFunctionRef(
  196. ref: UnresolvedRef,
  197. context: ResolutionContext
  198. ): ResolvedRef | null {
  199. // `this.<member>` refs are resolved ONLY by the class-scoped resolver in
  200. // resolveOne (resolveThisMemberFnRef) — never by name matching here.
  201. if (ref.referenceName.startsWith('this.')) return null;
  202. // In JS/TS/Python a bare identifier can never be a method value (methods
  203. // are only reachable through a receiver — `this.m` / `self.m` /
  204. // `Cls.m`), so bare fn-refs match FUNCTIONS only. This also sidesteps the
  205. // pre-existing TS quirk of class fields extracting as method-kind nodes,
  206. // which otherwise soaked up local names passed as arguments (excalidraw
  207. // A/B finding; same pattern in vendored docopt.py). Python's `self.m`
  208. // form keeps method targets via its own capture shape. C++ likewise: a
  209. // bare identifier can only be a FREE function (member values need
  210. // `&Cls::method`). PHP string callables name global FUNCTIONS (methods
  211. // need the `[$obj, 'm']` array form, which carries its own shape). Other
  212. // languages keep method targets: C# method groups, Swift/Dart
  213. // implicit-self, Java/Kotlin method references.
  214. const bareFnOnly =
  215. ref.language === 'typescript' || ref.language === 'tsx' ||
  216. ref.language === 'javascript' || ref.language === 'jsx' ||
  217. ref.language === 'arkts' ||
  218. ref.language === 'cpp' || ref.language === 'python' ||
  219. ref.language === 'php';
  220. // Python additionally accepts CLASS targets for bare identifiers (#1478):
  221. // class-as-value is a core Python idiom (`return SomeSerializer`,
  222. // `Meta.model = Org`, registry dicts, `admin.site.register(Model, Admin)`)
  223. // and, unlike TS, Python has no type-annotation recovery path. The
  224. // false-positive mechanism behind the function-only rule was lowercase
  225. // locals colliding with same-named METHODS (docopt.py) — a candidate must
  226. // be an exact-name CLASS node here, and the extraction gate (same-file
  227. // class ∪ imports) plus unique-or-drop still apply. Methods stay excluded.
  228. const bareClassOk = ref.language === 'python';
  229. // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"):
  230. // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m`
  231. // shape is an explicit member reference). Unique-or-drop like everything else.
  232. if (ref.referenceName.includes('::')) {
  233. const memberName = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2);
  234. const scoped = context
  235. .getNodesByName(memberName)
  236. .filter(
  237. (n) =>
  238. (n.kind === 'function' || n.kind === 'method') &&
  239. sameLanguageFamily(n.language, ref.language) &&
  240. n.id !== ref.fromNodeId &&
  241. (n.qualifiedName === ref.referenceName ||
  242. n.qualifiedName.endsWith(`::${ref.referenceName}`))
  243. );
  244. if (scoped.length === 0) return null;
  245. const sameFileScoped = scoped.filter((n) => n.filePath === ref.filePath);
  246. const pool = sameFileScoped.length > 0 ? sameFileScoped : scoped;
  247. if (sameFileScoped.length === 0 && scoped.length > 1) return null;
  248. const target = pool.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
  249. return {
  250. original: ref,
  251. targetNodeId: target.id,
  252. confidence: 0.9,
  253. resolvedBy: 'function-ref',
  254. };
  255. }
  256. let candidates = context
  257. .getNodesByName(ref.referenceName)
  258. .filter(
  259. (n) =>
  260. (n.kind === 'function' ||
  261. (!bareFnOnly && n.kind === 'method') ||
  262. (bareClassOk && n.kind === 'class')) &&
  263. sameLanguageFamily(n.language, ref.language) &&
  264. n.id !== ref.fromNodeId // a function registering itself is not a dependency edge
  265. );
  266. if (candidates.length === 0) return null;
  267. // Swift implicit-self: a bare identifier can name a METHOD only of the
  268. // ENCLOSING type (`Button(action: handleTap)` written inside that type) —
  269. // a same-named method on any OTHER class is a parameter collision
  270. // (Alamofire: a `request` parameter resolving to EventMonitor::request).
  271. // Scope method candidates to the from-symbol's type; top-level code has no
  272. // implicit self, so method targets are excluded there entirely. Free
  273. // functions are unaffected.
  274. if (ref.language === 'swift' && candidates.some((n) => n.kind === 'method')) {
  275. const fromNode = context.getNodeById?.(ref.fromNodeId);
  276. const sep = fromNode ? fromNode.qualifiedName.lastIndexOf('::') : -1;
  277. const classPrefix = fromNode && sep > 0 ? fromNode.qualifiedName.slice(0, sep) : null;
  278. candidates = candidates.filter((n) => {
  279. if (n.kind !== 'method') return true;
  280. if (!classPrefix) return false;
  281. const mSep = n.qualifiedName.lastIndexOf('::');
  282. if (mSep <= 0) return false;
  283. const methodPrefix = n.qualifiedName.slice(0, mSep);
  284. // Accept exact-scope matches plus suffix relationships either way, so
  285. // extension-declared members (`Holder::m`) still match a nested
  286. // from-scope (`Module::Holder::wire`) and vice versa.
  287. return (
  288. methodPrefix === classPrefix ||
  289. methodPrefix.endsWith(`::${classPrefix}`) ||
  290. classPrefix.endsWith(`::${methodPrefix}`)
  291. );
  292. });
  293. if (candidates.length === 0) return null;
  294. }
  295. // Same-file definition wins — the extraction gate guarantees most survivors
  296. // have one, and it's the dominant C pattern (static callback registered in
  297. // a same-file ops struct).
  298. const sameFile = candidates.filter((n) => n.filePath === ref.filePath);
  299. if (sameFile.length > 0) {
  300. // Swift: several same-named METHODS in one file is an API overload family
  301. // (`Session.request(...)` × N), and a bare identifier hitting it is almost
  302. // always a same-named parameter, not a method value (Alamofire A/B
  303. // finding) — refuse rather than guess. A single method (SwiftUI's
  304. // `action: handleTap`) still resolves.
  305. if (
  306. ref.language === 'swift' &&
  307. sameFile.length > 1 &&
  308. sameFile.every((n) => n.kind === 'method')
  309. ) {
  310. return null;
  311. }
  312. // Same-name overloads in one file are the same conceptual symbol; pick
  313. // the first by position for determinism.
  314. const target = sameFile.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
  315. return {
  316. original: ref,
  317. targetNodeId: target.id,
  318. confidence: sameFile.length === 1 ? 0.95 : 0.9,
  319. resolvedBy: 'function-ref',
  320. };
  321. }
  322. // Cross-file (imported names the import resolver didn't already claim):
  323. // only an unambiguous match resolves.
  324. if (candidates.length === 1) {
  325. return {
  326. original: ref,
  327. targetNodeId: candidates[0]!.id,
  328. confidence: 0.8,
  329. resolvedBy: 'function-ref',
  330. };
  331. }
  332. return null;
  333. }
  334. /**
  335. * A function nested inside another FUNCTION is only callable from within its
  336. * container — Python, JS/TS, and every closure language scope it lexically.
  337. * Resolving a bare name from elsewhere to a nested local fabricates an edge
  338. * scope already rules out: `join(...)` in one function must never bind to a
  339. * `join` defined inside a DIFFERENT function (#1230). A candidate whose
  340. * qualifiedName parent is a same-file function/method is kept only when the
  341. * ref originates inside that parent's line range. Class members are
  342. * unaffected (their parent resolves to a class-like node), as are top-level
  343. * symbols and C++ namespace-prefixed names (the prefix has no node).
  344. */
  345. function isLexicallyReachable(
  346. candidate: Node,
  347. ref: UnresolvedRef,
  348. context: ResolutionContext
  349. ): boolean {
  350. if (candidate.kind !== 'function') return true;
  351. const qn = candidate.qualifiedName;
  352. if (!qn || !qn.includes('::')) return true;
  353. const parentQn = qn.slice(0, qn.lastIndexOf('::'));
  354. const containers = context
  355. .getNodesByQualifiedName(parentQn)
  356. .filter(
  357. (p) =>
  358. p.filePath === candidate.filePath &&
  359. (p.kind === 'function' || p.kind === 'method') &&
  360. p.startLine <= candidate.startLine &&
  361. p.endLine >= candidate.endLine
  362. );
  363. if (containers.length === 0) return true;
  364. return (
  365. ref.filePath === candidate.filePath &&
  366. containers.some((p) => ref.line >= p.startLine && ref.line <= p.endLine)
  367. );
  368. }
  369. /**
  370. * Try to resolve a reference by exact name match
  371. */
  372. export function matchByExactName(
  373. ref: UnresolvedRef,
  374. context: ResolutionContext
  375. ): ResolvedRef | null {
  376. // `import`-kind nodes are import STATEMENTS, not definitions, so a reference
  377. // resolving to a sibling file's `import` is a meaningless edge — the real
  378. // import→definition resolution is the import resolver's job (resolveViaImport),
  379. // never name-matching here. Excluding them also removes a quadratic blow-up:
  380. // a ubiquitous package (`react`, `@superset-ui/core`, Python `logging`/`typing`)
  381. // is re-declared as an `import` node in every file that imports it, so K
  382. // unresolved import refs each scored K same-named import candidates through
  383. // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on
  384. // large import-heavy (front-end + back-end) repos (#915).
  385. const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
  386. .filter((n) => n.kind !== 'import')
  387. // Nested locals are only reachable from inside their container (#1230).
  388. .filter((n) => isLexicallyReachable(n, ref, context));
  389. if (candidates.length === 0) {
  390. return null;
  391. }
  392. // If only one match, use it — but penalize cross-language matches
  393. if (candidates.length === 1) {
  394. const isCrossLanguage = candidates[0]!.language !== ref.language;
  395. return {
  396. original: ref,
  397. targetNodeId: candidates[0]!.id,
  398. confidence: isCrossLanguage ? 0.5 : 0.9,
  399. resolvedBy: 'exact-match',
  400. };
  401. }
  402. // Ubiquitous-name ceiling (#999): above it, picking one target among K
  403. // same-named defs by directory proximity is unreliable AND O(K) per ref — the
  404. // quadratic behind the "Resolving refs" wedge on theme/SDK-vendoring repos.
  405. // Decline; the precise strategies (qualified-name, import, class-name) already
  406. // ran. Falls through to fuzzy, which itself only resolves a UNIQUE candidate.
  407. if (candidates.length > AMBIGUOUS_NAME_CEILING) {
  408. return null;
  409. }
  410. // Multiple matches - try to narrow down
  411. const bestMatch = findBestMatch(ref, candidates, context);
  412. if (bestMatch) {
  413. // Lower confidence when the match is from a distant/unrelated module
  414. const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
  415. const confidence = proximity >= 30 ? 0.7 : 0.4;
  416. return {
  417. original: ref,
  418. targetNodeId: bestMatch.id,
  419. confidence,
  420. resolvedBy: 'exact-match',
  421. };
  422. }
  423. return null;
  424. }
  425. /**
  426. * Try to resolve by qualified name
  427. */
  428. export function matchByQualifiedName(
  429. ref: UnresolvedRef,
  430. context: ResolutionContext
  431. ): ResolvedRef | null {
  432. // Check if the reference name looks qualified (contains :: or .)
  433. if (!ref.referenceName.includes('::') && !ref.referenceName.includes('.')) {
  434. return null;
  435. }
  436. // A method call `receiver.method()` can share an exact qualified name with a
  437. // config-file key: `service.process()` (a `calls` ref named `service.process`)
  438. // vs the yaml key `service.process`. Config keys are bound to their code refs
  439. // upstream by the framework resolvers (`@Value` → `references`); a `calls` ref
  440. // must never resolve to a yaml/properties config node — that's a wrong edge
  441. // AND it hides the real callee. Drop those from both the exact and the partial
  442. // candidate sets so resolution falls through to method resolution below (#1180).
  443. const keepForRef = (nodes: Node[]): Node[] =>
  444. ref.referenceKind === 'calls'
  445. ? nodes.filter(
  446. (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')),
  447. )
  448. : nodes;
  449. const candidates = keepForRef(context.getNodesByQualifiedName(ref.referenceName));
  450. if (candidates.length === 1) {
  451. return {
  452. original: ref,
  453. targetNodeId: candidates[0]!.id,
  454. confidence: 0.95,
  455. resolvedBy: 'qualified-name',
  456. };
  457. }
  458. // Several symbols share this exact qualified name (e.g. `Logger::log` declared
  459. // in two files — an ODR clash or separate translation units): prefer the one
  460. // in the call site's own file before the partial-match fallback below, else
  461. // the first-indexed def wins and a call in `b/svc` targets `a/svc` (#1079).
  462. if (candidates.length > 1) {
  463. const ordered = preferCallSiteFile(candidates, ref.filePath);
  464. if (ordered[0]!.filePath === ref.filePath) {
  465. return {
  466. original: ref,
  467. targetNodeId: ordered[0]!.id,
  468. confidence: 0.95,
  469. resolvedBy: 'qualified-name',
  470. };
  471. }
  472. }
  473. // Erlang qualified refs (#1610): every erlang function's qualifiedName
  474. // carries its arity (`mod::f/2`), and refs carry the call-site arity when it
  475. // is statically known.
  476. if (ref.language === 'erlang' && ref.referenceName.includes('::')) {
  477. // A ref WITH arity that missed the exact lookup names an arity that isn't
  478. // defined (or a module out of repo). Never fall through to the partial
  479. // match — its "last segment" would be the arity digits — and never settle
  480. // for a sibling arity: silent beats wrong.
  481. if (/\/\d{1,3}$/.test(ref.referenceName)) return null;
  482. // An arity-LESS qualified ref (dynamic MFA whose args list wasn't a
  483. // static literal): resolve only when the module defines exactly ONE arity
  484. // of that function; several arities with no signal is a guess.
  485. const base = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2);
  486. const prefix = `${ref.referenceName}/`;
  487. const arityCands = keepForRef(context.getNodesByName(base)).filter(
  488. (n) =>
  489. n.qualifiedName.startsWith(prefix) && /^\d{1,3}$/.test(n.qualifiedName.slice(prefix.length)),
  490. );
  491. if (arityCands.length === 1) {
  492. return {
  493. original: ref,
  494. targetNodeId: arityCands[0]!.id,
  495. confidence: 0.85,
  496. resolvedBy: 'qualified-name',
  497. };
  498. }
  499. return null;
  500. }
  501. // Try partial qualified name match — again preferring the call site's own
  502. // file when more than one symbol's qualifiedName ends with the reference.
  503. const parts = ref.referenceName.split(/[:.]/);
  504. const lastName = parts[parts.length - 1];
  505. if (lastName) {
  506. const partialCandidates = keepForRef(context.getNodesByName(lastName))
  507. .filter((candidate) => candidate.qualifiedName.endsWith(ref.referenceName));
  508. const chosen = preferCallSiteFile(partialCandidates, ref.filePath)[0];
  509. if (chosen) {
  510. return {
  511. original: ref,
  512. targetNodeId: chosen.id,
  513. confidence: 0.85,
  514. resolvedBy: 'qualified-name',
  515. };
  516. }
  517. }
  518. return null;
  519. }
  520. /**
  521. * When a symbol name is ambiguous across files, prefer the candidate(s) declared
  522. * in the call site's own file, keeping the rest in their original order (#1079).
  523. * A same-file definition is the strongest language-agnostic signal for which of
  524. * several same-named symbols a call means; without it, resolution collapses onto
  525. * whichever was indexed first, so a call in `b/svc` wrongly targets `a/svc`.
  526. * No-op when there are <2 candidates or none share the call site's file.
  527. */
  528. export function preferCallSiteFile(nodes: Node[], callSiteFile: string): Node[] {
  529. if (nodes.length < 2) return nodes;
  530. const same: Node[] = [];
  531. const other: Node[] = [];
  532. for (const n of nodes) {
  533. if (n.filePath === callSiteFile) same.push(n);
  534. else other.push(n);
  535. }
  536. return same.length ? [...same, ...other] : nodes;
  537. }
  538. /**
  539. * Languages whose object literals declare callable members — `export const
  540. * api = { call() {…}, get: () => {…} }` used as a namespace (#1573).
  541. */
  542. const OBJECT_LITERAL_LANGUAGES = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']);
  543. /** True when `inner`'s source range lies within `outer`'s (lines, then columns on a shared line). */
  544. function rangeWithin(inner: Node, outer: Node): boolean {
  545. const innerEnd = inner.endLine ?? inner.startLine;
  546. const outerEnd = outer.endLine ?? outer.startLine;
  547. if (inner.startLine < outer.startLine || innerEnd > outerEnd) return false;
  548. if (inner.startLine === outer.startLine && inner.startColumn < outer.startColumn) return false;
  549. if (innerEnd === outerEnd && inner.endColumn > outer.endColumn) return false;
  550. return true;
  551. }
  552. function sameRange(a: Node, b: Node): boolean {
  553. return (
  554. a.startLine === b.startLine &&
  555. a.startColumn === b.startColumn &&
  556. (a.endLine ?? a.startLine) === (b.endLine ?? b.startLine) &&
  557. a.endColumn === b.endColumn
  558. );
  559. }
  560. /**
  561. * Resolve `container.member` where `container` is a VALUE holding an object
  562. * literal — `export const api = { call() {…}, get: () => {…} }` used as the
  563. * module's namespace (#1573). The members are extracted as plain functions
  564. * with BARE qualified names inside the constant's source extent (there is no
  565. * `api::call`), so neither the `Container::member` lookup the class-shaped
  566. * kinds use (#825) nor the declared-type inference for singleton instances
  567. * (#1292) can reach them, and every such call resolved to nothing — or, via
  568. * an import, to the constant itself. This looks the member up by CONTAINMENT:
  569. * a node named `member` whose range lies inside the container's, in the
  570. * container's own file. A helper declared inside a member's body is not a
  571. * member and is skipped; nothing else in the file can donate a match. Calls
  572. * take callable kinds only; other references accept value members too.
  573. */
  574. export function resolveObjectLiteralMember(
  575. container: Node,
  576. member: string,
  577. ref: UnresolvedRef,
  578. context: ResolutionContext,
  579. confidence: number,
  580. resolvedBy: ResolvedRef['resolvedBy'],
  581. ): ResolvedRef | null {
  582. if (container.kind !== 'constant' && container.kind !== 'variable') return null;
  583. if (!OBJECT_LITERAL_LANGUAGES.has(container.language)) return null;
  584. if (!sameLanguageFamily(container.language, ref.language)) return null;
  585. const inFile = context.getNodesInFile(container.filePath);
  586. const callable = (n: Node) => n.kind === 'function' || n.kind === 'method';
  587. const valueMember = (n: Node) =>
  588. callable(n) || n.kind === 'property' || n.kind === 'variable' || n.kind === 'constant';
  589. const accepts = ref.referenceKind === 'calls' ? callable : valueMember;
  590. const inside = inFile.filter((n) => n.id !== container.id && rangeWithin(n, container));
  591. let candidates = inside.filter((n) => n.name === member && accepts(n));
  592. if (candidates.length === 0) return null;
  593. // Drop a candidate nested inside ANOTHER callable's body within the literal
  594. // (`{ run() { const call = () => {}; } }` — `call` is `run`'s local, not a
  595. // member). Strict containment: an identically-ranged sibling node for the
  596. // same member (a property node over an arrow function) is not a body.
  597. const bodies = inside.filter(callable);
  598. candidates = candidates.filter(
  599. (c) => !bodies.some((b) => b.id !== c.id && !sameRange(b, c) && rangeWithin(c, b))
  600. );
  601. if (candidates.length === 0) return null;
  602. // Several survivors (a property AND a function for one arrow member, say):
  603. // a callable first, then the earliest in source order.
  604. candidates.sort((a, b) => {
  605. const ca = callable(a) ? 0 : 1;
  606. const cb = callable(b) ? 0 : 1;
  607. if (ca !== cb) return ca - cb;
  608. return a.startLine - b.startLine || a.startColumn - b.startColumn;
  609. });
  610. return {
  611. original: ref,
  612. targetNodeId: candidates[0]!.id,
  613. confidence,
  614. resolvedBy,
  615. };
  616. }
  617. // Exported for the precedence unit tests (#1079): they assert the
  618. // preferredFqn → same-file → matches[0] ordering directly.
  619. export function resolveMethodOnType(
  620. typeName: string,
  621. methodName: string,
  622. ref: UnresolvedRef,
  623. context: ResolutionContext,
  624. confidence: number,
  625. resolvedBy: ResolvedRef['resolvedBy'],
  626. /**
  627. * Optional FQN that identifies WHICH class declaration `typeName`
  628. * refers to in the caller's file. When multiple candidates share
  629. * the same qualifiedName (`FooConverter::convert` in both
  630. * `dao/converter/` and `service/converter/`), the FQN's
  631. * file-path-suffix picks the right one — the disambiguation
  632. * signal Java imports carry but the call site doesn't (#314).
  633. */
  634. preferredFqn?: string,
  635. /** Recursion guard for the supertype/conformance walk. */
  636. depth = 0,
  637. ): ResolvedRef | null {
  638. // Look up methods by name and match by qualifiedName ending in
  639. // `<typeName>::<methodName>`. This works whether the method is defined
  640. // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate
  641. // file (`int Foo::bar() { ... }` in foo.cpp while class Foo is in foo.hpp).
  642. // The previous same-file approach missed the latter — the typical C++ layout.
  643. // Prefer the context's per-(type, method) memo: the raw name lookup fetches
  644. // EVERY node sharing the method name — tens of thousands of rows for a
  645. // collision-heavy Java name like `execute` — and re-filtering that per ref
  646. // was a dominant term in the #1122 watchdog kill on large repos. Only the
  647. // ref-independent filter is memoized; per-ref disambiguation stays below.
  648. let matches: Node[];
  649. if (context.getMethodMatches) {
  650. matches = context.getMethodMatches(typeName, methodName, ref.language);
  651. } else {
  652. const methodCandidates = context.getNodesByName(methodName);
  653. const want = `${typeName}::${methodName}`;
  654. matches = [];
  655. for (const m of methodCandidates) {
  656. if (m.kind !== 'method') continue;
  657. if (m.language !== ref.language) continue;
  658. const qn = m.qualifiedName;
  659. if (qn === want || qn.endsWith(`::${want}`)) {
  660. matches.push(m);
  661. }
  662. }
  663. }
  664. if (matches.length === 0) {
  665. // Conformance fallback: the method may be defined on a supertype `typeName`
  666. // extends, or on a protocol / trait it conforms to (e.g. a Swift protocol-
  667. // extension method, a C# default-interface or extension method, a Kotlin
  668. // extension on a supertype). Walk supertypes transitively (depth-capped) via
  669. // the resolved implements/extends edges — empty in the first resolution pass,
  670. // populated in the conformance pass. Still VALIDATED (the method must exist on
  671. // a supertype), so a wrong inference produces no edge.
  672. if (depth < 4 && context.getSupertypes) {
  673. const viaSupers = nmTimedT('rmot-supers', ref, (): ResolvedRef | null => {
  674. for (const supertype of context.getSupertypes!(typeName, ref.language)) {
  675. const via = resolveMethodOnType(
  676. supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1,
  677. );
  678. if (via) return via;
  679. }
  680. return null;
  681. });
  682. if (viaSupers) return viaSupers;
  683. }
  684. return null;
  685. }
  686. if (matches.length > 1 && preferredFqn) {
  687. const ext = ref.language === 'kotlin' ? '.kt' : '.java';
  688. const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
  689. const chosen = matches.find((m) => {
  690. const fp = m.filePath.replace(/\\/g, '/');
  691. return fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath);
  692. });
  693. if (chosen) {
  694. return {
  695. original: ref,
  696. targetNodeId: chosen.id,
  697. confidence,
  698. resolvedBy,
  699. };
  700. }
  701. }
  702. // Language-agnostic disambiguation: when several same-named methods survive
  703. // (e.g. two files each declaring `class Logger { void log(); }` — an ODR
  704. // clash, an anonymous-namespace type, or separate translation units), prefer
  705. // the definition in the CALL SITE's own file. Without this, every ambiguous
  706. // call collapses onto the first-indexed definition, so a call in `b/svc.cpp`
  707. // wrongly points at `a/svc.cpp` (#1079). This runs AFTER the `preferredFqn`
  708. // block, so Java/Kotlin import disambiguation — whose target is intentionally
  709. // in ANOTHER file (#314) — is unaffected: that block returns early whenever
  710. // an import FQN pins the class.
  711. const ordered = preferCallSiteFile(matches, ref.filePath);
  712. return {
  713. original: ref,
  714. targetNodeId: ordered[0]!.id,
  715. confidence,
  716. resolvedBy,
  717. };
  718. }
  719. // C++ keywords/control-flow tokens that can appear right before a receiver
  720. // (e.g. `return ptr->m()`) and must NOT be treated as a type.
  721. const CPP_NON_TYPE_TOKENS = new Set([
  722. 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
  723. 'break', 'continue', 'goto', 'throw', 'new', 'delete', 'co_await', 'co_yield',
  724. 'co_return', 'static_cast', 'const_cast', 'dynamic_cast', 'reinterpret_cast',
  725. 'sizeof', 'alignof', 'typeid', 'and', 'or', 'not', 'xor',
  726. ]);
  727. function normalizeCppTypeName(typeName: string): string | null {
  728. const normalized = typeName
  729. .replace(/\b(const|volatile|mutable|typename|class|struct)\b/g, ' ')
  730. .replace(/[&*]+/g, ' ')
  731. .replace(/<[^>]*>/g, ' ')
  732. .replace(/\s+/g, ' ')
  733. .trim();
  734. if (!normalized) return null;
  735. const parts = normalized.split(/::/).filter(Boolean);
  736. const last = parts[parts.length - 1];
  737. if (!last) return null;
  738. if (CPP_NON_TYPE_TOKENS.has(last)) return null;
  739. return last;
  740. }
  741. // Declarator regex: matches `Type receiver`, `Type* receiver`, `Type *receiver`,
  742. // `Type*receiver`, `Type<X> receiver`, etc., REQUIRING a declarator terminator
  743. // (`;`, `=`, `,`, `)`, `[`, `{`, `(`, or end-of-line) after the receiver. The
  744. // terminator rules out uses like `return receiver->m()` where the preceding
  745. // token is a keyword, not a type.
  746. function buildDeclaratorRegex(escapedReceiver: string): RegExp {
  747. return new RegExp(
  748. `([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`,
  749. );
  750. }
  751. function inferCppReceiverType(
  752. receiverName: string,
  753. ref: UnresolvedRef,
  754. context: ResolutionContext,
  755. depth = 0,
  756. ): string | null {
  757. // Per-file lines cache when available — this runs per `receiver->method()`
  758. // ref and re-splitting the file each time is the same quadratic as the
  759. // shared inferrer's (#1122).
  760. const lines = context.getFileLines
  761. ? context.getFileLines(ref.filePath)
  762. : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null);
  763. if (!lines || lines.length === 0) return null;
  764. const callLineIndex = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
  765. const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  766. const receiverPattern = new RegExp(`\\b${escapedReceiver}\\b`);
  767. const declaratorRegex = buildDeclaratorRegex(escapedReceiver);
  768. for (let i = callLineIndex; i >= 0; i--) {
  769. const line = lines[i];
  770. if (!line || !receiverPattern.test(line)) continue;
  771. const declaratorMatch = line.match(declaratorRegex);
  772. if (declaratorMatch) {
  773. const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
  774. if (normalized === 'auto') {
  775. // `auto x = Foo::instance();` — the declared type is deduced; recover it
  776. // from the initializer (call return type / construction) (#645).
  777. const initType = inferCppAutoInitializerType(line, receiverName, ref, context, depth);
  778. if (initType) return initType;
  779. // No usable initializer on this line — keep scanning earlier ones.
  780. } else if (normalized) {
  781. return normalized;
  782. }
  783. }
  784. }
  785. const headerCandidates = [
  786. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.h'),
  787. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hpp'),
  788. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hxx'),
  789. ].filter((candidate, index, arr) => arr.indexOf(candidate) === index && candidate !== ref.filePath);
  790. for (const headerPath of headerCandidates) {
  791. if (!context.fileExists(headerPath)) continue;
  792. const headerLines = context.getFileLines
  793. ? context.getFileLines(headerPath)
  794. : (context.readFile(headerPath)?.split(/\r?\n/) ?? null);
  795. if (!headerLines) continue;
  796. for (const line of headerLines) {
  797. if (!receiverPattern.test(line)) continue;
  798. const declaratorMatch = line.match(declaratorRegex);
  799. if (!declaratorMatch) continue;
  800. const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
  801. if (normalized && normalized !== 'auto') return normalized;
  802. }
  803. }
  804. return null;
  805. }
  806. /**
  807. * Last `::`-separated segment of a (possibly namespace-qualified) C++ name.
  808. */
  809. function cppLastSegment(name: string): string {
  810. const parts = name.split('::').filter(Boolean);
  811. return parts[parts.length - 1] ?? name;
  812. }
  813. /**
  814. * Return type captured at extraction for `Class::method` (or a free function),
  815. * read off the indexed node's `returnType` — used by the C++ (#645) and PHP
  816. * (#608) chained-call resolvers. Language-filtered. Null when not indexed or no
  817. * return type was recorded (a `void`/primitive return).
  818. */
  819. function lookupCalleeReturnType(
  820. callee: string,
  821. ref: UnresolvedRef,
  822. context: ResolutionContext,
  823. ): string | null {
  824. let method = callee;
  825. let cls: string | null = null;
  826. if (callee.includes('::')) {
  827. const parts = callee.split('::').filter(Boolean);
  828. method = parts[parts.length - 1] ?? callee;
  829. cls = parts.slice(0, -1).join('::');
  830. }
  831. const candidates = context.getNodesByName(method).filter(
  832. (n) =>
  833. (n.kind === 'method' || n.kind === 'function') &&
  834. n.language === ref.language &&
  835. !!n.returnType,
  836. );
  837. if (cls) {
  838. const want = `${cls}::${method}`;
  839. // The call site may name the class with MORE namespace qualification than
  840. // the stored node (`details::registry::instance` at the call vs
  841. // `registry::instance` on the node — the receiver type only carries the
  842. // immediate class), or LESS. Accept an exact match or either being a
  843. // namespace-suffix of the other; the shared `::<class>::<method>` tail keeps
  844. // it specific.
  845. const m = candidates.find(
  846. (n) =>
  847. n.qualifiedName === want ||
  848. n.qualifiedName.endsWith(`::${want}`) ||
  849. want.endsWith(`::${n.qualifiedName}`),
  850. );
  851. return m?.returnType ?? null;
  852. }
  853. return candidates.find((n) => n.kind === 'function')?.returnType ?? null;
  854. }
  855. /** Does the graph contain an aggregate type named `name`'s last segment? */
  856. function cppClassExists(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
  857. const last = cppLastSegment(name);
  858. return context
  859. .getNodesByName(last)
  860. .some((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'union') && n.language === ref.language);
  861. }
  862. /**
  863. * Infer the class produced by a C++ call/construction expression, using return
  864. * types captured at extraction (#645). Handles, in order:
  865. * - `make_unique<T>()` / `make_shared<T>()` → T
  866. * - single-level member call `recv.method()` → recv's type, then method's return
  867. * - `Class::method()` / free `func()` → the callee's recorded return type
  868. * - direct construction `Type()` / `ns::Type()` → Type
  869. * Returns null when undeterminable. Callers MUST still validate the outer method
  870. * exists on the result before creating an edge, so a wrong guess stays silent.
  871. */
  872. function resolveCppCallResultType(
  873. inner: string,
  874. ref: UnresolvedRef,
  875. context: ResolutionContext,
  876. depth = 0,
  877. ): string | null {
  878. if (depth > 3) return null; // guard against pathological mutual recursion
  879. const expr = inner.trim();
  880. const make = expr.match(/(?:^|::)(?:make_unique|make_shared)\s*<\s*([A-Za-z_]\w*)/);
  881. if (make) return make[1] ?? null;
  882. // Single-level member call `recv.method` (the `manager.view().render()` shape).
  883. const dotIdx = expr.lastIndexOf('.');
  884. if (dotIdx > 0) {
  885. const recv = expr.slice(0, dotIdx);
  886. const method = expr.slice(dotIdx + 1);
  887. if (recv.includes('.') || recv.includes('(') || recv.includes('::')) return null; // single level only
  888. const recvType = inferCppReceiverType(recv, ref, context, depth + 1);
  889. if (!recvType) return null;
  890. return lookupCalleeReturnType(`${recvType}::${method}`, ref, context);
  891. }
  892. const ret = lookupCalleeReturnType(expr, ref, context);
  893. if (ret) return ret;
  894. // Direct construction — the callee itself names a class/struct.
  895. if (cppClassExists(expr, ref, context)) return cppLastSegment(expr);
  896. return null;
  897. }
  898. /**
  899. * Recover the type of an `auto`-declared local from its initializer on the
  900. * declaration line — `auto x = Foo::instance();`, `auto w = make_unique<W>();`,
  901. * `auto p = new W();`, `auto w = Widget();` (#645).
  902. */
  903. function inferCppAutoInitializerType(
  904. line: string,
  905. receiverName: string,
  906. ref: UnresolvedRef,
  907. context: ResolutionContext,
  908. depth: number,
  909. ): string | null {
  910. const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  911. const m = line.match(new RegExp(`\\b${escaped}\\b\\s*=\\s*([^;]+)`));
  912. if (!m || !m[1]) return null;
  913. const init = m[1].trim();
  914. const neu = init.match(/^new\s+([A-Za-z_][\w:]*)/);
  915. if (neu && neu[1]) return cppLastSegment(neu[1]);
  916. // A call or construction: `Foo(...)`, `A::b(...)`, `make_unique<T>(...)`.
  917. const call = init.match(/^([A-Za-z_][\w:]*(?:\s*<[^>;]*>)?)\s*\(/);
  918. if (call && call[1]) return resolveCppCallResultType(call[1].replace(/\s+/g, ''), ref, context, depth + 1);
  919. return null;
  920. }
  921. /**
  922. * Resolve a C++ chained call whose receiver is itself a call — encoded by the
  923. * extractor as `<innerCallee>().<method>` (#645). The receiver's type is what
  924. * the inner call returns; the outer method is then resolved and VALIDATED on it
  925. * (resolveMethodOnType requires `cls::method` to exist), so a wrong inference
  926. * produces no edge rather than a wrong one.
  927. */
  928. export function matchCppCallChain(
  929. ref: UnresolvedRef,
  930. context: ResolutionContext,
  931. ): ResolvedRef | null {
  932. const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
  933. if (!m || !m[1] || !m[2]) return null;
  934. const cls = resolveCppCallResultType(m[1], ref, context);
  935. if (!cls) return null;
  936. return resolveMethodOnType(cls, m[2], ref, context, 0.85, 'instance-method');
  937. }
  938. /**
  939. * Resolve a `::`-scoped factory chain whose receiver is a scoped/static call —
  940. * PHP `Cls::for($x)->method()` (#608, the per-credential Laravel client idiom) or
  941. * Rust `Foo::new().bar()` (an associated-function call) — both encoded by the
  942. * extractor as `Cls::factory().method`. The receiver's type is what `Cls::factory`
  943. * returns: a `self` marker (PHP `: self`/`: static`, Rust `-> Self`) resolves to
  944. * the factory's own type, a concrete return type to that type. The outer method is
  945. * then resolved and VALIDATED on it (resolveMethodOnType requires the method to
  946. * exist on the type or a supertype it conforms to), so a wrong inference yields no
  947. * edge rather than a wrong one. Shared by the `::`-receiver languages (PHP, Rust).
  948. */
  949. export function matchScopedCallChain(
  950. ref: UnresolvedRef,
  951. context: ResolutionContext,
  952. ): ResolvedRef | null {
  953. const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
  954. if (!m || !m[1] || !m[2]) return null;
  955. const inner = m[1];
  956. const method = m[2];
  957. if (!inner.includes('::')) return null; // only static-factory (`Cls::method`) chains
  958. const factoryClass = inner.slice(0, inner.lastIndexOf('::'));
  959. const ret = lookupCalleeReturnType(inner, ref, context);
  960. if (!ret) return null;
  961. // `self` (the extractor's marker for self/static/$this) → the factory's class.
  962. const resolvedClass = ret === 'self' ? factoryClass : ret;
  963. return resolveMethodOnType(resolvedClass, method, ref, context, 0.85, 'instance-method');
  964. }
  965. /**
  966. * Languages where an unprefixed capitalized call `Foo(args)` constructs the
  967. * class (so a `Foo(args).method()` receiver's type is `Foo`). Java/C# need `new`,
  968. * so a bare `Foo()` there is a method call, not construction — excluded. Scala's
  969. * `Foo(args)` is a case-class / companion `apply`, which conventionally returns
  970. * `Foo` — and resolveMethodOnType validates, so a non-conventional `apply` that
  971. * returns another type simply yields no edge rather than a wrong one. Pascal/Delphi:
  972. * a `TFoo(x)` is a TYPECAST whose result is a `TFoo`, so `TFoo(x).method()` resolves
  973. * the method on `TFoo` — same shape, same validation.
  974. */
  975. const CONSTRUCTS_VIA_BARE_CALL = new Set(['kotlin', 'swift', 'scala', 'dart', 'pascal']);
  976. /**
  977. * Resolve a dotted chained call whose receiver is a static factory / fluent call —
  978. * `Foo.getInstance().bar()`, encoded by the extractor as `Foo.getInstance().bar`
  979. * (#645/#608 mechanism). The receiver's type is what `Foo.getInstance` returns
  980. * (its declared return type); the outer method is then resolved and VALIDATED on
  981. * it (resolveMethodOnType requires `Type::method` to exist), so a wrong inference
  982. * yields no edge rather than a wrong one (e.g. a same-named `bar()` on an
  983. * unrelated class is never matched). Shared by the dot-notation languages
  984. * (Java, Kotlin, C#, Swift) — same receiver shape, same `Class::method` qualified names.
  985. */
  986. export function matchDottedCallChain(
  987. ref: UnresolvedRef,
  988. context: ResolutionContext,
  989. ): ResolvedRef | null {
  990. const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
  991. if (!m || !m[1] || !m[2]) return null;
  992. const inner = m[1]; // `Foo.getInstance`
  993. const method = m[2]; // `bar`
  994. const lastDot = inner.lastIndexOf('.');
  995. if (lastDot <= 0) {
  996. // Go: bare package-level factory FUNCTION `New().method()` — the receiver's
  997. // type is what `New` returns; resolve the method on that.
  998. if (ref.language === 'go') {
  999. const ret = lookupCalleeReturnType(inner, ref, context);
  1000. if (ret) {
  1001. return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
  1002. }
  1003. // `inner` isn't a function with a captured return type — typically a
  1004. // package-level VARIABLE holding a function value (e.g. gin's `engine()`),
  1005. // whose type we can't recover. Fall back to bare-name resolution of the
  1006. // method so we don't DROP an edge the un-re-encoded bare path would have
  1007. // found. (When `inner` IS a real factory function but the method doesn't
  1008. // exist on its return type, `ret` is truthy and we returned no edge above —
  1009. // the absent-method safety guarantee is preserved.)
  1010. //
  1011. // CRITICAL: resolve the TARGET via a synthetic bare-name ref, but return the
  1012. // match tied to the ORIGINAL `ref` (referenceName `inner().method`). The
  1013. // batched resolver (resolveAndPersistBatched) reads unresolved rows from
  1014. // offset 0 every pass and relies on the post-batch cleanup (row-id delete
  1015. // for DB-loaded refs, referenceName-keyed delete otherwise, #1269) to
  1016. // clear each resolved row so the batch empties. If we propagated the
  1017. // synthetic ref's bare `method` as `.original`, a key-based delete
  1018. // would never match the stored `inner().method` row, the batch would
  1019. // never drain, and the loop would re-resolve + re-insert forever (a runaway
  1020. // that grew gin's graph to 5M edges / 1.4 GB before this fix).
  1021. const bareRef = { ...ref, referenceName: method };
  1022. const bareMatch = matchByExactName(bareRef, context) ?? matchFuzzy(bareRef, context);
  1023. return bareMatch ? { ...bareMatch, original: ref } : null;
  1024. }
  1025. // Constructor receiver `Foo(args).method()` (encoded `Foo().method`): a bare,
  1026. // capitalized inner is a class construction, so the receiver's type is the
  1027. // class itself — resolve the method on it. Only in languages where an
  1028. // unprefixed capitalized call constructs the class (Kotlin, Swift); in Java/C#
  1029. // a bare `Foo()` is a method call (constructors need `new`), so we must not
  1030. // assume construction. A lowercase bare inner is a top-level `factory().method()`
  1031. // whose type we can't recover — bail.
  1032. if (!CONSTRUCTS_VIA_BARE_CALL.has(ref.language) || !/^[A-Z]/.test(inner)) return null;
  1033. return resolveMethodOnType(inner, method, ref, context, 0.85, 'instance-method', importedFqnOf(inner, ref, context));
  1034. }
  1035. // Factory/fluent receiver `Receiver.factory(args).method()`: the receiver's
  1036. // type is what `Receiver.factory` returns (its declared return type).
  1037. const factoryClass = inner.slice(0, lastDot).split('.').pop(); // simple class name
  1038. const factoryMethod = inner.slice(lastDot + 1);
  1039. if (!factoryClass || !factoryMethod) return null;
  1040. const ret = lookupCalleeReturnType(`${factoryClass}::${factoryMethod}`, ref, context);
  1041. if (!ret) {
  1042. // Objective-C: a class-message factory — `[X alloc]`, `[X new]`,
  1043. // `[X sharedFoo]` — returns an instance of the RECEIVER class `X` by
  1044. // convention (`instancetype`). So when the factory's own return type isn't
  1045. // recoverable (its selector returns `instancetype`, or `alloc`/`new` aren't
  1046. // user-defined nodes at all), the receiver's type is the class `X` itself.
  1047. // This resolves the ubiquitous `[[X alloc] init]` and singleton chains.
  1048. // resolveMethodOnType validates against X (and its supertypes), so a class
  1049. // whose method actually lives elsewhere yields NO edge, not a wrong one — and
  1050. // crucially this does NOT fire when a concrete return type WAS captured but
  1051. // simply lacks the method (that already returned null above: absent-method
  1052. // safety, so a same-named decoy is still never matched).
  1053. if (ref.language === 'objc' && /^[A-Z]/.test(factoryClass)) {
  1054. return resolveMethodOnType(factoryClass, method, ref, context, 0.8, 'instance-method', importedFqnOf(factoryClass, ref, context));
  1055. }
  1056. // Pascal/Delphi: the extractor only re-encodes a `TFoo`/`IFoo`-prefixed chain
  1057. // (the type-naming convention), so `factoryClass` is always a real class here.
  1058. // A factory whose return type wasn't captured is a CONSTRUCTOR
  1059. // (`TFileMem.Create().SetCachePerformance` — `constructor Create` has no `:
  1060. // TBar` annotation but returns its own class) or an unannotated function. In
  1061. // both cases the receiver's type is the class itself, so resolve the method on
  1062. // `factoryClass`. resolveMethodOnType validates against it (and its
  1063. // supertypes), so a wrong inference yields no edge — and this never fires when
  1064. // a return type WAS captured but lacks the method (absent-method safety above).
  1065. if (ref.language === 'pascal' && /^[TI]/.test(factoryClass)) {
  1066. return resolveMethodOnType(factoryClass, method, ref, context, 0.8, 'instance-method', importedFqnOf(factoryClass, ref, context));
  1067. }
  1068. return null;
  1069. }
  1070. return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
  1071. }
  1072. /**
  1073. * When several classes share a simple type name, the caller file's import of
  1074. * that type is the only signal that names WHICH one (#314). Returns the imported
  1075. * FQN for `typeName` in the ref's file, or undefined.
  1076. */
  1077. function importedFqnOf(
  1078. typeName: string,
  1079. ref: UnresolvedRef,
  1080. context: ResolutionContext,
  1081. ): string | undefined {
  1082. const imports = context.getImportMappings(ref.filePath, ref.language);
  1083. return imports.find((i) => i.localName === typeName)?.source;
  1084. }
  1085. /**
  1086. * Java/Kotlin: infer a receiver's declared type by walking field declarations
  1087. * in the class enclosing the call site. The field's `signature` is already in
  1088. * the form "<TypeName> <fieldName>" (set by tree-sitter.ts extractField), so we
  1089. * pull the type from there. Handles Spring `@Resource UserBO userbo;` /
  1090. * `@Autowired private UserService userService;` where the receiver field name
  1091. * doesn't match the class name by Java naming convention.
  1092. *
  1093. * Returns the bare type name (generics stripped, dotted package stripped) or
  1094. * null when no matching field is in the enclosing class.
  1095. */
  1096. function inferJavaFieldReceiverType(
  1097. receiverName: string,
  1098. ref: UnresolvedRef,
  1099. context: ResolutionContext,
  1100. ): string | null {
  1101. const inFile = context.getNodesInFile(ref.filePath);
  1102. if (inFile.length === 0) return null;
  1103. // Find the class enclosing the call line (tightest match by latest start).
  1104. let enclosing: Node | null = null;
  1105. for (const n of inFile) {
  1106. if (n.kind !== 'class' && n.kind !== 'interface') continue;
  1107. if (n.language !== ref.language) continue;
  1108. const end = n.endLine ?? n.startLine;
  1109. if (n.startLine <= ref.line && end >= ref.line) {
  1110. if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n;
  1111. }
  1112. }
  1113. if (!enclosing) return null;
  1114. const enclosingEnd = enclosing.endLine ?? enclosing.startLine;
  1115. const field = inFile.find(
  1116. (n) =>
  1117. n.kind === 'field' &&
  1118. n.name === receiverName &&
  1119. n.language === ref.language &&
  1120. n.startLine >= enclosing.startLine &&
  1121. (n.endLine ?? n.startLine) <= enclosingEnd,
  1122. );
  1123. if (!field || !field.signature) return null;
  1124. // Signature shape: "<TypeName> <fieldName>" (extractField). Pull the type,
  1125. // strip generics + dotted package, drop array/varargs markers.
  1126. const beforeName = field.signature.slice(
  1127. 0,
  1128. field.signature.lastIndexOf(field.name),
  1129. );
  1130. const typeRaw = beforeName.trim();
  1131. if (!typeRaw) return null;
  1132. const typeNoGenerics = typeRaw.replace(/<[^>]*>/g, '').trim();
  1133. const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').trim();
  1134. const parts = typeNoArray.split(/[.\s]+/).filter(Boolean);
  1135. const lastPart = parts[parts.length - 1];
  1136. if (!lastPart) return null;
  1137. if (!/^[A-Z]/.test(lastPart)) return null; // primitives / lowercase → skip
  1138. return lastPart;
  1139. }
  1140. // ── Local-variable receiver-type inference (#1108) ──────────────────────────
  1141. //
  1142. // Instance calls through a local variable (`const lg = new Logger(); lg.log()`)
  1143. // only resolved in C++ before this — no other language could learn the
  1144. // receiver's type. Local variables are not indexed as nodes (node-explosion),
  1145. // so, like the C++ inferrer above, we read the enclosing function's source and
  1146. // match the receiver's declaration/initializer to recover its type. The type is
  1147. // then handed to resolveMethodOnType, which VALIDATES that the type actually
  1148. // declares the method, so a mis-inference produces NO edge — the safety net
  1149. // that lets the patterns below stay simple. C++ keeps its dedicated inferrer
  1150. // (header scan + `auto`); this covers every other language.
  1151. // Tokens a loose pattern might capture that are never a user-defined type.
  1152. const NON_TYPE_RECEIVER_TOKENS = new Set([
  1153. 'this', 'self', 'super', 'new', 'return', 'await', 'yield', 'typeof',
  1154. 'null', 'nil', 'None', 'true', 'false', 'True', 'False', 'undefined',
  1155. ]);
  1156. /**
  1157. * Normalize a captured type expression to a simple type name: drop generic
  1158. * args and pointer/ref markers, take the last `.`/`::`-qualified segment, and
  1159. * reject obvious non-types.
  1160. */
  1161. export function normalizeInferredTypeName(raw: string): string | null {
  1162. const cleaned = raw.replace(/<[^>]*>/g, '').replace(/[&*]/g, '').trim();
  1163. const seg = cleaned.split(/[.:]+/).filter(Boolean).pop();
  1164. if (!seg) return null;
  1165. if (NON_TYPE_RECEIVER_TOKENS.has(seg)) return null;
  1166. return seg;
  1167. }
  1168. /**
  1169. * Per-language patterns that recover a local variable's (or typed parameter's)
  1170. * type from its declaration/initializer. Each regex captures the type in group
  1171. * 1; `r` is the already-escaped receiver name. Ordered most-specific first.
  1172. * PascalCase is required in the capture where the language convention allows,
  1173. * as a cheap false-positive guard on top of resolveMethodOnType's validation.
  1174. */
  1175. /**
  1176. * Compiled-pattern memo for the receiver-type pattern builders below. They
  1177. * run for EVERY `receiver.method()` ref the matcher attempts, compiling 2–4
  1178. * fresh RegExp objects per call — and receivers repeat massively (`self`
  1179. * alone accounts for tens of thousands of refs on a Lua repo, measured 41µs
  1180. * per methodCall miss on kong with compilation a large slice). The patterns
  1181. * are a pure function of (language, receiver) and non-global (`.match()`
  1182. * never touches lastIndex), so shared instances are behavior-identical.
  1183. * FIFO-capped with no per-get mutation (the §7a.6 LRU-churn lesson): a hit
  1184. * costs one Map lookup, overflow evicts oldest, and an evicted entry simply
  1185. * recompiles exactly as every call did before this memo.
  1186. */
  1187. const PATTERN_MEMO = new Map<string, RegExp[]>();
  1188. const PATTERN_MEMO_CAP = 8192;
  1189. /**
  1190. * Per-context incremental receiver-scan states for inferLocalReceiverType
  1191. * (see the memo comment there). Keyed (file, scopeStart, language, receiver);
  1192. * entries are a few dozen bytes, count is bounded by distinct receiver uses
  1193. * (same order as the context's other per-file caches). MUST drop whenever the
  1194. * context's file caches drop — the states are derived from file lines — so
  1195. * ReferenceResolver.clearCaches calls clearNameMatcherMemos alongside
  1196. * clearImportResolverMemos.
  1197. */
  1198. type InferScanState = { hi: number; ansIdx: number; ansType: string | null };
  1199. const INFER_SCAN_STATES = new WeakMap<ResolutionContext, Map<string, InferScanState>>();
  1200. function getInferScanStates(context: ResolutionContext): Map<string, InferScanState> {
  1201. let m = INFER_SCAN_STATES.get(context);
  1202. if (!m) {
  1203. m = new Map();
  1204. INFER_SCAN_STATES.set(context, m);
  1205. }
  1206. return m;
  1207. }
  1208. /** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
  1209. export function clearNameMatcherMemos(context: ResolutionContext): void {
  1210. INFER_SCAN_STATES.delete(context);
  1211. }
  1212. function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
  1213. const hit = PATTERN_MEMO.get(key);
  1214. if (hit) return hit;
  1215. const patterns = build();
  1216. if (PATTERN_MEMO.size >= PATTERN_MEMO_CAP) {
  1217. const oldest = PATTERN_MEMO.keys().next().value;
  1218. if (oldest !== undefined) PATTERN_MEMO.delete(oldest);
  1219. }
  1220. PATTERN_MEMO.set(key, patterns);
  1221. return patterns;
  1222. }
  1223. export function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
  1224. return memoPatterns(`${language}|${r}`, () => buildLocalReceiverTypePatterns(language, r));
  1225. }
  1226. function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] {
  1227. switch (language) {
  1228. case 'typescript':
  1229. case 'javascript':
  1230. case 'tsx':
  1231. case 'jsx':
  1232. case 'arkts':
  1233. return [
  1234. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
  1235. // No keyword requirement, so this matches BOTH a local annotation
  1236. // (`const lg: Logger`) and a typed parameter (`function use(lg: Logger)`
  1237. // / `(lg: Logger) =>`) — the parameter case the old `const|let|var`
  1238. // prefix excluded (#1125). Mirrors Kotlin/Swift/Scala; the capture stops
  1239. // at `<` so a generic-typed param (`repo: Repository<User>`) still yields
  1240. // `Repository`. resolveMethodOnType validates the type actually declares
  1241. // the method, so the looser match produces no edge on a mis-inference.
  1242. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger (annotation or typed param)
  1243. ];
  1244. case 'python':
  1245. return [
  1246. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // lg = Logger(...)
  1247. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // lg: Logger (PEP 526)
  1248. ];
  1249. case 'java':
  1250. return [
  1251. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger()
  1252. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param
  1253. ];
  1254. case 'kotlin':
  1255. return [
  1256. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // val lg = Logger(...)
  1257. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // val lg: Logger / param
  1258. ];
  1259. case 'csharp':
  1260. return [
  1261. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger()
  1262. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param
  1263. ];
  1264. case 'swift':
  1265. return [
  1266. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // let lg = Logger(...)
  1267. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // let lg: Logger / param
  1268. ];
  1269. case 'rust':
  1270. return [
  1271. new RegExp(`\\blet\\s+(?:mut\\s+)?${r}\\b(?:\\s*:[^=]+)?=\\s*&?(?:mut\\s+)?([A-Z][\\w]*)`), // let lg = Logger::new()/Logger{}/Logger
  1272. // No `let`, so this covers a `let lg: Logger` binding AND a typed
  1273. // parameter (`fn use(lg: &Logger)`, a closure `|lg: Logger|`) — the
  1274. // parameter case the old `let`-anchored pattern excluded (#1125).
  1275. new RegExp(`\\b${r}\\s*:\\s*&?(?:mut\\s+)?([A-Z][\\w]*)`), // lg: Logger (binding or typed param)
  1276. ];
  1277. case 'go':
  1278. return [
  1279. new RegExp(`\\b${r}\\b\\s*:=\\s*&?([A-Za-z_][\\w.]*)\\s*{`), // lg := Logger{} / &Logger{}
  1280. new RegExp(`\\bvar\\s+${r}\\s+\\*?([A-Za-z_][\\w.]*)`), // var lg Logger / *Logger
  1281. // A typed parameter / method receiver (`func use(lg Logger)`,
  1282. // `func (l Logger) M()`) — name-before-type with no `var`/`:=` (#1125).
  1283. // PascalCase-guarded (unlike the anchored patterns above) to keep the
  1284. // keyword-free `ident Type` shape from matching unrelated pairs; the
  1285. // enclosing-scope bound already excludes package-level struct fields.
  1286. new RegExp(`\\b${r}\\s+\\*?([A-Z][\\w.]*)`), // func use(lg Logger) / (l Logger)
  1287. ];
  1288. case 'ruby':
  1289. return [
  1290. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w:]*)\\.new\\b`), // lg = Logger.new
  1291. ];
  1292. case 'scala':
  1293. return [
  1294. new RegExp(`\\b${r}\\b\\s*=\\s*(?:new\\s+)?([A-Z][\\w.]*)`), // val lg = new Logger / Logger(...)
  1295. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // val lg: Logger / param
  1296. ];
  1297. case 'dart':
  1298. return [
  1299. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // var lg = Logger(...)
  1300. // Trailing `[=;,)]` (not just `[=;]`) so a typed parameter — `Logger lg)`
  1301. // / `Logger lg,` — matches too, not only `Logger lg = ...` / `Logger lg;`
  1302. // (#1125). Mirrors Java/C#.
  1303. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg = ... / param
  1304. ];
  1305. case 'php':
  1306. return [
  1307. new RegExp(`\\$?${r}\\b\\s*=\\s*new\\s+([A-Za-z_\\\\][\\w\\\\]*)`), // $lg = new Logger()
  1308. // A typed parameter (`function use(Logger $lg)`, `?Logger $lg`,
  1309. // `\\App\\Logger $lg`, `&$lg` by-ref) and a typed `catch (E $e)` — the
  1310. // type sits before the `$`-variable (#1125). Namespace `\\` allowed.
  1311. new RegExp(`\\b([A-Za-z_\\\\][\\w\\\\]*)\\s+&?\\$${r}\\b`), // Logger $lg (typed param)
  1312. ];
  1313. case 'lua':
  1314. case 'luau':
  1315. return [
  1316. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\.new\\b`), // local lg = Logger.new()
  1317. new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\s*\\(`), // local lg = Logger(...) (callable table)
  1318. // Luau annotation (`local lg: Logger`) / typed param — but Lua's
  1319. // method-call syntax is the IDENTICAL `receiver:Name` shape, and the
  1320. // backward scan starts on the call's own line, so without a gate any
  1321. // PascalCase method call (`lg:Log()`, the Roblox convention)
  1322. // self-matches as "type = Log" before the scan reaches the real
  1323. // declaration (#1124). The lookahead rejects a capture followed by
  1324. // any of Lua's three call forms — `(args)`, `"s"`/`'s'`/`[[s]]`,
  1325. // `{t}` — and its leading `[\w.]` alternative stops backtracking from
  1326. // shrinking the capture to dodge the gate (`lg:Log()` would otherwise
  1327. // still match, as `Lo`).
  1328. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)(?![\\w.]|\\s*[({"'\\[])`), // local lg: Logger / typed param
  1329. ];
  1330. case 'r':
  1331. return [
  1332. new RegExp(`\\b${r}\\b\\s*(?:<-|<<-|=)\\s*([A-Z][\\w.]*)\\$new\\b`), // lg <- Logger$new() (R6)
  1333. ];
  1334. case 'pascal':
  1335. return [
  1336. new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w]*)`), // var lg: TLogger / param lg: TLogger
  1337. new RegExp(`\\b${r}\\b\\s*:=\\s*([A-Z][\\w.]*)\\.Create\\b`), // lg := TLogger.Create
  1338. ];
  1339. case 'cfml':
  1340. case 'cfscript':
  1341. return [
  1342. // svc = new UserService() / new path.to.UserService() — dotted component
  1343. // paths reduce to their final segment via normalizeInferredTypeName.
  1344. // Also matches inside tag markup (`<cfset svc = new UserService()>`)
  1345. // since the scan reads raw source lines.
  1346. new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`),
  1347. // The classic form: svc = createObject("component", "path.to.UserService")
  1348. // (casing of createObject varies in the wild), plus the modern
  1349. // single-argument form createObject("path.to.UserService").
  1350. new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']component["']\\s*,\\s*["']([\\w.]+)["']`),
  1351. new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']([\\w.]+)["']\\s*\\)`),
  1352. // Typed cfscript parameter: `function save(UserService svc)` /
  1353. // `required UserService svc` — CFML's built-in types (string, numeric,
  1354. // any, struct…) are lowercase by convention, so the PascalCase guard
  1355. // excludes them.
  1356. new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`),
  1357. // Tag-form typed argument, either attribute order:
  1358. // <cfargument name="svc" type="path.to.UserService">
  1359. new RegExp(`\\bcfargument[^>\\n]*\\bname\\s*=\\s*["']${r}["'][^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["']`, 'i'),
  1360. new RegExp(`\\bcfargument[^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["'][^>\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
  1361. // Component property (incl. WireBox DI): `property name="svc"
  1362. // inject="UserService";` / `<cfproperty name="svc" type="UserService">`,
  1363. // either attribute order. An inject DSL value with a namespace
  1364. // (`inject="svc@core"`) captures only the leading name and simply
  1365. // fails type-validation — no edge, never a wrong one.
  1366. new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\bname\\s*=\\s*["']${r}["'][^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["']`, 'i'),
  1367. new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["'][^;\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
  1368. ];
  1369. default:
  1370. return [];
  1371. }
  1372. }
  1373. /** 1-based start line of the tightest function/method enclosing the call. */
  1374. function enclosingScopeStartLine(ref: UnresolvedRef, context: ResolutionContext): number {
  1375. let start = 1;
  1376. for (const n of context.getNodesInFile(ref.filePath)) {
  1377. if (n.kind !== 'function' && n.kind !== 'method') continue;
  1378. if (n.language !== ref.language) continue;
  1379. const end = n.endLine ?? n.startLine;
  1380. if (n.startLine <= ref.line && end >= ref.line && n.startLine >= start) {
  1381. start = n.startLine;
  1382. }
  1383. }
  1384. return start;
  1385. }
  1386. /**
  1387. * Infer a receiver's type from its local declaration/initializer in the
  1388. * enclosing function body. Language-dispatched; returns null for languages
  1389. * without patterns or when no declaration is found. Bounded to the enclosing
  1390. * scope so a same-named variable in another function can't leak in.
  1391. */
  1392. function inferLocalReceiverType(
  1393. receiverName: string,
  1394. ref: UnresolvedRef,
  1395. context: ResolutionContext,
  1396. ): string | null {
  1397. // CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped
  1398. // field whose assignment or `property` declaration usually lives outside the
  1399. // calling function (the init-pseudoconstructor / WireBox-injection pattern),
  1400. // and `local.svc` is an explicit function-local. Strip the prefix so the
  1401. // declaration patterns match (`variables.svc = new X()`, `property
  1402. // name="svc" …`, `var svc = …` all bind the bare name), and widen the scan
  1403. // to the whole file for the component-scoped forms — nearest-declaration-
  1404. // backward still wins, so a function-local shadowing the field is preferred.
  1405. let scanReceiver = receiverName;
  1406. let componentScoped = false;
  1407. if (ref.language === 'cfml' || ref.language === 'cfscript') {
  1408. const scoped = receiverName.match(/^(variables|this|local|arguments)\.(.+)$/i);
  1409. if (scoped) {
  1410. scanReceiver = scoped[2]!;
  1411. const scope = scoped[1]!.toLowerCase();
  1412. componentScoped = scope === 'variables' || scope === 'this';
  1413. }
  1414. }
  1415. // PHP `$this->prop` receiver — the property's declaration lives outside the
  1416. // calling method (a promoted constructor parameter `private readonly Foo $prop`,
  1417. // a typed property `private Foo $prop;`, or a classic constructor parameter
  1418. // `Foo $prop` assigned in __construct). Strip the prefix and widen the scan to
  1419. // the whole file (the constructor may sit below the calling method), but —
  1420. // unlike CFML's scopes above — switch to PROPERTY-shaped patterns: a plain
  1421. // `$prop` local or parameter lives in a different namespace than `$this->prop`
  1422. // and can never shadow it, so the generic local patterns would type the
  1423. // property from unrelated same-named variables in other methods (a wrong
  1424. // 0.9-confidence edge, not a missing one).
  1425. let phpProperty = false;
  1426. if (ref.language === 'php') {
  1427. const scoped = receiverName.match(/^this->(.+)$/);
  1428. if (scoped) {
  1429. scanReceiver = scoped[1]!;
  1430. componentScoped = true;
  1431. phpProperty = true;
  1432. }
  1433. }
  1434. const escapedReceiver = scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1435. const patterns = phpProperty
  1436. ? phpPropertyTypePatterns(escapedReceiver)
  1437. : localReceiverTypePatterns(ref.language, escapedReceiver);
  1438. if (patterns.length === 0) return null;
  1439. // Split through the context's per-file lines cache when available: this runs
  1440. // for EVERY `receiver.method()` ref, and re-splitting the whole file per ref
  1441. // was ~20% of total index CPU on Java-heavy repos (#1122).
  1442. const lines = context.getFileLines
  1443. ? context.getFileLines(ref.filePath)
  1444. : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null);
  1445. if (!lines || lines.length === 0) return null;
  1446. const callIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
  1447. const startIdx = componentScoped
  1448. ? 0
  1449. : Math.max(0, enclosingScopeStartLine(ref, context) - 1);
  1450. const matchLine = (i: number): string | null => {
  1451. const line = lines[i];
  1452. if (!line) return null;
  1453. // A generated/minified line (one multi-KB statement) is not something a
  1454. // human-written local declaration lives on, and regexing it per ref is
  1455. // pure waste — skip it rather than scan it.
  1456. if (line.length > 10_000) return null;
  1457. for (const re of patterns) {
  1458. const m = line.match(re);
  1459. if (m && m[1]) {
  1460. const type = normalizeInferredTypeName(m[1]);
  1461. if (type) return type;
  1462. }
  1463. }
  1464. return null;
  1465. };
  1466. // Incremental-scan memo (INFER_SCAN_STATES): this scan runs for EVERY
  1467. // `receiver.method()` ref and was measured at 61µs/ref on kong (2.4s of
  1468. // worker time, 99% misses — `self:` calls hunting a declaration Lua never
  1469. // writes). Refs for the same (file, scope, receiver) arrive in ~ascending
  1470. // line order, and the scan is a pure function of the file's immutable
  1471. // lines, so each line pays its regex matches ONCE per key instead of once
  1472. // per ref: query(c) = highest matching line in [startIdx..c]; a monotonic
  1473. // call extends the stored watermark by scanning only (hi..c] (the region
  1474. // at-or-below the previous answer is already proven empty above it); a
  1475. // non-monotonic call (rare — refs are rowid-ordered) falls back to the
  1476. // plain bounded scan and leaves the state alone. componentScoped is keyed
  1477. // out — its position-independent whole-file sweep below has different
  1478. // semantics.
  1479. if (!componentScoped) {
  1480. const states = getInferScanStates(context);
  1481. const key = `${ref.filePath}|${startIdx}|${ref.language}|${scanReceiver}`;
  1482. const state = states.get(key);
  1483. if (!state) {
  1484. for (let i = callIdx; i >= startIdx; i--) {
  1485. const type = matchLine(i);
  1486. if (type) {
  1487. states.set(key, { hi: callIdx, ansIdx: i, ansType: type });
  1488. return type;
  1489. }
  1490. }
  1491. states.set(key, { hi: callIdx, ansIdx: -1, ansType: null });
  1492. return null;
  1493. }
  1494. if (callIdx >= state.hi) {
  1495. for (let i = callIdx; i > state.hi; i--) {
  1496. const type = matchLine(i);
  1497. if (type) {
  1498. state.ansIdx = i;
  1499. state.ansType = type;
  1500. break;
  1501. }
  1502. }
  1503. state.hi = callIdx;
  1504. return state.ansIdx >= startIdx ? state.ansType : null;
  1505. }
  1506. for (let i = callIdx; i >= startIdx; i--) {
  1507. const type = matchLine(i);
  1508. if (type) return type;
  1509. }
  1510. return null;
  1511. }
  1512. // Nearest declaration wins: scan backward from the call to the scope start.
  1513. for (let i = callIdx; i >= startIdx; i--) {
  1514. const type = matchLine(i);
  1515. if (type) return type;
  1516. }
  1517. // A component-scoped field's declaration is position-independent — the
  1518. // `variables.svc = new X()` pseudoconstructor assignment or `property`
  1519. // declaration may sit BELOW the calling function in the file — so when the
  1520. // backward pass finds nothing, sweep the remainder of the file too.
  1521. if (componentScoped) {
  1522. for (let i = callIdx + 1; i < lines.length; i++) {
  1523. const type = matchLine(i);
  1524. if (type) return type;
  1525. }
  1526. }
  1527. // A PHP property with no statically-typed declaration (classic pre-7.4
  1528. // style) may still be typed by what gets ASSIGNED to it — follow the
  1529. // `$this->prop = $var` assignment to the assigned variable's own typed
  1530. // declaration (a classic or multi-line constructor parameter, or a typed
  1531. // setter's parameter).
  1532. if (phpProperty) {
  1533. return inferPhpAssignedPropertyType(escapedReceiver, lines, callIdx);
  1534. }
  1535. return null;
  1536. }
  1537. /**
  1538. * Patterns that recover a PHP class property's declared type for a
  1539. * `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
  1540. * property-shaped declarations qualify —
  1541. * 1. a modifier-prefixed typed declaration, which covers both a typed
  1542. * property (`private ?Foo $prop;`) and a promoted constructor parameter
  1543. * (`private readonly Foo $prop`), and
  1544. * 2. the pseudoconstructor assignment (`$this->prop = new Foo(...)`).
  1545. * A bare `X $prop` parameter or `$prop = new X()` local elsewhere in the
  1546. * file must NOT match: those variables can never alias `$this->prop`.
  1547. * Union-typed properties (`Foo|Bar $prop`) yield no match and thus no edge —
  1548. * silent beats wrong. The classic untyped-property-assigned-in-constructor
  1549. * shape is handled by inferPhpAssignedPropertyType instead.
  1550. */
  1551. function phpPropertyTypePatterns(r: string): RegExp[] {
  1552. return memoPatterns(`php-prop|${r}`, () => buildPhpPropertyTypePatterns(r));
  1553. }
  1554. function buildPhpPropertyTypePatterns(r: string): RegExp[] {
  1555. return [
  1556. new RegExp(
  1557. `\\b(?:(?:private|protected|public|readonly|static|final)(?:\\(set\\))?\\s+)+\\??([A-Za-z_\\\\][\\w\\\\]*)\\s+&?\\$${r}\\b`,
  1558. ), // private readonly ?Foo $prop (typed property / promoted param)
  1559. new RegExp(`\\$this->${r}\\b\\s*=\\s*new\\s+([A-Za-z_\\\\][\\w\\\\]*)`), // $this->prop = new Foo()
  1560. ];
  1561. }
  1562. /**
  1563. * Second-chance typing for a PHP `$this->prop` receiver whose property
  1564. * declaration carries no static type (classic pre-7.4 style): find the
  1565. * `$this->prop = $var` assignment, then recover `$var`'s type from its own
  1566. * declaration WITHIN the assignment's function — the constructor's (possibly
  1567. * multi-line) parameter list, a typed setter's parameter, or a `= new X()`
  1568. * local. The backward scan stops at the enclosing `function` line (checked
  1569. * for a match first — a single-line `__construct(Foo $var) { ... }` carries
  1570. * the typed parameter itself), so a same-named variable in another method
  1571. * can never type the property.
  1572. */
  1573. function inferPhpAssignedPropertyType(
  1574. escapedProp: string,
  1575. lines: string[],
  1576. callIdx: number,
  1577. ): string | null {
  1578. const assignRe = new RegExp(`\\$this->${escapedProp}\\b\\s*=\\s*\\$(\\w+)\\b`);
  1579. const assignAt = (i: number): RegExpMatchArray | null => {
  1580. const line = lines[i];
  1581. if (!line || line.length > 10_000) return null;
  1582. return line.match(assignRe);
  1583. };
  1584. // The assignment is position-independent relative to the call — nearest-
  1585. // backward first, then sweep forward, same order as the componentScoped scan.
  1586. let assignIdx = -1;
  1587. let varName: string | null = null;
  1588. for (let i = callIdx; i >= 0; i--) {
  1589. const m = assignAt(i);
  1590. if (m) { assignIdx = i; varName = m[1]!; break; }
  1591. }
  1592. if (varName === null) {
  1593. for (let i = callIdx + 1; i < lines.length; i++) {
  1594. const m = assignAt(i);
  1595. if (m) { assignIdx = i; varName = m[1]!; break; }
  1596. }
  1597. }
  1598. if (varName === null) return null;
  1599. const varPatterns = localReceiverTypePatterns(
  1600. 'php',
  1601. varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
  1602. );
  1603. for (let i = assignIdx; i >= 0; i--) {
  1604. const line = lines[i];
  1605. if (line && line.length <= 10_000) {
  1606. for (const re of varPatterns) {
  1607. const m = line.match(re);
  1608. if (m && m[1]) {
  1609. const type = normalizeInferredTypeName(m[1]);
  1610. if (type) return type;
  1611. }
  1612. }
  1613. }
  1614. if (line && /\bfunction\b/.test(line)) break;
  1615. }
  1616. return null;
  1617. }
  1618. /**
  1619. * Try to resolve by method name on a class/object
  1620. */
  1621. export function matchMethodCall(
  1622. ref: UnresolvedRef,
  1623. context: ResolutionContext
  1624. ): ResolvedRef | null {
  1625. // Parse method call patterns like "obj.method" or "Class::method". The method
  1626. // part allows trailing `:` keywords so Objective-C selectors resolve
  1627. // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other
  1628. // languages' method refs, so this is a no-op for them.
  1629. // The receiver allows dots (`builder.Services.AddCoreServices`) so a CHAINED
  1630. // call resolves by its last segment — Strategy 3 below name-matches the method
  1631. // (with its existing single-candidate / receiver-overlap guards). Without this
  1632. // a multi-dot extension-method call (C# DI `builder.Services.AddCoreServices()`,
  1633. // `Guard.Against.X()`) matched no pattern and never resolved.
  1634. // C++ explicit operator call `a.operator+(b)` reaches the resolver as
  1635. // `a.operator+` (#1247) — the operator's symbol chars (`+`, `==`, `[]`, `()`)
  1636. // fail the \w method part of the plain pattern, so admit them explicitly.
  1637. // Names like `operatorTable` stay on the plain pattern (tried first); the
  1638. // operator form requires at least one non-word char after `operator`, and
  1639. // every downstream strategy compares the method part by exact string
  1640. // equality, so a stray match can't invent an edge.
  1641. const dotMatch =
  1642. ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/) ??
  1643. (ref.language === 'cpp'
  1644. ? ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/)
  1645. : null);
  1646. const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/);
  1647. // Lua/Luau method calls use a single colon (`lg:log`); R uses `$` (`lg$log`).
  1648. // Recognize these receiver/method separators so local-variable receiver-type
  1649. // inference (#1108) applies to them too — extraction already emits the ref in
  1650. // this shape, but the resolver otherwise only understood `.` and `::`.
  1651. const luaColonMatch = (ref.language === 'lua' || ref.language === 'luau')
  1652. ? ref.referenceName.match(/^([\w.]+):(\w+)$/)
  1653. : null;
  1654. const rDollarMatch = ref.language === 'r'
  1655. ? ref.referenceName.match(/^([\w.]+)\$(\w+)$/)
  1656. : null;
  1657. // PHP property receiver: `$this->prop->method()` reaches the resolver as
  1658. // `this->prop.method` (the extractor records the receiver's raw text with the
  1659. // leading `$` stripped). Resolve it EXCLUSIVELY through declared-type
  1660. // inference + resolveMethodOnType validation — the name-similarity strategies
  1661. // below must never see this shape, so a property whose type can't be
  1662. // recovered stays unlinked rather than guessed (a wrong inference produces no
  1663. // edge rather than a wrong one). Deeper chains (`this->a->b.method`) don't
  1664. // match the single-property pattern and stay unlinked, same as before.
  1665. const phpThisPropMatch = ref.language === 'php'
  1666. ? ref.referenceName.match(/^(this->\w+)\.(\w+)$/)
  1667. : null;
  1668. if (phpThisPropMatch) {
  1669. const [, receiver, phpMethodName] = phpThisPropMatch;
  1670. const inferredType = inferLocalReceiverType(receiver!, ref, context);
  1671. if (!inferredType) return null;
  1672. return resolveMethodOnType(
  1673. inferredType,
  1674. phpMethodName!,
  1675. ref,
  1676. context,
  1677. 0.9,
  1678. 'instance-method',
  1679. importedFqnOf(inferredType, ref, context),
  1680. );
  1681. }
  1682. const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch;
  1683. if (!match) {
  1684. return null;
  1685. }
  1686. const [, objectOrClass, methodName] = match;
  1687. // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose
  1688. // receiver type we can try to infer from its local declaration.
  1689. const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch;
  1690. // Infer the receiver's type from its local declaration/initializer in the
  1691. // enclosing scope, then resolve the method on that type (#1108). C++ keeps its
  1692. // dedicated inferrer (header scan + `auto`); every other language uses the
  1693. // shared source-based inferrer. resolveMethodOnType validates the method
  1694. // exists on the inferred type, so a mis-inference produces no edge.
  1695. if (inferableReceiver) {
  1696. const inferredType = nmTimedT('mc-infer', ref, () =>
  1697. ref.language === 'cpp'
  1698. ? inferCppReceiverType(objectOrClass!, ref, context)
  1699. : inferLocalReceiverType(objectOrClass!, ref, context));
  1700. if (inferredType) {
  1701. // Java/Kotlin: when two classes share the simple name, the file's import
  1702. // pins WHICH one (#314). Other languages disambiguate by call-site file.
  1703. const importedFqn =
  1704. ref.language === 'java' || ref.language === 'kotlin'
  1705. ? context
  1706. .getImportMappings(ref.filePath, ref.language)
  1707. .find((i) => i.localName === inferredType)?.source
  1708. : undefined;
  1709. const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
  1710. inferredType,
  1711. methodName!,
  1712. ref,
  1713. context,
  1714. 0.9,
  1715. 'instance-method',
  1716. importedFqn,
  1717. ));
  1718. if (typedMatch) {
  1719. return typedMatch;
  1720. }
  1721. }
  1722. }
  1723. // Go 2-hop field chain `base.field.Method` (#1276): the base's type comes
  1724. // from the enclosing scope (typed parameter / method receiver / local var),
  1725. // the field's declared type from that struct's own declaration lines, and
  1726. // the method is VALIDATED on the field's type by resolveMethodOnType. This
  1727. // branch is EXCLUSIVE for chained Go receivers: when the hop can't be
  1728. // inferred or the field's type is external (`conn *sql.DB` — no project
  1729. // node), the ref stays unresolved rather than falling through to the
  1730. // bare-name strategies below, which is exactly how `target.conn.Exec(...)`
  1731. // fabricated a dependency on an unrelated local interface's same-named
  1732. // method. Chained Go receivers were never emitted before #1276, so there
  1733. // is no prior recall to preserve on the fallback path.
  1734. if (ref.language === 'go' && dotMatch && objectOrClass!.includes('.')) {
  1735. return matchGoFieldChainCall(objectOrClass!, methodName!, ref, context);
  1736. }
  1737. // Rust call through a field of the enclosing type — `self.inner.run()`,
  1738. // emitted as `self.inner.run` (#1585). Same discipline as the Go branch
  1739. // above, and EXCLUSIVE for the same reason: validated field-type inference
  1740. // or nothing. Letting this shape reach the bare-name strategies below is
  1741. // how `self.inner.run()` resolved to a same-named method on an unrelated
  1742. // type — or to the calling method itself, a self-edge the source doesn't
  1743. // contain — whenever the field's type was external or merely shared a
  1744. // method name with something nearby.
  1745. if (ref.language === 'rust' && dotMatch && objectOrClass!.startsWith('self.')) {
  1746. return matchRustSelfFieldCall(objectOrClass!.slice('self.'.length), methodName!, ref, context);
  1747. }
  1748. // Java/Kotlin: receiver may be a field whose name doesn't match the type by
  1749. // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
  1750. // the field in the enclosing class to get its declared type, then resolve
  1751. // the method on that type. Covers Spring `@Resource`/`@Autowired` field
  1752. // injection where the field type is the concrete bean class.
  1753. if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) {
  1754. const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context);
  1755. if (inferredType) {
  1756. // When two classes share the same simple name, the caller file's
  1757. // import is the only signal that names WHICH one — pass the
  1758. // imported FQN so resolveMethodOnType can disambiguate (#314).
  1759. const imports = context.getImportMappings(ref.filePath, ref.language);
  1760. const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
  1761. const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
  1762. inferredType,
  1763. methodName!,
  1764. ref,
  1765. context,
  1766. 0.9,
  1767. 'instance-method',
  1768. importedFqn,
  1769. ));
  1770. if (typedMatch) {
  1771. return typedMatch;
  1772. }
  1773. }
  1774. }
  1775. // Object-literal namespace receiver (#1573): `api.call()` where `api` is a
  1776. // same-file `const api = { call() {…}, get: () => {…} }`. Its members are
  1777. // plain functions with bare names inside the constant's extent — no
  1778. // `Container::member` qualified name — so none of the class-shaped
  1779. // strategies below can see them (Strategy 3 only considers `method`
  1780. // kinds) and the call resolved to nothing at all. Same file only: a
  1781. // cross-file use reaches the same helper through the import path.
  1782. if (dotMatch && !objectOrClass!.includes('.') && OBJECT_LITERAL_LANGUAGES.has(ref.language)) {
  1783. const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null => {
  1784. const holders = preferCallSiteFile(context.getNodesByName(objectOrClass!), ref.filePath).filter(
  1785. (n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath
  1786. );
  1787. for (const holder of holders) {
  1788. const hit = resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method');
  1789. if (hit) return hit;
  1790. }
  1791. return null;
  1792. });
  1793. if (literalMatch) return literalMatch;
  1794. }
  1795. // Strategy 1: Direct class name match (existing logic). When the receiver
  1796. // names a class that exists in several files (`Logger.log()` / `Logger::log()`
  1797. // with a `Logger` in both `a/` and `b/`), try the class in the call site's
  1798. // own file first — otherwise the first-indexed class wins and a call in `b/`
  1799. // resolves to `a/`'s method (#1079).
  1800. const strat1 = nmTimedT('mc-class', ref, (): ResolvedRef | null => {
  1801. const classCandidates = preferCallSiteFile(
  1802. context.getNodesByName(objectOrClass!),
  1803. ref.filePath,
  1804. );
  1805. for (const classNode of classCandidates) {
  1806. if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') {
  1807. // Skip cross-language class matches
  1808. if (classNode.language !== ref.language) continue;
  1809. const nodesInFile = context.getNodesInFile(classNode.filePath);
  1810. const methodNode = nodesInFile.find(
  1811. (n) =>
  1812. n.kind === 'method' &&
  1813. n.name === methodName &&
  1814. n.qualifiedName.includes(classNode.name)
  1815. );
  1816. if (methodNode) {
  1817. return {
  1818. original: ref,
  1819. targetNodeId: methodNode.id,
  1820. confidence: 0.85,
  1821. resolvedBy: 'qualified-name',
  1822. };
  1823. }
  1824. }
  1825. }
  1826. return null;
  1827. });
  1828. if (strat1) return strat1;
  1829. // Strategy 2: Instance variable receiver - try capitalized form to find class
  1830. // e.g., "permissionEngine" → look for classes containing "PermissionEngine"
  1831. const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1);
  1832. if (capitalizedReceiver !== objectOrClass) {
  1833. const strat2 = nmTimedT('mc-capital', ref, (): ResolvedRef | null => {
  1834. const fuzzyClassCandidates = preferCallSiteFile(
  1835. context.getNodesByName(capitalizedReceiver),
  1836. ref.filePath,
  1837. );
  1838. for (const classNode of fuzzyClassCandidates) {
  1839. if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') {
  1840. // Skip cross-language class matches
  1841. if (classNode.language !== ref.language) continue;
  1842. const nodesInFile = context.getNodesInFile(classNode.filePath);
  1843. const methodNode = nodesInFile.find(
  1844. (n) =>
  1845. n.kind === 'method' &&
  1846. n.name === methodName &&
  1847. n.qualifiedName.includes(classNode.name)
  1848. );
  1849. if (methodNode) {
  1850. return {
  1851. original: ref,
  1852. targetNodeId: methodNode.id,
  1853. confidence: 0.8,
  1854. resolvedBy: 'instance-method',
  1855. };
  1856. }
  1857. }
  1858. }
  1859. return null;
  1860. });
  1861. if (strat2) return strat2;
  1862. }
  1863. // Strategy 3: Find methods by name across the codebase, match by receiver
  1864. // name similarity with the containing class. Handles abbreviated variable
  1865. // names like permissionEngine → PermissionRuleEngine.
  1866. if (methodName) {
  1867. const strat3 = nmTimedT('mc-byname', ref, (): ResolvedRef | null => {
  1868. const methodCandidates = context.getNodesByName(methodName!);
  1869. // Ubiquitous-method ceiling (#999): a method name re-declared across a
  1870. // vendored theme/SDK (Metronic's `init`/`update`/… on every widget) yields
  1871. // K candidates that receiver-word overlap can't reliably disambiguate —
  1872. // and filtering + scoring all K per call is the O(K²) cost that wedged
  1873. // "Resolving refs" for 15-28 min. Bail before the O(K) work; Strategy 1/2
  1874. // (class-name match) already had their precise shot above.
  1875. if (methodCandidates.length > AMBIGUOUS_NAME_CEILING) {
  1876. return null;
  1877. }
  1878. const methods = methodCandidates.filter(
  1879. (n) => n.kind === 'method' && n.name === methodName
  1880. );
  1881. // Filter to same-language candidates first
  1882. const sameLanguageMethods = methods.filter(m => m.language === ref.language);
  1883. const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods;
  1884. // If only one same-language method with this name exists, use it
  1885. if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) {
  1886. return {
  1887. original: ref,
  1888. targetNodeId: targetMethods[0]!.id,
  1889. confidence: 0.7,
  1890. resolvedBy: 'instance-method',
  1891. };
  1892. }
  1893. // Multiple methods: score by receiver name word overlap with class name
  1894. if (targetMethods.length > 1) {
  1895. const receiverWords = splitCamelCase(objectOrClass!);
  1896. let bestMatch: typeof targetMethods[0] | undefined;
  1897. let bestScore = 0;
  1898. // Same-file candidates first, so a score tie (`score > bestScore` keeps
  1899. // the first seen) resolves to the call site's own file rather than the
  1900. // first-indexed duplicate (#1079).
  1901. for (const method of preferCallSiteFile(targetMethods, ref.filePath)) {
  1902. const classWords = splitCamelCase(method.qualifiedName);
  1903. let score = receiverWords.filter(w =>
  1904. classWords.some(cw => cw.toLowerCase() === w.toLowerCase())
  1905. ).length;
  1906. // Bonus for same language
  1907. if (method.language === ref.language) score += 1;
  1908. if (score > bestScore) {
  1909. bestScore = score;
  1910. bestMatch = method;
  1911. }
  1912. }
  1913. if (bestMatch && bestScore >= 2) {
  1914. return {
  1915. original: ref,
  1916. targetNodeId: bestMatch.id,
  1917. confidence: 0.65,
  1918. resolvedBy: 'instance-method',
  1919. };
  1920. }
  1921. }
  1922. return null;
  1923. });
  1924. if (strat3) return strat3;
  1925. }
  1926. return null;
  1927. }
  1928. /** Go builtin/primitive field types that can never carry a project method. */
  1929. const GO_BUILTIN_FIELD_TYPES = new Set([
  1930. 'string', 'bool', 'byte', 'rune', 'error', 'any',
  1931. 'int', 'int8', 'int16', 'int32', 'int64',
  1932. 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
  1933. 'float32', 'float64', 'complex64', 'complex128',
  1934. 'chan', 'map', 'func', 'struct', 'interface',
  1935. ]);
  1936. /**
  1937. * Resolve a Go 2-hop field-chain call `base.field.Method(...)` (#1276):
  1938. * `target.conn.Exec("insert")` where `func (target *Target) Write()` and
  1939. * `type Target struct { conn *sql.DB }`. Two inference hops, both read from
  1940. * source the same way #1108 does:
  1941. * 1. `base`'s type from the enclosing scope (method receiver, typed
  1942. * parameter, or local declaration) via inferLocalReceiverType;
  1943. * 2. `field`'s declared type from the struct's own declaration lines.
  1944. * The method is then resolved AND VALIDATED on the field's type. A field
  1945. * whose type has no project node (`sql.DB`, any external dependency) yields
  1946. * null — the caller treats this branch as exclusive for chained Go
  1947. * receivers, so the ref stays unresolved instead of name-guessing.
  1948. */
  1949. function matchGoFieldChainCall(
  1950. receiverChain: string,
  1951. methodName: string,
  1952. ref: UnresolvedRef,
  1953. context: ResolutionContext
  1954. ): ResolvedRef | null {
  1955. const segs = receiverChain.split('.');
  1956. if (segs.length !== 2 || !segs[0] || !segs[1]) return null;
  1957. const [base, field] = segs;
  1958. const baseType = inferLocalReceiverType(base!, ref, context);
  1959. if (!baseType) return null;
  1960. const fieldEsc = field!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1961. const fieldTypeRe = new RegExp(`\\b${fieldEsc}\\s+\\*?\\[?\\]?([A-Za-z_][\\w.]*)`);
  1962. const structs = preferCallSiteFile(context.getNodesByName(baseType), ref.filePath).filter(
  1963. (n) => (n.kind === 'struct' || n.kind === 'class') && n.language === 'go'
  1964. );
  1965. for (const s of structs) {
  1966. const source = context.readFile(s.filePath);
  1967. if (!source) continue;
  1968. // Only the struct's own declaration lines — a same-named identifier
  1969. // elsewhere in the file can't donate a type. Matched LINE BY LINE with
  1970. // comments stripped: chi's `Mux` has a doc comment reading "the tree
  1971. // router" right above `tree *node`, and a whole-block match captured
  1972. // `router` from the prose instead of `node` from the field.
  1973. const declLines = source.split('\n').slice(Math.max(0, s.startLine - 1), s.endLine);
  1974. for (const rawLine of declLines) {
  1975. const line = rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '');
  1976. const m = line.match(fieldTypeRe);
  1977. if (!m || !m[1]) continue;
  1978. const rawType = m[1];
  1979. // A package-qualified field type (`http.Handler`, `sql.DB`) is only
  1980. // followed when the package is IN-MODULE: stripping the qualifier and
  1981. // matching the bare name would conflate a stdlib/third-party type with
  1982. // any same-named project type — on chi, `handler http.Handler` bound
  1983. // to an example app's unrelated local `Handler`. That is the exact
  1984. // fabrication this matcher exists to prevent (#1276).
  1985. if (rawType.includes('.')) {
  1986. const pkg = rawType.split('.')[0]!;
  1987. const mod = context.getGoModule?.();
  1988. const imp = context
  1989. .getImportMappings(s.filePath, 'go')
  1990. .find((i) => i.localName === pkg);
  1991. const inModule =
  1992. !!mod &&
  1993. !!imp &&
  1994. (imp.source === mod.modulePath || imp.source.startsWith(mod.modulePath + '/'));
  1995. if (!inModule) continue;
  1996. }
  1997. // Unexported (lowercase) types are idiomatic Go and stay eligible —
  1998. // chi's `mx.tree.FindRoute()` chains through `tree *node`. A
  1999. // mis-capture is harmless: resolveMethodOnType only returns a
  2000. // validated `<type>::<method>` match.
  2001. const fieldType = rawType.split('.').pop();
  2002. if (!fieldType || !/^[A-Za-z_]/.test(fieldType) || GO_BUILTIN_FIELD_TYPES.has(fieldType)) continue;
  2003. const resolved = resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method');
  2004. if (resolved) return resolved;
  2005. }
  2006. }
  2007. return null;
  2008. }
  2009. // Rust primitives and the prelude's own types: a field of one of these never
  2010. // names a project type, so a `self.<field>.<method>()` on it stays unresolved.
  2011. const RUST_NON_PROJECT_FIELD_TYPES = new Set([
  2012. 'bool', 'char', 'str', 'String',
  2013. 'i8', 'i16', 'i32', 'i64', 'i128', 'isize',
  2014. 'u8', 'u16', 'u32', 'u64', 'u128', 'usize',
  2015. 'f32', 'f64',
  2016. 'Self', 'self',
  2017. ]);
  2018. /**
  2019. * Reduce a Rust field's declared type text to the simple name of the type a
  2020. * method call on that field auto-derefs to, or null when there is none we can
  2021. * name. Only the layers Rust's method-call auto-deref looks through are
  2022. * unwrapped: references (`&`, `&'a mut`) and the owning smart pointers
  2023. * (`Box`, `Rc`, `Arc`) — `self.inner.run()` with `inner: Box<Inner>` calls
  2024. * `Inner::run`. Containers that do NOT auto-deref to their parameter
  2025. * (`Option<Inner>`, `Vec<Inner>`, `Mutex<Inner>`, `RefCell<Inner>`) keep their
  2026. * own name and, having no project node, resolve to nothing — `self.items.push()`
  2027. * must never become `Inner::push`. A trait object (`Box<dyn Source>`) yields
  2028. * the trait, whose method node the interface-impl synthesizer fans out. A
  2029. * generic parameter (`T`), a primitive, a tuple / array / raw pointer / fn
  2030. * type, or a non-identifier yields null.
  2031. */
  2032. export function rustFieldTypeName(raw: string): string | null {
  2033. let t = raw.trim();
  2034. for (;;) {
  2035. const before = t;
  2036. t = t.replace(/^&\s*(?:'\w+\s+)?(?:mut\s+)?/, '');
  2037. t = t.replace(/^(?:Box|Rc|Arc)\s*<\s*/, '');
  2038. t = t.replace(/^(?:dyn|impl)\s+/, '');
  2039. if (t === before) break;
  2040. }
  2041. // Drop generic args, the closing `>`s of unwrapped pointers, and trait-object
  2042. // bounds (`dyn Source + Send`); keep the last path segment.
  2043. t = t.replace(/[<>+].*$/, '').trim();
  2044. const seg = t.split('::').filter(Boolean).pop();
  2045. if (!seg || !/^[A-Za-z_]\w*$/.test(seg)) return null;
  2046. if (RUST_NON_PROJECT_FIELD_TYPES.has(seg)) return null;
  2047. if (/^[A-Z]$/.test(seg)) return null; // bare single-letter generic parameter
  2048. return seg;
  2049. }
  2050. /**
  2051. * Resolve a Rust call through a field of the enclosing type —
  2052. * `self.inner.run()`, emitted by the extractor as `self.inner.run` (#1585).
  2053. * Mirrors the Go 2-hop precedent above (#1276): the owner type is the calling
  2054. * method's qualified-name prefix (`Outer::run` → `Outer`), the field's declared
  2055. * type comes from the owner struct's OWN declaration lines, and the method is
  2056. * resolved AND VALIDATED on that type by resolveMethodOnType. The caller
  2057. * treats this branch as exclusive for `self.<field>` receivers: a field whose
  2058. * type is external (`std::vec::IntoIter`, `regex::Regex`), a generic
  2059. * parameter, or not declared where we can see it yields null and the ref stays
  2060. * unresolved. Rust struct fields are not graph nodes, so the declaration text
  2061. * is the only place the type lives.
  2062. */
  2063. function matchRustSelfFieldCall(
  2064. field: string,
  2065. methodName: string,
  2066. ref: UnresolvedRef,
  2067. context: ResolutionContext,
  2068. ): ResolvedRef | null {
  2069. // The extractor only ever emits a single field hop; anything else is not ours.
  2070. if (!field || field.includes('.')) return null;
  2071. const caller = context.getNodeById?.(ref.fromNodeId);
  2072. if (!caller) return null;
  2073. const sep = caller.qualifiedName.lastIndexOf('::');
  2074. if (sep <= 0) return null; // a free fn has no `self`
  2075. const owner = caller.qualifiedName.slice(0, sep).split('::').pop();
  2076. if (!owner) return null;
  2077. const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter(
  2078. (n) =>
  2079. (n.kind === 'struct' || n.kind === 'union' || n.kind === 'class') &&
  2080. n.language === 'rust'
  2081. );
  2082. const fieldEsc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  2083. // `pub inner: Inner,` / `inner: Box<dyn Source>,` / `pub(crate) inner: T }` —
  2084. // the type text runs to the field separator. A comma inside generic args
  2085. // (`HashMap<K, V>`) truncates the capture, which rustFieldTypeName then
  2086. // reduces to the container's own name — exactly the non-deref case it
  2087. // refuses anyway.
  2088. const fieldRe = new RegExp(`\\b${fieldEsc}\\s*:\\s*([^,{}]+)`);
  2089. for (const s of owners) {
  2090. const source = context.readFile(s.filePath);
  2091. if (!source) continue;
  2092. // Only the struct's own declaration lines, comment-stripped line by line —
  2093. // same discipline as the Go helper: prose or a same-named identifier
  2094. // elsewhere in the file can never donate a type.
  2095. const declLines = source.split('\n').slice(Math.max(0, s.startLine - 1), s.endLine);
  2096. for (const rawLine of declLines) {
  2097. const line = rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '');
  2098. const m = line.match(fieldRe);
  2099. if (!m || !m[1]) continue;
  2100. const fieldType = rustFieldTypeName(m[1]);
  2101. // The field is declared here; whether or not its type names a project
  2102. // symbol, this owner is the answer — no other same-named struct applies.
  2103. if (!fieldType) return null;
  2104. return resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method');
  2105. }
  2106. }
  2107. return null;
  2108. }
  2109. /**
  2110. * Split a camelCase or PascalCase string into words.
  2111. */
  2112. function splitCamelCase(str: string): string[] {
  2113. return str.replace(/([a-z])([A-Z])/g, '$1 $2')
  2114. .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
  2115. .split(/[\s._:\/\\]+/)
  2116. .filter(w => w.length > 1);
  2117. }
  2118. /**
  2119. * Compute directory proximity from a pre-split list of directory segments
  2120. * (`filePath1` minus its filename) and a second file path.
  2121. * Returns a score based on the number of shared leading directory segments.
  2122. * Higher score = closer in directory tree.
  2123. *
  2124. * Split into a pre-split variant because findBestMatch scores every candidate
  2125. * against the SAME `ref.filePath`; re-splitting it per candidate was a hot spot
  2126. * on large repos (#915), so the caller splits it once and passes the segments.
  2127. */
  2128. function pathProximityFromDirs(dir1: string[], filePath2: string): number {
  2129. const dir2 = filePath2.split('/');
  2130. dir2.pop(); // drop filename — matches the original slice(0, -1) on both paths
  2131. let shared = 0;
  2132. const limit = Math.min(dir1.length, dir2.length);
  2133. for (let i = 0; i < limit; i++) {
  2134. if (dir1[i] === dir2[i]) {
  2135. shared++;
  2136. } else {
  2137. break;
  2138. }
  2139. }
  2140. // Each shared directory segment contributes 15 points, capped at 80
  2141. return Math.min(shared * 15, 80);
  2142. }
  2143. /**
  2144. * Compute directory proximity between two file paths.
  2145. * Returns a score based on the number of shared directory segments.
  2146. */
  2147. function computePathProximity(filePath1: string, filePath2: string): number {
  2148. const dir1 = filePath1.split('/');
  2149. dir1.pop();
  2150. return pathProximityFromDirs(dir1, filePath2);
  2151. }
  2152. /**
  2153. * Find the best matching node when there are multiple candidates
  2154. */
  2155. function findBestMatch(
  2156. ref: UnresolvedRef,
  2157. candidates: Node[],
  2158. _context: ResolutionContext
  2159. ): Node | null {
  2160. // Prioritization rules:
  2161. // 1. Same file > different file
  2162. // 2. Directory proximity (same module/package > different module)
  2163. // 3. Same language > different language
  2164. // 4. Functions/methods > classes/types (for call references)
  2165. // 5. Exported > non-exported
  2166. let bestScore = -1;
  2167. let bestNode: Node | null = null;
  2168. // Split the ref's path once (it's the same across every candidate) instead of
  2169. // re-splitting it inside computePathProximity per candidate (#915 hot spot).
  2170. const refDirs = ref.filePath.split('/');
  2171. refDirs.pop();
  2172. // A same-language candidate ALWAYS outscores a cross-language one: same-language
  2173. // scores at least +50 (language bonus), while a cross-language candidate maxes
  2174. // out at +35 (−80 language, +80 proximity, +25 kind, +10 exported; it can never
  2175. // be in the same file). So when any same-language candidate exists, skip the
  2176. // cross-language ones — provably the same winner, without paying the per-candidate
  2177. // scoring. Cuts the candidate set to same-language size on mixed front-end +
  2178. // back-end repos (#915). When ALL candidates are cross-language (a legitimate
  2179. // cross-language `calls` bridge), none are skipped and behavior is unchanged.
  2180. const hasSameLanguage = candidates.some((c) => c.language === ref.language);
  2181. for (const candidate of candidates) {
  2182. if (hasSameLanguage && candidate.language !== ref.language) continue;
  2183. let score = 0;
  2184. // Same file bonus
  2185. if (candidate.filePath === ref.filePath) {
  2186. score += 100;
  2187. }
  2188. // Directory proximity bonus — strongly prefer same module/package
  2189. score += pathProximityFromDirs(refDirs, candidate.filePath);
  2190. // Language matching: strongly prefer same language, penalize cross-language
  2191. if (candidate.language === ref.language) {
  2192. score += 50;
  2193. } else {
  2194. score -= 80;
  2195. }
  2196. // For call references, prefer functions/methods
  2197. if (ref.referenceKind === 'calls') {
  2198. if (candidate.kind === 'function' || candidate.kind === 'method') {
  2199. score += 25;
  2200. }
  2201. }
  2202. // For instantiation references (`new Foo()`), prefer class-like
  2203. // targets — without this, a function named `Foo` in another module
  2204. // could outscore the actual class.
  2205. if (ref.referenceKind === 'instantiates') {
  2206. if (
  2207. candidate.kind === 'class' ||
  2208. candidate.kind === 'struct' ||
  2209. candidate.kind === 'union' ||
  2210. candidate.kind === 'interface'
  2211. ) {
  2212. score += 25;
  2213. }
  2214. }
  2215. // For decorator references (`@Foo`), prefer functions. Class
  2216. // decorators (Python `@SomeClass`, Java annotation interfaces)
  2217. // also resolve here, hence the smaller class bonus.
  2218. if (ref.referenceKind === 'decorates') {
  2219. if (candidate.kind === 'function' || candidate.kind === 'method') {
  2220. score += 25;
  2221. } else if (candidate.kind === 'class' || candidate.kind === 'interface') {
  2222. score += 15;
  2223. }
  2224. }
  2225. // Exported bonus
  2226. if (candidate.isExported) {
  2227. score += 10;
  2228. }
  2229. // Closer line number (within same file)
  2230. if (candidate.filePath === ref.filePath && candidate.startLine) {
  2231. const distance = Math.abs(candidate.startLine - ref.line);
  2232. score += Math.max(0, 20 - distance / 10);
  2233. }
  2234. if (score > bestScore) {
  2235. bestScore = score;
  2236. bestNode = candidate;
  2237. }
  2238. }
  2239. return bestNode;
  2240. }
  2241. /**
  2242. * Fuzzy match - last resort with lower confidence
  2243. */
  2244. export function matchFuzzy(
  2245. ref: UnresolvedRef,
  2246. context: ResolutionContext
  2247. ): ResolvedRef | null {
  2248. const lowerName = ref.referenceName.toLowerCase();
  2249. // Use pre-built lowercase index for O(1) lookup instead of scanning all nodes
  2250. const candidates = context.getNodesByLowerName(lowerName);
  2251. // Filter to callable kinds only (function, method, class)
  2252. const callableKinds = new Set(['function', 'method', 'class']);
  2253. const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref);
  2254. // Prefer same-language matches
  2255. const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
  2256. const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates;
  2257. if (finalCandidates.length === 1) {
  2258. const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
  2259. return {
  2260. original: ref,
  2261. targetNodeId: finalCandidates[0]!.id,
  2262. confidence: isCrossLanguage ? 0.3 : 0.5,
  2263. resolvedBy: 'fuzzy',
  2264. };
  2265. }
  2266. return null;
  2267. }
  2268. /**
  2269. * Match all strategies in order of confidence
  2270. */
  2271. /** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */
  2272. const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']);
  2273. /**
  2274. * CODEGRAPH_RESOLVE_PROFILE=2 sub-stage attribution for matchReference's
  2275. * strategy pipeline (`nm:<stage>|<refKind>|hit/miss`). Module-global because
  2276. * the matcher is a free function; each thread (main + every pool worker) has
  2277. * its own module instance, and dumpNameMatcherProfile is invoked from
  2278. * ReferenceResolver.dumpResolveProfile so worker tables surface too.
  2279. */
  2280. const NM_PROFILE: Map<string, { n: number; ns: bigint }> | null =
  2281. process.env.CODEGRAPH_RESOLVE_PROFILE === '2' ? new Map() : null;
  2282. function nmTimedT<T>(stage: string, ref: UnresolvedRef, fn: () => T): T {
  2283. if (!NM_PROFILE) return fn();
  2284. const t0 = process.hrtime.bigint();
  2285. const r = fn();
  2286. const dt = process.hrtime.bigint() - t0;
  2287. const key = `nm:${stage}|${ref.referenceKind}|${r ? 'hit' : 'miss'}`;
  2288. const slot = NM_PROFILE.get(key);
  2289. if (slot) {
  2290. slot.n++;
  2291. slot.ns += dt;
  2292. } else {
  2293. NM_PROFILE.set(key, { n: 1, ns: dt });
  2294. }
  2295. return r;
  2296. }
  2297. function nmTimed(stage: string, ref: UnresolvedRef, fn: () => ResolvedRef | null): ResolvedRef | null {
  2298. return nmTimedT(stage, ref, fn);
  2299. }
  2300. /** Dump this thread's matchReference sub-stage table to stderr (no-op unless =2). */
  2301. export function dumpNameMatcherProfile(label: string): void {
  2302. if (!NM_PROFILE || NM_PROFILE.size === 0) return;
  2303. const rows = [...NM_PROFILE.entries()]
  2304. .map(([k, v]) => ({ k, n: v.n, ms: Number(v.ns / 1_000_000n) }))
  2305. .sort((a, b) => b.ms - a.ms);
  2306. for (const r of rows) {
  2307. console.error(
  2308. `[resolve-profile] ${label} ${r.k}: n=${r.n} total=${(r.ms / 1000).toFixed(1)}s avg=${((r.ms * 1000) / Math.max(1, r.n)).toFixed(0)}µs`
  2309. );
  2310. }
  2311. }
  2312. export function matchReference(
  2313. ref: UnresolvedRef,
  2314. context: ResolutionContext
  2315. ): ResolvedRef | null {
  2316. // Function-as-value refs (#756) resolve ONLY through the dedicated matcher —
  2317. // never the fuzzy/qualified fallthrough below (a wrong callback edge is
  2318. // worse than none).
  2319. if (ref.referenceKind === 'function_ref') {
  2320. return matchFunctionRef(ref, context);
  2321. }
  2322. // ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`,
  2323. // `.width`) by the extractor — resolve ONLY to decorator-marked attribute
  2324. // helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global
  2325. // `@Builder`s used attribute-position). Framework attributes (`.width`,
  2326. // `.fontSize` — on nearly every UI line) match no such helper and stay
  2327. // unresolved, NEVER falling through to bare-name matching: on a samples
  2328. // monorepo that fallthrough manufactured 36k wrong edges, giving single
  2329. // same-named properties thousands of false callers. Ambiguity rule matches
  2330. // the rest of the file: several same-named helpers → prefer the call-site
  2331. // file, still ambiguous → drop the ref rather than guess.
  2332. if (ref.language === 'arkts' && ref.referenceName.startsWith('.')) {
  2333. const base = ref.referenceName.slice(1);
  2334. const candidates = context
  2335. .getNodesByName(base)
  2336. .filter(
  2337. (n) =>
  2338. n.language === 'arkts' &&
  2339. n.kind === 'function' &&
  2340. (n.decorators ?? []).some((d) => ARKUI_ATTRIBUTE_DECORATORS.has(d))
  2341. );
  2342. const chosen =
  2343. candidates.length > 1 ? preferCallSiteFile(candidates, ref.filePath) : candidates;
  2344. if (chosen.length !== 1) return null;
  2345. return {
  2346. original: ref,
  2347. targetNodeId: chosen[0]!.id,
  2348. confidence: 0.85,
  2349. resolvedBy: 'exact-match',
  2350. };
  2351. }
  2352. // Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
  2353. // bare-name matching grabs any same-named symbol — on emqx,
  2354. // `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro
  2355. // constant in an unrelated app. Resolve only to the behaviour module's
  2356. // namespace; an out-of-repo behaviour (OTP's gen_server/supervisor) stays
  2357. // unresolved rather than guessed. The same module-only rule applies to every
  2358. // ref an `.app`/`.app.src` resource file emits — its `{mod, …}` callback and
  2359. // `{applications, …}` dependency names can only mean modules, and on emqx
  2360. // the `ssl` OTP app otherwise resolved to a test helper FUNCTION named ssl.
  2361. if (
  2362. ref.language === 'erlang' &&
  2363. (ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath))
  2364. ) {
  2365. const modules = context
  2366. .getNodesByName(ref.referenceName)
  2367. .filter((n) => n.language === 'erlang' && n.kind === 'namespace');
  2368. const chosen = preferCallSiteFile(modules, ref.filePath)[0];
  2369. if (!chosen) return null;
  2370. return {
  2371. original: ref,
  2372. targetNodeId: chosen.id,
  2373. confidence: 0.9,
  2374. resolvedBy: 'exact-match',
  2375. };
  2376. }
  2377. // Erlang call/fun refs carry the call-site arity (`f/1` — #1610) because
  2378. // arity is part of the function's identity and every erlang function's
  2379. // qualifiedName carries it (`mod::f/1`). Resolve ONLY to a definition of
  2380. // that exact arity: the call site's own file first (a local call targets its
  2381. // own module by language semantics; `-import`ed functions ride the
  2382. // cross-file branch), and when no definition of that arity exists anywhere,
  2383. // resolve to NOTHING rather than a sibling arity — the real target may be
  2384. // macro-generated or out of repo, and a wrong-arity edge is worse than none.
  2385. if (
  2386. ref.language === 'erlang' &&
  2387. !ref.referenceName.includes('::') &&
  2388. (ref.referenceKind === 'calls' || ref.referenceKind === 'references')
  2389. ) {
  2390. const am = /^(.+)\/(\d{1,3})$/.exec(ref.referenceName);
  2391. if (am) {
  2392. // endsWith is length-anchored, so `/1` cannot match `…/11`.
  2393. const arityTail = `/${am[2]}`;
  2394. const candidates = context
  2395. .getNodesByName(am[1]!)
  2396. .filter(
  2397. (n) =>
  2398. n.language === 'erlang' && n.kind === 'function' && n.qualifiedName.endsWith(arityTail),
  2399. );
  2400. if (candidates.length > 0) {
  2401. const sameFile = candidates.find((n) => n.filePath === ref.filePath);
  2402. if (sameFile) {
  2403. return { original: ref, targetNodeId: sameFile.id, confidence: 0.95, resolvedBy: 'exact-match' };
  2404. }
  2405. if (candidates.length === 1) {
  2406. return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'exact-match' };
  2407. }
  2408. const best = findBestMatch(ref, candidates, context);
  2409. if (best) {
  2410. const proximity = computePathProximity(ref.filePath, best.filePath);
  2411. return {
  2412. original: ref,
  2413. targetNodeId: best.id,
  2414. confidence: proximity >= 30 ? 0.7 : 0.4,
  2415. resolvedBy: 'exact-match',
  2416. };
  2417. }
  2418. }
  2419. return null;
  2420. }
  2421. }
  2422. // Try strategies in order of confidence
  2423. let result: ResolvedRef | null;
  2424. // 0. File path match (e.g., "snippets/drawer-menu.liquid" → file node)
  2425. result = nmTimed('filePath', ref, () => matchByFilePath(ref, context));
  2426. if (result) return result;
  2427. // 1. Qualified name match (highest confidence)
  2428. result = nmTimed('qualifiedName', ref, () => matchByQualifiedName(ref, context));
  2429. if (result) return result;
  2430. // 1b. C++ chained call whose receiver is another call — `Foo::instance().bar()`
  2431. // encoded as `Foo::instance().bar` by the extractor (#645). Resolve the
  2432. // receiver's type from what the inner call returns, then the method on it.
  2433. if (ref.language === 'cpp' || ref.language === 'c') {
  2434. result = nmTimed('cppChain', ref, () => matchCppCallChain(ref, context));
  2435. if (result) return result;
  2436. }
  2437. // 1c. `::`-scoped factory chain — PHP `Cls::for($x)->method()` (#608) or Rust
  2438. // `Foo::new().bar()`, both encoded as `Cls::factory().method`. The receiver's
  2439. // type is the factory's `self` (PHP `: self`/`: static`, Rust `-> Self`) or
  2440. // concrete return type.
  2441. if (ref.language === 'php' || ref.language === 'rust') {
  2442. result = nmTimed('scopedChain', ref, () => matchScopedCallChain(ref, context));
  2443. if (result) return result;
  2444. }
  2445. // 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift /
  2446. // Go / Scala / Dart / Objective-C) — `Foo.getInstance().bar()` encoded as
  2447. // `Foo.getInstance().bar`, Go's bare-factory `New().Method()` as `New().Method`,
  2448. // Scala's companion factory, Dart's static factory / factory-constructor, or
  2449. // ObjC's chained message send `[[Foo create] doIt]` encoded as `Foo.create().doIt`
  2450. // (#645/#608 mechanism). Resolve the method's class from the inner call's
  2451. // declared return type, then validate it.
  2452. if (
  2453. ref.language === 'java' ||
  2454. ref.language === 'kotlin' ||
  2455. ref.language === 'csharp' ||
  2456. ref.language === 'swift' ||
  2457. ref.language === 'go' ||
  2458. ref.language === 'scala' ||
  2459. ref.language === 'dart' ||
  2460. ref.language === 'objc' ||
  2461. ref.language === 'pascal'
  2462. ) {
  2463. result = nmTimed('dottedChain', ref, () => matchDottedCallChain(ref, context));
  2464. if (result) return result;
  2465. }
  2466. // 2. Method call pattern
  2467. result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context));
  2468. if (result) return result;
  2469. // 3. Exact name match
  2470. result = nmTimed('exactName', ref, () => matchByExactName(ref, context));
  2471. if (result) return result;
  2472. // 4. Fuzzy match (lowest confidence)
  2473. result = nmTimed('fuzzy', ref, () => matchFuzzy(ref, context));
  2474. if (result) return result;
  2475. return null;
  2476. }