name-matcher.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. /**
  2. * Name Matcher
  3. *
  4. * Handles symbol name matching for reference resolution.
  5. */
  6. import { Node } from '../types';
  7. import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
  8. /**
  9. * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid")
  10. * by matching the filename against file nodes.
  11. */
  12. export function matchByFilePath(
  13. ref: UnresolvedRef,
  14. context: ResolutionContext
  15. ): ResolvedRef | null {
  16. if (!ref.referenceName.includes('/')) return null;
  17. // Extract the filename from the path
  18. const fileName = ref.referenceName.split('/').pop();
  19. if (!fileName) return null;
  20. // Search for file nodes with this name
  21. const candidates = context.getNodesByName(fileName);
  22. const fileNodes = candidates.filter(n => n.kind === 'file');
  23. if (fileNodes.length === 0) return null;
  24. // Prefer exact path match on qualified_name
  25. const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName);
  26. if (exactMatch) {
  27. return {
  28. original: ref,
  29. targetNodeId: exactMatch.id,
  30. confidence: 0.95,
  31. resolvedBy: 'file-path',
  32. };
  33. }
  34. // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches "src/snippets/foo.liquid")
  35. const suffixMatch = fileNodes.find(n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName));
  36. if (suffixMatch) {
  37. return {
  38. original: ref,
  39. targetNodeId: suffixMatch.id,
  40. confidence: 0.85,
  41. resolvedBy: 'file-path',
  42. };
  43. }
  44. // If only one file node with this name, use it with lower confidence
  45. if (fileNodes.length === 1) {
  46. return {
  47. original: ref,
  48. targetNodeId: fileNodes[0]!.id,
  49. confidence: 0.7,
  50. resolvedBy: 'file-path',
  51. };
  52. }
  53. return null;
  54. }
  55. /**
  56. * Try to resolve a reference by exact name match
  57. */
  58. export function matchByExactName(
  59. ref: UnresolvedRef,
  60. context: ResolutionContext
  61. ): ResolvedRef | null {
  62. const candidates = context.getNodesByName(ref.referenceName);
  63. if (candidates.length === 0) {
  64. return null;
  65. }
  66. // If only one match, use it — but penalize cross-language matches
  67. if (candidates.length === 1) {
  68. const isCrossLanguage = candidates[0]!.language !== ref.language;
  69. return {
  70. original: ref,
  71. targetNodeId: candidates[0]!.id,
  72. confidence: isCrossLanguage ? 0.5 : 0.9,
  73. resolvedBy: 'exact-match',
  74. };
  75. }
  76. // Multiple matches - try to narrow down
  77. const bestMatch = findBestMatch(ref, candidates, context);
  78. if (bestMatch) {
  79. // Lower confidence when the match is from a distant/unrelated module
  80. const proximity = computePathProximity(ref.filePath, bestMatch.filePath);
  81. const confidence = proximity >= 30 ? 0.7 : 0.4;
  82. return {
  83. original: ref,
  84. targetNodeId: bestMatch.id,
  85. confidence,
  86. resolvedBy: 'exact-match',
  87. };
  88. }
  89. return null;
  90. }
  91. /**
  92. * Try to resolve by qualified name
  93. */
  94. export function matchByQualifiedName(
  95. ref: UnresolvedRef,
  96. context: ResolutionContext
  97. ): ResolvedRef | null {
  98. // Check if the reference name looks qualified (contains :: or .)
  99. if (!ref.referenceName.includes('::') && !ref.referenceName.includes('.')) {
  100. return null;
  101. }
  102. const candidates = context.getNodesByQualifiedName(ref.referenceName);
  103. if (candidates.length === 1) {
  104. return {
  105. original: ref,
  106. targetNodeId: candidates[0]!.id,
  107. confidence: 0.95,
  108. resolvedBy: 'qualified-name',
  109. };
  110. }
  111. // Try partial qualified name match
  112. const parts = ref.referenceName.split(/[:.]/);
  113. const lastName = parts[parts.length - 1];
  114. if (lastName) {
  115. const partialCandidates = context.getNodesByName(lastName);
  116. for (const candidate of partialCandidates) {
  117. if (candidate.qualifiedName.endsWith(ref.referenceName)) {
  118. return {
  119. original: ref,
  120. targetNodeId: candidate.id,
  121. confidence: 0.85,
  122. resolvedBy: 'qualified-name',
  123. };
  124. }
  125. }
  126. }
  127. return null;
  128. }
  129. function resolveMethodOnType(
  130. typeName: string,
  131. methodName: string,
  132. ref: UnresolvedRef,
  133. context: ResolutionContext,
  134. confidence: number,
  135. resolvedBy: ResolvedRef['resolvedBy'],
  136. /**
  137. * Optional FQN that identifies WHICH class declaration `typeName`
  138. * refers to in the caller's file. When multiple candidates share
  139. * the same qualifiedName (`FooConverter::convert` in both
  140. * `dao/converter/` and `service/converter/`), the FQN's
  141. * file-path-suffix picks the right one — the disambiguation
  142. * signal Java imports carry but the call site doesn't (#314).
  143. */
  144. preferredFqn?: string,
  145. ): ResolvedRef | null {
  146. // Look up methods by name and match by qualifiedName ending in
  147. // `<typeName>::<methodName>`. This works whether the method is defined
  148. // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate
  149. // file (`int Foo::bar() { ... }` in foo.cpp while class Foo is in foo.hpp).
  150. // The previous same-file approach missed the latter — the typical C++ layout.
  151. const methodCandidates = context.getNodesByName(methodName);
  152. const want = `${typeName}::${methodName}`;
  153. const matches: Node[] = [];
  154. for (const m of methodCandidates) {
  155. if (m.kind !== 'method') continue;
  156. if (m.language !== ref.language) continue;
  157. const qn = m.qualifiedName;
  158. if (qn === want || qn.endsWith(`::${want}`)) {
  159. matches.push(m);
  160. }
  161. }
  162. if (matches.length === 0) return null;
  163. if (matches.length > 1 && preferredFqn) {
  164. const ext = ref.language === 'kotlin' ? '.kt' : '.java';
  165. const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
  166. const chosen = matches.find((m) => {
  167. const fp = m.filePath.replace(/\\/g, '/');
  168. return fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath);
  169. });
  170. if (chosen) {
  171. return {
  172. original: ref,
  173. targetNodeId: chosen.id,
  174. confidence,
  175. resolvedBy,
  176. };
  177. }
  178. }
  179. return {
  180. original: ref,
  181. targetNodeId: matches[0]!.id,
  182. confidence,
  183. resolvedBy,
  184. };
  185. }
  186. // C++ keywords/control-flow tokens that can appear right before a receiver
  187. // (e.g. `return ptr->m()`) and must NOT be treated as a type.
  188. const CPP_NON_TYPE_TOKENS = new Set([
  189. 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
  190. 'break', 'continue', 'goto', 'throw', 'new', 'delete', 'co_await', 'co_yield',
  191. 'co_return', 'static_cast', 'const_cast', 'dynamic_cast', 'reinterpret_cast',
  192. 'sizeof', 'alignof', 'typeid', 'and', 'or', 'not', 'xor',
  193. ]);
  194. function normalizeCppTypeName(typeName: string): string | null {
  195. const normalized = typeName
  196. .replace(/\b(const|volatile|mutable|typename|class|struct)\b/g, ' ')
  197. .replace(/[&*]+/g, ' ')
  198. .replace(/<[^>]*>/g, ' ')
  199. .replace(/\s+/g, ' ')
  200. .trim();
  201. if (!normalized) return null;
  202. const parts = normalized.split(/::/).filter(Boolean);
  203. const last = parts[parts.length - 1];
  204. if (!last) return null;
  205. if (CPP_NON_TYPE_TOKENS.has(last)) return null;
  206. return last;
  207. }
  208. // Declarator regex: matches `Type receiver`, `Type* receiver`, `Type *receiver`,
  209. // `Type*receiver`, `Type<X> receiver`, etc., REQUIRING a declarator terminator
  210. // (`;`, `=`, `,`, `)`, `[`, `{`, `(`, or end-of-line) after the receiver. The
  211. // terminator rules out uses like `return receiver->m()` where the preceding
  212. // token is a keyword, not a type.
  213. function buildDeclaratorRegex(escapedReceiver: string): RegExp {
  214. return new RegExp(
  215. `([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`,
  216. );
  217. }
  218. function inferCppReceiverType(
  219. receiverName: string,
  220. ref: UnresolvedRef,
  221. context: ResolutionContext,
  222. ): string | null {
  223. const source = context.readFile(ref.filePath);
  224. if (!source) return null;
  225. const lines = source.split(/\r?\n/);
  226. const callLineIndex = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
  227. const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  228. const receiverPattern = new RegExp(`\\b${escapedReceiver}\\b`);
  229. const declaratorRegex = buildDeclaratorRegex(escapedReceiver);
  230. for (let i = callLineIndex; i >= 0; i--) {
  231. const line = lines[i];
  232. if (!line || !receiverPattern.test(line)) continue;
  233. const declaratorMatch = line.match(declaratorRegex);
  234. if (declaratorMatch) {
  235. const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
  236. if (normalized) return normalized;
  237. }
  238. }
  239. const headerCandidates = [
  240. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.h'),
  241. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hpp'),
  242. ref.filePath.replace(/\.(?:c|cc|cpp|cxx)$/i, '.hxx'),
  243. ].filter((candidate, index, arr) => arr.indexOf(candidate) === index && candidate !== ref.filePath);
  244. for (const headerPath of headerCandidates) {
  245. if (!context.fileExists(headerPath)) continue;
  246. const headerSource = context.readFile(headerPath);
  247. if (!headerSource) continue;
  248. for (const line of headerSource.split(/\r?\n/)) {
  249. if (!receiverPattern.test(line)) continue;
  250. const declaratorMatch = line.match(declaratorRegex);
  251. if (!declaratorMatch) continue;
  252. const normalized = normalizeCppTypeName(declaratorMatch[1] ?? '');
  253. if (normalized) return normalized;
  254. }
  255. }
  256. return null;
  257. }
  258. /**
  259. * Java/Kotlin: infer a receiver's declared type by walking field declarations
  260. * in the class enclosing the call site. The field's `signature` is already in
  261. * the form "<TypeName> <fieldName>" (set by tree-sitter.ts extractField), so we
  262. * pull the type from there. Handles Spring `@Resource UserBO userbo;` /
  263. * `@Autowired private UserService userService;` where the receiver field name
  264. * doesn't match the class name by Java naming convention.
  265. *
  266. * Returns the bare type name (generics stripped, dotted package stripped) or
  267. * null when no matching field is in the enclosing class.
  268. */
  269. function inferJavaFieldReceiverType(
  270. receiverName: string,
  271. ref: UnresolvedRef,
  272. context: ResolutionContext,
  273. ): string | null {
  274. const inFile = context.getNodesInFile(ref.filePath);
  275. if (inFile.length === 0) return null;
  276. // Find the class enclosing the call line (tightest match by latest start).
  277. let enclosing: Node | null = null;
  278. for (const n of inFile) {
  279. if (n.kind !== 'class' && n.kind !== 'interface') continue;
  280. if (n.language !== ref.language) continue;
  281. const end = n.endLine ?? n.startLine;
  282. if (n.startLine <= ref.line && end >= ref.line) {
  283. if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n;
  284. }
  285. }
  286. if (!enclosing) return null;
  287. const enclosingEnd = enclosing.endLine ?? enclosing.startLine;
  288. const field = inFile.find(
  289. (n) =>
  290. n.kind === 'field' &&
  291. n.name === receiverName &&
  292. n.language === ref.language &&
  293. n.startLine >= enclosing.startLine &&
  294. (n.endLine ?? n.startLine) <= enclosingEnd,
  295. );
  296. if (!field || !field.signature) return null;
  297. // Signature shape: "<TypeName> <fieldName>" (extractField). Pull the type,
  298. // strip generics + dotted package, drop array/varargs markers.
  299. const beforeName = field.signature.slice(
  300. 0,
  301. field.signature.lastIndexOf(field.name),
  302. );
  303. const typeRaw = beforeName.trim();
  304. if (!typeRaw) return null;
  305. const typeNoGenerics = typeRaw.replace(/<[^>]*>/g, '').trim();
  306. const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').trim();
  307. const parts = typeNoArray.split(/[.\s]+/).filter(Boolean);
  308. const lastPart = parts[parts.length - 1];
  309. if (!lastPart) return null;
  310. if (!/^[A-Z]/.test(lastPart)) return null; // primitives / lowercase → skip
  311. return lastPart;
  312. }
  313. /**
  314. * Try to resolve by method name on a class/object
  315. */
  316. export function matchMethodCall(
  317. ref: UnresolvedRef,
  318. context: ResolutionContext
  319. ): ResolvedRef | null {
  320. // Parse method call patterns like "obj.method" or "Class::method"
  321. const dotMatch = ref.referenceName.match(/^(\w+)\.(\w+)$/);
  322. const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/);
  323. const match = dotMatch || colonMatch;
  324. if (!match) {
  325. return null;
  326. }
  327. const [, objectOrClass, methodName] = match;
  328. if (ref.language === 'cpp' && dotMatch) {
  329. const inferredType = inferCppReceiverType(objectOrClass!, ref, context);
  330. if (inferredType) {
  331. const typedMatch = resolveMethodOnType(
  332. inferredType,
  333. methodName!,
  334. ref,
  335. context,
  336. 0.9,
  337. 'instance-method',
  338. );
  339. if (typedMatch) {
  340. return typedMatch;
  341. }
  342. }
  343. }
  344. // Java/Kotlin: receiver may be a field whose name doesn't match the type by
  345. // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
  346. // the field in the enclosing class to get its declared type, then resolve
  347. // the method on that type. Covers Spring `@Resource`/`@Autowired` field
  348. // injection where the field type is the concrete bean class.
  349. if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) {
  350. const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context);
  351. if (inferredType) {
  352. // When two classes share the same simple name, the caller file's
  353. // import is the only signal that names WHICH one — pass the
  354. // imported FQN so resolveMethodOnType can disambiguate (#314).
  355. const imports = context.getImportMappings(ref.filePath, ref.language);
  356. const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
  357. const typedMatch = resolveMethodOnType(
  358. inferredType,
  359. methodName!,
  360. ref,
  361. context,
  362. 0.9,
  363. 'instance-method',
  364. importedFqn,
  365. );
  366. if (typedMatch) {
  367. return typedMatch;
  368. }
  369. }
  370. }
  371. // Strategy 1: Direct class name match (existing logic)
  372. const classCandidates = context.getNodesByName(objectOrClass!);
  373. for (const classNode of classCandidates) {
  374. if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
  375. // Skip cross-language class matches
  376. if (classNode.language !== ref.language) continue;
  377. const nodesInFile = context.getNodesInFile(classNode.filePath);
  378. const methodNode = nodesInFile.find(
  379. (n) =>
  380. n.kind === 'method' &&
  381. n.name === methodName &&
  382. n.qualifiedName.includes(classNode.name)
  383. );
  384. if (methodNode) {
  385. return {
  386. original: ref,
  387. targetNodeId: methodNode.id,
  388. confidence: 0.85,
  389. resolvedBy: 'qualified-name',
  390. };
  391. }
  392. }
  393. }
  394. // Strategy 2: Instance variable receiver - try capitalized form to find class
  395. // e.g., "permissionEngine" → look for classes containing "PermissionEngine"
  396. const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1);
  397. if (capitalizedReceiver !== objectOrClass) {
  398. const fuzzyClassCandidates = context.getNodesByName(capitalizedReceiver);
  399. for (const classNode of fuzzyClassCandidates) {
  400. if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
  401. // Skip cross-language class matches
  402. if (classNode.language !== ref.language) continue;
  403. const nodesInFile = context.getNodesInFile(classNode.filePath);
  404. const methodNode = nodesInFile.find(
  405. (n) =>
  406. n.kind === 'method' &&
  407. n.name === methodName &&
  408. n.qualifiedName.includes(classNode.name)
  409. );
  410. if (methodNode) {
  411. return {
  412. original: ref,
  413. targetNodeId: methodNode.id,
  414. confidence: 0.8,
  415. resolvedBy: 'instance-method',
  416. };
  417. }
  418. }
  419. }
  420. }
  421. // Strategy 3: Find methods by name across the codebase, match by receiver
  422. // name similarity with the containing class. Handles abbreviated variable
  423. // names like permissionEngine → PermissionRuleEngine.
  424. if (methodName) {
  425. const methodCandidates = context.getNodesByName(methodName!);
  426. const methods = methodCandidates.filter(
  427. (n) => n.kind === 'method' && n.name === methodName
  428. );
  429. // Filter to same-language candidates first
  430. const sameLanguageMethods = methods.filter(m => m.language === ref.language);
  431. const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods;
  432. // If only one same-language method with this name exists, use it
  433. if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) {
  434. return {
  435. original: ref,
  436. targetNodeId: targetMethods[0]!.id,
  437. confidence: 0.7,
  438. resolvedBy: 'instance-method',
  439. };
  440. }
  441. // Multiple methods: score by receiver name word overlap with class name
  442. if (targetMethods.length > 1) {
  443. const receiverWords = splitCamelCase(objectOrClass!);
  444. let bestMatch: typeof targetMethods[0] | undefined;
  445. let bestScore = 0;
  446. for (const method of targetMethods) {
  447. const classWords = splitCamelCase(method.qualifiedName);
  448. let score = receiverWords.filter(w =>
  449. classWords.some(cw => cw.toLowerCase() === w.toLowerCase())
  450. ).length;
  451. // Bonus for same language
  452. if (method.language === ref.language) score += 1;
  453. if (score > bestScore) {
  454. bestScore = score;
  455. bestMatch = method;
  456. }
  457. }
  458. if (bestMatch && bestScore >= 2) {
  459. return {
  460. original: ref,
  461. targetNodeId: bestMatch.id,
  462. confidence: 0.65,
  463. resolvedBy: 'instance-method',
  464. };
  465. }
  466. }
  467. }
  468. return null;
  469. }
  470. /**
  471. * Split a camelCase or PascalCase string into words.
  472. */
  473. function splitCamelCase(str: string): string[] {
  474. return str.replace(/([a-z])([A-Z])/g, '$1 $2')
  475. .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
  476. .split(/[\s._:\/\\]+/)
  477. .filter(w => w.length > 1);
  478. }
  479. /**
  480. * Compute directory proximity between two file paths.
  481. * Returns a score based on the number of shared directory segments.
  482. * Higher score = closer in directory tree.
  483. */
  484. function computePathProximity(filePath1: string, filePath2: string): number {
  485. const dir1 = filePath1.split('/').slice(0, -1);
  486. const dir2 = filePath2.split('/').slice(0, -1);
  487. let shared = 0;
  488. for (let i = 0; i < Math.min(dir1.length, dir2.length); i++) {
  489. if (dir1[i] === dir2[i]) {
  490. shared++;
  491. } else {
  492. break;
  493. }
  494. }
  495. // Each shared directory segment contributes 15 points, capped at 80
  496. return Math.min(shared * 15, 80);
  497. }
  498. /**
  499. * Find the best matching node when there are multiple candidates
  500. */
  501. function findBestMatch(
  502. ref: UnresolvedRef,
  503. candidates: Node[],
  504. _context: ResolutionContext
  505. ): Node | null {
  506. // Prioritization rules:
  507. // 1. Same file > different file
  508. // 2. Directory proximity (same module/package > different module)
  509. // 3. Same language > different language
  510. // 4. Functions/methods > classes/types (for call references)
  511. // 5. Exported > non-exported
  512. let bestScore = -1;
  513. let bestNode: Node | null = null;
  514. for (const candidate of candidates) {
  515. let score = 0;
  516. // Same file bonus
  517. if (candidate.filePath === ref.filePath) {
  518. score += 100;
  519. }
  520. // Directory proximity bonus — strongly prefer same module/package
  521. score += computePathProximity(ref.filePath, candidate.filePath);
  522. // Language matching: strongly prefer same language, penalize cross-language
  523. if (candidate.language === ref.language) {
  524. score += 50;
  525. } else {
  526. score -= 80;
  527. }
  528. // For call references, prefer functions/methods
  529. if (ref.referenceKind === 'calls') {
  530. if (candidate.kind === 'function' || candidate.kind === 'method') {
  531. score += 25;
  532. }
  533. }
  534. // For instantiation references (`new Foo()`), prefer class-like
  535. // targets — without this, a function named `Foo` in another module
  536. // could outscore the actual class.
  537. if (ref.referenceKind === 'instantiates') {
  538. if (
  539. candidate.kind === 'class' ||
  540. candidate.kind === 'struct' ||
  541. candidate.kind === 'interface'
  542. ) {
  543. score += 25;
  544. }
  545. }
  546. // For decorator references (`@Foo`), prefer functions. Class
  547. // decorators (Python `@SomeClass`, Java annotation interfaces)
  548. // also resolve here, hence the smaller class bonus.
  549. if (ref.referenceKind === 'decorates') {
  550. if (candidate.kind === 'function' || candidate.kind === 'method') {
  551. score += 25;
  552. } else if (candidate.kind === 'class' || candidate.kind === 'interface') {
  553. score += 15;
  554. }
  555. }
  556. // Exported bonus
  557. if (candidate.isExported) {
  558. score += 10;
  559. }
  560. // Closer line number (within same file)
  561. if (candidate.filePath === ref.filePath && candidate.startLine) {
  562. const distance = Math.abs(candidate.startLine - ref.line);
  563. score += Math.max(0, 20 - distance / 10);
  564. }
  565. if (score > bestScore) {
  566. bestScore = score;
  567. bestNode = candidate;
  568. }
  569. }
  570. return bestNode;
  571. }
  572. /**
  573. * Fuzzy match - last resort with lower confidence
  574. */
  575. export function matchFuzzy(
  576. ref: UnresolvedRef,
  577. context: ResolutionContext
  578. ): ResolvedRef | null {
  579. const lowerName = ref.referenceName.toLowerCase();
  580. // Use pre-built lowercase index for O(1) lookup instead of scanning all nodes
  581. const candidates = context.getNodesByLowerName(lowerName);
  582. // Filter to callable kinds only (function, method, class)
  583. const callableKinds = new Set(['function', 'method', 'class']);
  584. const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind));
  585. // Prefer same-language matches
  586. const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
  587. const finalCandidates = sameLanguageCandidates.length > 0 ? sameLanguageCandidates : callableCandidates;
  588. if (finalCandidates.length === 1) {
  589. const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
  590. return {
  591. original: ref,
  592. targetNodeId: finalCandidates[0]!.id,
  593. confidence: isCrossLanguage ? 0.3 : 0.5,
  594. resolvedBy: 'fuzzy',
  595. };
  596. }
  597. return null;
  598. }
  599. /**
  600. * Match all strategies in order of confidence
  601. */
  602. export function matchReference(
  603. ref: UnresolvedRef,
  604. context: ResolutionContext
  605. ): ResolvedRef | null {
  606. // Try strategies in order of confidence
  607. let result: ResolvedRef | null;
  608. // 0. File path match (e.g., "snippets/drawer-menu.liquid" → file node)
  609. result = matchByFilePath(ref, context);
  610. if (result) return result;
  611. // 1. Qualified name match (highest confidence)
  612. result = matchByQualifiedName(ref, context);
  613. if (result) return result;
  614. // 2. Method call pattern
  615. result = matchMethodCall(ref, context);
  616. if (result) return result;
  617. // 3. Exact name match
  618. result = matchByExactName(ref, context);
  619. if (result) return result;
  620. // 4. Fuzzy match (lowest confidence)
  621. result = matchFuzzy(ref, context);
  622. if (result) return result;
  623. return null;
  624. }