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.
- package/README.md +20 -0
- package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
- package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
- package/dist/_shared/scope-resolution/symbol-definition.d.ts +4 -0
- package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
- package/dist/cli/ai-context.js +1 -0
- package/dist/cli/analyze-config.js +40 -0
- package/dist/cli/analyze.d.ts +22 -0
- package/dist/cli/analyze.js +36 -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 +11 -0
- package/dist/cli/i18n/en.d.ts +13 -0
- package/dist/cli/i18n/en.js +13 -0
- package/dist/cli/i18n/resources.d.ts +26 -0
- package/dist/cli/i18n/zh-CN.d.ts +13 -0
- package/dist/cli/i18n/zh-CN.js +13 -0
- package/dist/cli/index.js +19 -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/skill-gen.js +1 -0
- package/dist/cli/status.js +26 -5
- package/dist/cli/tool.d.ts +12 -1
- package/dist/cli/tool.js +41 -1
- 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/graph/import-cycles.d.ts +10 -0
- package/dist/core/graph/import-cycles.js +103 -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/cfg/cfg-builder.d.ts +64 -0
- package/dist/core/ingestion/cfg/cfg-builder.js +129 -0
- package/dist/core/ingestion/cfg/collect.d.ts +30 -0
- package/dist/core/ingestion/cfg/collect.js +34 -0
- package/dist/core/ingestion/cfg/control-flow-context.d.ts +97 -0
- package/dist/core/ingestion/cfg/control-flow-context.js +113 -0
- package/dist/core/ingestion/cfg/emit.d.ts +144 -0
- package/dist/core/ingestion/cfg/emit.js +315 -0
- package/dist/core/ingestion/cfg/reaching-defs.d.ts +90 -0
- package/dist/core/ingestion/cfg/reaching-defs.js +364 -0
- package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
- package/dist/core/ingestion/cfg/traversal-result.js +2 -0
- package/dist/core/ingestion/cfg/types.d.ts +231 -0
- package/dist/core/ingestion/cfg/types.js +13 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +144 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1032 -0
- package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
- package/dist/core/ingestion/cfg/visitors/typescript.js +584 -0
- package/dist/core/ingestion/language-provider.d.ts +36 -2
- package/dist/core/ingestion/languages/c-cpp.js +11 -4
- package/dist/core/ingestion/languages/cpp/arity-metadata.js +6 -2
- package/dist/core/ingestion/languages/cpp/captures.js +24 -1
- package/dist/core/ingestion/languages/cpp/query.js +23 -0
- 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/languages/typescript.js +5 -0
- package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
- package/dist/core/ingestion/method-extractors/generic.js +1 -0
- package/dist/core/ingestion/method-types.d.ts +2 -0
- package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
- package/dist/core/ingestion/model/symbol-table.js +1 -0
- package/dist/core/ingestion/parsing-processor.js +22 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
- package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
- package/dist/core/ingestion/pipeline.d.ts +57 -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/scope-extractor.js +3 -0
- package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +12 -0
- package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +145 -24
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -0
- package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +21 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +207 -0
- package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
- package/dist/core/ingestion/taint/emit.d.ts +124 -0
- package/dist/core/ingestion/taint/emit.js +204 -0
- package/dist/core/ingestion/taint/match.d.ts +153 -0
- package/dist/core/ingestion/taint/match.js +278 -0
- package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
- package/dist/core/ingestion/taint/path-codec.js +190 -0
- package/dist/core/ingestion/taint/propagate.d.ts +216 -0
- package/dist/core/ingestion/taint/propagate.js +664 -0
- package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
- package/dist/core/ingestion/taint/site-safety.js +98 -0
- package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
- package/dist/core/ingestion/taint/source-sink-config.js +11 -11
- package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
- package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
- package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
- package/dist/core/ingestion/taint/typescript-model.js +102 -0
- package/dist/core/ingestion/utils/method-props.js +1 -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 +11 -0
- package/dist/core/ingestion/workers/parse-worker.js +70 -53
- 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 +15 -0
- package/dist/core/ingestion/workers/worker-pool.js +33 -16
- 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 +87 -0
- package/dist/core/run-analyze.js +280 -25
- 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 +86 -2
- package/dist/mcp/local/local-backend.js +657 -27
- package/dist/mcp/resources.js +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.js +109 -0
- package/dist/server/analyze-launch.d.ts +29 -0
- package/dist/server/analyze-launch.js +137 -0
- package/dist/server/analyze-upload.d.ts +33 -0
- package/dist/server/analyze-upload.js +123 -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.d.ts +17 -1
- package/dist/server/analyze-worker.js +7 -1
- package/dist/server/api.js +46 -140
- package/dist/server/git-clone.d.ts +1 -0
- package/dist/server/git-clone.js +1 -1
- package/dist/server/middleware.d.ts +11 -0
- package/dist/server/middleware.js +27 -0
- package/dist/server/upload-ingest.d.ts +56 -0
- package/dist/server/upload-ingest.js +276 -0
- package/dist/server/upload-paths.d.ts +31 -0
- package/dist/server/upload-paths.js +51 -0
- package/dist/server/upload-sweep.d.ts +20 -0
- package/dist/server/upload-sweep.js +57 -0
- 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.d.ts +22 -1
- package/dist/storage/parse-cache.js +32 -10
- package/dist/storage/repo-manager.d.ts +122 -10
- package/dist/storage/repo-manager.js +162 -21
- package/hooks/antigravity/gitnexus-antigravity-hook.cjs +42 -8
- package/hooks/claude/gitnexus-hook.cjs +36 -8
- package/hooks/claude/hook-db-lock-probe.cjs +123 -2
- package/package.json +4 -2
- package/scripts/assert-publish-grammar-coverage.cjs +31 -0
- package/scripts/build-tree-sitter-grammars.cjs +16 -10
- package/skills/gitnexus-guide.md +11 -0
- 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/web/assets/{agent-CKPMqImC.js → agent-ay4LD70X.js} +1 -1
- package/web/assets/{architectureDiagram-UL44E2DR-D1EXI0zA.js → architectureDiagram-UL44E2DR-Dc-viYhd.js} +1 -1
- package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-4rpojOyj.js} +1 -1
- package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-BnOy944n.js} +1 -1
- package/web/assets/{classDiagram-KGZ6W3CR-Bv93af_b.js → classDiagram-KGZ6W3CR-Bsgpy98Q.js} +1 -1
- package/web/assets/{classDiagram-v2-72OJOZXJ-CLfEqHUa.js → classDiagram-v2-72OJOZXJ-D5atDGjc.js} +1 -1
- package/web/assets/{diagram-3NCE3AQN-BqAtKUpW.js → diagram-3NCE3AQN-Cz1OEMVi.js} +1 -1
- package/web/assets/{diagram-GF46GFSD-CaBG6n6o.js → diagram-GF46GFSD-CWYwzfP8.js} +1 -1
- package/web/assets/{diagram-QXG6HAR7-CZ-O3rcV.js → diagram-QXG6HAR7-DtBObr8L.js} +1 -1
- package/web/assets/{diagram-WEQXMOUZ-P4lSL4GH.js → diagram-WEQXMOUZ-BUV44Ov_.js} +1 -1
- package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-Ds9s-sRF.js} +1 -1
- package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-CupStHQb.js} +1 -1
- package/web/assets/index-COMMmbxW.css +2 -0
- package/web/assets/{index-CG6q8eTs.js → index-Do8AE5yF.js} +85 -85
- package/web/assets/{infoDiagram-3YFTVSEB-DmLICZx1.js → infoDiagram-3YFTVSEB-DPTXSbgu.js} +1 -1
- package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CeK-Qhet.js} +1 -1
- package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-D4tx6QQ-.js} +1 -1
- package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-Bcto_kGa.js} +1 -1
- package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-DPIeBTG6.js} +1 -1
- package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-DDGXjVKO.js} +1 -1
- package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-BzDqpcVW.js} +1 -1
- package/web/assets/{stateDiagram-7D4R322I-CB2nABwH.js → stateDiagram-7D4R322I-Bx__als3.js} +1 -1
- package/web/assets/{stateDiagram-v2-36443NZ5-COBGd2RL.js → stateDiagram-v2-36443NZ5-QqOh2yO-.js} +1 -1
- package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-C1eogTOG.js} +1 -1
- package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-D20F4rSW.js} +1 -1
- package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-BeBHg7ST.js} +1 -1
- package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-CdOWeoNI.js} +1 -1
- package/web/index.html +2 -2
- package/scripts/materialize-vendor-grammars.cjs +0 -97
- 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
|
-
|
|
436
|
-
|
|
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(
|
|
439
|
-
this.lastStalenessCheck.delete(
|
|
440
|
-
this.
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
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
|
-
|
|
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 {
|