gitnexus 1.6.7 → 1.6.8-rc.10

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 (82) hide show
  1. package/README.md +20 -0
  2. package/dist/cli/analyze-config.js +39 -0
  3. package/dist/cli/analyze.d.ts +16 -0
  4. package/dist/cli/analyze.js +34 -6
  5. package/dist/cli/clean.d.ts +1 -0
  6. package/dist/cli/clean.js +43 -1
  7. package/dist/cli/eval-server.js +43 -0
  8. package/dist/cli/help-i18n.js +6 -0
  9. package/dist/cli/i18n/en.d.ts +11 -0
  10. package/dist/cli/i18n/en.js +11 -0
  11. package/dist/cli/i18n/resources.d.ts +22 -0
  12. package/dist/cli/i18n/zh-CN.d.ts +11 -0
  13. package/dist/cli/i18n/zh-CN.js +11 -0
  14. package/dist/cli/index.js +9 -0
  15. package/dist/cli/list.js +12 -0
  16. package/dist/cli/optional-grammars.d.ts +6 -8
  17. package/dist/cli/optional-grammars.js +8 -11
  18. package/dist/cli/status.js +26 -5
  19. package/dist/cli/tool.d.ts +5 -0
  20. package/dist/cli/tool.js +5 -0
  21. package/dist/core/embeddings/embedder.js +4 -0
  22. package/dist/core/embeddings/embedding-pipeline.js +27 -16
  23. package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
  24. package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
  25. package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
  26. package/dist/core/group/extractors/http-patterns/java.js +3 -50
  27. package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
  28. package/dist/core/group/extractors/include-extractor.js +7 -7
  29. package/dist/core/ingestion/language-provider.d.ts +25 -2
  30. package/dist/core/ingestion/languages/c-cpp.js +11 -4
  31. package/dist/core/ingestion/languages/java.js +3 -0
  32. package/dist/core/ingestion/languages/kotlin/query.js +3 -2
  33. package/dist/core/ingestion/languages/kotlin.js +5 -1
  34. package/dist/core/ingestion/parsing-processor.js +21 -0
  35. package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
  36. package/dist/core/ingestion/pipeline.d.ts +9 -0
  37. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
  38. package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
  39. package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
  40. package/dist/core/ingestion/route-extractors/spring.js +136 -0
  41. package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
  42. package/dist/core/ingestion/workers/clone-safety.js +465 -0
  43. package/dist/core/ingestion/workers/parse-worker.d.ts +10 -0
  44. package/dist/core/ingestion/workers/parse-worker.js +27 -52
  45. package/dist/core/ingestion/workers/post-result.d.ts +22 -0
  46. package/dist/core/ingestion/workers/post-result.js +87 -0
  47. package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
  48. package/dist/core/ingestion/workers/result-merge.js +43 -0
  49. package/dist/core/ingestion/workers/worker-pool.d.ts +6 -0
  50. package/dist/core/ingestion/workers/worker-pool.js +28 -14
  51. package/dist/core/lbug/lbug-adapter.d.ts +19 -0
  52. package/dist/core/lbug/lbug-adapter.js +56 -1
  53. package/dist/core/run-analyze.d.ts +45 -0
  54. package/dist/core/run-analyze.js +175 -24
  55. package/dist/core/tree-sitter/parser-loader.js +5 -4
  56. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  57. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  58. package/dist/mcp/core/embedder.js +4 -0
  59. package/dist/mcp/local/local-backend.d.ts +55 -2
  60. package/dist/mcp/local/local-backend.js +363 -25
  61. package/dist/mcp/tools.js +32 -0
  62. package/dist/server/analyze-worker-ipc.d.ts +58 -0
  63. package/dist/server/analyze-worker-ipc.js +16 -0
  64. package/dist/server/analyze-worker.js +7 -1
  65. package/dist/storage/branch-index.d.ts +52 -0
  66. package/dist/storage/branch-index.js +65 -0
  67. package/dist/storage/git.d.ts +11 -0
  68. package/dist/storage/git.js +28 -0
  69. package/dist/storage/parse-cache.js +3 -0
  70. package/dist/storage/repo-manager.d.ts +57 -2
  71. package/dist/storage/repo-manager.js +132 -18
  72. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +24 -4
  73. package/hooks/claude/gitnexus-hook.cjs +18 -3
  74. package/package.json +2 -2
  75. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  76. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  77. package/vendor/tree-sitter-c/package.json +1 -1
  78. package/vendor/tree-sitter-dart/package.json +1 -1
  79. package/vendor/tree-sitter-kotlin/package.json +1 -1
  80. package/vendor/tree-sitter-proto/package.json +1 -1
  81. package/vendor/tree-sitter-swift/package.json +1 -1
  82. package/scripts/materialize-vendor-grammars.cjs +0 -97
@@ -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';
@@ -96,12 +96,32 @@ export const VALID_RELATION_TYPES = new Set([
96
96
  'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
97
97
  'METHOD_IMPLEMENTS',
98
98
  'ACCESSES',
99
+ // Emitted by emit-references.ts / scope-resolution/graph-bridge/edges.ts and
100
+ // already part of the default impact relTypes + context() incoming queries.
101
+ // It was missing from this allowlist, so `impact({relationTypes:['USES']})`
102
+ // silently filtered to [] and fell back to the full default traversal
103
+ // (#2129/#1858 review F5). No IMPACT_RELATION_CONFIDENCE floor → 0.5 fallback,
104
+ // matching the FETCHES / WRAPS / HANDLES_ROUTE precedent below.
105
+ 'USES',
99
106
  'HANDLES_ROUTE',
100
107
  'FETCHES',
101
108
  'HANDLES_TOOL',
102
109
  'ENTRY_POINT_OF',
103
110
  'WRAPS',
104
111
  ]);
112
+ /**
113
+ * Relation types the #1858 epistemic-boundary probe keys on. Kept as
114
+ * module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
115
+ * binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
116
+ * The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
117
+ * "statically verifiable, but the concrete binding past it is not".
118
+ */
119
+ export const EPISTEMIC_HERITAGE_RELATION_TYPES = [
120
+ 'IMPLEMENTS',
121
+ 'METHOD_IMPLEMENTS',
122
+ 'EXTENDS',
123
+ ];
124
+ export const EPISTEMIC_CONSUMER_RELATION_TYPES = ['CALLS', 'USES', 'ACCESSES'];
105
125
  /**
106
126
  * Per-relation-type confidence floor for impact analysis.
107
127
  *
@@ -315,6 +335,12 @@ export class LocalBackend {
315
335
  initializedRepos = new Set();
316
336
  reinitPromises = new Map();
317
337
  lastStalenessCheck = new Map();
338
+ // Last meta.indexedAt observed for an open pool, keyed by lbugPath. Keyed by
339
+ // pool (not stored on the handle) because branch handles are produced fresh
340
+ // by applyBranchScope on every resolveRepo call, so mutating the handle would
341
+ // not persist across calls and the staleness check would reinit forever
342
+ // (#2106).
343
+ lastObservedIndexedAt = new Map();
318
344
  groupToolSvc = null;
319
345
  /**
320
346
  * One-shot stderr warnings for sibling-clone drift, keyed by
@@ -410,6 +436,8 @@ export class LocalBackend {
410
436
  lastCommit: entry.lastCommit,
411
437
  remoteUrl: entry.remoteUrl,
412
438
  stats: entry.stats,
439
+ branch: entry.branch,
440
+ branches: entry.branches,
413
441
  };
414
442
  nextRepos.set(id, handle);
415
443
  // Build lightweight context (no LadybugDB needed)
@@ -432,13 +460,31 @@ export class LocalBackend {
432
460
  // the resolve→query wrong-clone window for good (#2067). Only a path that
433
461
  // dropped out of the registry must release its pooled connection + state.
434
462
  const liveLbugPaths = new Set([...nextRepos.values()].map((h) => h.lbugPath));
435
- for (const prev of this.repos.values()) {
436
- if (liveLbugPaths.has(prev.lbugPath))
463
+ // Branch pools (opened on demand by applyBranchScope) are NOT in this.repos
464
+ // — branch handles are minted fresh and discarded — so add every registered
465
+ // branch's lbugPath to the live set. Pure string work over the already-in-
466
+ // memory registry snapshot; no disk I/O on this hot path (#2106 R3).
467
+ for (const entry of entries) {
468
+ for (const b of entry.branches ?? []) {
469
+ liveLbugPaths.add(getStoragePaths(entry.path, b.branch).lbugPath);
470
+ }
471
+ }
472
+ // initializedRepos is the authoritative set of OPENED pool keys (flat AND
473
+ // branch); union it with the previously-known flat handles so an orphaned
474
+ // branch pool (e.g. after `clean --branch` removes its summary) is closed
475
+ // and forgotten too, not just flat handles.
476
+ const knownKeys = new Set([
477
+ ...[...this.repos.values()].map((h) => h.lbugPath),
478
+ ...this.initializedRepos,
479
+ ]);
480
+ for (const key of knownKeys) {
481
+ if (liveLbugPaths.has(key))
437
482
  continue;
438
- this.initializedRepos.delete(prev.lbugPath);
439
- this.lastStalenessCheck.delete(prev.lbugPath);
440
- this.reinitPromises.delete(prev.lbugPath);
441
- closeLbug(prev.lbugPath).catch(() => { });
483
+ this.initializedRepos.delete(key);
484
+ this.lastStalenessCheck.delete(key);
485
+ this.lastObservedIndexedAt.delete(key);
486
+ this.reinitPromises.delete(key);
487
+ closeLbug(key).catch(() => { });
442
488
  }
443
489
  this.repos = nextRepos;
444
490
  this.contextCache = nextContext;
@@ -507,7 +553,7 @@ export class LocalBackend {
507
553
  * On a miss, re-reads the registry once in case a new repo was indexed
508
554
  * while the MCP server was running.
509
555
  */
510
- async resolveRepo(repoParam) {
556
+ async resolveRepo(repoParam, branch) {
511
557
  let refreshedAfterAmbiguity = false;
512
558
  let result;
513
559
  try {
@@ -532,7 +578,7 @@ export class LocalBackend {
532
578
  this.maybeWarnSiblingDrift(result).catch(() => {
533
579
  /* best-effort; never throw from resolveRepo */
534
580
  });
535
- return result;
581
+ return this.applyBranchScope(result, branch);
536
582
  }
537
583
  // Miss — refresh registry and try once more (skip if already refreshed above)
538
584
  if (!refreshedAfterAmbiguity) {
@@ -541,7 +587,7 @@ export class LocalBackend {
541
587
  const retried = this.resolveRepoFromCache(repoParam);
542
588
  if (retried) {
543
589
  this.maybeWarnSiblingDrift(retried).catch(() => { });
544
- return retried;
590
+ return this.applyBranchScope(retried, branch);
545
591
  }
546
592
  // Still no match — throw with helpful message
547
593
  if (this.repos.size === 0) {
@@ -562,6 +608,49 @@ export class LocalBackend {
562
608
  }
563
609
  throw new Error(`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`);
564
610
  }
611
+ /**
612
+ * Re-point a resolved repo handle at a specific branch index (#2106).
613
+ *
614
+ * - No `branch` (default) → the primary/flat handle, unchanged (backward
615
+ * compatible: every existing caller passes no branch).
616
+ * - `branch` equal to the known primary → the flat handle.
617
+ * - `branch` matching an indexed non-primary branch → a handle whose
618
+ * `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
619
+ * `lbugPath`, so this is the only change needed to scope every tool.
620
+ * - `branch` that was never indexed → a clear error (never a silently-empty
621
+ * result against the wrong DB).
622
+ */
623
+ async applyBranchScope(handle, branch) {
624
+ if (!branch)
625
+ return handle;
626
+ if (handle.branch && handle.branch === branch)
627
+ return handle;
628
+ const summary = handle.branches?.find((b) => b.branch === branch);
629
+ if (summary) {
630
+ const { lbugPath } = getStoragePaths(handle.repoPath, branch);
631
+ return {
632
+ ...handle,
633
+ lbugPath,
634
+ indexedAt: summary.indexedAt,
635
+ lastCommit: summary.lastCommit,
636
+ stats: summary.stats,
637
+ };
638
+ }
639
+ // Legacy entry (pre-#2106): the registry has no recorded primary `branch`,
640
+ // so a `--branch <primary>` request misses the checks above. Read the flat
641
+ // meta.json (next to the flat handle's lbug) to learn the primary and serve
642
+ // the flat handle only when it actually matches — never serve flat for an
643
+ // arbitrary unindexed branch (#2106 R4).
644
+ if (!handle.branch) {
645
+ const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
646
+ if (flatMeta?.branch && flatMeta.branch === branch)
647
+ return handle;
648
+ }
649
+ const indexed = [handle.branch, ...(handle.branches?.map((b) => b.branch) ?? [])].filter(Boolean);
650
+ const available = indexed.length > 0 ? indexed.join(', ') : '(primary only)';
651
+ throw new Error(`Branch "${branch}" is not indexed for "${handle.name}". ` +
652
+ `Indexed branches: ${available}. Run: gitnexus analyze --branch ${branch}`);
653
+ }
565
654
  /**
566
655
  * Try to resolve a repo from the in-memory cache. Returns null on miss.
567
656
  * Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
@@ -681,10 +770,19 @@ export class LocalBackend {
681
770
  return; // Checked recently — skip
682
771
  this.lastStalenessCheck.set(poolKey, now);
683
772
  try {
684
- const metaPath = path.join(repo.storagePath, 'meta.json');
773
+ // Read the meta.json that sits next to THIS handle's lbug. For the
774
+ // flat/primary handle this is `<storagePath>/meta.json` (unchanged);
775
+ // for a branch handle it is `<storagePath>/branches/<slug>/meta.json`.
776
+ // Reading the flat meta for a branch handle would compare the branch
777
+ // index's indexedAt against the primary's and thrash the pool (#2106).
778
+ const metaPath = path.join(path.dirname(repo.lbugPath), 'meta.json');
685
779
  const metaRaw = await fs.readFile(metaPath, 'utf-8');
686
780
  const meta = JSON.parse(metaRaw);
687
- if (meta.indexedAt && meta.indexedAt !== repo.indexedAt) {
781
+ // Compare against the last indexedAt OBSERVED for this pool (keyed by
782
+ // lbugPath), not the handle's — branch handles are fresh spreads so a
783
+ // handle mutation would not persist and would reinit on every check.
784
+ const observed = this.lastObservedIndexedAt.get(poolKey) ?? repo.indexedAt;
785
+ if (meta.indexedAt && meta.indexedAt !== observed) {
688
786
  // Index was rebuilt — close stale connection and re-init.
689
787
  // Wrap in reinitPromises to prevent TOCTOU race where concurrent
690
788
  // callers both detect staleness and double-close the pool.
@@ -692,7 +790,7 @@ export class LocalBackend {
692
790
  try {
693
791
  await closeLbug(poolKey);
694
792
  this.initializedRepos.delete(poolKey);
695
- repo.indexedAt = meta.indexedAt;
793
+ this.lastObservedIndexedAt.set(poolKey, meta.indexedAt);
696
794
  await initLbug(poolKey, repo.lbugPath);
697
795
  this.initializedRepos.add(poolKey);
698
796
  }
@@ -714,6 +812,7 @@ export class LocalBackend {
714
812
  try {
715
813
  await initLbug(poolKey, repo.lbugPath);
716
814
  this.initializedRepos.add(poolKey);
815
+ this.lastObservedIndexedAt.set(poolKey, repo.indexedAt);
717
816
  }
718
817
  catch (err) {
719
818
  // If lock error, mark as not initialized so next call retries
@@ -792,6 +891,14 @@ export class LocalBackend {
792
891
  lastCommit: s.lastCommit,
793
892
  }))
794
893
  : undefined,
894
+ branch: h.branch,
895
+ branches: h.branches && h.branches.length > 0
896
+ ? h.branches.map((b) => ({
897
+ branch: b.branch,
898
+ indexedAt: b.indexedAt,
899
+ lastCommit: b.lastCommit,
900
+ }))
901
+ : undefined,
795
902
  };
796
903
  });
797
904
  }
@@ -913,8 +1020,10 @@ export class LocalBackend {
913
1020
  p.repo.startsWith('@')) {
914
1021
  return this.callToolAtGroupRepo(method, p);
915
1022
  }
916
- // Resolve repo from optional param (re-reads registry on miss)
917
- const repo = await this.resolveRepo(params?.repo);
1023
+ // Resolve repo from optional param (re-reads registry on miss). An optional
1024
+ // `branch` param scopes the resolved handle to that branch's index (#2106).
1025
+ const repoParams = params;
1026
+ const repo = await this.resolveRepo(repoParams?.repo, repoParams?.branch);
918
1027
  switch (method) {
919
1028
  case 'query':
920
1029
  return this.query(repo, params);
@@ -2011,6 +2120,23 @@ export class LocalBackend {
2011
2120
  // Method/Function/Constructor enrichment: fetch method-specific properties
2012
2121
  const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
2013
2122
  const isMethodLike = symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
2123
+ // #1858 review F2 — start the epistemic boundary probe here (right after
2124
+ // `symKind` is known) so it runs CONCURRENTLY with the methodMetadata fetch
2125
+ // below, mirroring how _runImpactBFS overlaps it with the BFS. It is awaited
2126
+ // at result assembly. (It cannot start earlier — `symKind` is only computed
2127
+ // on this line, after the incoming/outgoing round-trips.)
2128
+ //
2129
+ // #1858 review F3 — pass an interface-preserving type, NOT `symKind`.
2130
+ // `symKind` collapses a single-resolved Interface to 'Class' (resolvedLabel
2131
+ // is '' on the single-candidate path), which would skip computeEpistemicBoundary's
2132
+ // `symType === 'Interface'` self-boundary branch and under-report a leaf
2133
+ // interface as 'exact'. `enrichCandidateLabels` runs BEFORE the single-candidate
2134
+ // early return and patches `sym.type` from '' to 'Interface' (LadybugDB returns
2135
+ // '' for labels()[0] on Interface/Class), so `sym.type` is the reliable signal
2136
+ // here — mirroring impact()'s `resolvedLabel || symbol.type` derivation. Do not
2137
+ // "fix" enrichment ordering; F3 depends on enrichment-before-early-return.
2138
+ const epistemicSymType = (resolvedLabel || sym.type || symKind || '');
2139
+ const epistemicPromise = this.computeEpistemicBoundary(repo, symId, epistemicSymType, (sym.name || sym[1]));
2014
2140
  let methodMetadata;
2015
2141
  if (isMethodLike) {
2016
2142
  try {
@@ -2041,6 +2167,12 @@ export class LocalBackend {
2041
2167
  /* method metadata unavailable — omit silently */
2042
2168
  }
2043
2169
  }
2170
+ // #1858 — same epistemic boundary signal as impact(): when this symbol sits
2171
+ // behind an interface / indirection boundary, callers binding via DI or
2172
+ // dynamic dispatch are not reflected in `incoming`, so the view is a lower
2173
+ // bound. Additive; never suppresses a field. Resolved from the probe started
2174
+ // above (concurrent with methodMetadata).
2175
+ const epistemic = await epistemicPromise;
2044
2176
  return {
2045
2177
  status: 'found',
2046
2178
  symbol: {
@@ -2053,6 +2185,7 @@ export class LocalBackend {
2053
2185
  ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
2054
2186
  ...(methodMetadata ? { methodMetadata } : {}),
2055
2187
  },
2188
+ ...epistemic,
2056
2189
  incoming: categorize(incomingRows),
2057
2190
  outgoing: categorize(outgoingRows),
2058
2191
  ...(typedPropertyRows.length > 0
@@ -2586,21 +2719,103 @@ export class LocalBackend {
2586
2719
  };
2587
2720
  }
2588
2721
  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) => ({
2722
+ // #2129 — a bare name that collides with several symbols must NOT report a
2723
+ // bare `impactedCount: 0`. The real blast radius lives under whichever
2724
+ // candidate the caller meant; a flat zero here is precisely the silent
2725
+ // under-report the "run impact before editing" workflow exists to prevent
2726
+ // (the dropped caller calls a *different* same-name node, so it never shows
2727
+ // up against the one the resolver happened to pick). Run a bounded,
2728
+ // summary-only BFS per candidate so each one's true count + risk is
2729
+ // visible, and surface the maximum at the top level so the headline can
2730
+ // never read as "safe to refactor". Candidates arrive sorted by score.
2731
+ const AMBIGUOUS_MAX_CANDIDATES = 6;
2732
+ const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
2733
+ // `partialProbe` is intentionally a SECOND incompleteness flag, distinct
2734
+ // from the traversal-interrupted `partial` flag used elsewhere: it means
2735
+ // one or more per-candidate probes threw, so maxRisk / maxImpactedCount
2736
+ // are lower bounds over the probes that succeeded (a failed candidate must
2737
+ // not be masked by a benign sibling success).
2738
+ let probeFailed = false;
2739
+ const candidateSummaries = await Promise.all(probed.map(async (c) => {
2740
+ const cType = c.type || '';
2741
+ const cRelTypes = (cType === 'Class' || cType === 'Interface') &&
2742
+ !hasExplicitRelationTypes &&
2743
+ !relationTypes.includes('ACCESSES')
2744
+ ? [...relationTypes, 'ACCESSES']
2745
+ : relationTypes;
2746
+ // #1858/#2129 review F8 — name the shape the probe summary is read
2747
+ // through (`_runImpactBFS` returns `Promise<any>`, so this is the
2748
+ // narrowing cast) so a future rename of those fields fails tsc instead
2749
+ // of silently zeroing candidate counts.
2750
+ let summary = null;
2751
+ try {
2752
+ summary = await this._runImpactBFS(repo, { id: c.id, name: c.name, filePath: c.filePath }, cType, direction, {
2753
+ maxDepth,
2754
+ relationTypes: cRelTypes,
2755
+ includeTests,
2756
+ minConfidence,
2757
+ summaryOnly: true,
2758
+ skipEpistemic: true,
2759
+ skipEnrichment: true,
2760
+ });
2761
+ }
2762
+ catch (e) {
2763
+ probeFailed = true;
2764
+ logQueryError('impact:ambiguous-candidate', e);
2765
+ }
2766
+ return {
2597
2767
  uid: c.id,
2598
2768
  name: c.name,
2599
2769
  kind: c.type,
2600
2770
  filePath: c.filePath,
2601
2771
  line: c.startLine,
2602
2772
  score: Number(c.score.toFixed(2)),
2603
- })),
2773
+ impactedCount: summary?.impactedCount ?? 0,
2774
+ risk: summary?.risk ?? 'UNKNOWN',
2775
+ direct: summary?.summary?.direct ?? 0,
2776
+ };
2777
+ }));
2778
+ // Rank by blast radius so the most-impactful interpretation is first, and
2779
+ // hoist the maximum count/risk to the top level so the response cannot be
2780
+ // misread as "no impact".
2781
+ candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount);
2782
+ const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0);
2783
+ const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
2784
+ // If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion
2785
+ // under the fan-out), the worst real risk is genuinely unknown, not LOW.
2786
+ // Reporting LOW here would re-introduce the false-safe signal. Only fall to
2787
+ // the LOW seed when at least one candidate produced a real risk.
2788
+ const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk));
2789
+ const maxRisk = anyKnownRisk
2790
+ ? candidateSummaries.reduce((worst, c) => (RISK_ORDER.indexOf(c.risk) > RISK_ORDER.indexOf(worst) ? c.risk : worst), 'LOW')
2791
+ : 'UNKNOWN';
2792
+ const truncated = outcome.candidates.length > probed.length;
2793
+ return {
2794
+ status: 'ambiguous',
2795
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'` +
2796
+ (truncated
2797
+ ? ` (showing ${candidateSummaries.length} of ${outcome.candidates.length})`
2798
+ : '') +
2799
+ `. Blast radius differs per candidate (max ${maxImpactedCount} impacted at risk ${maxRisk}). ` +
2800
+ `Disambiguate with target_uid (or file_path/kind) for a single authoritative result.`,
2801
+ target: { name: target },
2802
+ direction,
2803
+ // Full match count — `candidates[]` is truncated to AMBIGUOUS_MAX_CANDIDATES,
2804
+ // so consumers (CLI formatter) need this to report "N of M" honestly (#2129
2805
+ // review F11; the CLI previously read the truncated array length).
2806
+ totalCandidates: outcome.candidates.length,
2807
+ // `impactedCount` stays 0 and `risk` stays UNKNOWN — there is no single
2808
+ // resolved symbol, and UNKNOWN must NOT read as "safe to refactor". The
2809
+ // real blast radius is surfaced per-candidate plus `maxImpactedCount` /
2810
+ // `maxRisk` so a real caller can never hide behind the ambiguous zero
2811
+ // (#2129).
2812
+ impactedCount: 0,
2813
+ risk: 'UNKNOWN',
2814
+ maxImpactedCount,
2815
+ maxRisk,
2816
+ ...(probeFailed ? { partialProbe: true } : {}),
2817
+ ...(truncated && { candidatesTruncated: true }),
2818
+ candidates: candidateSummaries,
2604
2819
  };
2605
2820
  }
2606
2821
  const sym = {
@@ -2624,12 +2839,109 @@ export class LocalBackend {
2624
2839
  summaryOnly: params.summaryOnly,
2625
2840
  });
2626
2841
  }
2842
+ /**
2843
+ * #1858 — epistemic lower-bound detection.
2844
+ *
2845
+ * impact()/context() traverse only edges materialized in the graph. When the
2846
+ * queried symbol sits on an interface / abstract boundary, callers that bind
2847
+ * to the interface via DI, a container, or dynamic dispatch — rather than
2848
+ * naming the concrete symbol — are not traced. The reported count is then a
2849
+ * lower bound, not an exact figure. Instead of returning a confident count
2850
+ * that silently omits those callers, annotate the result with
2851
+ * `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
2852
+ * resolved leaf with no indirection stays `epistemic: 'exact'`.
2853
+ *
2854
+ * Aligns with the numeric confidence model rather than the long-deleted
2855
+ * TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
2856
+ * (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
2857
+ * `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
2858
+ * concrete binding past it is not".
2859
+ *
2860
+ * Never throws: on query error it returns 'exact', so it can only add signal,
2861
+ * never suppress a result.
2862
+ */
2863
+ async computeEpistemicBoundary(repo, symId, symType, symName) {
2864
+ const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES;
2865
+ const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES;
2866
+ try {
2867
+ // Discover the interface / abstract supertypes on the target's boundary.
2868
+ // If the target is itself an interface, it is its own boundary node.
2869
+ const boundary = new Map();
2870
+ if (symType === 'Interface') {
2871
+ boundary.set(symId, { name: symName || '', label: 'Interface' });
2872
+ }
2873
+ const ifaceRows = await executeParameterized(repo.lbugPath, `MATCH (x)-[r:CodeRelation]->(iface)
2874
+ WHERE x.id = $symId AND r.type IN $heritage
2875
+ RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
2876
+ LIMIT 25`, { symId, heritage: HERITAGE_TYPES }).catch(() => []);
2877
+ for (const r of ifaceRows) {
2878
+ const id = (r.id ?? r[0]);
2879
+ if (id && !boundary.has(id)) {
2880
+ boundary.set(id, {
2881
+ name: (r.name ?? r[1] ?? ''),
2882
+ label: (r.label ?? r[2] ?? 'Interface'),
2883
+ });
2884
+ }
2885
+ }
2886
+ if (boundary.size === 0)
2887
+ return { epistemic: 'exact' };
2888
+ const ifaceIds = Array.from(boundary.keys());
2889
+ // Count per interface id with scalar equality. A parameterized
2890
+ // `iface.id IN $ids` combined with `COUNT(DISTINCT ...)` + implicit
2891
+ // group-by returns no rows under the LadybugDB cypher subset, so query
2892
+ // each boundary node individually (boundary is small — capped at 25).
2893
+ const countByType = async (types) => {
2894
+ const m = new Map();
2895
+ await Promise.all(ifaceIds.map(async (ifaceId) => {
2896
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (other)-[r:CodeRelation]->(iface)
2897
+ WHERE iface.id = $ifaceId AND r.type IN $types
2898
+ RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }).catch(() => []);
2899
+ const cnt = rows.length > 0 ? Number(rows[0].cnt ?? rows[0][0] ?? 0) : 0;
2900
+ m.set(ifaceId, cnt);
2901
+ }));
2902
+ return m;
2903
+ };
2904
+ const [implCounts, consumerCounts] = await Promise.all([
2905
+ countByType(HERITAGE_TYPES),
2906
+ countByType(CONSUMER_TYPES),
2907
+ ]);
2908
+ const boundaries = [];
2909
+ for (const [id, info] of boundary) {
2910
+ const impls = implCounts.get(id) ?? 0;
2911
+ const consumers = consumerCounts.get(id) ?? 0;
2912
+ // Flag only a genuine indirection risk: an interface that is actually
2913
+ // consumed (callers bind to it) or that has multiple implementations
2914
+ // (runtime dispatch is ambiguous). A concrete type implementing an
2915
+ // interface nothing references is fully traced → stays exact.
2916
+ if (consumers >= 1 || impls >= 2) {
2917
+ const label = (info.label || 'Interface').toLowerCase();
2918
+ const name = info.name || '(unnamed)';
2919
+ const article = /^[aeiou]/.test(label) ? 'an' : 'a';
2920
+ const parts = [];
2921
+ if (impls >= 1)
2922
+ parts.push(`${impls} ${impls === 1 ? 'implementation' : 'implementations'}`);
2923
+ if (consumers >= 1)
2924
+ parts.push(`${consumers} interface-level ${consumers === 1 ? 'consumer' : 'consumers'}`);
2925
+ boundaries.push(`${name} is ${article} ${label} with ${parts.join(' and ')}; callers that bind via the ${label} ` +
2926
+ `(e.g. a DI container or dynamic dispatch) are not traced to the concrete symbol — ` +
2927
+ `actual impact may be higher.`);
2928
+ }
2929
+ }
2930
+ if (boundaries.length === 0)
2931
+ return { epistemic: 'exact' };
2932
+ return { epistemic: 'lower-bound', boundaries };
2933
+ }
2934
+ catch {
2935
+ return { epistemic: 'exact' };
2936
+ }
2937
+ }
2627
2938
  /**
2628
2939
  * Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
2629
2940
  */
2630
2941
  async _runImpactBFS(repo, sym, symType, direction, opts) {
2631
2942
  const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
2632
2943
  const skipPerSymbolEnrichment = opts.skipPerSymbolEnrichment ?? false;
2944
+ const skipEnrichment = opts.skipEnrichment ?? false;
2633
2945
  const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
2634
2946
  const paginationLimit = hasExplicitLimit
2635
2947
  ? Math.max(1, Math.min(Math.trunc(opts.limit), 10000))
@@ -2646,6 +2958,18 @@ export class LocalBackend {
2646
2958
  const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
2647
2959
  const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
2648
2960
  const symId = sym.id || sym[0];
2961
+ // #1858 — kick off the epistemic boundary probe concurrently with the BFS.
2962
+ // It depends only on symId/symType/symName (all known now) and touches no
2963
+ // shared state, so its extra round-trip overlaps the traversal instead of
2964
+ // adding to the serial path. `skipEpistemic` (ambiguous #2129 candidate
2965
+ // probes, group fan-out) resolves to no field, preserving prior behavior.
2966
+ // #1858/#2129 review F8 — the skip case adds no field, so `epistemic` is
2967
+ // optional here (the union's `{}` subtype). computeEpistemicBoundary's own
2968
+ // return keeps `epistemic` REQUIRED — only this promise widens to the skip
2969
+ // subtype.
2970
+ const epistemicPromise = opts.skipEpistemic
2971
+ ? Promise.resolve({})
2972
+ : this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]));
2649
2973
  const impacted = [];
2650
2974
  const visited = new Set([symId]);
2651
2975
  let frontier = [symId];
@@ -2783,7 +3107,13 @@ export class LocalBackend {
2783
3107
  // Max number of chunks to process to avoid unbounded DB round-trips.
2784
3108
  // Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
2785
3109
  const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
2786
- if (impacted.length > 0) {
3110
+ // `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
3111
+ // process/module aggregation passes entirely — those probes need only the
3112
+ // count + a count-based risk, so paying the bounded-but-real enrichment cost
3113
+ // ~6× per ambiguous call is wasted. risk then derives from directCount /
3114
+ // total only (processCount/moduleCount stay 0), an acceptable approximation
3115
+ // for a disambiguation aid.
3116
+ if (impacted.length > 0 && !skipEnrichment) {
2787
3117
  // ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
2788
3118
  // Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
2789
3119
  // process + entry point info in 1 round-trip per chunk. Converted to
@@ -3004,6 +3334,9 @@ export class LocalBackend {
3004
3334
  for (const [depth, items] of Object.entries(grouped)) {
3005
3335
  byDepthCounts[Number(depth)] = items.length;
3006
3336
  }
3337
+ // #1858 — await the epistemic boundary probe kicked off alongside the BFS
3338
+ // above. Additive: leaves impactedCount and every existing field untouched.
3339
+ const epistemic = await epistemicPromise;
3007
3340
  const base = {
3008
3341
  target: {
3009
3342
  id: symId,
@@ -3014,6 +3347,7 @@ export class LocalBackend {
3014
3347
  direction,
3015
3348
  impactedCount: impacted.length,
3016
3349
  risk,
3350
+ ...epistemic,
3017
3351
  ...(!traversalComplete && { partial: true }),
3018
3352
  summary: {
3019
3353
  direct: directCount,
@@ -3203,6 +3537,10 @@ export class LocalBackend {
3203
3537
  includeTests: opts.includeTests,
3204
3538
  minConfidence: opts.minConfidence,
3205
3539
  skipPerSymbolEnrichment: true,
3540
+ // Group cross-repo fan-out consumes only byDepth (cross-impact.ts), not
3541
+ // the #1858 epistemic/boundaries fields — computing them per neighbor is
3542
+ // dead work on the highest-volume path, so suppress them here too.
3543
+ skipEpistemic: true,
3206
3544
  });
3207
3545
  }
3208
3546
  catch {
package/dist/mcp/tools.js CHANGED
@@ -560,3 +560,35 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
560
560
  },
561
561
  },
562
562
  ];
563
+ /**
564
+ * Per-repo tools that accept an optional `branch` scope (#2106). Single source
565
+ * of truth: the schema property is injected here so it cannot drift from the
566
+ * server-side default in `local-backend.ts` (`resolveRepo(repo, branch)`).
567
+ * `list_repos` and the `group_*` tools are intentionally excluded — they are
568
+ * not single-repo, single-branch operations.
569
+ */
570
+ const BRANCH_SCOPED_TOOLS = new Set([
571
+ 'query',
572
+ 'cypher',
573
+ 'context',
574
+ 'detect_changes',
575
+ 'impact',
576
+ 'rename',
577
+ 'route_map',
578
+ 'tool_map',
579
+ 'shape_check',
580
+ 'api_impact',
581
+ ]);
582
+ for (const tool of GITNEXUS_TOOLS) {
583
+ if (!BRANCH_SCOPED_TOOLS.has(tool.name))
584
+ continue;
585
+ if (tool.inputSchema.properties.branch)
586
+ continue;
587
+ // Optional — `required` is left unchanged so omitting `branch` keeps today's
588
+ // default/primary-branch behavior. Ignored in group mode (repo starts "@").
589
+ tool.inputSchema.properties.branch = {
590
+ type: 'string',
591
+ description: 'Optional: scope to a specific branch index (multi-branch repos, #2106). ' +
592
+ 'Omit for the default/primary branch. Ignored in group mode.',
593
+ };
594
+ }