@quolu/lattice 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/package.json +4 -2
  2. package/sensor/NOTICE +8 -19
  3. package/sensor/dist/bin/lattice-sensor.js +31 -0
  4. package/sensor/dist/db/index.d.ts +114 -0
  5. package/sensor/dist/db/index.d.ts.map +1 -1
  6. package/sensor/dist/db/index.js +235 -7
  7. package/sensor/dist/db/index.js.map +1 -1
  8. package/sensor/dist/db/migrations.js +1 -1
  9. package/sensor/dist/db/migrations.js.map +1 -1
  10. package/sensor/dist/db/queries.d.ts +46 -8
  11. package/sensor/dist/db/queries.d.ts.map +1 -1
  12. package/sensor/dist/db/queries.js +99 -16
  13. package/sensor/dist/db/queries.js.map +1 -1
  14. package/sensor/dist/db/wal-valve.d.ts +15 -2
  15. package/sensor/dist/db/wal-valve.d.ts.map +1 -1
  16. package/sensor/dist/db/wal-valve.js +88 -6
  17. package/sensor/dist/db/wal-valve.js.map +1 -1
  18. package/sensor/dist/extraction/extraction-version.d.ts +1 -1
  19. package/sensor/dist/extraction/extraction-version.d.ts.map +1 -1
  20. package/sensor/dist/extraction/extraction-version.js +5 -1
  21. package/sensor/dist/extraction/extraction-version.js.map +1 -1
  22. package/sensor/dist/extraction/function-ref.d.ts.map +1 -1
  23. package/sensor/dist/extraction/function-ref.js +6 -0
  24. package/sensor/dist/extraction/function-ref.js.map +1 -1
  25. package/sensor/dist/extraction/grammars.d.ts.map +1 -1
  26. package/sensor/dist/extraction/grammars.js +46 -0
  27. package/sensor/dist/extraction/grammars.js.map +1 -1
  28. package/sensor/dist/extraction/index.d.ts +11 -1
  29. package/sensor/dist/extraction/index.d.ts.map +1 -1
  30. package/sensor/dist/extraction/index.js +68 -16
  31. package/sensor/dist/extraction/index.js.map +1 -1
  32. package/sensor/dist/extraction/kernel/decode.d.ts.map +1 -1
  33. package/sensor/dist/extraction/kernel/decode.js +9 -3
  34. package/sensor/dist/extraction/kernel/decode.js.map +1 -1
  35. package/sensor/dist/extraction/kernel/index.d.ts +2 -0
  36. package/sensor/dist/extraction/kernel/index.d.ts.map +1 -1
  37. package/sensor/dist/extraction/kernel/index.js +122 -8
  38. package/sensor/dist/extraction/kernel/index.js.map +1 -1
  39. package/sensor/dist/extraction/kernel/layout.d.ts +10 -1
  40. package/sensor/dist/extraction/kernel/layout.d.ts.map +1 -1
  41. package/sensor/dist/extraction/kernel/layout.js +11 -2
  42. package/sensor/dist/extraction/kernel/layout.js.map +1 -1
  43. package/sensor/dist/extraction/kernel/loader.d.ts +42 -0
  44. package/sensor/dist/extraction/kernel/loader.d.ts.map +1 -1
  45. package/sensor/dist/extraction/kernel/loader.js.map +1 -1
  46. package/sensor/dist/extraction/languages/c-cpp.d.ts +13 -0
  47. package/sensor/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  48. package/sensor/dist/extraction/languages/c-cpp.js +809 -13
  49. package/sensor/dist/extraction/languages/c-cpp.js.map +1 -1
  50. package/sensor/dist/extraction/store-worker.js +29 -0
  51. package/sensor/dist/extraction/store-worker.js.map +1 -1
  52. package/sensor/dist/extraction/tree-sitter.d.ts +4 -1
  53. package/sensor/dist/extraction/tree-sitter.d.ts.map +1 -1
  54. package/sensor/dist/extraction/tree-sitter.js +20 -4
  55. package/sensor/dist/extraction/tree-sitter.js.map +1 -1
  56. package/sensor/dist/extraction/wasm/tree-sitter-c.wasm +0 -0
  57. package/sensor/dist/extraction/wasm/tree-sitter-cpp.wasm +0 -0
  58. package/sensor/dist/extraction/wasm/tree-sitter-dart.wasm +0 -0
  59. package/sensor/dist/extraction/wasm/tree-sitter-kotlin.wasm +0 -0
  60. package/sensor/dist/extraction/wasm/tree-sitter-php.wasm +0 -0
  61. package/sensor/dist/extraction/wasm/tree-sitter-ruby.wasm +0 -0
  62. package/sensor/dist/extraction/wasm/tree-sitter-rust.wasm +0 -0
  63. package/sensor/dist/extraction/wasm/tree-sitter-swift.wasm +0 -0
  64. package/sensor/dist/index.d.ts +12 -1
  65. package/sensor/dist/index.d.ts.map +1 -1
  66. package/sensor/dist/index.js +42 -7
  67. package/sensor/dist/index.js.map +1 -1
  68. package/sensor/dist/mcp/daemon-registry.d.ts +5 -2
  69. package/sensor/dist/mcp/daemon-registry.d.ts.map +1 -1
  70. package/sensor/dist/mcp/daemon-registry.js +5 -2
  71. package/sensor/dist/mcp/daemon-registry.js.map +1 -1
  72. package/sensor/dist/mcp/index.d.ts +24 -0
  73. package/sensor/dist/mcp/index.d.ts.map +1 -1
  74. package/sensor/dist/mcp/index.js +51 -2
  75. package/sensor/dist/mcp/index.js.map +1 -1
  76. package/sensor/dist/mcp/liveness-watchdog.d.ts.map +1 -1
  77. package/sensor/dist/mcp/liveness-watchdog.js +3 -1
  78. package/sensor/dist/mcp/liveness-watchdog.js.map +1 -1
  79. package/sensor/dist/mcp/server-instructions.d.ts +1 -1
  80. package/sensor/dist/mcp/server-instructions.d.ts.map +1 -1
  81. package/sensor/dist/mcp/server-instructions.js +1 -0
  82. package/sensor/dist/mcp/server-instructions.js.map +1 -1
  83. package/sensor/dist/mcp/tools.d.ts +48 -0
  84. package/sensor/dist/mcp/tools.d.ts.map +1 -1
  85. package/sensor/dist/mcp/tools.js +231 -4
  86. package/sensor/dist/mcp/tools.js.map +1 -1
  87. package/sensor/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
  88. package/sensor/dist/resolution/c-fnptr-synthesizer.js +561 -127
  89. package/sensor/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
  90. package/sensor/dist/resolution/callback-synthesizer.d.ts +1 -1
  91. package/sensor/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  92. package/sensor/dist/resolution/callback-synthesizer.js +80 -11
  93. package/sensor/dist/resolution/callback-synthesizer.js.map +1 -1
  94. package/sensor/dist/resolution/import-resolver.d.ts.map +1 -1
  95. package/sensor/dist/resolution/import-resolver.js +34 -2
  96. package/sensor/dist/resolution/import-resolver.js.map +1 -1
  97. package/sensor/dist/resolution/index.d.ts +34 -0
  98. package/sensor/dist/resolution/index.d.ts.map +1 -1
  99. package/sensor/dist/resolution/index.js +393 -51
  100. package/sensor/dist/resolution/index.js.map +1 -1
  101. package/sensor/dist/resolution/memory-budget.d.ts +48 -0
  102. package/sensor/dist/resolution/memory-budget.d.ts.map +1 -0
  103. package/sensor/dist/resolution/memory-budget.js +162 -0
  104. package/sensor/dist/resolution/memory-budget.js.map +1 -0
  105. package/sensor/dist/resolution/name-matcher.d.ts +4 -7
  106. package/sensor/dist/resolution/name-matcher.d.ts.map +1 -1
  107. package/sensor/dist/resolution/name-matcher.js +256 -91
  108. package/sensor/dist/resolution/name-matcher.js.map +1 -1
  109. package/sensor/dist/resolution/resolver-pool.d.ts +41 -1
  110. package/sensor/dist/resolution/resolver-pool.d.ts.map +1 -1
  111. package/sensor/dist/resolution/resolver-pool.js +103 -3
  112. package/sensor/dist/resolution/resolver-pool.js.map +1 -1
  113. package/sensor/dist/resolution/resolver-worker.js +27 -0
  114. package/sensor/dist/resolution/resolver-worker.js.map +1 -1
  115. package/sensor/dist/sync/watcher.d.ts +8 -1
  116. package/sensor/dist/sync/watcher.d.ts.map +1 -1
  117. package/sensor/dist/sync/watcher.js +44 -2
  118. package/sensor/dist/sync/watcher.js.map +1 -1
  119. package/sensor/dist/types.d.ts +7 -3
  120. package/sensor/dist/types.d.ts.map +1 -1
  121. package/src/cli-help.mjs +16 -0
  122. package/src/sensor-adapter.mjs +4 -0
  123. package/src/sensor-cli.mjs +37 -5
  124. package/src/sensor-diff.mjs +661 -0
@@ -18,9 +18,11 @@ exports.matchCppCallChain = matchCppCallChain;
18
18
  exports.matchScopedCallChain = matchScopedCallChain;
19
19
  exports.matchDottedCallChain = matchDottedCallChain;
20
20
  exports.normalizeInferredTypeName = normalizeInferredTypeName;
21
+ exports.clearNameMatcherMemos = clearNameMatcherMemos;
21
22
  exports.localReceiverTypePatterns = localReceiverTypePatterns;
22
23
  exports.matchMethodCall = matchMethodCall;
23
24
  exports.matchFuzzy = matchFuzzy;
25
+ exports.dumpNameMatcherProfile = dumpNameMatcherProfile;
24
26
  exports.matchReference = matchReference;
25
27
  /**
26
28
  * Ceiling on how many same-named definitions a FUZZY name-match strategy will
@@ -229,6 +231,15 @@ function matchFunctionRef(ref, context) {
229
231
  ref.language === 'arkts' ||
230
232
  ref.language === 'cpp' || ref.language === 'python' ||
231
233
  ref.language === 'php';
234
+ // Python additionally accepts CLASS targets for bare identifiers (#1478):
235
+ // class-as-value is a core Python idiom (`return SomeSerializer`,
236
+ // `Meta.model = Org`, registry dicts, `admin.site.register(Model, Admin)`)
237
+ // and, unlike TS, Python has no type-annotation recovery path. The
238
+ // false-positive mechanism behind the function-only rule was lowercase
239
+ // locals colliding with same-named METHODS (docopt.py) — a candidate must
240
+ // be an exact-name CLASS node here, and the extraction gate (same-file
241
+ // class ∪ imports) plus unique-or-drop still apply. Methods stay excluded.
242
+ const bareClassOk = ref.language === 'python';
232
243
  // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"):
233
244
  // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m`
234
245
  // shape is an explicit member reference). Unique-or-drop like everything else.
@@ -257,7 +268,9 @@ function matchFunctionRef(ref, context) {
257
268
  }
258
269
  let candidates = context
259
270
  .getNodesByName(ref.referenceName)
260
- .filter((n) => (n.kind === 'function' || (!bareFnOnly && n.kind === 'method')) &&
271
+ .filter((n) => (n.kind === 'function' ||
272
+ (!bareFnOnly && n.kind === 'method') ||
273
+ (bareClassOk && n.kind === 'class')) &&
261
274
  sameLanguageFamily(n.language, ref.language) &&
262
275
  n.id !== ref.fromNodeId // a function registering itself is not a dependency edge
263
276
  );
@@ -546,11 +559,16 @@ depth = 0) {
546
559
  // populated in the conformance pass. Still VALIDATED (the method must exist on
547
560
  // a supertype), so a wrong inference produces no edge.
548
561
  if (depth < 4 && context.getSupertypes) {
549
- for (const supertype of context.getSupertypes(typeName, ref.language)) {
550
- const via = resolveMethodOnType(supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1);
551
- if (via)
552
- return via;
553
- }
562
+ const viaSupers = nmTimedT('rmot-supers', ref, () => {
563
+ for (const supertype of context.getSupertypes(typeName, ref.language)) {
564
+ const via = resolveMethodOnType(supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1);
565
+ if (via)
566
+ return via;
567
+ }
568
+ return null;
569
+ });
570
+ if (viaSupers)
571
+ return viaSupers;
554
572
  }
555
573
  return null;
556
574
  }
@@ -1032,7 +1050,50 @@ function normalizeInferredTypeName(raw) {
1032
1050
  * PascalCase is required in the capture where the language convention allows,
1033
1051
  * as a cheap false-positive guard on top of resolveMethodOnType's validation.
1034
1052
  */
1053
+ /**
1054
+ * Compiled-pattern memo for the receiver-type pattern builders below. They
1055
+ * run for EVERY `receiver.method()` ref the matcher attempts, compiling 2–4
1056
+ * fresh RegExp objects per call — and receivers repeat massively (`self`
1057
+ * alone accounts for tens of thousands of refs on a Lua repo, measured 41µs
1058
+ * per methodCall miss on kong with compilation a large slice). The patterns
1059
+ * are a pure function of (language, receiver) and non-global (`.match()`
1060
+ * never touches lastIndex), so shared instances are behavior-identical.
1061
+ * FIFO-capped with no per-get mutation (the §7a.6 LRU-churn lesson): a hit
1062
+ * costs one Map lookup, overflow evicts oldest, and an evicted entry simply
1063
+ * recompiles exactly as every call did before this memo.
1064
+ */
1065
+ const PATTERN_MEMO = new Map();
1066
+ const PATTERN_MEMO_CAP = 8192;
1067
+ const INFER_SCAN_STATES = new WeakMap();
1068
+ function getInferScanStates(context) {
1069
+ let m = INFER_SCAN_STATES.get(context);
1070
+ if (!m) {
1071
+ m = new Map();
1072
+ INFER_SCAN_STATES.set(context, m);
1073
+ }
1074
+ return m;
1075
+ }
1076
+ /** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
1077
+ function clearNameMatcherMemos(context) {
1078
+ INFER_SCAN_STATES.delete(context);
1079
+ }
1080
+ function memoPatterns(key, build) {
1081
+ const hit = PATTERN_MEMO.get(key);
1082
+ if (hit)
1083
+ return hit;
1084
+ const patterns = build();
1085
+ if (PATTERN_MEMO.size >= PATTERN_MEMO_CAP) {
1086
+ const oldest = PATTERN_MEMO.keys().next().value;
1087
+ if (oldest !== undefined)
1088
+ PATTERN_MEMO.delete(oldest);
1089
+ }
1090
+ PATTERN_MEMO.set(key, patterns);
1091
+ return patterns;
1092
+ }
1035
1093
  function localReceiverTypePatterns(language, r) {
1094
+ return memoPatterns(`${language}|${r}`, () => buildLocalReceiverTypePatterns(language, r));
1095
+ }
1096
+ function buildLocalReceiverTypePatterns(language, r) {
1036
1097
  switch (language) {
1037
1098
  case 'typescript':
1038
1099
  case 'javascript':
@@ -1275,6 +1336,53 @@ function inferLocalReceiverType(receiverName, ref, context) {
1275
1336
  }
1276
1337
  return null;
1277
1338
  };
1339
+ // Incremental-scan memo (INFER_SCAN_STATES): this scan runs for EVERY
1340
+ // `receiver.method()` ref and was measured at 61µs/ref on kong (2.4s of
1341
+ // worker time, 99% misses — `self:` calls hunting a declaration Lua never
1342
+ // writes). Refs for the same (file, scope, receiver) arrive in ~ascending
1343
+ // line order, and the scan is a pure function of the file's immutable
1344
+ // lines, so each line pays its regex matches ONCE per key instead of once
1345
+ // per ref: query(c) = highest matching line in [startIdx..c]; a monotonic
1346
+ // call extends the stored watermark by scanning only (hi..c] (the region
1347
+ // at-or-below the previous answer is already proven empty above it); a
1348
+ // non-monotonic call (rare — refs are rowid-ordered) falls back to the
1349
+ // plain bounded scan and leaves the state alone. componentScoped is keyed
1350
+ // out — its position-independent whole-file sweep below has different
1351
+ // semantics.
1352
+ if (!componentScoped) {
1353
+ const states = getInferScanStates(context);
1354
+ const key = `${ref.filePath}|${startIdx}|${ref.language}|${scanReceiver}`;
1355
+ const state = states.get(key);
1356
+ if (!state) {
1357
+ for (let i = callIdx; i >= startIdx; i--) {
1358
+ const type = matchLine(i);
1359
+ if (type) {
1360
+ states.set(key, { hi: callIdx, ansIdx: i, ansType: type });
1361
+ return type;
1362
+ }
1363
+ }
1364
+ states.set(key, { hi: callIdx, ansIdx: -1, ansType: null });
1365
+ return null;
1366
+ }
1367
+ if (callIdx >= state.hi) {
1368
+ for (let i = callIdx; i > state.hi; i--) {
1369
+ const type = matchLine(i);
1370
+ if (type) {
1371
+ state.ansIdx = i;
1372
+ state.ansType = type;
1373
+ break;
1374
+ }
1375
+ }
1376
+ state.hi = callIdx;
1377
+ return state.ansIdx >= startIdx ? state.ansType : null;
1378
+ }
1379
+ for (let i = callIdx; i >= startIdx; i--) {
1380
+ const type = matchLine(i);
1381
+ if (type)
1382
+ return type;
1383
+ }
1384
+ return null;
1385
+ }
1278
1386
  // Nearest declaration wins: scan backward from the call to the scope start.
1279
1387
  for (let i = callIdx; i >= startIdx; i--) {
1280
1388
  const type = matchLine(i);
@@ -1317,6 +1425,9 @@ function inferLocalReceiverType(receiverName, ref, context) {
1317
1425
  * shape is handled by inferPhpAssignedPropertyType instead.
1318
1426
  */
1319
1427
  function phpPropertyTypePatterns(r) {
1428
+ return memoPatterns(`php-prop|${r}`, () => buildPhpPropertyTypePatterns(r));
1429
+ }
1430
+ function buildPhpPropertyTypePatterns(r) {
1320
1431
  return [
1321
1432
  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)
1322
1433
  new RegExp(`\\$this->${r}\\b\\s*=\\s*new\\s+([A-Za-z_\\\\][\\w\\\\]*)`), // $this->prop = new Foo()
@@ -1450,9 +1561,9 @@ function matchMethodCall(ref, context) {
1450
1561
  // shared source-based inferrer. resolveMethodOnType validates the method
1451
1562
  // exists on the inferred type, so a mis-inference produces no edge.
1452
1563
  if (inferableReceiver) {
1453
- const inferredType = ref.language === 'cpp'
1564
+ const inferredType = nmTimedT('mc-infer', ref, () => ref.language === 'cpp'
1454
1565
  ? inferCppReceiverType(objectOrClass, ref, context)
1455
- : inferLocalReceiverType(objectOrClass, ref, context);
1566
+ : inferLocalReceiverType(objectOrClass, ref, context));
1456
1567
  if (inferredType) {
1457
1568
  // Java/Kotlin: when two classes share the simple name, the file's import
1458
1569
  // pins WHICH one (#314). Other languages disambiguate by call-site file.
@@ -1461,7 +1572,7 @@ function matchMethodCall(ref, context) {
1461
1572
  .getImportMappings(ref.filePath, ref.language)
1462
1573
  .find((i) => i.localName === inferredType)?.source
1463
1574
  : undefined;
1464
- const typedMatch = resolveMethodOnType(inferredType, methodName, ref, context, 0.9, 'instance-method', importedFqn);
1575
+ const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(inferredType, methodName, ref, context, 0.9, 'instance-method', importedFqn));
1465
1576
  if (typedMatch) {
1466
1577
  return typedMatch;
1467
1578
  }
@@ -1494,7 +1605,7 @@ function matchMethodCall(ref, context) {
1494
1605
  // imported FQN so resolveMethodOnType can disambiguate (#314).
1495
1606
  const imports = context.getImportMappings(ref.filePath, ref.language);
1496
1607
  const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
1497
- const typedMatch = resolveMethodOnType(inferredType, methodName, ref, context, 0.9, 'instance-method', importedFqn);
1608
+ const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(inferredType, methodName, ref, context, 0.9, 'instance-method', importedFqn));
1498
1609
  if (typedMatch) {
1499
1610
  return typedMatch;
1500
1611
  }
@@ -1505,32 +1616,9 @@ function matchMethodCall(ref, context) {
1505
1616
  // with a `Logger` in both `a/` and `b/`), try the class in the call site's
1506
1617
  // own file first — otherwise the first-indexed class wins and a call in `b/`
1507
1618
  // resolves to `a/`'s method (#1079).
1508
- const classCandidates = preferCallSiteFile(context.getNodesByName(objectOrClass), ref.filePath);
1509
- for (const classNode of classCandidates) {
1510
- if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
1511
- // Skip cross-language class matches
1512
- if (classNode.language !== ref.language)
1513
- continue;
1514
- const nodesInFile = context.getNodesInFile(classNode.filePath);
1515
- const methodNode = nodesInFile.find((n) => n.kind === 'method' &&
1516
- n.name === methodName &&
1517
- n.qualifiedName.includes(classNode.name));
1518
- if (methodNode) {
1519
- return {
1520
- original: ref,
1521
- targetNodeId: methodNode.id,
1522
- confidence: 0.85,
1523
- resolvedBy: 'qualified-name',
1524
- };
1525
- }
1526
- }
1527
- }
1528
- // Strategy 2: Instance variable receiver - try capitalized form to find class
1529
- // e.g., "permissionEngine" → look for classes containing "PermissionEngine"
1530
- const capitalizedReceiver = objectOrClass.charAt(0).toUpperCase() + objectOrClass.slice(1);
1531
- if (capitalizedReceiver !== objectOrClass) {
1532
- const fuzzyClassCandidates = preferCallSiteFile(context.getNodesByName(capitalizedReceiver), ref.filePath);
1533
- for (const classNode of fuzzyClassCandidates) {
1619
+ const strat1 = nmTimedT('mc-class', ref, () => {
1620
+ const classCandidates = preferCallSiteFile(context.getNodesByName(objectOrClass), ref.filePath);
1621
+ for (const classNode of classCandidates) {
1534
1622
  if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
1535
1623
  // Skip cross-language class matches
1536
1624
  if (classNode.language !== ref.language)
@@ -1543,68 +1631,106 @@ function matchMethodCall(ref, context) {
1543
1631
  return {
1544
1632
  original: ref,
1545
1633
  targetNodeId: methodNode.id,
1546
- confidence: 0.8,
1547
- resolvedBy: 'instance-method',
1634
+ confidence: 0.85,
1635
+ resolvedBy: 'qualified-name',
1548
1636
  };
1549
1637
  }
1550
1638
  }
1551
1639
  }
1640
+ return null;
1641
+ });
1642
+ if (strat1)
1643
+ return strat1;
1644
+ // Strategy 2: Instance variable receiver - try capitalized form to find class
1645
+ // e.g., "permissionEngine" → look for classes containing "PermissionEngine"
1646
+ const capitalizedReceiver = objectOrClass.charAt(0).toUpperCase() + objectOrClass.slice(1);
1647
+ if (capitalizedReceiver !== objectOrClass) {
1648
+ const strat2 = nmTimedT('mc-capital', ref, () => {
1649
+ const fuzzyClassCandidates = preferCallSiteFile(context.getNodesByName(capitalizedReceiver), ref.filePath);
1650
+ for (const classNode of fuzzyClassCandidates) {
1651
+ if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
1652
+ // Skip cross-language class matches
1653
+ if (classNode.language !== ref.language)
1654
+ continue;
1655
+ const nodesInFile = context.getNodesInFile(classNode.filePath);
1656
+ const methodNode = nodesInFile.find((n) => n.kind === 'method' &&
1657
+ n.name === methodName &&
1658
+ n.qualifiedName.includes(classNode.name));
1659
+ if (methodNode) {
1660
+ return {
1661
+ original: ref,
1662
+ targetNodeId: methodNode.id,
1663
+ confidence: 0.8,
1664
+ resolvedBy: 'instance-method',
1665
+ };
1666
+ }
1667
+ }
1668
+ }
1669
+ return null;
1670
+ });
1671
+ if (strat2)
1672
+ return strat2;
1552
1673
  }
1553
1674
  // Strategy 3: Find methods by name across the codebase, match by receiver
1554
1675
  // name similarity with the containing class. Handles abbreviated variable
1555
1676
  // names like permissionEngine → PermissionRuleEngine.
1556
1677
  if (methodName) {
1557
- const methodCandidates = context.getNodesByName(methodName);
1558
- // Ubiquitous-method ceiling (#999): a method name re-declared across a
1559
- // vendored theme/SDK (Metronic's `init`/`update`/… on every widget) yields
1560
- // K candidates that receiver-word overlap can't reliably disambiguate —
1561
- // and filtering + scoring all K per call is the O(K²) cost that wedged
1562
- // "Resolving refs" for 15-28 min. Bail before the O(K) work; Strategy 1/2
1563
- // (class-name match) already had their precise shot above.
1564
- if (methodCandidates.length > AMBIGUOUS_NAME_CEILING) {
1565
- return null;
1566
- }
1567
- const methods = methodCandidates.filter((n) => n.kind === 'method' && n.name === methodName);
1568
- // Filter to same-language candidates first
1569
- const sameLanguageMethods = methods.filter(m => m.language === ref.language);
1570
- const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods;
1571
- // If only one same-language method with this name exists, use it
1572
- if (targetMethods.length === 1 && targetMethods[0].language === ref.language) {
1573
- return {
1574
- original: ref,
1575
- targetNodeId: targetMethods[0].id,
1576
- confidence: 0.7,
1577
- resolvedBy: 'instance-method',
1578
- };
1579
- }
1580
- // Multiple methods: score by receiver name word overlap with class name
1581
- if (targetMethods.length > 1) {
1582
- const receiverWords = splitCamelCase(objectOrClass);
1583
- let bestMatch;
1584
- let bestScore = 0;
1585
- // Same-file candidates first, so a score tie (`score > bestScore` keeps
1586
- // the first seen) resolves to the call site's own file rather than the
1587
- // first-indexed duplicate (#1079).
1588
- for (const method of preferCallSiteFile(targetMethods, ref.filePath)) {
1589
- const classWords = splitCamelCase(method.qualifiedName);
1590
- let score = receiverWords.filter(w => classWords.some(cw => cw.toLowerCase() === w.toLowerCase())).length;
1591
- // Bonus for same language
1592
- if (method.language === ref.language)
1593
- score += 1;
1594
- if (score > bestScore) {
1595
- bestScore = score;
1596
- bestMatch = method;
1597
- }
1678
+ const strat3 = nmTimedT('mc-byname', ref, () => {
1679
+ const methodCandidates = context.getNodesByName(methodName);
1680
+ // Ubiquitous-method ceiling (#999): a method name re-declared across a
1681
+ // vendored theme/SDK (Metronic's `init`/`update`/… on every widget) yields
1682
+ // K candidates that receiver-word overlap can't reliably disambiguate
1683
+ // and filtering + scoring all K per call is the O(K²) cost that wedged
1684
+ // "Resolving refs" for 15-28 min. Bail before the O(K) work; Strategy 1/2
1685
+ // (class-name match) already had their precise shot above.
1686
+ if (methodCandidates.length > AMBIGUOUS_NAME_CEILING) {
1687
+ return null;
1598
1688
  }
1599
- if (bestMatch && bestScore >= 2) {
1689
+ const methods = methodCandidates.filter((n) => n.kind === 'method' && n.name === methodName);
1690
+ // Filter to same-language candidates first
1691
+ const sameLanguageMethods = methods.filter(m => m.language === ref.language);
1692
+ const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods;
1693
+ // If only one same-language method with this name exists, use it
1694
+ if (targetMethods.length === 1 && targetMethods[0].language === ref.language) {
1600
1695
  return {
1601
1696
  original: ref,
1602
- targetNodeId: bestMatch.id,
1603
- confidence: 0.65,
1697
+ targetNodeId: targetMethods[0].id,
1698
+ confidence: 0.7,
1604
1699
  resolvedBy: 'instance-method',
1605
1700
  };
1606
1701
  }
1607
- }
1702
+ // Multiple methods: score by receiver name word overlap with class name
1703
+ if (targetMethods.length > 1) {
1704
+ const receiverWords = splitCamelCase(objectOrClass);
1705
+ let bestMatch;
1706
+ let bestScore = 0;
1707
+ // Same-file candidates first, so a score tie (`score > bestScore` keeps
1708
+ // the first seen) resolves to the call site's own file rather than the
1709
+ // first-indexed duplicate (#1079).
1710
+ for (const method of preferCallSiteFile(targetMethods, ref.filePath)) {
1711
+ const classWords = splitCamelCase(method.qualifiedName);
1712
+ let score = receiverWords.filter(w => classWords.some(cw => cw.toLowerCase() === w.toLowerCase())).length;
1713
+ // Bonus for same language
1714
+ if (method.language === ref.language)
1715
+ score += 1;
1716
+ if (score > bestScore) {
1717
+ bestScore = score;
1718
+ bestMatch = method;
1719
+ }
1720
+ }
1721
+ if (bestMatch && bestScore >= 2) {
1722
+ return {
1723
+ original: ref,
1724
+ targetNodeId: bestMatch.id,
1725
+ confidence: 0.65,
1726
+ resolvedBy: 'instance-method',
1727
+ };
1728
+ }
1729
+ }
1730
+ return null;
1731
+ });
1732
+ if (strat3)
1733
+ return strat3;
1608
1734
  }
1609
1735
  return null;
1610
1736
  }
@@ -1846,6 +1972,45 @@ function matchFuzzy(ref, context) {
1846
1972
  */
1847
1973
  /** ArkUI attribute-helper decorators a `.attr(...)` chain may resolve to. */
1848
1974
  const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExtend', 'Builder']);
1975
+ /**
1976
+ * LATTICE_SENSOR_RESOLVE_PROFILE=2 sub-stage attribution for matchReference's
1977
+ * strategy pipeline (`nm:<stage>|<refKind>|hit/miss`). Module-global because
1978
+ * the matcher is a free function; each thread (main + every pool worker) has
1979
+ * its own module instance, and dumpNameMatcherProfile is invoked from
1980
+ * ReferenceResolver.dumpResolveProfile so worker tables surface too.
1981
+ */
1982
+ const NM_PROFILE = process.env.LATTICE_SENSOR_RESOLVE_PROFILE === '2' ? new Map() : null;
1983
+ function nmTimedT(stage, ref, fn) {
1984
+ if (!NM_PROFILE)
1985
+ return fn();
1986
+ const t0 = process.hrtime.bigint();
1987
+ const r = fn();
1988
+ const dt = process.hrtime.bigint() - t0;
1989
+ const key = `nm:${stage}|${ref.referenceKind}|${r ? 'hit' : 'miss'}`;
1990
+ const slot = NM_PROFILE.get(key);
1991
+ if (slot) {
1992
+ slot.n++;
1993
+ slot.ns += dt;
1994
+ }
1995
+ else {
1996
+ NM_PROFILE.set(key, { n: 1, ns: dt });
1997
+ }
1998
+ return r;
1999
+ }
2000
+ function nmTimed(stage, ref, fn) {
2001
+ return nmTimedT(stage, ref, fn);
2002
+ }
2003
+ /** Dump this thread's matchReference sub-stage table to stderr (no-op unless =2). */
2004
+ function dumpNameMatcherProfile(label) {
2005
+ if (!NM_PROFILE || NM_PROFILE.size === 0)
2006
+ return;
2007
+ const rows = [...NM_PROFILE.entries()]
2008
+ .map(([k, v]) => ({ k, n: v.n, ms: Number(v.ns / 1000000n) }))
2009
+ .sort((a, b) => b.ms - a.ms);
2010
+ for (const r of rows) {
2011
+ 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`);
2012
+ }
2013
+ }
1849
2014
  function matchReference(ref, context) {
1850
2015
  // Function-as-value refs (#756) resolve ONLY through the dedicated matcher —
1851
2016
  // never the fuzzy/qualified fallthrough below (a wrong callback edge is
@@ -1907,18 +2072,18 @@ function matchReference(ref, context) {
1907
2072
  // Try strategies in order of confidence
1908
2073
  let result;
1909
2074
  // 0. File path match (e.g., "snippets/drawer-menu.liquid" → file node)
1910
- result = matchByFilePath(ref, context);
2075
+ result = nmTimed('filePath', ref, () => matchByFilePath(ref, context));
1911
2076
  if (result)
1912
2077
  return result;
1913
2078
  // 1. Qualified name match (highest confidence)
1914
- result = matchByQualifiedName(ref, context);
2079
+ result = nmTimed('qualifiedName', ref, () => matchByQualifiedName(ref, context));
1915
2080
  if (result)
1916
2081
  return result;
1917
2082
  // 1b. C++ chained call whose receiver is another call — `Foo::instance().bar()`
1918
2083
  // encoded as `Foo::instance().bar` by the extractor (#645). Resolve the
1919
2084
  // receiver's type from what the inner call returns, then the method on it.
1920
2085
  if (ref.language === 'cpp' || ref.language === 'c') {
1921
- result = matchCppCallChain(ref, context);
2086
+ result = nmTimed('cppChain', ref, () => matchCppCallChain(ref, context));
1922
2087
  if (result)
1923
2088
  return result;
1924
2089
  }
@@ -1927,7 +2092,7 @@ function matchReference(ref, context) {
1927
2092
  // type is the factory's `self` (PHP `: self`/`: static`, Rust `-> Self`) or
1928
2093
  // concrete return type.
1929
2094
  if (ref.language === 'php' || ref.language === 'rust') {
1930
- result = matchScopedCallChain(ref, context);
2095
+ result = nmTimed('scopedChain', ref, () => matchScopedCallChain(ref, context));
1931
2096
  if (result)
1932
2097
  return result;
1933
2098
  }
@@ -1947,20 +2112,20 @@ function matchReference(ref, context) {
1947
2112
  ref.language === 'dart' ||
1948
2113
  ref.language === 'objc' ||
1949
2114
  ref.language === 'pascal') {
1950
- result = matchDottedCallChain(ref, context);
2115
+ result = nmTimed('dottedChain', ref, () => matchDottedCallChain(ref, context));
1951
2116
  if (result)
1952
2117
  return result;
1953
2118
  }
1954
2119
  // 2. Method call pattern
1955
- result = matchMethodCall(ref, context);
2120
+ result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context));
1956
2121
  if (result)
1957
2122
  return result;
1958
2123
  // 3. Exact name match
1959
- result = matchByExactName(ref, context);
2124
+ result = nmTimed('exactName', ref, () => matchByExactName(ref, context));
1960
2125
  if (result)
1961
2126
  return result;
1962
2127
  // 4. Fuzzy match (lowest confidence)
1963
- result = matchFuzzy(ref, context);
2128
+ result = nmTimed('fuzzy', ref, () => matchFuzzy(ref, context));
1964
2129
  if (result)
1965
2130
  return result;
1966
2131
  return null;