name-matcher.ts 21 KB

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