gitnexus 1.6.8-rc.2 → 1.6.8-rc.21

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 (193) hide show
  1. package/README.md +20 -0
  2. package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
  3. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  4. package/dist/_shared/scope-resolution/symbol-definition.d.ts +4 -0
  5. package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
  6. package/dist/cli/ai-context.js +1 -0
  7. package/dist/cli/analyze-config.js +40 -0
  8. package/dist/cli/analyze.d.ts +22 -0
  9. package/dist/cli/analyze.js +36 -6
  10. package/dist/cli/clean.d.ts +1 -0
  11. package/dist/cli/clean.js +43 -1
  12. package/dist/cli/eval-server.js +43 -0
  13. package/dist/cli/help-i18n.js +11 -0
  14. package/dist/cli/i18n/en.d.ts +13 -0
  15. package/dist/cli/i18n/en.js +13 -0
  16. package/dist/cli/i18n/resources.d.ts +26 -0
  17. package/dist/cli/i18n/zh-CN.d.ts +13 -0
  18. package/dist/cli/i18n/zh-CN.js +13 -0
  19. package/dist/cli/index.js +19 -0
  20. package/dist/cli/list.js +12 -0
  21. package/dist/cli/optional-grammars.d.ts +6 -8
  22. package/dist/cli/optional-grammars.js +8 -11
  23. package/dist/cli/skill-gen.js +1 -0
  24. package/dist/cli/status.js +26 -5
  25. package/dist/cli/tool.d.ts +12 -1
  26. package/dist/cli/tool.js +41 -1
  27. package/dist/core/embeddings/embedder.js +4 -0
  28. package/dist/core/embeddings/embedding-pipeline.js +27 -16
  29. package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
  30. package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
  31. package/dist/core/graph/import-cycles.d.ts +10 -0
  32. package/dist/core/graph/import-cycles.js +103 -0
  33. package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
  34. package/dist/core/group/extractors/http-patterns/java.js +3 -50
  35. package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
  36. package/dist/core/group/extractors/include-extractor.js +7 -7
  37. package/dist/core/ingestion/cfg/cfg-builder.d.ts +64 -0
  38. package/dist/core/ingestion/cfg/cfg-builder.js +129 -0
  39. package/dist/core/ingestion/cfg/collect.d.ts +30 -0
  40. package/dist/core/ingestion/cfg/collect.js +34 -0
  41. package/dist/core/ingestion/cfg/control-flow-context.d.ts +97 -0
  42. package/dist/core/ingestion/cfg/control-flow-context.js +113 -0
  43. package/dist/core/ingestion/cfg/emit.d.ts +144 -0
  44. package/dist/core/ingestion/cfg/emit.js +315 -0
  45. package/dist/core/ingestion/cfg/reaching-defs.d.ts +90 -0
  46. package/dist/core/ingestion/cfg/reaching-defs.js +364 -0
  47. package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
  48. package/dist/core/ingestion/cfg/traversal-result.js +2 -0
  49. package/dist/core/ingestion/cfg/types.d.ts +231 -0
  50. package/dist/core/ingestion/cfg/types.js +13 -0
  51. package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +144 -0
  52. package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1032 -0
  53. package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
  54. package/dist/core/ingestion/cfg/visitors/typescript.js +584 -0
  55. package/dist/core/ingestion/language-provider.d.ts +36 -2
  56. package/dist/core/ingestion/languages/c-cpp.js +11 -4
  57. package/dist/core/ingestion/languages/cpp/arity-metadata.js +6 -2
  58. package/dist/core/ingestion/languages/cpp/captures.js +24 -1
  59. package/dist/core/ingestion/languages/cpp/query.js +23 -0
  60. package/dist/core/ingestion/languages/java.js +3 -0
  61. package/dist/core/ingestion/languages/kotlin/query.js +3 -2
  62. package/dist/core/ingestion/languages/kotlin.js +5 -1
  63. package/dist/core/ingestion/languages/typescript.js +5 -0
  64. package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
  65. package/dist/core/ingestion/method-extractors/generic.js +1 -0
  66. package/dist/core/ingestion/method-types.d.ts +2 -0
  67. package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
  68. package/dist/core/ingestion/model/symbol-table.js +1 -0
  69. package/dist/core/ingestion/parsing-processor.js +22 -0
  70. package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
  71. package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
  72. package/dist/core/ingestion/pipeline.d.ts +57 -0
  73. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
  74. package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
  75. package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
  76. package/dist/core/ingestion/route-extractors/spring.js +136 -0
  77. package/dist/core/ingestion/scope-extractor.js +3 -0
  78. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +12 -0
  79. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +145 -24
  80. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -0
  81. package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
  82. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +21 -0
  83. package/dist/core/ingestion/scope-resolution/pipeline/run.js +207 -0
  84. package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
  85. package/dist/core/ingestion/taint/emit.d.ts +124 -0
  86. package/dist/core/ingestion/taint/emit.js +204 -0
  87. package/dist/core/ingestion/taint/match.d.ts +153 -0
  88. package/dist/core/ingestion/taint/match.js +278 -0
  89. package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
  90. package/dist/core/ingestion/taint/path-codec.js +190 -0
  91. package/dist/core/ingestion/taint/propagate.d.ts +216 -0
  92. package/dist/core/ingestion/taint/propagate.js +664 -0
  93. package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
  94. package/dist/core/ingestion/taint/site-safety.js +98 -0
  95. package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
  96. package/dist/core/ingestion/taint/source-sink-config.js +11 -11
  97. package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
  98. package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
  99. package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
  100. package/dist/core/ingestion/taint/typescript-model.js +102 -0
  101. package/dist/core/ingestion/utils/method-props.js +1 -0
  102. package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
  103. package/dist/core/ingestion/workers/clone-safety.js +465 -0
  104. package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
  105. package/dist/core/ingestion/workers/parse-worker.js +70 -53
  106. package/dist/core/ingestion/workers/post-result.d.ts +22 -0
  107. package/dist/core/ingestion/workers/post-result.js +87 -0
  108. package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
  109. package/dist/core/ingestion/workers/result-merge.js +43 -0
  110. package/dist/core/ingestion/workers/worker-pool.d.ts +15 -0
  111. package/dist/core/ingestion/workers/worker-pool.js +33 -16
  112. package/dist/core/lbug/lbug-adapter.d.ts +19 -0
  113. package/dist/core/lbug/lbug-adapter.js +56 -1
  114. package/dist/core/run-analyze.d.ts +87 -0
  115. package/dist/core/run-analyze.js +280 -25
  116. package/dist/core/tree-sitter/parser-loader.js +5 -4
  117. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  118. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  119. package/dist/mcp/core/embedder.js +4 -0
  120. package/dist/mcp/local/local-backend.d.ts +86 -2
  121. package/dist/mcp/local/local-backend.js +657 -27
  122. package/dist/mcp/resources.js +1 -0
  123. package/dist/mcp/tools.d.ts +9 -0
  124. package/dist/mcp/tools.js +109 -0
  125. package/dist/server/analyze-launch.d.ts +29 -0
  126. package/dist/server/analyze-launch.js +137 -0
  127. package/dist/server/analyze-upload.d.ts +33 -0
  128. package/dist/server/analyze-upload.js +123 -0
  129. package/dist/server/analyze-worker-ipc.d.ts +58 -0
  130. package/dist/server/analyze-worker-ipc.js +16 -0
  131. package/dist/server/analyze-worker.d.ts +17 -1
  132. package/dist/server/analyze-worker.js +7 -1
  133. package/dist/server/api.js +46 -140
  134. package/dist/server/git-clone.d.ts +1 -0
  135. package/dist/server/git-clone.js +1 -1
  136. package/dist/server/middleware.d.ts +11 -0
  137. package/dist/server/middleware.js +27 -0
  138. package/dist/server/upload-ingest.d.ts +56 -0
  139. package/dist/server/upload-ingest.js +276 -0
  140. package/dist/server/upload-paths.d.ts +31 -0
  141. package/dist/server/upload-paths.js +51 -0
  142. package/dist/server/upload-sweep.d.ts +20 -0
  143. package/dist/server/upload-sweep.js +57 -0
  144. package/dist/storage/branch-index.d.ts +52 -0
  145. package/dist/storage/branch-index.js +65 -0
  146. package/dist/storage/git.d.ts +11 -0
  147. package/dist/storage/git.js +28 -0
  148. package/dist/storage/parse-cache.d.ts +22 -1
  149. package/dist/storage/parse-cache.js +32 -10
  150. package/dist/storage/repo-manager.d.ts +122 -10
  151. package/dist/storage/repo-manager.js +162 -21
  152. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +42 -8
  153. package/hooks/claude/gitnexus-hook.cjs +36 -8
  154. package/hooks/claude/hook-db-lock-probe.cjs +123 -2
  155. package/package.json +4 -2
  156. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  157. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  158. package/skills/gitnexus-guide.md +11 -0
  159. package/vendor/tree-sitter-c/package.json +1 -1
  160. package/vendor/tree-sitter-dart/package.json +1 -1
  161. package/vendor/tree-sitter-kotlin/package.json +1 -1
  162. package/vendor/tree-sitter-proto/package.json +1 -1
  163. package/vendor/tree-sitter-swift/package.json +1 -1
  164. package/web/assets/{agent-CKPMqImC.js → agent-ay4LD70X.js} +1 -1
  165. package/web/assets/{architectureDiagram-UL44E2DR-D1EXI0zA.js → architectureDiagram-UL44E2DR-Dc-viYhd.js} +1 -1
  166. package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-4rpojOyj.js} +1 -1
  167. package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-BnOy944n.js} +1 -1
  168. package/web/assets/{classDiagram-KGZ6W3CR-Bv93af_b.js → classDiagram-KGZ6W3CR-Bsgpy98Q.js} +1 -1
  169. package/web/assets/{classDiagram-v2-72OJOZXJ-CLfEqHUa.js → classDiagram-v2-72OJOZXJ-D5atDGjc.js} +1 -1
  170. package/web/assets/{diagram-3NCE3AQN-BqAtKUpW.js → diagram-3NCE3AQN-Cz1OEMVi.js} +1 -1
  171. package/web/assets/{diagram-GF46GFSD-CaBG6n6o.js → diagram-GF46GFSD-CWYwzfP8.js} +1 -1
  172. package/web/assets/{diagram-QXG6HAR7-CZ-O3rcV.js → diagram-QXG6HAR7-DtBObr8L.js} +1 -1
  173. package/web/assets/{diagram-WEQXMOUZ-P4lSL4GH.js → diagram-WEQXMOUZ-BUV44Ov_.js} +1 -1
  174. package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-Ds9s-sRF.js} +1 -1
  175. package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-CupStHQb.js} +1 -1
  176. package/web/assets/index-COMMmbxW.css +2 -0
  177. package/web/assets/{index-CG6q8eTs.js → index-Do8AE5yF.js} +85 -85
  178. package/web/assets/{infoDiagram-3YFTVSEB-DmLICZx1.js → infoDiagram-3YFTVSEB-DPTXSbgu.js} +1 -1
  179. package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CeK-Qhet.js} +1 -1
  180. package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-D4tx6QQ-.js} +1 -1
  181. package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-Bcto_kGa.js} +1 -1
  182. package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-DPIeBTG6.js} +1 -1
  183. package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-DDGXjVKO.js} +1 -1
  184. package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-BzDqpcVW.js} +1 -1
  185. package/web/assets/{stateDiagram-7D4R322I-CB2nABwH.js → stateDiagram-7D4R322I-Bx__als3.js} +1 -1
  186. package/web/assets/{stateDiagram-v2-36443NZ5-COBGd2RL.js → stateDiagram-v2-36443NZ5-QqOh2yO-.js} +1 -1
  187. package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-C1eogTOG.js} +1 -1
  188. package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-D20F4rSW.js} +1 -1
  189. package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-BeBHg7ST.js} +1 -1
  190. package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-CdOWeoNI.js} +1 -1
  191. package/web/index.html +2 -2
  192. package/scripts/materialize-vendor-grammars.cjs +0 -97
  193. package/web/assets/index-BKWA-m7o.css +0 -2
@@ -17,7 +17,7 @@ import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/l
17
17
  // import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
18
18
  import { parseDiffHunks, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
19
19
  import { realpathSync } from 'fs';
20
- import { listRegisteredRepos, cleanupOldKuzuFiles, canonicalizePath, RegistryAmbiguousTargetError, } from '../../storage/repo-manager.js';
20
+ import { listRegisteredRepos, cleanupOldKuzuFiles, canonicalizePath, getStoragePaths, loadMeta, RegistryAmbiguousTargetError, } from '../../storage/repo-manager.js';
21
21
  import { GroupService } from '../../core/group/service.js';
22
22
  import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member.js';
23
23
  import { collectBestChunks } from '../../core/embeddings/types.js';
@@ -27,7 +27,22 @@ import { getExactScanLimit, isVectorExtensionSupportedByPlatform, } from '../../
27
27
  import { PhaseTimer } from '../../core/search/phase-timer.js';
28
28
  import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
29
29
  import { logger } from '../../core/logger.js';
30
- import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
30
+ import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, EXPLAIN_DEFAULT_LIMIT, EXPLAIN_MAX_LIMIT, } from '../tools.js';
31
+ import { findImportCycles } from '../../core/graph/import-cycles.js';
32
+ import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
33
+ import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js';
34
+ /** Real source-file extensions (`.ts`, `.py`, …) from the resolver's list,
35
+ * excluding the empty entry and the `/index.*` forms — used to decide whether
36
+ * an `explain` target is a file path vs a (possibly dotted) symbol name. */
37
+ const SOURCE_FILE_EXTENSIONS = EXTENSIONS.filter((e) => e.startsWith('.') && !e.includes('/'));
38
+ /** A target is path-ish if it has a path separator or ends in a known source
39
+ * extension. A bare dotted symbol (`UserController.create`) is NOT path-ish. */
40
+ function looksLikeFilePath(target) {
41
+ if (/[\\/]/.test(target))
42
+ return true;
43
+ const lower = target.toLowerCase();
44
+ return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
45
+ }
31
46
  // AI context generation is CLI-only (gitnexus analyze)
32
47
  // import { generateAIContextFiles } from '../../cli/ai-context.js';
33
48
  /**
@@ -96,12 +111,32 @@ export const VALID_RELATION_TYPES = new Set([
96
111
  'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
97
112
  'METHOD_IMPLEMENTS',
98
113
  'ACCESSES',
114
+ // Emitted by emit-references.ts / scope-resolution/graph-bridge/edges.ts and
115
+ // already part of the default impact relTypes + context() incoming queries.
116
+ // It was missing from this allowlist, so `impact({relationTypes:['USES']})`
117
+ // silently filtered to [] and fell back to the full default traversal
118
+ // (#2129/#1858 review F5). No IMPACT_RELATION_CONFIDENCE floor → 0.5 fallback,
119
+ // matching the FETCHES / WRAPS / HANDLES_ROUTE precedent below.
120
+ 'USES',
99
121
  'HANDLES_ROUTE',
100
122
  'FETCHES',
101
123
  'HANDLES_TOOL',
102
124
  'ENTRY_POINT_OF',
103
125
  'WRAPS',
104
126
  ]);
127
+ /**
128
+ * Relation types the #1858 epistemic-boundary probe keys on. Kept as
129
+ * module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
130
+ * binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
131
+ * The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
132
+ * "statically verifiable, but the concrete binding past it is not".
133
+ */
134
+ export const EPISTEMIC_HERITAGE_RELATION_TYPES = [
135
+ 'IMPLEMENTS',
136
+ 'METHOD_IMPLEMENTS',
137
+ 'EXTENDS',
138
+ ];
139
+ export const EPISTEMIC_CONSUMER_RELATION_TYPES = ['CALLS', 'USES', 'ACCESSES'];
105
140
  /**
106
141
  * Per-relation-type confidence floor for impact analysis.
107
142
  *
@@ -315,6 +350,12 @@ export class LocalBackend {
315
350
  initializedRepos = new Set();
316
351
  reinitPromises = new Map();
317
352
  lastStalenessCheck = new Map();
353
+ // Last meta.indexedAt observed for an open pool, keyed by lbugPath. Keyed by
354
+ // pool (not stored on the handle) because branch handles are produced fresh
355
+ // by applyBranchScope on every resolveRepo call, so mutating the handle would
356
+ // not persist across calls and the staleness check would reinit forever
357
+ // (#2106).
358
+ lastObservedIndexedAt = new Map();
318
359
  groupToolSvc = null;
319
360
  /**
320
361
  * One-shot stderr warnings for sibling-clone drift, keyed by
@@ -410,6 +451,8 @@ export class LocalBackend {
410
451
  lastCommit: entry.lastCommit,
411
452
  remoteUrl: entry.remoteUrl,
412
453
  stats: entry.stats,
454
+ branch: entry.branch,
455
+ branches: entry.branches,
413
456
  };
414
457
  nextRepos.set(id, handle);
415
458
  // Build lightweight context (no LadybugDB needed)
@@ -432,13 +475,31 @@ export class LocalBackend {
432
475
  // the resolve→query wrong-clone window for good (#2067). Only a path that
433
476
  // dropped out of the registry must release its pooled connection + state.
434
477
  const liveLbugPaths = new Set([...nextRepos.values()].map((h) => h.lbugPath));
435
- for (const prev of this.repos.values()) {
436
- if (liveLbugPaths.has(prev.lbugPath))
478
+ // Branch pools (opened on demand by applyBranchScope) are NOT in this.repos
479
+ // — branch handles are minted fresh and discarded — so add every registered
480
+ // branch's lbugPath to the live set. Pure string work over the already-in-
481
+ // memory registry snapshot; no disk I/O on this hot path (#2106 R3).
482
+ for (const entry of entries) {
483
+ for (const b of entry.branches ?? []) {
484
+ liveLbugPaths.add(getStoragePaths(entry.path, b.branch).lbugPath);
485
+ }
486
+ }
487
+ // initializedRepos is the authoritative set of OPENED pool keys (flat AND
488
+ // branch); union it with the previously-known flat handles so an orphaned
489
+ // branch pool (e.g. after `clean --branch` removes its summary) is closed
490
+ // and forgotten too, not just flat handles.
491
+ const knownKeys = new Set([
492
+ ...[...this.repos.values()].map((h) => h.lbugPath),
493
+ ...this.initializedRepos,
494
+ ]);
495
+ for (const key of knownKeys) {
496
+ if (liveLbugPaths.has(key))
437
497
  continue;
438
- this.initializedRepos.delete(prev.lbugPath);
439
- this.lastStalenessCheck.delete(prev.lbugPath);
440
- this.reinitPromises.delete(prev.lbugPath);
441
- closeLbug(prev.lbugPath).catch(() => { });
498
+ this.initializedRepos.delete(key);
499
+ this.lastStalenessCheck.delete(key);
500
+ this.lastObservedIndexedAt.delete(key);
501
+ this.reinitPromises.delete(key);
502
+ closeLbug(key).catch(() => { });
442
503
  }
443
504
  this.repos = nextRepos;
444
505
  this.contextCache = nextContext;
@@ -507,7 +568,7 @@ export class LocalBackend {
507
568
  * On a miss, re-reads the registry once in case a new repo was indexed
508
569
  * while the MCP server was running.
509
570
  */
510
- async resolveRepo(repoParam) {
571
+ async resolveRepo(repoParam, branch) {
511
572
  let refreshedAfterAmbiguity = false;
512
573
  let result;
513
574
  try {
@@ -532,7 +593,7 @@ export class LocalBackend {
532
593
  this.maybeWarnSiblingDrift(result).catch(() => {
533
594
  /* best-effort; never throw from resolveRepo */
534
595
  });
535
- return result;
596
+ return this.applyBranchScope(result, branch);
536
597
  }
537
598
  // Miss — refresh registry and try once more (skip if already refreshed above)
538
599
  if (!refreshedAfterAmbiguity) {
@@ -541,7 +602,7 @@ export class LocalBackend {
541
602
  const retried = this.resolveRepoFromCache(repoParam);
542
603
  if (retried) {
543
604
  this.maybeWarnSiblingDrift(retried).catch(() => { });
544
- return retried;
605
+ return this.applyBranchScope(retried, branch);
545
606
  }
546
607
  // Still no match — throw with helpful message
547
608
  if (this.repos.size === 0) {
@@ -562,6 +623,49 @@ export class LocalBackend {
562
623
  }
563
624
  throw new Error(`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`);
564
625
  }
626
+ /**
627
+ * Re-point a resolved repo handle at a specific branch index (#2106).
628
+ *
629
+ * - No `branch` (default) → the primary/flat handle, unchanged (backward
630
+ * compatible: every existing caller passes no branch).
631
+ * - `branch` equal to the known primary → the flat handle.
632
+ * - `branch` matching an indexed non-primary branch → a handle whose
633
+ * `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
634
+ * `lbugPath`, so this is the only change needed to scope every tool.
635
+ * - `branch` that was never indexed → a clear error (never a silently-empty
636
+ * result against the wrong DB).
637
+ */
638
+ async applyBranchScope(handle, branch) {
639
+ if (!branch)
640
+ return handle;
641
+ if (handle.branch && handle.branch === branch)
642
+ return handle;
643
+ const summary = handle.branches?.find((b) => b.branch === branch);
644
+ if (summary) {
645
+ const { lbugPath } = getStoragePaths(handle.repoPath, branch);
646
+ return {
647
+ ...handle,
648
+ lbugPath,
649
+ indexedAt: summary.indexedAt,
650
+ lastCommit: summary.lastCommit,
651
+ stats: summary.stats,
652
+ };
653
+ }
654
+ // Legacy entry (pre-#2106): the registry has no recorded primary `branch`,
655
+ // so a `--branch <primary>` request misses the checks above. Read the flat
656
+ // meta.json (next to the flat handle's lbug) to learn the primary and serve
657
+ // the flat handle only when it actually matches — never serve flat for an
658
+ // arbitrary unindexed branch (#2106 R4).
659
+ if (!handle.branch) {
660
+ const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
661
+ if (flatMeta?.branch && flatMeta.branch === branch)
662
+ return handle;
663
+ }
664
+ const indexed = [handle.branch, ...(handle.branches?.map((b) => b.branch) ?? [])].filter(Boolean);
665
+ const available = indexed.length > 0 ? indexed.join(', ') : '(primary only)';
666
+ throw new Error(`Branch "${branch}" is not indexed for "${handle.name}". ` +
667
+ `Indexed branches: ${available}. Run: gitnexus analyze --branch ${branch}`);
668
+ }
565
669
  /**
566
670
  * Try to resolve a repo from the in-memory cache. Returns null on miss.
567
671
  * Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
@@ -681,10 +785,19 @@ export class LocalBackend {
681
785
  return; // Checked recently — skip
682
786
  this.lastStalenessCheck.set(poolKey, now);
683
787
  try {
684
- const metaPath = path.join(repo.storagePath, 'meta.json');
788
+ // Read the meta.json that sits next to THIS handle's lbug. For the
789
+ // flat/primary handle this is `<storagePath>/meta.json` (unchanged);
790
+ // for a branch handle it is `<storagePath>/branches/<slug>/meta.json`.
791
+ // Reading the flat meta for a branch handle would compare the branch
792
+ // index's indexedAt against the primary's and thrash the pool (#2106).
793
+ const metaPath = path.join(path.dirname(repo.lbugPath), 'meta.json');
685
794
  const metaRaw = await fs.readFile(metaPath, 'utf-8');
686
795
  const meta = JSON.parse(metaRaw);
687
- if (meta.indexedAt && meta.indexedAt !== repo.indexedAt) {
796
+ // Compare against the last indexedAt OBSERVED for this pool (keyed by
797
+ // lbugPath), not the handle's — branch handles are fresh spreads so a
798
+ // handle mutation would not persist and would reinit on every check.
799
+ const observed = this.lastObservedIndexedAt.get(poolKey) ?? repo.indexedAt;
800
+ if (meta.indexedAt && meta.indexedAt !== observed) {
688
801
  // Index was rebuilt — close stale connection and re-init.
689
802
  // Wrap in reinitPromises to prevent TOCTOU race where concurrent
690
803
  // callers both detect staleness and double-close the pool.
@@ -692,7 +805,7 @@ export class LocalBackend {
692
805
  try {
693
806
  await closeLbug(poolKey);
694
807
  this.initializedRepos.delete(poolKey);
695
- repo.indexedAt = meta.indexedAt;
808
+ this.lastObservedIndexedAt.set(poolKey, meta.indexedAt);
696
809
  await initLbug(poolKey, repo.lbugPath);
697
810
  this.initializedRepos.add(poolKey);
698
811
  }
@@ -714,6 +827,7 @@ export class LocalBackend {
714
827
  try {
715
828
  await initLbug(poolKey, repo.lbugPath);
716
829
  this.initializedRepos.add(poolKey);
830
+ this.lastObservedIndexedAt.set(poolKey, repo.indexedAt);
717
831
  }
718
832
  catch (err) {
719
833
  // If lock error, mark as not initialized so next call retries
@@ -792,6 +906,14 @@ export class LocalBackend {
792
906
  lastCommit: s.lastCommit,
793
907
  }))
794
908
  : undefined,
909
+ branch: h.branch,
910
+ branches: h.branches && h.branches.length > 0
911
+ ? h.branches.map((b) => ({
912
+ branch: b.branch,
913
+ indexedAt: b.indexedAt,
914
+ lastCommit: b.lastCommit,
915
+ }))
916
+ : undefined,
795
917
  };
796
918
  });
797
919
  }
@@ -913,8 +1035,10 @@ export class LocalBackend {
913
1035
  p.repo.startsWith('@')) {
914
1036
  return this.callToolAtGroupRepo(method, p);
915
1037
  }
916
- // Resolve repo from optional param (re-reads registry on miss)
917
- const repo = await this.resolveRepo(params?.repo);
1038
+ // Resolve repo from optional param (re-reads registry on miss). An optional
1039
+ // `branch` param scopes the resolved handle to that branch's index (#2106).
1040
+ const repoParams = params;
1041
+ const repo = await this.resolveRepo(repoParams?.repo, repoParams?.branch);
918
1042
  switch (method) {
919
1043
  case 'query':
920
1044
  return this.query(repo, params);
@@ -924,10 +1048,14 @@ export class LocalBackend {
924
1048
  }
925
1049
  case 'context':
926
1050
  return this.context(repo, params);
1051
+ case 'explain':
1052
+ return this.explain(repo, params);
927
1053
  case 'impact':
928
1054
  return this.impact(repo, params);
929
1055
  case 'detect_changes':
930
1056
  return this.detectChanges(repo, params);
1057
+ case 'check':
1058
+ return this.check(repo, params);
931
1059
  case 'rename':
932
1060
  return this.rename(repo, params);
933
1061
  // Legacy aliases for backwards compatibility
@@ -950,6 +1078,37 @@ export class LocalBackend {
950
1078
  }
951
1079
  }
952
1080
  // ─── Tool Implementations ────────────────────────────────────────
1081
+ /** Check repository graph invariants that are suitable for CI gating. */
1082
+ async check(repo, params) {
1083
+ if (params?.cycles === false) {
1084
+ return { error: 'No checks selected. Set "cycles" to true.' };
1085
+ }
1086
+ await this.ensureInitialized(repo);
1087
+ const rowLimit = 100_001;
1088
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (source:File)-[r:CodeRelation]->(target:File)
1089
+ WHERE r.type = 'IMPORTS'
1090
+ AND (r.reason IS NULL OR (
1091
+ r.reason <> 'swift-scope: implicit module visibility'
1092
+ AND r.reason <> 'markdown-link'
1093
+ ))
1094
+ RETURN source.filePath AS source, target.filePath AS target
1095
+ LIMIT ${rowLimit}`, {});
1096
+ if (rows.length === rowLimit) {
1097
+ return {
1098
+ error: `Import graph exceeds the ${rowLimit - 1} edge safety limit.`,
1099
+ truncated: true,
1100
+ };
1101
+ }
1102
+ const cycles = findImportCycles(rows.map((row) => ({
1103
+ source: String(row.source ?? row[0] ?? ''),
1104
+ target: String(row.target ?? row[1] ?? ''),
1105
+ })));
1106
+ return {
1107
+ status: cycles.length === 0 ? 'clean' : 'cycles_found',
1108
+ cycleCount: cycles.length,
1109
+ cycles: cycles.map((files) => ({ files })),
1110
+ };
1111
+ }
953
1112
  /**
954
1113
  * Query tool — process-grouped search.
955
1114
  *
@@ -1280,9 +1439,15 @@ export class LocalBackend {
1280
1439
  WHERE n.id IN $nodeIds
1281
1440
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
1282
1441
  `, { nodeIds })
1283
- : await executeParameterized(repo.lbugPath, `
1442
+ : await executeParameterized(repo.lbugPath,
1443
+ // Same BasicBlock exclusion as detect_changes (#2082 U7): on a
1444
+ // --pdg index a function-heavy file has far more BasicBlock rows
1445
+ // than symbols, so an unfiltered LIMIT 3 would surface nameless
1446
+ // substrate rows and displace the real symbols.
1447
+ `
1284
1448
  MATCH (n)
1285
1449
  WHERE n.filePath = $filePath
1450
+ AND NOT n.id STARTS WITH 'BasicBlock:'
1286
1451
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
1287
1452
  LIMIT 3
1288
1453
  `, { filePath: fullPath });
@@ -2011,6 +2176,23 @@ export class LocalBackend {
2011
2176
  // Method/Function/Constructor enrichment: fetch method-specific properties
2012
2177
  const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
2013
2178
  const isMethodLike = symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
2179
+ // #1858 review F2 — start the epistemic boundary probe here (right after
2180
+ // `symKind` is known) so it runs CONCURRENTLY with the methodMetadata fetch
2181
+ // below, mirroring how _runImpactBFS overlaps it with the BFS. It is awaited
2182
+ // at result assembly. (It cannot start earlier — `symKind` is only computed
2183
+ // on this line, after the incoming/outgoing round-trips.)
2184
+ //
2185
+ // #1858 review F3 — pass an interface-preserving type, NOT `symKind`.
2186
+ // `symKind` collapses a single-resolved Interface to 'Class' (resolvedLabel
2187
+ // is '' on the single-candidate path), which would skip computeEpistemicBoundary's
2188
+ // `symType === 'Interface'` self-boundary branch and under-report a leaf
2189
+ // interface as 'exact'. `enrichCandidateLabels` runs BEFORE the single-candidate
2190
+ // early return and patches `sym.type` from '' to 'Interface' (LadybugDB returns
2191
+ // '' for labels()[0] on Interface/Class), so `sym.type` is the reliable signal
2192
+ // here — mirroring impact()'s `resolvedLabel || symbol.type` derivation. Do not
2193
+ // "fix" enrichment ordering; F3 depends on enrichment-before-early-return.
2194
+ const epistemicSymType = (resolvedLabel || sym.type || symKind || '');
2195
+ const epistemicPromise = this.computeEpistemicBoundary(repo, symId, epistemicSymType, (sym.name || sym[1]));
2014
2196
  let methodMetadata;
2015
2197
  if (isMethodLike) {
2016
2198
  try {
@@ -2041,6 +2223,12 @@ export class LocalBackend {
2041
2223
  /* method metadata unavailable — omit silently */
2042
2224
  }
2043
2225
  }
2226
+ // #1858 — same epistemic boundary signal as impact(): when this symbol sits
2227
+ // behind an interface / indirection boundary, callers binding via DI or
2228
+ // dynamic dispatch are not reflected in `incoming`, so the view is a lower
2229
+ // bound. Additive; never suppresses a field. Resolved from the probe started
2230
+ // above (concurrent with methodMetadata).
2231
+ const epistemic = await epistemicPromise;
2044
2232
  return {
2045
2233
  status: 'found',
2046
2234
  symbol: {
@@ -2053,6 +2241,7 @@ export class LocalBackend {
2053
2241
  ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
2054
2242
  ...(methodMetadata ? { methodMetadata } : {}),
2055
2243
  },
2244
+ ...epistemic,
2056
2245
  incoming: categorize(incomingRows),
2057
2246
  outgoing: categorize(outgoingRows),
2058
2247
  ...(typedPropertyRows.length > 0
@@ -2074,6 +2263,230 @@ export class LocalBackend {
2074
2263
  })),
2075
2264
  };
2076
2265
  }
2266
+ /**
2267
+ * Explain tool (#2083 M3 U6) — persisted taint-finding explanation.
2268
+ * WAL-aware wrapper mirroring `context`.
2269
+ */
2270
+ async explain(repo, params) {
2271
+ try {
2272
+ return await this._explainImpl(repo, params);
2273
+ }
2274
+ catch (err) {
2275
+ const msg = (err instanceof Error ? err.message : String(err)) || 'Explain query failed';
2276
+ if (isWalCorruptionError(err)) {
2277
+ return {
2278
+ error: msg,
2279
+ recoverySuggestion: WAL_RECOVERY_SUGGESTION,
2280
+ };
2281
+ }
2282
+ throw err;
2283
+ }
2284
+ }
2285
+ /**
2286
+ * Taint findings are persisted as `TAINTED` rows in CodeRelation whose
2287
+ * endpoints are BOTH BasicBlock nodes — the label anchor restricts every
2288
+ * query here to the BasicBlock→BasicBlock partition of the rel table
2289
+ * (which holds only the sparse, per-function-capped pdg layers), never a
2290
+ * global symbol-space scan (the S1 verdict; LadybugDB has no rel-property
2291
+ * index, so the label anchor IS the bound).
2292
+ *
2293
+ * Anchoring granularity:
2294
+ * - file target → BasicBlock id prefix (`BasicBlock:<filePath>:` — the
2295
+ * shared `basicBlockId` template) with an exact-or-suffix path match so
2296
+ * `vuln.ts` finds `src/vuln.ts`.
2297
+ * - symbol target → resolved via `resolveSymbolCandidates` (the context()
2298
+ * path: ambiguous ⇒ ranked candidates, unknown ⇒ not-found), then the
2299
+ * file id-prefix PLUS source-block startLine within the symbol's
2300
+ * [startLine, endLine] span. Findings are intra-procedural, so filtering
2301
+ * the SOURCE endpoint is sufficient — both endpoints share the function.
2302
+ * Symbols without a line span degrade to the file-level filter.
2303
+ *
2304
+ * The per-finding `sinkKind` and hop path decode from the persisted
2305
+ * `reason` via the SHARED `taint/path-codec.ts` (the U4 write path encodes
2306
+ * with the same module — `;<kind>` header + ordered `variable:line` hops).
2307
+ */
2308
+ async _explainImpl(repo, params) {
2309
+ await this.ensureInitialized(repo);
2310
+ const rawLimit = params.limit ?? EXPLAIN_DEFAULT_LIMIT;
2311
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > EXPLAIN_MAX_LIMIT) {
2312
+ return {
2313
+ error: `Invalid "limit": expected an integer in [1, ${EXPLAIN_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
2314
+ };
2315
+ }
2316
+ const limit = rawLimit;
2317
+ const NO_TAINT_NOTE = 'no taint layer — run gitnexus analyze --pdg to record taint findings for this repo';
2318
+ // Cheap meta probe: the TAINT layer exists iff the pdg stamp carries a
2319
+ // `taintModelVersion` (the field M3 added). An M1/M2-era `--pdg` index has
2320
+ // `meta.pdg` defined but no taintModelVersion — BasicBlock/REACHING_DEF
2321
+ // exist, zero TAINTED rows do — so it must surface the no-taint-layer hint,
2322
+ // not the generic "analyzed, nothing found" note. An unreadable meta (e.g.
2323
+ // a seeded test DB) falls through to the row-existence probe below.
2324
+ let pdgStamped;
2325
+ try {
2326
+ const meta = await loadMeta(path.dirname(repo.lbugPath));
2327
+ if (meta)
2328
+ pdgStamped = meta.pdg?.taintModelVersion !== undefined;
2329
+ }
2330
+ catch {
2331
+ /* meta unreadable — decide from the DB below */
2332
+ }
2333
+ if (pdgStamped === false) {
2334
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2335
+ }
2336
+ // Resolve the optional anchor into a WHERE clause on the SOURCE block.
2337
+ const target = typeof params.target === 'string' ? params.target.trim() : '';
2338
+ let anchorClause = '';
2339
+ const queryParams = {};
2340
+ let anchor;
2341
+ // Build the anchor as a file filter (used only when `target` is path-ish).
2342
+ const buildFileAnchor = () => {
2343
+ // Exact path via the BasicBlock id-prefix template, OR a
2344
+ // path-separator-aligned suffix so partial paths work like context()'s
2345
+ // file_path hint ("vuln.ts" ⇒ "src/vuln.ts", never "devuln.ts").
2346
+ anchorClause =
2347
+ 'AND (a.id STARTS WITH $idPrefix OR a.filePath = $targetPath OR a.filePath ENDS WITH $targetSuffix)';
2348
+ queryParams.idPrefix = `BasicBlock:${target}:`;
2349
+ queryParams.targetPath = target;
2350
+ queryParams.targetSuffix = `/${target}`;
2351
+ anchor = { file: target };
2352
+ };
2353
+ // Resolve `target` as a symbol into the anchor. Returns an early-return
2354
+ // payload (not_found / ambiguous) or undefined on success.
2355
+ const resolveSymbolAnchor = async () => {
2356
+ const outcome = await this.resolveSymbolCandidates(repo, { name: target }, {});
2357
+ if (outcome.kind === 'not_found') {
2358
+ return { error: `Symbol '${target}' not found` };
2359
+ }
2360
+ if (outcome.kind === 'ambiguous') {
2361
+ return {
2362
+ status: 'ambiguous',
2363
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'. Re-call explain with the file path, or disambiguate via context() first.`,
2364
+ candidates: outcome.candidates.map((c) => ({
2365
+ uid: c.id,
2366
+ name: c.name,
2367
+ kind: c.type,
2368
+ filePath: c.filePath,
2369
+ line: c.startLine,
2370
+ score: Number(c.score.toFixed(2)),
2371
+ })),
2372
+ };
2373
+ }
2374
+ const sym = outcome.symbol;
2375
+ queryParams.idPrefix = `BasicBlock:${sym.filePath}:`;
2376
+ anchor = { file: sym.filePath, symbol: sym.name };
2377
+ if (typeof sym.startLine === 'number' &&
2378
+ typeof sym.endLine === 'number' &&
2379
+ sym.endLine >= sym.startLine) {
2380
+ anchorClause =
2381
+ 'AND a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd';
2382
+ queryParams.symStart = sym.startLine;
2383
+ queryParams.symEnd = sym.endLine;
2384
+ anchor.startLine = sym.startLine;
2385
+ anchor.endLine = sym.endLine;
2386
+ }
2387
+ else {
2388
+ // No usable span — degrade to the file-level filter (documented).
2389
+ anchorClause = 'AND a.id STARTS WITH $idPrefix';
2390
+ }
2391
+ return undefined;
2392
+ };
2393
+ // Bounded by construction: the BasicBlock→BasicBlock partition holds only
2394
+ // the sparse pdg layers, TAINTED rows are per-function-capped at analyze
2395
+ // time, and the page is LIMIT-bounded (the limit is a validated integer —
2396
+ // interpolated because LadybugDB does not parameterize LIMIT).
2397
+ const runAnchoredQuery = async () => {
2398
+ const matchClause = `
2399
+ MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
2400
+ WHERE r.type = 'TAINTED' ${anchorClause}`;
2401
+ const [qRows, countRows] = await Promise.all([
2402
+ executeParameterized(repo.lbugPath, `${matchClause}
2403
+ RETURN a.id AS sourceBlockId, a.filePath AS file, a.startLine AS sourceStart,
2404
+ b.startLine AS sinkStart, r.reason AS reason, b.id AS sinkBlockId
2405
+ ORDER BY sourceBlockId, sinkBlockId, reason
2406
+ LIMIT ${limit}`, queryParams),
2407
+ executeParameterized(repo.lbugPath, `${matchClause}
2408
+ RETURN COUNT(*) AS total`, queryParams),
2409
+ ]);
2410
+ return {
2411
+ rows: qRows,
2412
+ totalFindings: Number(countRows[0]?.total ?? countRows[0]?.[0] ?? 0),
2413
+ };
2414
+ };
2415
+ if (target) {
2416
+ if (looksLikeFilePath(target)) {
2417
+ buildFileAnchor();
2418
+ }
2419
+ else {
2420
+ // A bare or dotted symbol name (`UserController.create`) — resolve as a
2421
+ // symbol rather than silently file-anchoring to an empty result.
2422
+ const early = await resolveSymbolAnchor();
2423
+ if (early)
2424
+ return early;
2425
+ }
2426
+ }
2427
+ const { rows, totalFindings } = await runAnchoredQuery();
2428
+ if (totalFindings === 0 && pdgStamped === undefined && !target) {
2429
+ // Meta was unreadable and the repo-wide enumerate found nothing — the
2430
+ // count above WAS the existence probe; surface the layer hint.
2431
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2432
+ }
2433
+ if (totalFindings === 0 && pdgStamped === undefined && target) {
2434
+ // Anchored miss with unreadable meta: one extra bounded probe decides
2435
+ // "no findings for this anchor" vs "no taint layer at all".
2436
+ const probe = await executeParameterized(repo.lbugPath, `MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock) WHERE r.type = 'TAINTED' RETURN r.reason AS reason LIMIT 1`, {});
2437
+ if (probe.length === 0) {
2438
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2439
+ }
2440
+ }
2441
+ const findings = rows.map((r) => {
2442
+ const sourceBlockId = String(r.sourceBlockId ?? r[0] ?? '');
2443
+ const file = String(r.file ?? r[1] ?? '');
2444
+ const sourceStart = (r.sourceStart ?? r[2]);
2445
+ const sinkStart = (r.sinkStart ?? r[3]);
2446
+ const reason = r.reason ?? r[4];
2447
+ // basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` —
2448
+ // split from the RIGHT (the filePath may itself contain ':').
2449
+ const idParts = sourceBlockId.split(':');
2450
+ const fnLine = Number(idParts[idParts.length - 3]);
2451
+ const decoded = decodeTaintPath(reason);
2452
+ if (!decoded.ok) {
2453
+ // Unreadable reason (foreign/corrupt row): surface the finding's
2454
+ // existence with its block anchors, never throw.
2455
+ return {
2456
+ file,
2457
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2458
+ sinkKind: 'unknown',
2459
+ source: { line: sourceStart },
2460
+ sink: { line: sinkStart },
2461
+ hops: [],
2462
+ pathIncomplete: true,
2463
+ };
2464
+ }
2465
+ const hops = decoded.hops.map((h) => ({
2466
+ variable: h.variable,
2467
+ line: h.line,
2468
+ ...(h.viaCall ? { viaCall: true } : {}),
2469
+ }));
2470
+ const first = hops[0];
2471
+ const last = hops[hops.length - 1];
2472
+ return {
2473
+ file,
2474
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2475
+ sinkKind: decoded.kind ?? 'unknown',
2476
+ source: first ? { variable: first.variable, line: first.line } : { line: sourceStart },
2477
+ sink: { line: last?.line ?? sinkStart },
2478
+ hops,
2479
+ ...(decoded.truncated ? { pathIncomplete: true } : {}),
2480
+ };
2481
+ });
2482
+ return {
2483
+ ...(anchor ? { anchor } : {}),
2484
+ findings,
2485
+ totalFindings,
2486
+ ...(totalFindings > findings.length ? { truncated: true } : {}),
2487
+ note: 'Intra-procedural findings only — cross-function, closure/callback, property/field, and implicit flows are not modeled; absence of a finding is not proof of safety. SANITIZES (kill) edges are queryable via cypher.',
2488
+ };
2489
+ }
2077
2490
  /**
2078
2491
  * Legacy explore — kept for backwards compatibility with resources.ts.
2079
2492
  * Routes cluster/process types to direct graph queries.
@@ -2269,8 +2682,20 @@ export class LocalBackend {
2269
2682
  queryParams[`hunkStart${i}`] = hunk.startLine;
2270
2683
  queryParams[`hunkEnd${i}`] = hunk.endLine;
2271
2684
  });
2685
+ // Exclude BasicBlock rows by id prefix: on a --pdg index every edited
2686
+ // function otherwise contributes N nameless BasicBlock pseudo-"symbols"
2687
+ // (they carry filePath/start/end but no name), inflating changed_count
2688
+ // and risk level with rows no consumer can act on (#2082 U7). Blocks
2689
+ // are implementation substrate, not symbols — the owning Function row
2690
+ // already represents the change. The id prefix (`BasicBlock:<file>:…`,
2691
+ // cfg/emit.ts basicBlockId) beats a label predicate (`labels(n)[0]` is
2692
+ // known to come back empty for several node types — see
2693
+ // enrichCandidateLabels) AND beats `n.name IS NOT NULL` (which would
2694
+ // also drop legitimate symbols whose name loaded as NULL, e.g.
2695
+ // quoted-empty CSV fields for anonymous constructs).
2272
2696
  const symbolQuery = `
2273
2697
  MATCH (n) WHERE n.filePath ENDS WITH $filePath
2698
+ AND NOT n.id STARTS WITH 'BasicBlock:'
2274
2699
  AND n.startLine IS NOT NULL AND n.endLine IS NOT NULL
2275
2700
  AND (${overlapConditions})
2276
2701
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type,
@@ -2586,21 +3011,103 @@ export class LocalBackend {
2586
3011
  };
2587
3012
  }
2588
3013
  if (outcome.kind === 'ambiguous') {
2589
- return {
2590
- status: 'ambiguous',
2591
- message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`,
2592
- target: { name: target },
2593
- direction,
2594
- impactedCount: 0,
2595
- risk: 'UNKNOWN',
2596
- candidates: outcome.candidates.map((c) => ({
3014
+ // #2129 — a bare name that collides with several symbols must NOT report a
3015
+ // bare `impactedCount: 0`. The real blast radius lives under whichever
3016
+ // candidate the caller meant; a flat zero here is precisely the silent
3017
+ // under-report the "run impact before editing" workflow exists to prevent
3018
+ // (the dropped caller calls a *different* same-name node, so it never shows
3019
+ // up against the one the resolver happened to pick). Run a bounded,
3020
+ // summary-only BFS per candidate so each one's true count + risk is
3021
+ // visible, and surface the maximum at the top level so the headline can
3022
+ // never read as "safe to refactor". Candidates arrive sorted by score.
3023
+ const AMBIGUOUS_MAX_CANDIDATES = 6;
3024
+ const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
3025
+ // `partialProbe` is intentionally a SECOND incompleteness flag, distinct
3026
+ // from the traversal-interrupted `partial` flag used elsewhere: it means
3027
+ // one or more per-candidate probes threw, so maxRisk / maxImpactedCount
3028
+ // are lower bounds over the probes that succeeded (a failed candidate must
3029
+ // not be masked by a benign sibling success).
3030
+ let probeFailed = false;
3031
+ const candidateSummaries = await Promise.all(probed.map(async (c) => {
3032
+ const cType = c.type || '';
3033
+ const cRelTypes = (cType === 'Class' || cType === 'Interface') &&
3034
+ !hasExplicitRelationTypes &&
3035
+ !relationTypes.includes('ACCESSES')
3036
+ ? [...relationTypes, 'ACCESSES']
3037
+ : relationTypes;
3038
+ // #1858/#2129 review F8 — name the shape the probe summary is read
3039
+ // through (`_runImpactBFS` returns `Promise<any>`, so this is the
3040
+ // narrowing cast) so a future rename of those fields fails tsc instead
3041
+ // of silently zeroing candidate counts.
3042
+ let summary = null;
3043
+ try {
3044
+ summary = await this._runImpactBFS(repo, { id: c.id, name: c.name, filePath: c.filePath }, cType, direction, {
3045
+ maxDepth,
3046
+ relationTypes: cRelTypes,
3047
+ includeTests,
3048
+ minConfidence,
3049
+ summaryOnly: true,
3050
+ skipEpistemic: true,
3051
+ skipEnrichment: true,
3052
+ });
3053
+ }
3054
+ catch (e) {
3055
+ probeFailed = true;
3056
+ logQueryError('impact:ambiguous-candidate', e);
3057
+ }
3058
+ return {
2597
3059
  uid: c.id,
2598
3060
  name: c.name,
2599
3061
  kind: c.type,
2600
3062
  filePath: c.filePath,
2601
3063
  line: c.startLine,
2602
3064
  score: Number(c.score.toFixed(2)),
2603
- })),
3065
+ impactedCount: summary?.impactedCount ?? 0,
3066
+ risk: summary?.risk ?? 'UNKNOWN',
3067
+ direct: summary?.summary?.direct ?? 0,
3068
+ };
3069
+ }));
3070
+ // Rank by blast radius so the most-impactful interpretation is first, and
3071
+ // hoist the maximum count/risk to the top level so the response cannot be
3072
+ // misread as "no impact".
3073
+ candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount);
3074
+ const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0);
3075
+ const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
3076
+ // If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion
3077
+ // under the fan-out), the worst real risk is genuinely unknown, not LOW.
3078
+ // Reporting LOW here would re-introduce the false-safe signal. Only fall to
3079
+ // the LOW seed when at least one candidate produced a real risk.
3080
+ const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk));
3081
+ const maxRisk = anyKnownRisk
3082
+ ? candidateSummaries.reduce((worst, c) => (RISK_ORDER.indexOf(c.risk) > RISK_ORDER.indexOf(worst) ? c.risk : worst), 'LOW')
3083
+ : 'UNKNOWN';
3084
+ const truncated = outcome.candidates.length > probed.length;
3085
+ return {
3086
+ status: 'ambiguous',
3087
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'` +
3088
+ (truncated
3089
+ ? ` (showing ${candidateSummaries.length} of ${outcome.candidates.length})`
3090
+ : '') +
3091
+ `. Blast radius differs per candidate (max ${maxImpactedCount} impacted at risk ${maxRisk}). ` +
3092
+ `Disambiguate with target_uid (or file_path/kind) for a single authoritative result.`,
3093
+ target: { name: target },
3094
+ direction,
3095
+ // Full match count — `candidates[]` is truncated to AMBIGUOUS_MAX_CANDIDATES,
3096
+ // so consumers (CLI formatter) need this to report "N of M" honestly (#2129
3097
+ // review F11; the CLI previously read the truncated array length).
3098
+ totalCandidates: outcome.candidates.length,
3099
+ // `impactedCount` stays 0 and `risk` stays UNKNOWN — there is no single
3100
+ // resolved symbol, and UNKNOWN must NOT read as "safe to refactor". The
3101
+ // real blast radius is surfaced per-candidate plus `maxImpactedCount` /
3102
+ // `maxRisk` so a real caller can never hide behind the ambiguous zero
3103
+ // (#2129).
3104
+ impactedCount: 0,
3105
+ risk: 'UNKNOWN',
3106
+ maxImpactedCount,
3107
+ maxRisk,
3108
+ ...(probeFailed ? { partialProbe: true } : {}),
3109
+ ...(truncated && { candidatesTruncated: true }),
3110
+ candidates: candidateSummaries,
2604
3111
  };
2605
3112
  }
2606
3113
  const sym = {
@@ -2624,12 +3131,109 @@ export class LocalBackend {
2624
3131
  summaryOnly: params.summaryOnly,
2625
3132
  });
2626
3133
  }
3134
+ /**
3135
+ * #1858 — epistemic lower-bound detection.
3136
+ *
3137
+ * impact()/context() traverse only edges materialized in the graph. When the
3138
+ * queried symbol sits on an interface / abstract boundary, callers that bind
3139
+ * to the interface via DI, a container, or dynamic dispatch — rather than
3140
+ * naming the concrete symbol — are not traced. The reported count is then a
3141
+ * lower bound, not an exact figure. Instead of returning a confident count
3142
+ * that silently omits those callers, annotate the result with
3143
+ * `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
3144
+ * resolved leaf with no indirection stays `epistemic: 'exact'`.
3145
+ *
3146
+ * Aligns with the numeric confidence model rather than the long-deleted
3147
+ * TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
3148
+ * (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
3149
+ * `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
3150
+ * concrete binding past it is not".
3151
+ *
3152
+ * Never throws: on query error it returns 'exact', so it can only add signal,
3153
+ * never suppress a result.
3154
+ */
3155
+ async computeEpistemicBoundary(repo, symId, symType, symName) {
3156
+ const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES;
3157
+ const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES;
3158
+ try {
3159
+ // Discover the interface / abstract supertypes on the target's boundary.
3160
+ // If the target is itself an interface, it is its own boundary node.
3161
+ const boundary = new Map();
3162
+ if (symType === 'Interface') {
3163
+ boundary.set(symId, { name: symName || '', label: 'Interface' });
3164
+ }
3165
+ const ifaceRows = await executeParameterized(repo.lbugPath, `MATCH (x)-[r:CodeRelation]->(iface)
3166
+ WHERE x.id = $symId AND r.type IN $heritage
3167
+ RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
3168
+ LIMIT 25`, { symId, heritage: HERITAGE_TYPES }).catch(() => []);
3169
+ for (const r of ifaceRows) {
3170
+ const id = (r.id ?? r[0]);
3171
+ if (id && !boundary.has(id)) {
3172
+ boundary.set(id, {
3173
+ name: (r.name ?? r[1] ?? ''),
3174
+ label: (r.label ?? r[2] ?? 'Interface'),
3175
+ });
3176
+ }
3177
+ }
3178
+ if (boundary.size === 0)
3179
+ return { epistemic: 'exact' };
3180
+ const ifaceIds = Array.from(boundary.keys());
3181
+ // Count per interface id with scalar equality. A parameterized
3182
+ // `iface.id IN $ids` combined with `COUNT(DISTINCT ...)` + implicit
3183
+ // group-by returns no rows under the LadybugDB cypher subset, so query
3184
+ // each boundary node individually (boundary is small — capped at 25).
3185
+ const countByType = async (types) => {
3186
+ const m = new Map();
3187
+ await Promise.all(ifaceIds.map(async (ifaceId) => {
3188
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (other)-[r:CodeRelation]->(iface)
3189
+ WHERE iface.id = $ifaceId AND r.type IN $types
3190
+ RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }).catch(() => []);
3191
+ const cnt = rows.length > 0 ? Number(rows[0].cnt ?? rows[0][0] ?? 0) : 0;
3192
+ m.set(ifaceId, cnt);
3193
+ }));
3194
+ return m;
3195
+ };
3196
+ const [implCounts, consumerCounts] = await Promise.all([
3197
+ countByType(HERITAGE_TYPES),
3198
+ countByType(CONSUMER_TYPES),
3199
+ ]);
3200
+ const boundaries = [];
3201
+ for (const [id, info] of boundary) {
3202
+ const impls = implCounts.get(id) ?? 0;
3203
+ const consumers = consumerCounts.get(id) ?? 0;
3204
+ // Flag only a genuine indirection risk: an interface that is actually
3205
+ // consumed (callers bind to it) or that has multiple implementations
3206
+ // (runtime dispatch is ambiguous). A concrete type implementing an
3207
+ // interface nothing references is fully traced → stays exact.
3208
+ if (consumers >= 1 || impls >= 2) {
3209
+ const label = (info.label || 'Interface').toLowerCase();
3210
+ const name = info.name || '(unnamed)';
3211
+ const article = /^[aeiou]/.test(label) ? 'an' : 'a';
3212
+ const parts = [];
3213
+ if (impls >= 1)
3214
+ parts.push(`${impls} ${impls === 1 ? 'implementation' : 'implementations'}`);
3215
+ if (consumers >= 1)
3216
+ parts.push(`${consumers} interface-level ${consumers === 1 ? 'consumer' : 'consumers'}`);
3217
+ boundaries.push(`${name} is ${article} ${label} with ${parts.join(' and ')}; callers that bind via the ${label} ` +
3218
+ `(e.g. a DI container or dynamic dispatch) are not traced to the concrete symbol — ` +
3219
+ `actual impact may be higher.`);
3220
+ }
3221
+ }
3222
+ if (boundaries.length === 0)
3223
+ return { epistemic: 'exact' };
3224
+ return { epistemic: 'lower-bound', boundaries };
3225
+ }
3226
+ catch {
3227
+ return { epistemic: 'exact' };
3228
+ }
3229
+ }
2627
3230
  /**
2628
3231
  * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
2629
3232
  */
2630
3233
  async _runImpactBFS(repo, sym, symType, direction, opts) {
2631
3234
  const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
2632
3235
  const skipPerSymbolEnrichment = opts.skipPerSymbolEnrichment ?? false;
3236
+ const skipEnrichment = opts.skipEnrichment ?? false;
2633
3237
  const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
2634
3238
  const paginationLimit = hasExplicitLimit
2635
3239
  ? Math.max(1, Math.min(Math.trunc(opts.limit), 10000))
@@ -2646,6 +3250,18 @@ export class LocalBackend {
2646
3250
  const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
2647
3251
  const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
2648
3252
  const symId = sym.id || sym[0];
3253
+ // #1858 — kick off the epistemic boundary probe concurrently with the BFS.
3254
+ // It depends only on symId/symType/symName (all known now) and touches no
3255
+ // shared state, so its extra round-trip overlaps the traversal instead of
3256
+ // adding to the serial path. `skipEpistemic` (ambiguous #2129 candidate
3257
+ // probes, group fan-out) resolves to no field, preserving prior behavior.
3258
+ // #1858/#2129 review F8 — the skip case adds no field, so `epistemic` is
3259
+ // optional here (the union's `{}` subtype). computeEpistemicBoundary's own
3260
+ // return keeps `epistemic` REQUIRED — only this promise widens to the skip
3261
+ // subtype.
3262
+ const epistemicPromise = opts.skipEpistemic
3263
+ ? Promise.resolve({})
3264
+ : this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]));
2649
3265
  const impacted = [];
2650
3266
  const visited = new Set([symId]);
2651
3267
  let frontier = [symId];
@@ -2783,7 +3399,13 @@ export class LocalBackend {
2783
3399
  // Max number of chunks to process to avoid unbounded DB round-trips.
2784
3400
  // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
2785
3401
  const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
2786
- if (impacted.length > 0) {
3402
+ // `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
3403
+ // process/module aggregation passes entirely — those probes need only the
3404
+ // count + a count-based risk, so paying the bounded-but-real enrichment cost
3405
+ // ~6× per ambiguous call is wasted. risk then derives from directCount /
3406
+ // total only (processCount/moduleCount stay 0), an acceptable approximation
3407
+ // for a disambiguation aid.
3408
+ if (impacted.length > 0 && !skipEnrichment) {
2787
3409
  // ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
2788
3410
  // Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
2789
3411
  // process + entry point info in 1 round-trip per chunk. Converted to
@@ -3004,6 +3626,9 @@ export class LocalBackend {
3004
3626
  for (const [depth, items] of Object.entries(grouped)) {
3005
3627
  byDepthCounts[Number(depth)] = items.length;
3006
3628
  }
3629
+ // #1858 — await the epistemic boundary probe kicked off alongside the BFS
3630
+ // above. Additive: leaves impactedCount and every existing field untouched.
3631
+ const epistemic = await epistemicPromise;
3007
3632
  const base = {
3008
3633
  target: {
3009
3634
  id: symId,
@@ -3014,6 +3639,7 @@ export class LocalBackend {
3014
3639
  direction,
3015
3640
  impactedCount: impacted.length,
3016
3641
  risk,
3642
+ ...epistemic,
3017
3643
  ...(!traversalComplete && { partial: true }),
3018
3644
  summary: {
3019
3645
  direct: directCount,
@@ -3203,6 +3829,10 @@ export class LocalBackend {
3203
3829
  includeTests: opts.includeTests,
3204
3830
  minConfidence: opts.minConfidence,
3205
3831
  skipPerSymbolEnrichment: true,
3832
+ // Group cross-repo fan-out consumes only byDepth (cross-impact.ts), not
3833
+ // the #1858 epistemic/boundaries fields — computing them per neighbor is
3834
+ // dead work on the highest-volume path, so suppress them here too.
3835
+ skipEpistemic: true,
3206
3836
  });
3207
3837
  }
3208
3838
  catch {