| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361 |
- /**
- * Name Matcher
- *
- * Handles symbol name matching for reference resolution.
- */
- import { Language, Node } from '../types';
- import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
- /**
- * Ceiling on how many same-named definitions a FUZZY name-match strategy will
- * score. A name defined more times than this is "ubiquitous" — a method/symbol
- * re-declared across a vendored theme or SDK (e.g. `init`/`update`/`render` on
- * every widget of a committed Metronic theme — #999). No directory-proximity or
- * receiver-word-overlap score can reliably pick THE one true target among
- * thousands, so the fuzzy strategies (matchByExactName's findBestMatch, and
- * matchMethodCall Strategy 3) decline above the ceiling instead of emitting a
- * low-confidence, almost-certainly-wrong edge. This also caps their per-ref cost
- * at O(ceiling): without it, K same-named refs each scored K candidates — the
- * O(K²) blow-up that pinned a core for 15-28 min at "Resolving refs … 94%" on a
- * repo vendoring a large JS/TS theme (#999). The PRECISE strategies are
- * unaffected: qualified-name, import-based, and class-name (Strategy 1/2)
- * resolution all still run and resolve a ubiquitous name when the context names
- * its exact target. Real repos top out near ~40 same-named methods, so a normal
- * codebase never reaches this; only bulk-vendored code does. Tune via
- * `CODEGRAPH_AMBIGUOUS_NAME_CEILING`.
- */
- const DEFAULT_AMBIGUOUS_NAME_CEILING = 500;
- function resolveAmbiguousNameCeiling(): number {
- const raw = process.env.CODEGRAPH_AMBIGUOUS_NAME_CEILING;
- if (!raw) return DEFAULT_AMBIGUOUS_NAME_CEILING;
- const parsed = Number.parseInt(raw, 10);
- return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_AMBIGUOUS_NAME_CEILING;
- }
- const AMBIGUOUS_NAME_CEILING = resolveAmbiguousNameCeiling();
- /**
- * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid")
- * by matching the filename against file nodes.
- */
- export function matchByFilePath(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // Path-like (`a/b.liquid`) OR a bare filename ending in a short extension
- // (`Foo.h` — an Objective-C `#import "Foo.h"`, resolved to the header by
- // basename). A bare ref WITHOUT an extension is a symbol name, not a file, so
- // leave it to the symbol-matching strategies.
- if (!ref.referenceName.includes('/') && !/\.[A-Za-z][A-Za-z0-9]{0,3}$/.test(ref.referenceName)) {
- return null;
- }
- // Extract the filename from the path
- const fileName = ref.referenceName.split('/').pop();
- if (!fileName) return null;
- // Search for file nodes with this name
- const candidates = context.getNodesByName(fileName);
- const fileNodes = candidates.filter(n => n.kind === 'file');
- if (fileNodes.length === 0) return null;
- // Prefer exact path match on qualified_name
- const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName);
- if (exactMatch) {
- return {
- original: ref,
- targetNodeId: exactMatch.id,
- confidence: 0.95,
- resolvedBy: 'file-path',
- };
- }
- // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches
- // "src/snippets/foo.liquid"). When several files share the basename — a
- // `#include "RNCAsyncStorage.h"` with a same-named header on another platform
- // (windows/code/ vs apple/) — prefer the one in the includer's own directory,
- // then by directory proximity / same language family. A C/C++ include (and any
- // bare-filename import) resolves relative to the including file, not to an
- // arbitrary same-named header elsewhere in the tree.
- const suffixMatches = fileNodes.filter(
- n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName)
- );
- if (suffixMatches.length > 0) {
- return {
- original: ref,
- targetNodeId: pickClosestFileNode(suffixMatches, ref).id,
- confidence: 0.85,
- resolvedBy: 'file-path',
- };
- }
- // If only one file node with this name, use it with lower confidence
- if (fileNodes.length === 1) {
- return {
- original: ref,
- targetNodeId: fileNodes[0]!.id,
- confidence: 0.7,
- resolvedBy: 'file-path',
- };
- }
- return null;
- }
- /**
- * Among several file nodes that all match a bare include/import by basename,
- * pick the one closest to the referencing file: same directory first, then by
- * directory-tree proximity, with the same language family as a tiebreak. A
- * C/C++ `#include "X.h"` (and any bare-filename import) resolves relative to the
- * including file — not to an arbitrary same-named header on another platform.
- */
- function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
- const dirOf = (p: string): string => {
- const i = p.lastIndexOf('/');
- return i >= 0 ? p.slice(0, i) : '';
- };
- const refDir = dirOf(ref.filePath);
- const sameDir = candidates.filter((c) => dirOf(c.filePath) === refDir);
- const pool = sameDir.length > 0 ? sameDir : candidates;
- let best = pool[0]!;
- let bestScore = -Infinity;
- for (const c of pool) {
- const score =
- computePathProximity(ref.filePath, c.filePath) +
- (sameLanguageFamily(c.language, ref.language) ? 5 : 0);
- if (score > bestScore) {
- bestScore = score;
- best = c;
- }
- }
- return best;
- }
- /**
- * Language families that share a type system / runtime, so a same-language-only
- * reference may still resolve across them (a Kotlin `Foo.BAR` can name a Java
- * `Foo`). Anything not listed forms its own singleton family.
- */
- const LANGUAGE_FAMILY: Record<string, string> = {
- java: 'jvm', kotlin: 'jvm', scala: 'jvm',
- swift: 'apple', objc: 'apple',
- // ArkTS is a TS superset — every HarmonyOS project mixes `.ets` UI with
- // `.ts` logic modules, so refs must cross freely between them.
- typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', arkts: 'web',
- c: 'c', cpp: 'c',
- // Razor/Blazor markup names C# types — same family so `@model Foo` /
- // `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
- csharp: 'dotnet', razor: 'dotnet',
- };
- export function sameLanguageFamily(a: string, b: string): boolean {
- if (a === b) return true;
- const fa = LANGUAGE_FAMILY[a];
- return fa !== undefined && fa === LANGUAGE_FAMILY[b];
- }
- /**
- * True when `lang` belongs to a known multi-language family (jvm/apple/web/c).
- * Languages not listed (php, python, go, ruby, rust, dart, …) and config
- * formats (yaml/xml/blade) form their own singleton families and return
- * `false` — used to leave config↔code framework bridges (whose config side is
- * never a known programming-language family) out of the cross-family gate.
- */
- export function isKnownLanguageFamily(lang: string): boolean {
- return LANGUAGE_FAMILY[lang] !== undefined;
- }
- /**
- * True when `a` and `b` are two DIFFERENT *known* language families — the
- * signature of a coincidental cross-language name collision (a TS `import
- * React` matching a Swift `import React`, a C++ `#include "X.h"` matching a
- * same-named ObjC header on another platform). The both-*known* test is
- * deliberately weaker than {@link sameLanguageFamily}'s negation: a
- * single-file-component language that carries its own tag (`vue`/`svelte`)
- * importing a `.ts` module, or any singleton-family language (php/go/ruby/…),
- * returns `false` here and is left alone.
- */
- export function crossesKnownFamily(a: string, b: string): boolean {
- return isKnownLanguageFamily(a) && isKnownLanguageFamily(b) && !sameLanguageFamily(a, b);
- }
- /**
- * Drop cross-language candidates from a name lookup. Two regimes:
- * - `references` (type-usage): a type named in language X resolves to a
- * SAME-family type, never a coincidentally same-named symbol in another
- * language (the Android `BatteryManager` system class vs a JS one). Strict
- * same-family filter — cross-language communication is `calls`, not refs.
- * - `imports` (import binding): an `import`/`#include` never crosses two
- * KNOWN families (TS `import React` ↮ Swift `import React`). Weaker
- * both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives.
- */
- function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
- if (ref.referenceKind === 'references' || ref.referenceKind === 'function_ref') {
- return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
- }
- if (ref.referenceKind === 'imports') {
- return candidates.filter((c) => !crossesKnownFamily(c.language, ref.language));
- }
- return candidates;
- }
- /**
- * Resolve a function-as-value reference (#756) — a function name used as a
- * callback/function-pointer value (`register(handler)`, `o->cb = handler`,
- * `{ .cb = handler }`, `signal(SIGINT, handler)`). The ONLY strategy allowed
- * for `function_ref` refs: exact name, function/method targets only, same
- * language family, same-file first, and cross-file only when the match is
- * UNIQUE. No fuzzy fallback, no qualified-name walking — a wrong callback
- * edge is worse than none.
- */
- export function matchFunctionRef(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // `this.<member>` refs are resolved ONLY by the class-scoped resolver in
- // resolveOne (resolveThisMemberFnRef) — never by name matching here.
- if (ref.referenceName.startsWith('this.')) return null;
- // In JS/TS/Python a bare identifier can never be a method value (methods
- // are only reachable through a receiver — `this.m` / `self.m` /
- // `Cls.m`), so bare fn-refs match FUNCTIONS only. This also sidesteps the
- // pre-existing TS quirk of class fields extracting as method-kind nodes,
- // which otherwise soaked up local names passed as arguments (excalidraw
- // A/B finding; same pattern in vendored docopt.py). Python's `self.m`
- // form keeps method targets via its own capture shape. C++ likewise: a
- // bare identifier can only be a FREE function (member values need
- // `&Cls::method`). PHP string callables name global FUNCTIONS (methods
- // need the `[$obj, 'm']` array form, which carries its own shape). Other
- // languages keep method targets: C# method groups, Swift/Dart
- // implicit-self, Java/Kotlin method references.
- const bareFnOnly =
- ref.language === 'typescript' || ref.language === 'tsx' ||
- ref.language === 'javascript' || ref.language === 'jsx' ||
- ref.language === 'arkts' ||
- ref.language === 'cpp' || ref.language === 'python' ||
- ref.language === 'php';
- // Python additionally accepts CLASS targets for bare identifiers (#1478):
- // class-as-value is a core Python idiom (`return SomeSerializer`,
- // `Meta.model = Org`, registry dicts, `admin.site.register(Model, Admin)`)
- // and, unlike TS, Python has no type-annotation recovery path. The
- // false-positive mechanism behind the function-only rule was lowercase
- // locals colliding with same-named METHODS (docopt.py) — a candidate must
- // be an exact-name CLASS node here, and the extraction gate (same-file
- // class ∪ imports) plus unique-or-drop still apply. Methods stay excluded.
- const bareClassOk = ref.language === 'python';
- // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"):
- // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m`
- // shape is an explicit member reference). Unique-or-drop like everything else.
- if (ref.referenceName.includes('::')) {
- const memberName = ref.referenceName.slice(ref.referenceName.lastIndexOf('::') + 2);
- const scoped = context
- .getNodesByName(memberName)
- .filter(
- (n) =>
- (n.kind === 'function' || n.kind === 'method') &&
- sameLanguageFamily(n.language, ref.language) &&
- n.id !== ref.fromNodeId &&
- (n.qualifiedName === ref.referenceName ||
- n.qualifiedName.endsWith(`::${ref.referenceName}`))
- );
- if (scoped.length === 0) return null;
- const sameFileScoped = scoped.filter((n) => n.filePath === ref.filePath);
- const pool = sameFileScoped.length > 0 ? sameFileScoped : scoped;
- if (sameFileScoped.length === 0 && scoped.length > 1) return null;
- const target = pool.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
- return {
- original: ref,
- targetNodeId: target.id,
- confidence: 0.9,
- resolvedBy: 'function-ref',
- };
- }
- let candidates = context
- .getNodesByName(ref.referenceName)
- .filter(
- (n) =>
- (n.kind === 'function' ||
- (!bareFnOnly && n.kind === 'method') ||
- (bareClassOk && n.kind === 'class')) &&
- sameLanguageFamily(n.language, ref.language) &&
- n.id !== ref.fromNodeId // a function registering itself is not a dependency edge
- );
- if (candidates.length === 0) return null;
- // Swift implicit-self: a bare identifier can name a METHOD only of the
- // ENCLOSING type (`Button(action: handleTap)` written inside that type) —
- // a same-named method on any OTHER class is a parameter collision
- // (Alamofire: a `request` parameter resolving to EventMonitor::request).
- // Scope method candidates to the from-symbol's type; top-level code has no
- // implicit self, so method targets are excluded there entirely. Free
- // functions are unaffected.
- if (ref.language === 'swift' && candidates.some((n) => n.kind === 'method')) {
- const fromNode = context.getNodeById?.(ref.fromNodeId);
- const sep = fromNode ? fromNode.qualifiedName.lastIndexOf('::') : -1;
- const classPrefix = fromNode && sep > 0 ? fromNode.qualifiedName.slice(0, sep) : null;
- candidates = candidates.filter((n) => {
- if (n.kind !== 'method') return true;
- if (!classPrefix) return false;
- const mSep = n.qualifiedName.lastIndexOf('::');
- if (mSep <= 0) return false;
- const methodPrefix = n.qualifiedName.slice(0, mSep);
- // Accept exact-scope matches plus suffix relationships either way, so
- // extension-declared members (`Holder::m`) still match a nested
- // from-scope (`Module::Holder::wire`) and vice versa.
- return (
- methodPrefix === classPrefix ||
- methodPrefix.endsWith(`::${classPrefix}`) ||
- classPrefix.endsWith(`::${methodPrefix}`)
- );
- });
- if (candidates.length === 0) return null;
- }
- // Same-file definition wins — the extraction gate guarantees most survivors
- // have one, and it's the dominant C pattern (static callback registered in
- // a same-file ops struct).
- const sameFile = candidates.filter((n) => n.filePath === ref.filePath);
- if (sameFile.length > 0) {
- // Swift: several same-named METHODS in one file is an API overload family
- // (`Session.request(...)` × N), and a bare identifier hitting it is almost
- // always a same-named parameter, not a method value (Alamofire A/B
- // finding) — refuse rather than guess. A single method (SwiftUI's
- // `action: handleTap`) still resolves.
- if (
- ref.language === 'swift' &&
- sameFile.length > 1 &&
- sameFile.every((n) => n.kind === 'method')
- ) {
- return null;
- }
- // Same-name overloads in one file are the same conceptual symbol; pick
- // the first by position for determinism.
- const target = sameFile.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
- return {
- original: ref,
- targetNodeId: target.id,
- confidence: sameFile.length === 1 ? 0.95 : 0.9,
- resolvedBy: 'function-ref',
- };
- }
- // Cross-file (imported names the import resolver didn't already claim):
- // only an unambiguous match resolves.
- if (candidates.length === 1) {
- return {
- original: ref,
- targetNodeId: candidates[0]!.id,
- confidence: 0.8,
- resolvedBy: 'function-ref',
- };
- }
- return null;
- }
- /**
- * A function nested inside another FUNCTION is only callable from within its
- * container — Python, JS/TS, and every closure language scope it lexically.
- * Resolving a bare name from elsewhere to a nested local fabricates an edge
- * scope already rules out: `join(...)` in one function must never bind to a
- * `join` defined inside a DIFFERENT function (#1230). A candidate whose
- * qualifiedName parent is a same-file function/method is kept only when the
- * ref originates inside that parent's line range. Class members are
- * unaffected (their parent resolves to a class-like node), as are top-level
- * symbols and C++ namespace-prefixed names (the prefix has no node).
- */
- function isLexicallyReachable(
- candidate: Node,
- ref: UnresolvedRef,
- context: ResolutionContext
- ): boolean {
- if (candidate.kind !== 'function') return true;
- const qn = candidate.qualifiedName;
- if (!qn || !qn.includes('::')) return true;
- const parentQn = qn.slice(0, qn.lastIndexOf('::'));
- const containers = context
- .getNodesByQualifiedName(parentQn)
- .filter(
- (p) =>
- p.filePath === candidate.filePath &&
- (p.kind === 'function' || p.kind === 'method') &&
- p.startLine <= candidate.startLine &&
- p.endLine >= candidate.endLine
- );
- if (containers.length === 0) return true;
- return (
- ref.filePath === candidate.filePath &&
- containers.some((p) => ref.line >= p.startLine && ref.line <= p.endLine)
- );
- }
- /**
- * Try to resolve a reference by exact name match
- */
- export function matchByExactName(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // `import`-kind nodes are import STATEMENTS, not definitions, so a reference
- // resolving to a sibling file's `import` is a meaningless edge — the real
- // import→definition resolution is the import resolver's job (resolveViaImport),
- // never name-matching here. Excluding them also removes a quadratic blow-up:
- // a ubiquitous package (`react`, `@superset-ui/core`, Python `logging`/`typing`)
- // is re-declared as an `import` node in every file that imports it, so K
- // unresolved import refs each scored K same-named import candidates through
- // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on
- // large import-heavy (front-end + back-end) repos (#915).
- const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
- .filter((n) => n.kind !== 'import')
- // Nested locals are only reachable from inside their container (#1230).
- .filter((n) => isLexicallyReachable(n, ref, context));
- if (candidates.length === 0) {
- return null;
- }
- // If only one match, use it — but penalize cross-language matches
- if (candidates.length === 1) {
- const isCrossLanguage = candidates[0]!.language !== ref.language;
- return {
- original: ref,
- targetNodeId: candidates[0]!.id,
- confidence: isCrossLanguage ? 0.5 : 0.9,
- resolvedBy: 'exact-match',
- };
- }
- // Ubiquitous-name ceiling (#999): above it, picking one target among K
- // same-named defs by directory proximity is unreliable AND O(K) per ref — the
- // quadratic behind the "Resolving refs" wedge on theme/SDK-vendoring repos.
- // Decline; the precise strategies (qualified-name, import, class-name) already
- // ran. Falls through to fuzzy, which itself only resolves a UNIQUE candidate.
- if (candidates.length > AMBIGUOUS_NAME_CEILING) {
- return null;
- }
- // Multiple matches - try to narrow down
- const bestMatch = findBestMatch(ref, candidates, context);
- if (bestMatch) {
- // Lower confidence when the match is from a distant/unrelated module
- const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
- const confidence = proximity >= 30 ? 0.7 : 0.4;
- return {
- original: ref,
- targetNodeId: bestMatch.id,
- confidence,
- resolvedBy: 'exact-match',
- };
- }
- return null;
- }
- /**
- * Try to resolve by qualified name
- */
- export function matchByQualifiedName(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // Check if the reference name looks qualified (contains :: or .)
- if (!ref.referenceName.includes('::') && !ref.referenceName.includes('.')) {
- return null;
- }
- // A method call `receiver.method()` can share an exact qualified name with a
- // config-file key: `service.process()` (a `calls` ref named `service.process`)
- // vs the yaml key `service.process`. Config keys are bound to their code refs
- // upstream by the framework resolvers (`@Value` → `references`); a `calls` ref
- // must never resolve to a yaml/properties config node — that's a wrong edge
- // AND it hides the real callee. Drop those from both the exact and the partial
- // candidate sets so resolution falls through to method resolution below (#1180).
- const keepForRef = (nodes: Node[]): Node[] =>
- ref.referenceKind === 'calls'
- ? nodes.filter(
- (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')),
- )
- : nodes;
- const candidates = keepForRef(context.getNodesByQualifiedName(ref.referenceName));
- if (candidates.length === 1) {
- return {
- original: ref,
- targetNodeId: candidates[0]!.id,
- confidence: 0.95,
- resolvedBy: 'qualified-name',
- };
- }
- // Several symbols share this exact qualified name (e.g. `Logger::log` declared
- // in two files — an ODR clash or separate translation units): prefer the one
- // in the call site's own file before the partial-match fallback below, else
- // the first-indexed def wins and a call in `b/svc` targets `a/svc` (#1079).
- if (candidates.length > 1) {
- const ordered = preferCallSiteFile(candidates, ref.filePath);
- if (ordered[0]!.filePath === ref.filePath) {
- return {
- original: ref,
- targetNodeId: ordered[0]!.id,
- confidence: 0.95,
- resolvedBy: 'qualified-name',
- };
- }
- }
- // Try partial qualified name match — again preferring the call site's own
- // file when more than one symbol's qualifiedName ends with the reference.
- const parts = ref.referenceName.split(/[:.]/);
- const lastName = parts[parts.length - 1];
- if (lastName) {
- const partialCandidates = keepForRef(context.getNodesByName(lastName))
- .filter((candidate) => candidate.qualifiedName.endsWith(ref.referenceName));
- const chosen = preferCallSiteFile(partialCandidates, ref.filePath)[0];
- if (chosen) {
- return {
- original: ref,
- targetNodeId: chosen.id,
- confidence: 0.85,
- resolvedBy: 'qualified-name',
- };
- }
- }
- return null;
- }
- /**
- * When a symbol name is ambiguous across files, prefer the candidate(s) declared
- * in the call site's own file, keeping the rest in their original order (#1079).
- * A same-file definition is the strongest language-agnostic signal for which of
- * several same-named symbols a call means; without it, resolution collapses onto
- * whichever was indexed first, so a call in `b/svc` wrongly targets `a/svc`.
- * No-op when there are <2 candidates or none share the call site's file.
- */
- export function preferCallSiteFile(nodes: Node[], callSiteFile: string): Node[] {
- if (nodes.length < 2) return nodes;
- const same: Node[] = [];
- const other: Node[] = [];
- for (const n of nodes) {
- if (n.filePath === callSiteFile) same.push(n);
- else other.push(n);
- }
- return same.length ? [...same, ...other] : nodes;
- }
- // Exported for the precedence unit tests (#1079): they assert the
- // preferredFqn → same-file → matches[0] ordering directly.
- export function resolveMethodOnType(
- typeName: string,
- methodName: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- confidence: number,
- resolvedBy: ResolvedRef['resolvedBy'],
- /**
- * Optional FQN that identifies WHICH class declaration `typeName`
- * refers to in the caller's file. When multiple candidates share
- * the same qualifiedName (`FooConverter::convert` in both
- * `dao/converter/` and `service/converter/`), the FQN's
- * file-path-suffix picks the right one — the disambiguation
- * signal Java imports carry but the call site doesn't (#314).
- */
- preferredFqn?: string,
- /** Recursion guard for the supertype/conformance walk. */
- depth = 0,
- ): ResolvedRef | null {
- // Look up methods by name and match by qualifiedName ending in
- // `<typeName>::<methodName>`. This works whether the method is defined
- // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate
- // file (`int Foo::bar() { ... }` in foo.cpp while class Foo is in foo.hpp).
- // The previous same-file approach missed the latter — the typical C++ layout.
- // Prefer the context's per-(type, method) memo: the raw name lookup fetches
- // EVERY node sharing the method name — tens of thousands of rows for a
- // collision-heavy Java name like `execute` — and re-filtering that per ref
- // was a dominant term in the #1122 watchdog kill on large repos. Only the
- // ref-independent filter is memoized; per-ref disambiguation stays below.
- let matches: Node[];
- if (context.getMethodMatches) {
- matches = context.getMethodMatches(typeName, methodName, ref.language);
- } else {
- const methodCandidates = context.getNodesByName(methodName);
- const want = `${typeName}::${methodName}`;
- matches = [];
- for (const m of methodCandidates) {
- if (m.kind !== 'method') continue;
- if (m.language !== ref.language) continue;
- const qn = m.qualifiedName;
- if (qn === want || qn.endsWith(`::${want}`)) {
- matches.push(m);
- }
- }
- }
- if (matches.length === 0) {
- // Conformance fallback: the method may be defined on a supertype `typeName`
- // extends, or on a protocol / trait it conforms to (e.g. a Swift protocol-
- // extension method, a C# default-interface or extension method, a Kotlin
- // extension on a supertype). Walk supertypes transitively (depth-capped) via
- // the resolved implements/extends edges — empty in the first resolution pass,
- // populated in the conformance pass. Still VALIDATED (the method must exist on
- // a supertype), so a wrong inference produces no edge.
- if (depth < 4 && context.getSupertypes) {
- const viaSupers = nmTimedT('rmot-supers', ref, (): ResolvedRef | null => {
- for (const supertype of context.getSupertypes!(typeName, ref.language)) {
- const via = resolveMethodOnType(
- supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1,
- );
- if (via) return via;
- }
- return null;
- });
- if (viaSupers) return viaSupers;
- }
- return null;
- }
- if (matches.length > 1 && preferredFqn) {
- const ext = ref.language === 'kotlin' ? '.kt' : '.java';
- const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
- const chosen = matches.find((m) => {
- const fp = m.filePath.replace(/\\/g, '/');
- return fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath);
- });
- if (chosen) {
- return {
- original: ref,
- targetNodeId: chosen.id,
- confidence,
- resolvedBy,
- };
- }
- }
- // Language-agnostic disambiguation: when several same-named methods survive
- // (e.g. two files each declaring `class Logger { void log(); }` — an ODR
- // clash, an anonymous-namespace type, or separate translation units), prefer
- // the definition in the CALL SITE's own file. Without this, every ambiguous
- // call collapses onto the first-indexed definition, so a call in `b/svc.cpp`
- // wrongly points at `a/svc.cpp` (#1079). This runs AFTER the `preferredFqn`
- // block, so Java/Kotlin import disambiguation — whose target is intentionally
- // in ANOTHER file (#314) — is unaffected: that block returns early whenever
- // an import FQN pins the class.
- const ordered = preferCallSiteFile(matches, ref.filePath);
- return {
- original: ref,
- targetNodeId: ordered[0]!.id,
- confidence,
- resolvedBy,
- };
- }
- // C++ keywords/control-flow tokens that can appear right before a receiver
- // (e.g. `return ptr->m()`) and must NOT be treated as a type.
- const CPP_NON_TYPE_TOKENS = new Set([
- 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
- 'break', 'continue', 'goto', 'throw', 'new', 'delete', 'co_await', 'co_yield',
- 'co_return', 'static_cast', 'const_cast', 'dynamic_cast', 'reinterpret_cast',
- 'sizeof', 'alignof', 'typeid', 'and', 'or', 'not', 'xor',
- ]);
- function normalizeCppTypeName(typeName: string): string | null {
- const normalized = typeName
- .replace(/\b(const|volatile|mutable|typename|class|struct)\b/g, ' ')
- .replace(/[&*]+/g, ' ')
- .replace(/<[^>]*>/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
- if (!normalized) return null;
- const parts = normalized.split(/::/).filter(Boolean);
- const last = parts[parts.length - 1];
- if (!last) return null;
- if (CPP_NON_TYPE_TOKENS.has(last)) return null;
- return last;
- }
- // Declarator regex: matches `Type receiver`, `Type* receiver`, `Type *receiver`,
- // `Type*receiver`, `Type<X> receiver`, etc., REQUIRING a declarator terminator
- // (`;`, `=`, `,`, `)`, `[`, `{`, `(`, or end-of-line) after the receiver. The
- // terminator rules out uses like `return receiver->m()` where the preceding
- // token is a keyword, not a type.
- function buildDeclaratorRegex(escapedReceiver: string): RegExp {
- return new RegExp(
- `([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`,
- );
- }
- function inferCppReceiverType(
- receiverName: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- depth = 0,
- ): string | null {
- // Per-file lines cache when available — this runs per `receiver->method()`
- // ref and re-splitting the file each time is the same quadratic as the
- // shared inferrer's (#1122).
- const lines = context.getFileLines
- ? context.getFileLines(ref.filePath)
- : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null);
- if (!lines || lines.length === 0) return null;
- const callLineIndex = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
- const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const receiverPattern = new RegExp(`\\b${escapedReceiver}\\b`);
- const declaratorRegex = buildDeclaratorRegex(escapedReceiver);
- for (let i = callLineIndex; i >= 0; i--) {
- const line = lines[i];
- if (!line || !receiverPattern.test(line)) continue;
- const declaratorMatch = line.match(declaratorRegex);
- if (declaratorMatch) {
- const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
- if (normalized === 'auto') {
- // `auto x = Foo::instance();` — the declared type is deduced; recover it
- // from the initializer (call return type / construction) (#645).
- const initType = inferCppAutoInitializerType(line, receiverName, ref, context, depth);
- if (initType) return initType;
- // No usable initializer on this line — keep scanning earlier ones.
- } else if (normalized) {
- return normalized;
- }
- }
- }
- const headerCandidates = [
- ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.h'),
- ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hpp'),
- ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hxx'),
- ].filter((candidate, index, arr) => arr.indexOf(candidate) === index && candidate !== ref.filePath);
- for (const headerPath of headerCandidates) {
- if (!context.fileExists(headerPath)) continue;
- const headerLines = context.getFileLines
- ? context.getFileLines(headerPath)
- : (context.readFile(headerPath)?.split(/\r?\n/) ?? null);
- if (!headerLines) continue;
- for (const line of headerLines) {
- if (!receiverPattern.test(line)) continue;
- const declaratorMatch = line.match(declaratorRegex);
- if (!declaratorMatch) continue;
- const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
- if (normalized && normalized !== 'auto') return normalized;
- }
- }
- return null;
- }
- /**
- * Last `::`-separated segment of a (possibly namespace-qualified) C++ name.
- */
- function cppLastSegment(name: string): string {
- const parts = name.split('::').filter(Boolean);
- return parts[parts.length - 1] ?? name;
- }
- /**
- * Return type captured at extraction for `Class::method` (or a free function),
- * read off the indexed node's `returnType` — used by the C++ (#645) and PHP
- * (#608) chained-call resolvers. Language-filtered. Null when not indexed or no
- * return type was recorded (a `void`/primitive return).
- */
- function lookupCalleeReturnType(
- callee: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- ): string | null {
- let method = callee;
- let cls: string | null = null;
- if (callee.includes('::')) {
- const parts = callee.split('::').filter(Boolean);
- method = parts[parts.length - 1] ?? callee;
- cls = parts.slice(0, -1).join('::');
- }
- const candidates = context.getNodesByName(method).filter(
- (n) =>
- (n.kind === 'method' || n.kind === 'function') &&
- n.language === ref.language &&
- !!n.returnType,
- );
- if (cls) {
- const want = `${cls}::${method}`;
- // The call site may name the class with MORE namespace qualification than
- // the stored node (`details::registry::instance` at the call vs
- // `registry::instance` on the node — the receiver type only carries the
- // immediate class), or LESS. Accept an exact match or either being a
- // namespace-suffix of the other; the shared `::<class>::<method>` tail keeps
- // it specific.
- const m = candidates.find(
- (n) =>
- n.qualifiedName === want ||
- n.qualifiedName.endsWith(`::${want}`) ||
- want.endsWith(`::${n.qualifiedName}`),
- );
- return m?.returnType ?? null;
- }
- return candidates.find((n) => n.kind === 'function')?.returnType ?? null;
- }
- /** Does the graph contain an aggregate type named `name`'s last segment? */
- function cppClassExists(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
- const last = cppLastSegment(name);
- return context
- .getNodesByName(last)
- .some((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'union') && n.language === ref.language);
- }
- /**
- * Infer the class produced by a C++ call/construction expression, using return
- * types captured at extraction (#645). Handles, in order:
- * - `make_unique<T>()` / `make_shared<T>()` → T
- * - single-level member call `recv.method()` → recv's type, then method's return
- * - `Class::method()` / free `func()` → the callee's recorded return type
- * - direct construction `Type()` / `ns::Type()` → Type
- * Returns null when undeterminable. Callers MUST still validate the outer method
- * exists on the result before creating an edge, so a wrong guess stays silent.
- */
- function resolveCppCallResultType(
- inner: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- depth = 0,
- ): string | null {
- if (depth > 3) return null; // guard against pathological mutual recursion
- const expr = inner.trim();
- const make = expr.match(/(?:^|::)(?:make_unique|make_shared)\s*<\s*([A-Za-z_]\w*)/);
- if (make) return make[1] ?? null;
- // Single-level member call `recv.method` (the `manager.view().render()` shape).
- const dotIdx = expr.lastIndexOf('.');
- if (dotIdx > 0) {
- const recv = expr.slice(0, dotIdx);
- const method = expr.slice(dotIdx + 1);
- if (recv.includes('.') || recv.includes('(') || recv.includes('::')) return null; // single level only
- const recvType = inferCppReceiverType(recv, ref, context, depth + 1);
- if (!recvType) return null;
- return lookupCalleeReturnType(`${recvType}::${method}`, ref, context);
- }
- const ret = lookupCalleeReturnType(expr, ref, context);
- if (ret) return ret;
- // Direct construction — the callee itself names a class/struct.
- if (cppClassExists(expr, ref, context)) return cppLastSegment(expr);
- return null;
- }
- /**
- * Recover the type of an `auto`-declared local from its initializer on the
- * declaration line — `auto x = Foo::instance();`, `auto w = make_unique<W>();`,
- * `auto p = new W();`, `auto w = Widget();` (#645).
- */
- function inferCppAutoInitializerType(
- line: string,
- receiverName: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- depth: number,
- ): string | null {
- const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const m = line.match(new RegExp(`\\b${escaped}\\b\\s*=\\s*([^;]+)`));
- if (!m || !m[1]) return null;
- const init = m[1].trim();
- const neu = init.match(/^new\s+([A-Za-z_][\w:]*)/);
- if (neu && neu[1]) return cppLastSegment(neu[1]);
- // A call or construction: `Foo(...)`, `A::b(...)`, `make_unique<T>(...)`.
- const call = init.match(/^([A-Za-z_][\w:]*(?:\s*<[^>;]*>)?)\s*\(/);
- if (call && call[1]) return resolveCppCallResultType(call[1].replace(/\s+/g, ''), ref, context, depth + 1);
- return null;
- }
- /**
- * Resolve a C++ chained call whose receiver is itself a call — encoded by the
- * extractor as `<innerCallee>().<method>` (#645). The receiver's type is what
- * the inner call returns; the outer method is then resolved and VALIDATED on it
- * (resolveMethodOnType requires `cls::method` to exist), so a wrong inference
- * produces no edge rather than a wrong one.
- */
- export function matchCppCallChain(
- ref: UnresolvedRef,
- context: ResolutionContext,
- ): ResolvedRef | null {
- const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
- if (!m || !m[1] || !m[2]) return null;
- const cls = resolveCppCallResultType(m[1], ref, context);
- if (!cls) return null;
- return resolveMethodOnType(cls, m[2], ref, context, 0.85, 'instance-method');
- }
- /**
- * Resolve a `::`-scoped factory chain whose receiver is a scoped/static call —
- * PHP `Cls::for($x)->method()` (#608, the per-credential Laravel client idiom) or
- * Rust `Foo::new().bar()` (an associated-function call) — both encoded by the
- * extractor as `Cls::factory().method`. The receiver's type is what `Cls::factory`
- * returns: a `self` marker (PHP `: self`/`: static`, Rust `-> Self`) resolves to
- * the factory's own type, a concrete return type to that type. The outer method is
- * then resolved and VALIDATED on it (resolveMethodOnType requires the method to
- * exist on the type or a supertype it conforms to), so a wrong inference yields no
- * edge rather than a wrong one. Shared by the `::`-receiver languages (PHP, Rust).
- */
- export function matchScopedCallChain(
- ref: UnresolvedRef,
- context: ResolutionContext,
- ): ResolvedRef | null {
- const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
- if (!m || !m[1] || !m[2]) return null;
- const inner = m[1];
- const method = m[2];
- if (!inner.includes('::')) return null; // only static-factory (`Cls::method`) chains
- const factoryClass = inner.slice(0, inner.lastIndexOf('::'));
- const ret = lookupCalleeReturnType(inner, ref, context);
- if (!ret) return null;
- // `self` (the extractor's marker for self/static/$this) → the factory's class.
- const resolvedClass = ret === 'self' ? factoryClass : ret;
- return resolveMethodOnType(resolvedClass, method, ref, context, 0.85, 'instance-method');
- }
- /**
- * Languages where an unprefixed capitalized call `Foo(args)` constructs the
- * class (so a `Foo(args).method()` receiver's type is `Foo`). Java/C# need `new`,
- * so a bare `Foo()` there is a method call, not construction — excluded. Scala's
- * `Foo(args)` is a case-class / companion `apply`, which conventionally returns
- * `Foo` — and resolveMethodOnType validates, so a non-conventional `apply` that
- * returns another type simply yields no edge rather than a wrong one. Pascal/Delphi:
- * a `TFoo(x)` is a TYPECAST whose result is a `TFoo`, so `TFoo(x).method()` resolves
- * the method on `TFoo` — same shape, same validation.
- */
- const CONSTRUCTS_VIA_BARE_CALL = new Set(['kotlin', 'swift', 'scala', 'dart', 'pascal']);
- /**
- * Resolve a dotted chained call whose receiver is a static factory / fluent call —
- * `Foo.getInstance().bar()`, encoded by the extractor as `Foo.getInstance().bar`
- * (#645/#608 mechanism). The receiver's type is what `Foo.getInstance` returns
- * (its declared return type); the outer method is then resolved and VALIDATED on
- * it (resolveMethodOnType requires `Type::method` to exist), so a wrong inference
- * yields no edge rather than a wrong one (e.g. a same-named `bar()` on an
- * unrelated class is never matched). Shared by the dot-notation languages
- * (Java, Kotlin, C#, Swift) — same receiver shape, same `Class::method` qualified names.
- */
- export function matchDottedCallChain(
- ref: UnresolvedRef,
- context: ResolutionContext,
- ): ResolvedRef | null {
- const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
- if (!m || !m[1] || !m[2]) return null;
- const inner = m[1]; // `Foo.getInstance`
- const method = m[2]; // `bar`
- const lastDot = inner.lastIndexOf('.');
- if (lastDot <= 0) {
- // Go: bare package-level factory FUNCTION `New().method()` — the receiver's
- // type is what `New` returns; resolve the method on that.
- if (ref.language === 'go') {
- const ret = lookupCalleeReturnType(inner, ref, context);
- if (ret) {
- return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
- }
- // `inner` isn't a function with a captured return type — typically a
- // package-level VARIABLE holding a function value (e.g. gin's `engine()`),
- // whose type we can't recover. Fall back to bare-name resolution of the
- // method so we don't DROP an edge the un-re-encoded bare path would have
- // found. (When `inner` IS a real factory function but the method doesn't
- // exist on its return type, `ret` is truthy and we returned no edge above —
- // the absent-method safety guarantee is preserved.)
- //
- // CRITICAL: resolve the TARGET via a synthetic bare-name ref, but return the
- // match tied to the ORIGINAL `ref` (referenceName `inner().method`). The
- // batched resolver (resolveAndPersistBatched) reads unresolved rows from
- // offset 0 every pass and relies on the post-batch cleanup (row-id delete
- // for DB-loaded refs, referenceName-keyed delete otherwise, #1269) to
- // clear each resolved row so the batch empties. If we propagated the
- // synthetic ref's bare `method` as `.original`, a key-based delete
- // would never match the stored `inner().method` row, the batch would
- // never drain, and the loop would re-resolve + re-insert forever (a runaway
- // that grew gin's graph to 5M edges / 1.4 GB before this fix).
- const bareRef = { ...ref, referenceName: method };
- const bareMatch = matchByExactName(bareRef, context) ?? matchFuzzy(bareRef, context);
- return bareMatch ? { ...bareMatch, original: ref } : null;
- }
- // Constructor receiver `Foo(args).method()` (encoded `Foo().method`): a bare,
- // capitalized inner is a class construction, so the receiver's type is the
- // class itself — resolve the method on it. Only in languages where an
- // unprefixed capitalized call constructs the class (Kotlin, Swift); in Java/C#
- // a bare `Foo()` is a method call (constructors need `new`), so we must not
- // assume construction. A lowercase bare inner is a top-level `factory().method()`
- // whose type we can't recover — bail.
- if (!CONSTRUCTS_VIA_BARE_CALL.has(ref.language) || !/^[A-Z]/.test(inner)) return null;
- return resolveMethodOnType(inner, method, ref, context, 0.85, 'instance-method', importedFqnOf(inner, ref, context));
- }
- // Factory/fluent receiver `Receiver.factory(args).method()`: the receiver's
- // type is what `Receiver.factory` returns (its declared return type).
- const factoryClass = inner.slice(0, lastDot).split('.').pop(); // simple class name
- const factoryMethod = inner.slice(lastDot + 1);
- if (!factoryClass || !factoryMethod) return null;
- const ret = lookupCalleeReturnType(`${factoryClass}::${factoryMethod}`, ref, context);
- if (!ret) {
- // Objective-C: a class-message factory — `[X alloc]`, `[X new]`,
- // `[X sharedFoo]` — returns an instance of the RECEIVER class `X` by
- // convention (`instancetype`). So when the factory's own return type isn't
- // recoverable (its selector returns `instancetype`, or `alloc`/`new` aren't
- // user-defined nodes at all), the receiver's type is the class `X` itself.
- // This resolves the ubiquitous `[[X alloc] init]` and singleton chains.
- // resolveMethodOnType validates against X (and its supertypes), so a class
- // whose method actually lives elsewhere yields NO edge, not a wrong one — and
- // crucially this does NOT fire when a concrete return type WAS captured but
- // simply lacks the method (that already returned null above: absent-method
- // safety, so a same-named decoy is still never matched).
- if (ref.language === 'objc' && /^[A-Z]/.test(factoryClass)) {
- return resolveMethodOnType(factoryClass, method, ref, context, 0.8, 'instance-method', importedFqnOf(factoryClass, ref, context));
- }
- // Pascal/Delphi: the extractor only re-encodes a `TFoo`/`IFoo`-prefixed chain
- // (the type-naming convention), so `factoryClass` is always a real class here.
- // A factory whose return type wasn't captured is a CONSTRUCTOR
- // (`TFileMem.Create().SetCachePerformance` — `constructor Create` has no `:
- // TBar` annotation but returns its own class) or an unannotated function. In
- // both cases the receiver's type is the class itself, so resolve the method on
- // `factoryClass`. resolveMethodOnType validates against it (and its
- // supertypes), so a wrong inference yields no edge — and this never fires when
- // a return type WAS captured but lacks the method (absent-method safety above).
- if (ref.language === 'pascal' && /^[TI]/.test(factoryClass)) {
- return resolveMethodOnType(factoryClass, method, ref, context, 0.8, 'instance-method', importedFqnOf(factoryClass, ref, context));
- }
- return null;
- }
- return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
- }
- /**
- * When several classes share a simple type name, the caller file's import of
- * that type is the only signal that names WHICH one (#314). Returns the imported
- * FQN for `typeName` in the ref's file, or undefined.
- */
- function importedFqnOf(
- typeName: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- ): string | undefined {
- const imports = context.getImportMappings(ref.filePath, ref.language);
- return imports.find((i) => i.localName === typeName)?.source;
- }
- /**
- * Java/Kotlin: infer a receiver's declared type by walking field declarations
- * in the class enclosing the call site. The field's `signature` is already in
- * the form "<TypeName> <fieldName>" (set by tree-sitter.ts extractField), so we
- * pull the type from there. Handles Spring `@Resource UserBO userbo;` /
- * `@Autowired private UserService userService;` where the receiver field name
- * doesn't match the class name by Java naming convention.
- *
- * Returns the bare type name (generics stripped, dotted package stripped) or
- * null when no matching field is in the enclosing class.
- */
- function inferJavaFieldReceiverType(
- receiverName: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- ): string | null {
- const inFile = context.getNodesInFile(ref.filePath);
- if (inFile.length === 0) return null;
- // Find the class enclosing the call line (tightest match by latest start).
- let enclosing: Node | null = null;
- for (const n of inFile) {
- if (n.kind !== 'class' && n.kind !== 'interface') continue;
- if (n.language !== ref.language) continue;
- const end = n.endLine ?? n.startLine;
- if (n.startLine <= ref.line && end >= ref.line) {
- if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n;
- }
- }
- if (!enclosing) return null;
- const enclosingEnd = enclosing.endLine ?? enclosing.startLine;
- const field = inFile.find(
- (n) =>
- n.kind === 'field' &&
- n.name === receiverName &&
- n.language === ref.language &&
- n.startLine >= enclosing.startLine &&
- (n.endLine ?? n.startLine) <= enclosingEnd,
- );
- if (!field || !field.signature) return null;
- // Signature shape: "<TypeName> <fieldName>" (extractField). Pull the type,
- // strip generics + dotted package, drop array/varargs markers.
- const beforeName = field.signature.slice(
- 0,
- field.signature.lastIndexOf(field.name),
- );
- const typeRaw = beforeName.trim();
- if (!typeRaw) return null;
- const typeNoGenerics = typeRaw.replace(/<[^>]*>/g, '').trim();
- const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').trim();
- const parts = typeNoArray.split(/[.\s]+/).filter(Boolean);
- const lastPart = parts[parts.length - 1];
- if (!lastPart) return null;
- if (!/^[A-Z]/.test(lastPart)) return null; // primitives / lowercase → skip
- return lastPart;
- }
- // ── Local-variable receiver-type inference (#1108) ──────────────────────────
- //
- // Instance calls through a local variable (`const lg = new Logger(); lg.log()`)
- // only resolved in C++ before this — no other language could learn the
- // receiver's type. Local variables are not indexed as nodes (node-explosion),
- // so, like the C++ inferrer above, we read the enclosing function's source and
- // match the receiver's declaration/initializer to recover its type. The type is
- // then handed to resolveMethodOnType, which VALIDATES that the type actually
- // declares the method, so a mis-inference produces NO edge — the safety net
- // that lets the patterns below stay simple. C++ keeps its dedicated inferrer
- // (header scan + `auto`); this covers every other language.
- // Tokens a loose pattern might capture that are never a user-defined type.
- const NON_TYPE_RECEIVER_TOKENS = new Set([
- 'this', 'self', 'super', 'new', 'return', 'await', 'yield', 'typeof',
- 'null', 'nil', 'None', 'true', 'false', 'True', 'False', 'undefined',
- ]);
- /**
- * Normalize a captured type expression to a simple type name: drop generic
- * args and pointer/ref markers, take the last `.`/`::`-qualified segment, and
- * reject obvious non-types.
- */
- export function normalizeInferredTypeName(raw: string): string | null {
- const cleaned = raw.replace(/<[^>]*>/g, '').replace(/[&*]/g, '').trim();
- const seg = cleaned.split(/[.:]+/).filter(Boolean).pop();
- if (!seg) return null;
- if (NON_TYPE_RECEIVER_TOKENS.has(seg)) return null;
- return seg;
- }
- /**
- * Per-language patterns that recover a local variable's (or typed parameter's)
- * type from its declaration/initializer. Each regex captures the type in group
- * 1; `r` is the already-escaped receiver name. Ordered most-specific first.
- * PascalCase is required in the capture where the language convention allows,
- * as a cheap false-positive guard on top of resolveMethodOnType's validation.
- */
- /**
- * Compiled-pattern memo for the receiver-type pattern builders below. They
- * run for EVERY `receiver.method()` ref the matcher attempts, compiling 2–4
- * fresh RegExp objects per call — and receivers repeat massively (`self`
- * alone accounts for tens of thousands of refs on a Lua repo, measured 41µs
- * per methodCall miss on kong with compilation a large slice). The patterns
- * are a pure function of (language, receiver) and non-global (`.match()`
- * never touches lastIndex), so shared instances are behavior-identical.
- * FIFO-capped with no per-get mutation (the §7a.6 LRU-churn lesson): a hit
- * costs one Map lookup, overflow evicts oldest, and an evicted entry simply
- * recompiles exactly as every call did before this memo.
- */
- const PATTERN_MEMO = new Map<string, RegExp[]>();
- const PATTERN_MEMO_CAP = 8192;
- /**
- * Per-context incremental receiver-scan states for inferLocalReceiverType
- * (see the memo comment there). Keyed (file, scopeStart, language, receiver);
- * entries are a few dozen bytes, count is bounded by distinct receiver uses
- * (same order as the context's other per-file caches). MUST drop whenever the
- * context's file caches drop — the states are derived from file lines — so
- * ReferenceResolver.clearCaches calls clearNameMatcherMemos alongside
- * clearImportResolverMemos.
- */
- type InferScanState = { hi: number; ansIdx: number; ansType: string | null };
- const INFER_SCAN_STATES = new WeakMap<ResolutionContext, Map<string, InferScanState>>();
- function getInferScanStates(context: ResolutionContext): Map<string, InferScanState> {
- let m = INFER_SCAN_STATES.get(context);
- if (!m) {
- m = new Map();
- INFER_SCAN_STATES.set(context, m);
- }
- return m;
- }
- /** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
- export function clearNameMatcherMemos(context: ResolutionContext): void {
- INFER_SCAN_STATES.delete(context);
- }
- function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
- const hit = PATTERN_MEMO.get(key);
- if (hit) return hit;
- const patterns = build();
- if (PATTERN_MEMO.size >= PATTERN_MEMO_CAP) {
- const oldest = PATTERN_MEMO.keys().next().value;
- if (oldest !== undefined) PATTERN_MEMO.delete(oldest);
- }
- PATTERN_MEMO.set(key, patterns);
- return patterns;
- }
- export function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
- return memoPatterns(`${language}|${r}`, () => buildLocalReceiverTypePatterns(language, r));
- }
- function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] {
- switch (language) {
- case 'typescript':
- case 'javascript':
- case 'tsx':
- case 'jsx':
- case 'arkts':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
- // No keyword requirement, so this matches BOTH a local annotation
- // (`const lg: Logger`) and a typed parameter (`function use(lg: Logger)`
- // / `(lg: Logger) =>`) — the parameter case the old `const|let|var`
- // prefix excluded (#1125). Mirrors Kotlin/Swift/Scala; the capture stops
- // at `<` so a generic-typed param (`repo: Repository<User>`) still yields
- // `Repository`. resolveMethodOnType validates the type actually declares
- // the method, so the looser match produces no edge on a mis-inference.
- new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger (annotation or typed param)
- ];
- case 'python':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // lg = Logger(...)
- new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // lg: Logger (PEP 526)
- ];
- case 'java':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger()
- new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param
- ];
- case 'kotlin':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // val lg = Logger(...)
- new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // val lg: Logger / param
- ];
- case 'csharp':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger()
- new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param
- ];
- case 'swift':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // let lg = Logger(...)
- new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // let lg: Logger / param
- ];
- case 'rust':
- return [
- new RegExp(`\\blet\\s+(?:mut\\s+)?${r}\\b(?:\\s*:[^=]+)?=\\s*&?(?:mut\\s+)?([A-Z][\\w]*)`), // let lg = Logger::new()/Logger{}/Logger
- // No `let`, so this covers a `let lg: Logger` binding AND a typed
- // parameter (`fn use(lg: &Logger)`, a closure `|lg: Logger|`) — the
- // parameter case the old `let`-anchored pattern excluded (#1125).
- new RegExp(`\\b${r}\\s*:\\s*&?(?:mut\\s+)?([A-Z][\\w]*)`), // lg: Logger (binding or typed param)
- ];
- case 'go':
- return [
- new RegExp(`\\b${r}\\b\\s*:=\\s*&?([A-Za-z_][\\w.]*)\\s*{`), // lg := Logger{} / &Logger{}
- new RegExp(`\\bvar\\s+${r}\\s+\\*?([A-Za-z_][\\w.]*)`), // var lg Logger / *Logger
- // A typed parameter / method receiver (`func use(lg Logger)`,
- // `func (l Logger) M()`) — name-before-type with no `var`/`:=` (#1125).
- // PascalCase-guarded (unlike the anchored patterns above) to keep the
- // keyword-free `ident Type` shape from matching unrelated pairs; the
- // enclosing-scope bound already excludes package-level struct fields.
- new RegExp(`\\b${r}\\s+\\*?([A-Z][\\w.]*)`), // func use(lg Logger) / (l Logger)
- ];
- case 'ruby':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w:]*)\\.new\\b`), // lg = Logger.new
- ];
- case 'scala':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*(?:new\\s+)?([A-Z][\\w.]*)`), // val lg = new Logger / Logger(...)
- new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)`), // val lg: Logger / param
- ];
- case 'dart':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // var lg = Logger(...)
- // Trailing `[=;,)]` (not just `[=;]`) so a typed parameter — `Logger lg)`
- // / `Logger lg,` — matches too, not only `Logger lg = ...` / `Logger lg;`
- // (#1125). Mirrors Java/C#.
- new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg = ... / param
- ];
- case 'php':
- return [
- new RegExp(`\\$?${r}\\b\\s*=\\s*new\\s+([A-Za-z_\\\\][\\w\\\\]*)`), // $lg = new Logger()
- // A typed parameter (`function use(Logger $lg)`, `?Logger $lg`,
- // `\\App\\Logger $lg`, `&$lg` by-ref) and a typed `catch (E $e)` — the
- // type sits before the `$`-variable (#1125). Namespace `\\` allowed.
- new RegExp(`\\b([A-Za-z_\\\\][\\w\\\\]*)\\s+&?\\$${r}\\b`), // Logger $lg (typed param)
- ];
- case 'lua':
- case 'luau':
- return [
- new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\.new\\b`), // local lg = Logger.new()
- new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w]*)\\s*\\(`), // local lg = Logger(...) (callable table)
- // Luau annotation (`local lg: Logger`) / typed param — but Lua's
- // method-call syntax is the IDENTICAL `receiver:Name` shape, and the
- // backward scan starts on the call's own line, so without a gate any
- // PascalCase method call (`lg:Log()`, the Roblox convention)
- // self-matches as "type = Log" before the scan reaches the real
- // declaration (#1124). The lookahead rejects a capture followed by
- // any of Lua's three call forms — `(args)`, `"s"`/`'s'`/`[[s]]`,
- // `{t}` — and its leading `[\w.]` alternative stops backtracking from
- // shrinking the capture to dodge the gate (`lg:Log()` would otherwise
- // still match, as `Lo`).
- new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.]*)(?![\\w.]|\\s*[({"'\\[])`), // local lg: Logger / typed param
- ];
- case 'r':
- return [
- new RegExp(`\\b${r}\\b\\s*(?:<-|<<-|=)\\s*([A-Z][\\w.]*)\\$new\\b`), // lg <- Logger$new() (R6)
- ];
- case 'pascal':
- return [
- new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w]*)`), // var lg: TLogger / param lg: TLogger
- new RegExp(`\\b${r}\\b\\s*:=\\s*([A-Z][\\w.]*)\\.Create\\b`), // lg := TLogger.Create
- ];
- case 'cfml':
- case 'cfscript':
- return [
- // svc = new UserService() / new path.to.UserService() — dotted component
- // paths reduce to their final segment via normalizeInferredTypeName.
- // Also matches inside tag markup (`<cfset svc = new UserService()>`)
- // since the scan reads raw source lines.
- new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`),
- // The classic form: svc = createObject("component", "path.to.UserService")
- // (casing of createObject varies in the wild), plus the modern
- // single-argument form createObject("path.to.UserService").
- new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']component["']\\s*,\\s*["']([\\w.]+)["']`),
- new RegExp(`\\b${r}\\b\\s*=\\s*[Cc]reate[Oo]bject\\s*\\(\\s*["']([\\w.]+)["']\\s*\\)`),
- // Typed cfscript parameter: `function save(UserService svc)` /
- // `required UserService svc` — CFML's built-in types (string, numeric,
- // any, struct…) are lowercase by convention, so the PascalCase guard
- // excludes them.
- new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`),
- // Tag-form typed argument, either attribute order:
- // <cfargument name="svc" type="path.to.UserService">
- new RegExp(`\\bcfargument[^>\\n]*\\bname\\s*=\\s*["']${r}["'][^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["']`, 'i'),
- new RegExp(`\\bcfargument[^>\\n]*\\btype\\s*=\\s*["']([\\w.]+)["'][^>\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
- // Component property (incl. WireBox DI): `property name="svc"
- // inject="UserService";` / `<cfproperty name="svc" type="UserService">`,
- // either attribute order. An inject DSL value with a namespace
- // (`inject="svc@core"`) captures only the leading name and simply
- // fails type-validation — no edge, never a wrong one.
- new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\bname\\s*=\\s*["']${r}["'][^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["']`, 'i'),
- new RegExp(`\\b(?:cf)?property\\b[^;\\n]*\\b(?:type|inject)\\s*=\\s*["']([\\w.]+)["'][^;\\n]*\\bname\\s*=\\s*["']${r}["']`, 'i'),
- ];
- default:
- return [];
- }
- }
- /** 1-based start line of the tightest function/method enclosing the call. */
- function enclosingScopeStartLine(ref: UnresolvedRef, context: ResolutionContext): number {
- let start = 1;
- for (const n of context.getNodesInFile(ref.filePath)) {
- if (n.kind !== 'function' && n.kind !== 'method') continue;
- if (n.language !== ref.language) continue;
- const end = n.endLine ?? n.startLine;
- if (n.startLine <= ref.line && end >= ref.line && n.startLine >= start) {
- start = n.startLine;
- }
- }
- return start;
- }
- /**
- * Infer a receiver's type from its local declaration/initializer in the
- * enclosing function body. Language-dispatched; returns null for languages
- * without patterns or when no declaration is found. Bounded to the enclosing
- * scope so a same-named variable in another function can't leak in.
- */
- function inferLocalReceiverType(
- receiverName: string,
- ref: UnresolvedRef,
- context: ResolutionContext,
- ): string | null {
- // CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped
- // field whose assignment or `property` declaration usually lives outside the
- // calling function (the init-pseudoconstructor / WireBox-injection pattern),
- // and `local.svc` is an explicit function-local. Strip the prefix so the
- // declaration patterns match (`variables.svc = new X()`, `property
- // name="svc" …`, `var svc = …` all bind the bare name), and widen the scan
- // to the whole file for the component-scoped forms — nearest-declaration-
- // backward still wins, so a function-local shadowing the field is preferred.
- let scanReceiver = receiverName;
- let componentScoped = false;
- if (ref.language === 'cfml' || ref.language === 'cfscript') {
- const scoped = receiverName.match(/^(variables|this|local|arguments)\.(.+)$/i);
- if (scoped) {
- scanReceiver = scoped[2]!;
- const scope = scoped[1]!.toLowerCase();
- componentScoped = scope === 'variables' || scope === 'this';
- }
- }
- // PHP `$this->prop` receiver — the property's declaration lives outside the
- // calling method (a promoted constructor parameter `private readonly Foo $prop`,
- // a typed property `private Foo $prop;`, or a classic constructor parameter
- // `Foo $prop` assigned in __construct). Strip the prefix and widen the scan to
- // the whole file (the constructor may sit below the calling method), but —
- // unlike CFML's scopes above — switch to PROPERTY-shaped patterns: a plain
- // `$prop` local or parameter lives in a different namespace than `$this->prop`
- // and can never shadow it, so the generic local patterns would type the
- // property from unrelated same-named variables in other methods (a wrong
- // 0.9-confidence edge, not a missing one).
- let phpProperty = false;
- if (ref.language === 'php') {
- const scoped = receiverName.match(/^this->(.+)$/);
- if (scoped) {
- scanReceiver = scoped[1]!;
- componentScoped = true;
- phpProperty = true;
- }
- }
- const escapedReceiver = scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const patterns = phpProperty
- ? phpPropertyTypePatterns(escapedReceiver)
- : localReceiverTypePatterns(ref.language, escapedReceiver);
- if (patterns.length === 0) return null;
- // Split through the context's per-file lines cache when available: this runs
- // for EVERY `receiver.method()` ref, and re-splitting the whole file per ref
- // was ~20% of total index CPU on Java-heavy repos (#1122).
- const lines = context.getFileLines
- ? context.getFileLines(ref.filePath)
- : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null);
- if (!lines || lines.length === 0) return null;
- const callIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
- const startIdx = componentScoped
- ? 0
- : Math.max(0, enclosingScopeStartLine(ref, context) - 1);
- const matchLine = (i: number): string | null => {
- const line = lines[i];
- if (!line) return null;
- // A generated/minified line (one multi-KB statement) is not something a
- // human-written local declaration lives on, and regexing it per ref is
- // pure waste — skip it rather than scan it.
- if (line.length > 10_000) return null;
- for (const re of patterns) {
- const m = line.match(re);
- if (m && m[1]) {
- const type = normalizeInferredTypeName(m[1]);
- if (type) return type;
- }
- }
- return null;
- };
- // Incremental-scan memo (INFER_SCAN_STATES): this scan runs for EVERY
- // `receiver.method()` ref and was measured at 61µs/ref on kong (2.4s of
- // worker time, 99% misses — `self:` calls hunting a declaration Lua never
- // writes). Refs for the same (file, scope, receiver) arrive in ~ascending
- // line order, and the scan is a pure function of the file's immutable
- // lines, so each line pays its regex matches ONCE per key instead of once
- // per ref: query(c) = highest matching line in [startIdx..c]; a monotonic
- // call extends the stored watermark by scanning only (hi..c] (the region
- // at-or-below the previous answer is already proven empty above it); a
- // non-monotonic call (rare — refs are rowid-ordered) falls back to the
- // plain bounded scan and leaves the state alone. componentScoped is keyed
- // out — its position-independent whole-file sweep below has different
- // semantics.
- if (!componentScoped) {
- const states = getInferScanStates(context);
- const key = `${ref.filePath}|${startIdx}|${ref.language}|${scanReceiver}`;
- const state = states.get(key);
- if (!state) {
- for (let i = callIdx; i >= startIdx; i--) {
- const type = matchLine(i);
- if (type) {
- states.set(key, { hi: callIdx, ansIdx: i, ansType: type });
- return type;
- }
- }
- states.set(key, { hi: callIdx, ansIdx: -1, ansType: null });
- return null;
- }
- if (callIdx >= state.hi) {
- for (let i = callIdx; i > state.hi; i--) {
- const type = matchLine(i);
- if (type) {
- state.ansIdx = i;
- state.ansType = type;
- break;
- }
- }
- state.hi = callIdx;
- return state.ansIdx >= startIdx ? state.ansType : null;
- }
- for (let i = callIdx; i >= startIdx; i--) {
- const type = matchLine(i);
- if (type) return type;
- }
- return null;
- }
- // Nearest declaration wins: scan backward from the call to the scope start.
- for (let i = callIdx; i >= startIdx; i--) {
- const type = matchLine(i);
- if (type) return type;
- }
- // A component-scoped field's declaration is position-independent — the
- // `variables.svc = new X()` pseudoconstructor assignment or `property`
- // declaration may sit BELOW the calling function in the file — so when the
- // backward pass finds nothing, sweep the remainder of the file too.
- if (componentScoped) {
- for (let i = callIdx + 1; i < lines.length; i++) {
- const type = matchLine(i);
- if (type) return type;
- }
- }
- // A PHP property with no statically-typed declaration (classic pre-7.4
- // style) may still be typed by what gets ASSIGNED to it — follow the
- // `$this->prop = $var` assignment to the assigned variable's own typed
- // declaration (a classic or multi-line constructor parameter, or a typed
- // setter's parameter).
- if (phpProperty) {
- return inferPhpAssignedPropertyType(escapedReceiver, lines, callIdx);
- }
- return null;
- }
- /**
- * Patterns that recover a PHP class property's declared type for a
- * `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
- * property-shaped declarations qualify —
- * 1. a modifier-prefixed typed declaration, which covers both a typed
- * property (`private ?Foo $prop;`) and a promoted constructor parameter
- * (`private readonly Foo $prop`), and
- * 2. the pseudoconstructor assignment (`$this->prop = new Foo(...)`).
- * A bare `X $prop` parameter or `$prop = new X()` local elsewhere in the
- * file must NOT match: those variables can never alias `$this->prop`.
- * Union-typed properties (`Foo|Bar $prop`) yield no match and thus no edge —
- * silent beats wrong. The classic untyped-property-assigned-in-constructor
- * shape is handled by inferPhpAssignedPropertyType instead.
- */
- function phpPropertyTypePatterns(r: string): RegExp[] {
- return memoPatterns(`php-prop|${r}`, () => buildPhpPropertyTypePatterns(r));
- }
- function buildPhpPropertyTypePatterns(r: string): RegExp[] {
- return [
- new RegExp(
- `\\b(?:(?:private|protected|public|readonly|static|final)(?:\\(set\\))?\\s+)+\\??([A-Za-z_\\\\][\\w\\\\]*)\\s+&?\\$${r}\\b`,
- ), // private readonly ?Foo $prop (typed property / promoted param)
- new RegExp(`\\$this->${r}\\b\\s*=\\s*new\\s+([A-Za-z_\\\\][\\w\\\\]*)`), // $this->prop = new Foo()
- ];
- }
- /**
- * Second-chance typing for a PHP `$this->prop` receiver whose property
- * declaration carries no static type (classic pre-7.4 style): find the
- * `$this->prop = $var` assignment, then recover `$var`'s type from its own
- * declaration WITHIN the assignment's function — the constructor's (possibly
- * multi-line) parameter list, a typed setter's parameter, or a `= new X()`
- * local. The backward scan stops at the enclosing `function` line (checked
- * for a match first — a single-line `__construct(Foo $var) { ... }` carries
- * the typed parameter itself), so a same-named variable in another method
- * can never type the property.
- */
- function inferPhpAssignedPropertyType(
- escapedProp: string,
- lines: string[],
- callIdx: number,
- ): string | null {
- const assignRe = new RegExp(`\\$this->${escapedProp}\\b\\s*=\\s*\\$(\\w+)\\b`);
- const assignAt = (i: number): RegExpMatchArray | null => {
- const line = lines[i];
- if (!line || line.length > 10_000) return null;
- return line.match(assignRe);
- };
- // The assignment is position-independent relative to the call — nearest-
- // backward first, then sweep forward, same order as the componentScoped scan.
- let assignIdx = -1;
- let varName: string | null = null;
- for (let i = callIdx; i >= 0; i--) {
- const m = assignAt(i);
- if (m) { assignIdx = i; varName = m[1]!; break; }
- }
- if (varName === null) {
- for (let i = callIdx + 1; i < lines.length; i++) {
- const m = assignAt(i);
- if (m) { assignIdx = i; varName = m[1]!; break; }
- }
- }
- if (varName === null) return null;
- const varPatterns = localReceiverTypePatterns(
- 'php',
- varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
- );
- for (let i = assignIdx; i >= 0; i--) {
- const line = lines[i];
- if (line && line.length <= 10_000) {
- for (const re of varPatterns) {
- const m = line.match(re);
- if (m && m[1]) {
- const type = normalizeInferredTypeName(m[1]);
- if (type) return type;
- }
- }
- }
- if (line && /\bfunction\b/.test(line)) break;
- }
- return null;
- }
- /**
- * Try to resolve by method name on a class/object
- */
- export function matchMethodCall(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // Parse method call patterns like "obj.method" or "Class::method". The method
- // part allows trailing `:` keywords so Objective-C selectors resolve
- // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other
- // languages' method refs, so this is a no-op for them.
- // The receiver allows dots (`builder.Services.AddCoreServices`) so a CHAINED
- // call resolves by its last segment — Strategy 3 below name-matches the method
- // (with its existing single-candidate / receiver-overlap guards). Without this
- // a multi-dot extension-method call (C# DI `builder.Services.AddCoreServices()`,
- // `Guard.Against.X()`) matched no pattern and never resolved.
- // C++ explicit operator call `a.operator+(b)` reaches the resolver as
- // `a.operator+` (#1247) — the operator's symbol chars (`+`, `==`, `[]`, `()`)
- // fail the \w method part of the plain pattern, so admit them explicitly.
- // Names like `operatorTable` stay on the plain pattern (tried first); the
- // operator form requires at least one non-word char after `operator`, and
- // every downstream strategy compares the method part by exact string
- // equality, so a stray match can't invent an edge.
- const dotMatch =
- ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/) ??
- (ref.language === 'cpp'
- ? ref.referenceName.match(/^([\w.]+)\.(operator[^\w\s.]+)$/)
- : null);
- const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/);
- // Lua/Luau method calls use a single colon (`lg:log`); R uses `$` (`lg$log`).
- // Recognize these receiver/method separators so local-variable receiver-type
- // inference (#1108) applies to them too — extraction already emits the ref in
- // this shape, but the resolver otherwise only understood `.` and `::`.
- const luaColonMatch = (ref.language === 'lua' || ref.language === 'luau')
- ? ref.referenceName.match(/^([\w.]+):(\w+)$/)
- : null;
- const rDollarMatch = ref.language === 'r'
- ? ref.referenceName.match(/^([\w.]+)\$(\w+)$/)
- : null;
- // PHP property receiver: `$this->prop->method()` reaches the resolver as
- // `this->prop.method` (the extractor records the receiver's raw text with the
- // leading `$` stripped). Resolve it EXCLUSIVELY through declared-type
- // inference + resolveMethodOnType validation — the name-similarity strategies
- // below must never see this shape, so a property whose type can't be
- // recovered stays unlinked rather than guessed (a wrong inference produces no
- // edge rather than a wrong one). Deeper chains (`this->a->b.method`) don't
- // match the single-property pattern and stay unlinked, same as before.
- const phpThisPropMatch = ref.language === 'php'
- ? ref.referenceName.match(/^(this->\w+)\.(\w+)$/)
- : null;
- if (phpThisPropMatch) {
- const [, receiver, phpMethodName] = phpThisPropMatch;
- const inferredType = inferLocalReceiverType(receiver!, ref, context);
- if (!inferredType) return null;
- return resolveMethodOnType(
- inferredType,
- phpMethodName!,
- ref,
- context,
- 0.9,
- 'instance-method',
- importedFqnOf(inferredType, ref, context),
- );
- }
- const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch;
- if (!match) {
- return null;
- }
- const [, objectOrClass, methodName] = match;
- // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose
- // receiver type we can try to infer from its local declaration.
- const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch;
- // Infer the receiver's type from its local declaration/initializer in the
- // enclosing scope, then resolve the method on that type (#1108). C++ keeps its
- // dedicated inferrer (header scan + `auto`); every other language uses the
- // shared source-based inferrer. resolveMethodOnType validates the method
- // exists on the inferred type, so a mis-inference produces no edge.
- if (inferableReceiver) {
- const inferredType = nmTimedT('mc-infer', ref, () =>
- ref.language === 'cpp'
- ? inferCppReceiverType(objectOrClass!, ref, context)
- : inferLocalReceiverType(objectOrClass!, ref, context));
- if (inferredType) {
- // Java/Kotlin: when two classes share the simple name, the file's import
- // pins WHICH one (#314). Other languages disambiguate by call-site file.
- const importedFqn =
- ref.language === 'java' || ref.language === 'kotlin'
- ? context
- .getImportMappings(ref.filePath, ref.language)
- .find((i) => i.localName === inferredType)?.source
- : undefined;
- const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
- inferredType,
- methodName!,
- ref,
- context,
- 0.9,
- 'instance-method',
- importedFqn,
- ));
- if (typedMatch) {
- return typedMatch;
- }
- }
- }
- // Go 2-hop field chain `base.field.Method` (#1276): the base's type comes
- // from the enclosing scope (typed parameter / method receiver / local var),
- // the field's declared type from that struct's own declaration lines, and
- // the method is VALIDATED on the field's type by resolveMethodOnType. This
- // branch is EXCLUSIVE for chained Go receivers: when the hop can't be
- // inferred or the field's type is external (`conn *sql.DB` — no project
- // node), the ref stays unresolved rather than falling through to the
- // bare-name strategies below, which is exactly how `target.conn.Exec(...)`
- // fabricated a dependency on an unrelated local interface's same-named
- // method. Chained Go receivers were never emitted before #1276, so there
- // is no prior recall to preserve on the fallback path.
- if (ref.language === 'go' && dotMatch && objectOrClass!.includes('.')) {
- return matchGoFieldChainCall(objectOrClass!, methodName!, ref, context);
- }
- // Java/Kotlin: receiver may be a field whose name doesn't match the type by
- // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
- // the field in the enclosing class to get its declared type, then resolve
- // the method on that type. Covers Spring `@Resource`/`@Autowired` field
- // injection where the field type is the concrete bean class.
- if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) {
- const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context);
- if (inferredType) {
- // When two classes share the same simple name, the caller file's
- // import is the only signal that names WHICH one — pass the
- // imported FQN so resolveMethodOnType can disambiguate (#314).
- const imports = context.getImportMappings(ref.filePath, ref.language);
- const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
- const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
- inferredType,
- methodName!,
- ref,
- context,
- 0.9,
- 'instance-method',
- importedFqn,
- ));
- if (typedMatch) {
- return typedMatch;
- }
- }
- }
- // Strategy 1: Direct class name match (existing logic). When the receiver
- // names a class that exists in several files (`Logger.log()` / `Logger::log()`
- // with a `Logger` in both `a/` and `b/`), try the class in the call site's
- // own file first — otherwise the first-indexed class wins and a call in `b/`
- // resolves to `a/`'s method (#1079).
- const strat1 = nmTimedT('mc-class', ref, (): ResolvedRef | null => {
- const classCandidates = preferCallSiteFile(
- context.getNodesByName(objectOrClass!),
- ref.filePath,
- );
- for (const classNode of classCandidates) {
- if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') {
- // Skip cross-language class matches
- if (classNode.language !== ref.language) continue;
- const nodesInFile = context.getNodesInFile(classNode.filePath);
- const methodNode = nodesInFile.find(
- (n) =>
- n.kind === 'method' &&
- n.name === methodName &&
- n.qualifiedName.includes(classNode.name)
- );
- if (methodNode) {
- return {
- original: ref,
- targetNodeId: methodNode.id,
- confidence: 0.85,
- resolvedBy: 'qualified-name',
- };
- }
- }
- }
- return null;
- });
- if (strat1) return strat1;
- // Strategy 2: Instance variable receiver - try capitalized form to find class
- // e.g., "permissionEngine" → look for classes containing "PermissionEngine"
- const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1);
- if (capitalizedReceiver !== objectOrClass) {
- const strat2 = nmTimedT('mc-capital', ref, (): ResolvedRef | null => {
- const fuzzyClassCandidates = preferCallSiteFile(
- context.getNodesByName(capitalizedReceiver),
- ref.filePath,
- );
- for (const classNode of fuzzyClassCandidates) {
- if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'union' || classNode.kind === 'interface') {
- // Skip cross-language class matches
- if (classNode.language !== ref.language) continue;
- const nodesInFile = context.getNodesInFile(classNode.filePath);
- const methodNode = nodesInFile.find(
- (n) =>
- n.kind === 'method' &&
- n.name === methodName &&
- n.qualifiedName.includes(classNode.name)
- );
- if (methodNode) {
- return {
- original: ref,
- targetNodeId: methodNode.id,
- confidence: 0.8,
- resolvedBy: 'instance-method',
- };
- }
- }
- }
- return null;
- });
- if (strat2) return strat2;
- }
- // Strategy 3: Find methods by name across the codebase, match by receiver
- // name similarity with the containing class. Handles abbreviated variable
- // names like permissionEngine → PermissionRuleEngine.
- if (methodName) {
- const strat3 = nmTimedT('mc-byname', ref, (): ResolvedRef | null => {
- const methodCandidates = context.getNodesByName(methodName!);
- // Ubiquitous-method ceiling (#999): a method name re-declared across a
- // vendored theme/SDK (Metronic's `init`/`update`/… on every widget) yields
- // K candidates that receiver-word overlap can't reliably disambiguate —
- // and filtering + scoring all K per call is the O(K²) cost that wedged
- // "Resolving refs" for 15-28 min. Bail before the O(K) work; Strategy 1/2
- // (class-name match) already had their precise shot above.
- if (methodCandidates.length > AMBIGUOUS_NAME_CEILING) {
- return null;
- }
- const methods = methodCandidates.filter(
- (n) => n.kind === 'method' && n.name === methodName
- );
- // Filter to same-language candidates first
- const sameLanguageMethods = methods.filter(m => m.language === ref.language);
- const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods;
- // If only one same-language method with this name exists, use it
- if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) {
- return {
- original: ref,
- targetNodeId: targetMethods[0]!.id,
- confidence: 0.7,
- resolvedBy: 'instance-method',
- };
- }
- // Multiple methods: score by receiver name word overlap with class name
- if (targetMethods.length > 1) {
- const receiverWords = splitCamelCase(objectOrClass!);
- let bestMatch: typeof targetMethods[0] | undefined;
- let bestScore = 0;
- // Same-file candidates first, so a score tie (`score > bestScore` keeps
- // the first seen) resolves to the call site's own file rather than the
- // first-indexed duplicate (#1079).
- for (const method of preferCallSiteFile(targetMethods, ref.filePath)) {
- const classWords = splitCamelCase(method.qualifiedName);
- let score = receiverWords.filter(w =>
- classWords.some(cw => cw.toLowerCase() === w.toLowerCase())
- ).length;
- // Bonus for same language
- if (method.language === ref.language) score += 1;
- if (score > bestScore) {
- bestScore = score;
- bestMatch = method;
- }
- }
- if (bestMatch && bestScore >= 2) {
- return {
- original: ref,
- targetNodeId: bestMatch.id,
- confidence: 0.65,
- resolvedBy: 'instance-method',
- };
- }
- }
- return null;
- });
- if (strat3) return strat3;
- }
- return null;
- }
- /** Go builtin/primitive field types that can never carry a project method. */
- const GO_BUILTIN_FIELD_TYPES = new Set([
- 'string', 'bool', 'byte', 'rune', 'error', 'any',
- 'int', 'int8', 'int16', 'int32', 'int64',
- 'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
- 'float32', 'float64', 'complex64', 'complex128',
- 'chan', 'map', 'func', 'struct', 'interface',
- ]);
- /**
- * Resolve a Go 2-hop field-chain call `base.field.Method(...)` (#1276):
- * `target.conn.Exec("insert")` where `func (target *Target) Write()` and
- * `type Target struct { conn *sql.DB }`. Two inference hops, both read from
- * source the same way #1108 does:
- * 1. `base`'s type from the enclosing scope (method receiver, typed
- * parameter, or local declaration) via inferLocalReceiverType;
- * 2. `field`'s declared type from the struct's own declaration lines.
- * The method is then resolved AND VALIDATED on the field's type. A field
- * whose type has no project node (`sql.DB`, any external dependency) yields
- * null — the caller treats this branch as exclusive for chained Go
- * receivers, so the ref stays unresolved instead of name-guessing.
- */
- function matchGoFieldChainCall(
- receiverChain: string,
- methodName: string,
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- const segs = receiverChain.split('.');
- if (segs.length !== 2 || !segs[0] || !segs[1]) return null;
- const [base, field] = segs;
- const baseType = inferLocalReceiverType(base!, ref, context);
- if (!baseType) return null;
- const fieldEsc = field!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const fieldTypeRe = new RegExp(`\\b${fieldEsc}\\s+\\*?\\[?\\]?([A-Za-z_][\\w.]*)`);
- const structs = preferCallSiteFile(context.getNodesByName(baseType), ref.filePath).filter(
- (n) => (n.kind === 'struct' || n.kind === 'class') && n.language === 'go'
- );
- for (const s of structs) {
- const source = context.readFile(s.filePath);
- if (!source) continue;
- // Only the struct's own declaration lines — a same-named identifier
- // elsewhere in the file can't donate a type. Matched LINE BY LINE with
- // comments stripped: chi's `Mux` has a doc comment reading "the tree
- // router" right above `tree *node`, and a whole-block match captured
- // `router` from the prose instead of `node` from the field.
- const declLines = source.split('\n').slice(Math.max(0, s.startLine - 1), s.endLine);
- for (const rawLine of declLines) {
- const line = rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '');
- const m = line.match(fieldTypeRe);
- if (!m || !m[1]) continue;
- const rawType = m[1];
- // A package-qualified field type (`http.Handler`, `sql.DB`) is only
- // followed when the package is IN-MODULE: stripping the qualifier and
- // matching the bare name would conflate a stdlib/third-party type with
- // any same-named project type — on chi, `handler http.Handler` bound
- // to an example app's unrelated local `Handler`. That is the exact
- // fabrication this matcher exists to prevent (#1276).
- if (rawType.includes('.')) {
- const pkg = rawType.split('.')[0]!;
- const mod = context.getGoModule?.();
- const imp = context
- .getImportMappings(s.filePath, 'go')
- .find((i) => i.localName === pkg);
- const inModule =
- !!mod &&
- !!imp &&
- (imp.source === mod.modulePath || imp.source.startsWith(mod.modulePath + '/'));
- if (!inModule) continue;
- }
- // Unexported (lowercase) types are idiomatic Go and stay eligible —
- // chi's `mx.tree.FindRoute()` chains through `tree *node`. A
- // mis-capture is harmless: resolveMethodOnType only returns a
- // validated `<type>::<method>` match.
- const fieldType = rawType.split('.').pop();
- if (!fieldType || !/^[A-Za-z_]/.test(fieldType) || GO_BUILTIN_FIELD_TYPES.has(fieldType)) continue;
- const resolved = resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method');
- if (resolved) return resolved;
- }
- }
- return null;
- }
- /**
- * Split a camelCase or PascalCase string into words.
- */
- function splitCamelCase(str: string): string[] {
- return str.replace(/([a-z])([A-Z])/g, '$1 $2')
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
- .split(/[\s._:\/\\]+/)
- .filter(w => w.length > 1);
- }
- /**
- * Compute directory proximity from a pre-split list of directory segments
- * (`filePath1` minus its filename) and a second file path.
- * Returns a score based on the number of shared leading directory segments.
- * Higher score = closer in directory tree.
- *
- * Split into a pre-split variant because findBestMatch scores every candidate
- * against the SAME `ref.filePath`; re-splitting it per candidate was a hot spot
- * on large repos (#915), so the caller splits it once and passes the segments.
- */
- function pathProximityFromDirs(dir1: string[], filePath2: string): number {
- const dir2 = filePath2.split('/');
- dir2.pop(); // drop filename — matches the original slice(0, -1) on both paths
- let shared = 0;
- const limit = Math.min(dir1.length, dir2.length);
- for (let i = 0; i < limit; i++) {
- if (dir1[i] === dir2[i]) {
- shared++;
- } else {
- break;
- }
- }
- // Each shared directory segment contributes 15 points, capped at 80
- return Math.min(shared * 15, 80);
- }
- /**
- * Compute directory proximity between two file paths.
- * Returns a score based on the number of shared directory segments.
- */
- function computePathProximity(filePath1: string, filePath2: string): number {
- const dir1 = filePath1.split('/');
- dir1.pop();
- return pathProximityFromDirs(dir1, filePath2);
- }
- /**
- * Find the best matching node when there are multiple candidates
- */
- function findBestMatch(
- ref: UnresolvedRef,
- candidates: Node[],
- _context: ResolutionContext
- ): Node | null {
- // Prioritization rules:
- // 1. Same file > different file
- // 2. Directory proximity (same module/package > different module)
- // 3. Same language > different language
- // 4. Functions/methods > classes/types (for call references)
- // 5. Exported > non-exported
- let bestScore = -1;
- let bestNode: Node | null = null;
- // Split the ref's path once (it's the same across every candidate) instead of
- // re-splitting it inside computePathProximity per candidate (#915 hot spot).
- const refDirs = ref.filePath.split('/');
- refDirs.pop();
- // A same-language candidate ALWAYS outscores a cross-language one: same-language
- // scores at least +50 (language bonus), while a cross-language candidate maxes
- // out at +35 (−80 language, +80 proximity, +25 kind, +10 exported; it can never
- // be in the same file). So when any same-language candidate exists, skip the
- // cross-language ones — provably the same winner, without paying the per-candidate
- // scoring. Cuts the candidate set to same-language size on mixed front-end +
- // back-end repos (#915). When ALL candidates are cross-language (a legitimate
- // cross-language `calls` bridge), none are skipped and behavior is unchanged.
- const hasSameLanguage = candidates.some((c) => c.language === ref.language);
- for (const candidate of candidates) {
- if (hasSameLanguage && candidate.language !== ref.language) continue;
- let score = 0;
- // Same file bonus
- if (candidate.filePath === ref.filePath) {
- score += 100;
- }
- // Directory proximity bonus — strongly prefer same module/package
- score += pathProximityFromDirs(refDirs, candidate.filePath);
- // Language matching: strongly prefer same language, penalize cross-language
- if (candidate.language === ref.language) {
- score += 50;
- } else {
- score -= 80;
- }
- // For call references, prefer functions/methods
- if (ref.referenceKind === 'calls') {
- if (candidate.kind === 'function' || candidate.kind === 'method') {
- score += 25;
- }
- }
- // For instantiation references (`new Foo()`), prefer class-like
- // targets — without this, a function named `Foo` in another module
- // could outscore the actual class.
- if (ref.referenceKind === 'instantiates') {
- if (
- candidate.kind === 'class' ||
- candidate.kind === 'struct' ||
- candidate.kind === 'union' ||
- candidate.kind === 'interface'
- ) {
- score += 25;
- }
- }
- // For decorator references (`@Foo`), prefer functions. Class
- // decorators (Python `@SomeClass`, Java annotation interfaces)
- // also resolve here, hence the smaller class bonus.
- if (ref.referenceKind === 'decorates') {
- if (candidate.kind === 'function' || candidate.kind === 'method') {
- score += 25;
- } else if (candidate.kind === 'class' || candidate.kind === 'interface') {
- score += 15;
- }
- }
- // Exported bonus
- if (candidate.isExported) {
- score += 10;
- }
- // Closer line number (within same file)
- if (candidate.filePath === ref.filePath && candidate.startLine) {
- const distance = Math.abs(candidate.startLine - ref.line);
- score += Math.max(0, 20 - distance / 10);
- }
- if (score > bestScore) {
- bestScore = score;
- bestNode = candidate;
- }
- }
- return bestNode;
- }
- /**
- * Fuzzy match - last resort with lower confidence
- */
- export function matchFuzzy(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- const lowerName = ref.referenceName.toLowerCase();
- // Use pre-built lowercase index for O(1) lookup instead of scanning all nodes
- const candidates = context.getNodesByLowerName(lowerName);
- // Filter to callable kinds only (function, method, class)
- const callableKinds = new Set(['function', 'method', 'class']);
- const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref);
- // Prefer same-language matches
- const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
- const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates;
- if (finalCandidates.length === 1) {
- const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
- return {
- original: ref,
- targetNodeId: finalCandidates[0]!.id,
- confidence: isCrossLanguage ? 0.3 : 0.5,
- resolvedBy: 'fuzzy',
- };
- }
- return null;
- }
- /**
- * Match all strategies in order of confidence
- */
- /** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */
- const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']);
- /**
- * CODEGRAPH_RESOLVE_PROFILE=2 sub-stage attribution for matchReference's
- * strategy pipeline (`nm:<stage>|<refKind>|hit/miss`). Module-global because
- * the matcher is a free function; each thread (main + every pool worker) has
- * its own module instance, and dumpNameMatcherProfile is invoked from
- * ReferenceResolver.dumpResolveProfile so worker tables surface too.
- */
- const NM_PROFILE: Map<string, { n: number; ns: bigint }> | null =
- process.env.CODEGRAPH_RESOLVE_PROFILE === '2' ? new Map() : null;
- function nmTimedT<T>(stage: string, ref: UnresolvedRef, fn: () => T): T {
- if (!NM_PROFILE) return fn();
- const t0 = process.hrtime.bigint();
- const r = fn();
- const dt = process.hrtime.bigint() - t0;
- const key = `nm:${stage}|${ref.referenceKind}|${r ? 'hit' : 'miss'}`;
- const slot = NM_PROFILE.get(key);
- if (slot) {
- slot.n++;
- slot.ns += dt;
- } else {
- NM_PROFILE.set(key, { n: 1, ns: dt });
- }
- return r;
- }
- function nmTimed(stage: string, ref: UnresolvedRef, fn: () => ResolvedRef | null): ResolvedRef | null {
- return nmTimedT(stage, ref, fn);
- }
- /** Dump this thread's matchReference sub-stage table to stderr (no-op unless =2). */
- export function dumpNameMatcherProfile(label: string): void {
- if (!NM_PROFILE || NM_PROFILE.size === 0) return;
- const rows = [...NM_PROFILE.entries()]
- .map(([k, v]) => ({ k, n: v.n, ms: Number(v.ns / 1_000_000n) }))
- .sort((a, b) => b.ms - a.ms);
- for (const r of rows) {
- console.error(
- `[resolve-profile] ${label} ${r.k}: n=${r.n} total=${(r.ms / 1000).toFixed(1)}s avg=${((r.ms * 1000) / Math.max(1, r.n)).toFixed(0)}µs`
- );
- }
- }
- export function matchReference(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // Function-as-value refs (#756) resolve ONLY through the dedicated matcher —
- // never the fuzzy/qualified fallthrough below (a wrong callback edge is
- // worse than none).
- if (ref.referenceKind === 'function_ref') {
- return matchFunctionRef(ref, context);
- }
- // ArkTS chained UI attributes — emitted with a leading dot (`.titleStyle`,
- // `.width`) by the extractor — resolve ONLY to decorator-marked attribute
- // helpers: `@Extend`/`@Styles`/`@AnimatableExtend` functions (and global
- // `@Builder`s used attribute-position). Framework attributes (`.width`,
- // `.fontSize` — on nearly every UI line) match no such helper and stay
- // unresolved, NEVER falling through to bare-name matching: on a samples
- // monorepo that fallthrough manufactured 36k wrong edges, giving single
- // same-named properties thousands of false callers. Ambiguity rule matches
- // the rest of the file: several same-named helpers → prefer the call-site
- // file, still ambiguous → drop the ref rather than guess.
- if (ref.language === 'arkts' && ref.referenceName.startsWith('.')) {
- const base = ref.referenceName.slice(1);
- const candidates = context
- .getNodesByName(base)
- .filter(
- (n) =>
- n.language === 'arkts' &&
- n.kind === 'function' &&
- (n.decorators ?? []).some((d) => ARKUI_ATTRIBUTE_DECORATORS.has(d))
- );
- const chosen =
- candidates.length > 1 ? preferCallSiteFile(candidates, ref.filePath) : candidates;
- if (chosen.length !== 1) return null;
- return {
- original: ref,
- targetNodeId: chosen[0]!.id,
- confidence: 0.85,
- resolvedBy: 'exact-match',
- };
- }
- // Erlang `-behaviour(m)` refs target a MODULE. Letting them fall through to
- // bare-name matching grabs any same-named symbol — on emqx,
- // `-behaviour(supervisor)` resolved to a `-define(supervisor, …)` macro
- // constant in an unrelated app. Resolve only to the behaviour module's
- // namespace; an out-of-repo behaviour (OTP's gen_server/supervisor) stays
- // unresolved rather than guessed. The same module-only rule applies to every
- // ref an `.app`/`.app.src` resource file emits — its `{mod, …}` callback and
- // `{applications, …}` dependency names can only mean modules, and on emqx
- // the `ssl` OTP app otherwise resolved to a test helper FUNCTION named ssl.
- if (
- ref.language === 'erlang' &&
- (ref.referenceKind === 'implements' || /\.app(?:\.src)?$/i.test(ref.filePath))
- ) {
- const modules = context
- .getNodesByName(ref.referenceName)
- .filter((n) => n.language === 'erlang' && n.kind === 'namespace');
- const chosen = preferCallSiteFile(modules, ref.filePath)[0];
- if (!chosen) return null;
- return {
- original: ref,
- targetNodeId: chosen.id,
- confidence: 0.9,
- resolvedBy: 'exact-match',
- };
- }
- // Try strategies in order of confidence
- let result: ResolvedRef | null;
- // 0. File path match (e.g., "snippets/drawer-menu.liquid" → file node)
- result = nmTimed('filePath', ref, () => matchByFilePath(ref, context));
- if (result) return result;
- // 1. Qualified name match (highest confidence)
- result = nmTimed('qualifiedName', ref, () => matchByQualifiedName(ref, context));
- if (result) return result;
- // 1b. C++ chained call whose receiver is another call — `Foo::instance().bar()`
- // encoded as `Foo::instance().bar` by the extractor (#645). Resolve the
- // receiver's type from what the inner call returns, then the method on it.
- if (ref.language === 'cpp' || ref.language === 'c') {
- result = nmTimed('cppChain', ref, () => matchCppCallChain(ref, context));
- if (result) return result;
- }
- // 1c. `::`-scoped factory chain — PHP `Cls::for($x)->method()` (#608) or Rust
- // `Foo::new().bar()`, both encoded as `Cls::factory().method`. The receiver's
- // type is the factory's `self` (PHP `: self`/`: static`, Rust `-> Self`) or
- // concrete return type.
- if (ref.language === 'php' || ref.language === 'rust') {
- result = nmTimed('scopedChain', ref, () => matchScopedCallChain(ref, context));
- if (result) return result;
- }
- // 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift /
- // Go / Scala / Dart / Objective-C) — `Foo.getInstance().bar()` encoded as
- // `Foo.getInstance().bar`, Go's bare-factory `New().Method()` as `New().Method`,
- // Scala's companion factory, Dart's static factory / factory-constructor, or
- // ObjC's chained message send `[[Foo create] doIt]` encoded as `Foo.create().doIt`
- // (#645/#608 mechanism). Resolve the method's class from the inner call's
- // declared return type, then validate it.
- if (
- ref.language === 'java' ||
- ref.language === 'kotlin' ||
- ref.language === 'csharp' ||
- ref.language === 'swift' ||
- ref.language === 'go' ||
- ref.language === 'scala' ||
- ref.language === 'dart' ||
- ref.language === 'objc' ||
- ref.language === 'pascal'
- ) {
- result = nmTimed('dottedChain', ref, () => matchDottedCallChain(ref, context));
- if (result) return result;
- }
- // 2. Method call pattern
- result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context));
- if (result) return result;
- // 3. Exact name match
- result = nmTimed('exactName', ref, () => matchByExactName(ref, context));
- if (result) return result;
- // 4. Fuzzy match (lowest confidence)
- result = nmTimed('fuzzy', ref, () => matchFuzzy(ref, context));
- if (result) return result;
- return null;
- }
|