/** * 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 = { 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` / // `` 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.` 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; } /** * Languages whose object literals declare callable members — `export const * api = { call() {…}, get: () => {…} }` used as a namespace (#1573). */ const OBJECT_LITERAL_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); /** True when `inner`'s source range lies within `outer`'s (lines, then columns on a shared line). */ function rangeWithin(inner: Node, outer: Node): boolean { const innerEnd = inner.endLine ?? inner.startLine; const outerEnd = outer.endLine ?? outer.startLine; if (inner.startLine < outer.startLine || innerEnd > outerEnd) return false; if (inner.startLine === outer.startLine && inner.startColumn < outer.startColumn) return false; if (innerEnd === outerEnd && inner.endColumn > outer.endColumn) return false; return true; } function sameRange(a: Node, b: Node): boolean { return ( a.startLine === b.startLine && a.startColumn === b.startColumn && (a.endLine ?? a.startLine) === (b.endLine ?? b.startLine) && a.endColumn === b.endColumn ); } /** * Resolve `container.member` where `container` is a VALUE holding an object * literal — `export const api = { call() {…}, get: () => {…} }` used as the * module's namespace (#1573). The members are extracted as plain functions * with BARE qualified names inside the constant's source extent (there is no * `api::call`), so neither the `Container::member` lookup the class-shaped * kinds use (#825) nor the declared-type inference for singleton instances * (#1292) can reach them, and every such call resolved to nothing — or, via * an import, to the constant itself. This looks the member up by CONTAINMENT: * a node named `member` whose range lies inside the container's, in the * container's own file. A helper declared inside a member's body is not a * member and is skipped; nothing else in the file can donate a match. Calls * take callable kinds only; other references accept value members too. */ export function resolveObjectLiteralMember( container: Node, member: string, ref: UnresolvedRef, context: ResolutionContext, confidence: number, resolvedBy: ResolvedRef['resolvedBy'], ): ResolvedRef | null { if (container.kind !== 'constant' && container.kind !== 'variable') return null; if (!OBJECT_LITERAL_LANGUAGES.has(container.language)) return null; if (!sameLanguageFamily(container.language, ref.language)) return null; const inFile = context.getNodesInFile(container.filePath); const callable = (n: Node) => n.kind === 'function' || n.kind === 'method'; const valueMember = (n: Node) => callable(n) || n.kind === 'property' || n.kind === 'variable' || n.kind === 'constant'; const accepts = ref.referenceKind === 'calls' ? callable : valueMember; const inside = inFile.filter((n) => n.id !== container.id && rangeWithin(n, container)); let candidates = inside.filter((n) => n.name === member && accepts(n)); if (candidates.length === 0) return null; // Drop a candidate nested inside ANOTHER callable's body within the literal // (`{ run() { const call = () => {}; } }` — `call` is `run`'s local, not a // member). Strict containment: an identically-ranged sibling node for the // same member (a property node over an arrow function) is not a body. const bodies = inside.filter(callable); candidates = candidates.filter( (c) => !bodies.some((b) => b.id !== c.id && !sameRange(b, c) && rangeWithin(c, b)) ); if (candidates.length === 0) return null; // Several survivors (a property AND a function for one arrow member, say): // a callable first, then the earliest in source order. candidates.sort((a, b) => { const ca = callable(a) ? 0 : 1; const cb = callable(b) ? 0 : 1; if (ca !== cb) return ca - cb; return a.startLine - b.startLine || a.startColumn - b.startColumn; }); return { original: ref, targetNodeId: candidates[0]!.id, confidence, resolvedBy, }; } // 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 // `::`. 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 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 `::::` 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()` / `make_shared()` → 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();`, * `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(...)`. 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 `().` (#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 " " (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: " " (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(); 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>(); function getInferScanStates(context: ResolutionContext): Map { 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`) 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 (``) // 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: // 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";` / ``, // 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); } // Rust call through a field of the enclosing type — `self.inner.run()`, // emitted as `self.inner.run` (#1585). Same discipline as the Go branch // above, and EXCLUSIVE for the same reason: validated field-type inference // or nothing. Letting this shape reach the bare-name strategies below is // how `self.inner.run()` resolved to a same-named method on an unrelated // type — or to the calling method itself, a self-edge the source doesn't // contain — whenever the field's type was external or merely shared a // method name with something nearby. if (ref.language === 'rust' && dotMatch && objectOrClass!.startsWith('self.')) { return matchRustSelfFieldCall(objectOrClass!.slice('self.'.length), 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; } } } // Object-literal namespace receiver (#1573): `api.call()` where `api` is a // same-file `const api = { call() {…}, get: () => {…} }`. Its members are // plain functions with bare names inside the constant's extent — no // `Container::member` qualified name — so none of the class-shaped // strategies below can see them (Strategy 3 only considers `method` // kinds) and the call resolved to nothing at all. Same file only: a // cross-file use reaches the same helper through the import path. if (dotMatch && !objectOrClass!.includes('.') && OBJECT_LITERAL_LANGUAGES.has(ref.language)) { const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null => { const holders = preferCallSiteFile(context.getNodesByName(objectOrClass!), ref.filePath).filter( (n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath ); for (const holder of holders) { const hit = resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method'); if (hit) return hit; } return null; }); if (literalMatch) return literalMatch; } // 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 `::` 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; } // Rust primitives and the prelude's own types: a field of one of these never // names a project type, so a `self..()` on it stays unresolved. const RUST_NON_PROJECT_FIELD_TYPES = new Set([ 'bool', 'char', 'str', 'String', 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', 'u8', 'u16', 'u32', 'u64', 'u128', 'usize', 'f32', 'f64', 'Self', 'self', ]); /** * Reduce a Rust field's declared type text to the simple name of the type a * method call on that field auto-derefs to, or null when there is none we can * name. Only the layers Rust's method-call auto-deref looks through are * unwrapped: references (`&`, `&'a mut`) and the owning smart pointers * (`Box`, `Rc`, `Arc`) — `self.inner.run()` with `inner: Box` calls * `Inner::run`. Containers that do NOT auto-deref to their parameter * (`Option`, `Vec`, `Mutex`, `RefCell`) keep their * own name and, having no project node, resolve to nothing — `self.items.push()` * must never become `Inner::push`. A trait object (`Box`) yields * the trait, whose method node the interface-impl synthesizer fans out. A * generic parameter (`T`), a primitive, a tuple / array / raw pointer / fn * type, or a non-identifier yields null. */ export function rustFieldTypeName(raw: string): string | null { let t = raw.trim(); for (;;) { const before = t; t = t.replace(/^&\s*(?:'\w+\s+)?(?:mut\s+)?/, ''); t = t.replace(/^(?:Box|Rc|Arc)\s*<\s*/, ''); t = t.replace(/^(?:dyn|impl)\s+/, ''); if (t === before) break; } // Drop generic args, the closing `>`s of unwrapped pointers, and trait-object // bounds (`dyn Source + Send`); keep the last path segment. t = t.replace(/[<>+].*$/, '').trim(); const seg = t.split('::').filter(Boolean).pop(); if (!seg || !/^[A-Za-z_]\w*$/.test(seg)) return null; if (RUST_NON_PROJECT_FIELD_TYPES.has(seg)) return null; if (/^[A-Z]$/.test(seg)) return null; // bare single-letter generic parameter return seg; } /** * Resolve a Rust call through a field of the enclosing type — * `self.inner.run()`, emitted by the extractor as `self.inner.run` (#1585). * Mirrors the Go 2-hop precedent above (#1276): the owner type is the calling * method's qualified-name prefix (`Outer::run` → `Outer`), the field's declared * type comes from the owner struct's OWN declaration lines, and the method is * resolved AND VALIDATED on that type by resolveMethodOnType. The caller * treats this branch as exclusive for `self.` receivers: a field whose * type is external (`std::vec::IntoIter`, `regex::Regex`), a generic * parameter, or not declared where we can see it yields null and the ref stays * unresolved. Rust struct fields are not graph nodes, so the declaration text * is the only place the type lives. */ function matchRustSelfFieldCall( field: string, methodName: string, ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { // The extractor only ever emits a single field hop; anything else is not ours. if (!field || field.includes('.')) return null; const caller = context.getNodeById?.(ref.fromNodeId); if (!caller) return null; const sep = caller.qualifiedName.lastIndexOf('::'); if (sep <= 0) return null; // a free fn has no `self` const owner = caller.qualifiedName.slice(0, sep).split('::').pop(); if (!owner) return null; const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter( (n) => (n.kind === 'struct' || n.kind === 'union' || n.kind === 'class') && n.language === 'rust' ); const fieldEsc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // `pub inner: Inner,` / `inner: Box,` / `pub(crate) inner: T }` — // the type text runs to the field separator. A comma inside generic args // (`HashMap`) truncates the capture, which rustFieldTypeName then // reduces to the container's own name — exactly the non-deref case it // refuses anyway. const fieldRe = new RegExp(`\\b${fieldEsc}\\s*:\\s*([^,{}]+)`); for (const s of owners) { const source = context.readFile(s.filePath); if (!source) continue; // Only the struct's own declaration lines, comment-stripped line by line — // same discipline as the Go helper: prose or a same-named identifier // elsewhere in the file can never donate a type. 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(fieldRe); if (!m || !m[1]) continue; const fieldType = rustFieldTypeName(m[1]); // The field is declared here; whether or not its type names a project // symbol, this owner is the answer — no other same-named struct applies. if (!fieldType) return null; return resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method'); } } 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:||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 | null = process.env.CODEGRAPH_RESOLVE_PROFILE === '2' ? new Map() : null; function nmTimedT(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; }