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.
- package/README.md +20 -0
- package/dist/cli/analyze-config.js +39 -0
- package/dist/cli/analyze.d.ts +16 -0
- package/dist/cli/analyze.js +34 -6
- package/dist/cli/clean.d.ts +1 -0
- package/dist/cli/clean.js +43 -1
- package/dist/cli/eval-server.js +43 -0
- package/dist/cli/help-i18n.js +6 -0
- package/dist/cli/i18n/en.d.ts +11 -0
- package/dist/cli/i18n/en.js +11 -0
- package/dist/cli/i18n/resources.d.ts +22 -0
- package/dist/cli/i18n/zh-CN.d.ts +11 -0
- package/dist/cli/i18n/zh-CN.js +11 -0
- package/dist/cli/index.js +9 -0
- package/dist/cli/list.js +12 -0
- package/dist/cli/optional-grammars.d.ts +6 -8
- package/dist/cli/optional-grammars.js +8 -11
- package/dist/cli/status.js +26 -5
- package/dist/cli/tool.d.ts +5 -0
- package/dist/cli/tool.js +5 -0
- package/dist/core/embeddings/embedder.js +4 -0
- package/dist/core/embeddings/embedding-pipeline.js +27 -16
- package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
- package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
- package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
- package/dist/core/group/extractors/http-patterns/java.js +3 -50
- package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
- package/dist/core/group/extractors/include-extractor.js +7 -7
- package/dist/core/ingestion/language-provider.d.ts +25 -2
- package/dist/core/ingestion/languages/c-cpp.js +11 -4
- package/dist/core/ingestion/languages/java.js +3 -0
- package/dist/core/ingestion/languages/kotlin/query.js +3 -2
- package/dist/core/ingestion/languages/kotlin.js +5 -1
- package/dist/core/ingestion/parsing-processor.js +21 -0
- package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
- package/dist/core/ingestion/pipeline.d.ts +9 -0
- package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
- package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
- package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
- package/dist/core/ingestion/route-extractors/spring.js +136 -0
- package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
- package/dist/core/ingestion/workers/clone-safety.js +465 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +10 -0
- package/dist/core/ingestion/workers/parse-worker.js +27 -52
- package/dist/core/ingestion/workers/post-result.d.ts +22 -0
- package/dist/core/ingestion/workers/post-result.js +87 -0
- package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
- package/dist/core/ingestion/workers/result-merge.js +43 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +6 -0
- package/dist/core/ingestion/workers/worker-pool.js +28 -14
- package/dist/core/lbug/lbug-adapter.d.ts +19 -0
- package/dist/core/lbug/lbug-adapter.js +56 -1
- package/dist/core/run-analyze.d.ts +45 -0
- package/dist/core/run-analyze.js +175 -24
- package/dist/core/tree-sitter/parser-loader.js +5 -4
- package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
- package/dist/core/tree-sitter/vendored-grammars.js +57 -0
- package/dist/mcp/core/embedder.js +4 -0
- package/dist/mcp/local/local-backend.d.ts +55 -2
- package/dist/mcp/local/local-backend.js +363 -25
- package/dist/mcp/tools.js +32 -0
- package/dist/server/analyze-worker-ipc.d.ts +58 -0
- package/dist/server/analyze-worker-ipc.js +16 -0
- package/dist/server/analyze-worker.js +7 -1
- package/dist/storage/branch-index.d.ts +52 -0
- package/dist/storage/branch-index.js +65 -0
- package/dist/storage/git.d.ts +11 -0
- package/dist/storage/git.js +28 -0
- package/dist/storage/parse-cache.js +3 -0
- package/dist/storage/repo-manager.d.ts +57 -2
- package/dist/storage/repo-manager.js +132 -18
- package/hooks/antigravity/gitnexus-antigravity-hook.cjs +24 -4
- package/hooks/claude/gitnexus-hook.cjs +18 -3
- package/package.json +2 -2
- package/scripts/assert-publish-grammar-coverage.cjs +31 -0
- package/scripts/build-tree-sitter-grammars.cjs +16 -10
- package/vendor/tree-sitter-c/package.json +1 -1
- package/vendor/tree-sitter-dart/package.json +1 -1
- package/vendor/tree-sitter-kotlin/package.json +1 -1
- package/vendor/tree-sitter-proto/package.json +1 -1
- package/vendor/tree-sitter-swift/package.json +1 -1
- 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
|
-
|
|
436
|
-
|
|
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(
|
|
439
|
-
this.lastStalenessCheck.delete(
|
|
440
|
-
this.
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
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
|
-
|
|
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
|
+
}
|