/** * Name Matcher * * Handles symbol name matching for reference resolution. */ import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { JS_BUILT_INS } from './js-builtins'; import { resolveViaImport } from './import-resolver'; /** * 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; } /** Languages with no nested named functions: nesting in the graph is never a scope. */ const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); /** * 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; // C and C++ have no nested named functions, so a function the graph shows // inside another is an extraction artifact, not a scope: tree-sitter-c // cannot parse a macro call whose arguments are designated initializers // (betaflight's `RESET_CONFIG(pidProfile_t, pidProfile, .pid = {…})`), and // its error recovery runs the enclosing function_definition to the end of // the file, nesting every function after it. Trusting that nesting rejected // 117 real calls into pid.c on that tree; the functions are reachable. if (NO_NESTED_FUNCTIONS.has(candidate.language)) 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) ); } /** Languages whose module boundary is `import`/`export` (or CommonJS). */ const ESM_FAMILY = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); /** * A line-initial `import` statement — the marker that a JS/TS file is a MODULE * rather than a classic script. Line-anchored and followed by a name, brace, * star or quote, so a dynamic `import(` and the word inside a comment or string * do not match. */ const HAS_IMPORT_STATEMENT = /^[ \t]*import[\s{*'"]/m; /** * Anything the file could offer another file, in every form the extractor's own * `isExported` flag misses. `^export` covers the declaration and later forms * (`export const`, `export { x }`, `export default x`, `export *`); the * CommonJS shapes cover files that never use ESM syntax at all, in both the dot * and the bracket form; and `declare global` contributes names to every file * whether or not the module exports anything of its own. Kept as a source test * rather than a node scan precisely because `isExported` is set only from an * `export_statement` ancestor, so `const x = …; export { x }` and * `module.exports = { x }` both read as unexported on the node. */ const HAS_ESM_EXPORT = /^[ \t]*export[\s{*]|^[ \t]*declare\s+global\b/m; const HAS_CJS_EXPORT = /\bmodule\.exports\b|\bexports\s*[.[]/; /** * Per-context memo of "this file is a module that exports nothing", asked once * per candidate FILE rather than once per reference. Derived from file source, * so it drops with the context's file caches — clearNameMatcherMemos deletes it * alongside INFER_SCAN_STATES. */ const SEALED_MODULES = new WeakMap>(); /** * Whether `filePath` is a JS/TS module that exports NOTHING — an import * statement present, no export of any form. No reference from another file can * reach any binding in such a file, so every one of its symbols is a false * candidate for a cross-file name match. * * This is the general case behind a package name capturing a same-named local: * on `vitejs/vite`, 157 cross-file `imports` refs — every `import { defineConfig * } from 'vite'` in the playground and the create-vite templates — resolved onto * `playground/ssr-html/test-stacktrace.js::vite`, which is `const vite = await * createServer(…)` at module scope in a file with zero exports. The existing * guards cannot see it: `isLexicallyReachable` returns early for any candidate * that is not a `function`, and the bare-import guard correctly declines because * `vite` IS a workspace member, so the specifier really is project-local. What * is wrong is only which node the name lands on. * * Deliberately narrow on three axes, because each is a class this would * otherwise resolve wrongly in the opposite direction: * * - **A classic script is exempt.** Requiring an `import` statement means a * non-module `.js` file — concatenated globals, a browser `