gitnexus 1.6.11-rc.2 → 1.6.11-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +43 -1
  2. package/dist/_shared/impact-risk.d.ts +37 -0
  3. package/dist/_shared/impact-risk.d.ts.map +1 -0
  4. package/dist/_shared/impact-risk.js +92 -0
  5. package/dist/_shared/impact-risk.js.map +1 -0
  6. package/dist/_shared/index.d.ts +2 -0
  7. package/dist/_shared/index.d.ts.map +1 -1
  8. package/dist/_shared/index.js +2 -0
  9. package/dist/_shared/index.js.map +1 -1
  10. package/dist/cli/ai-context.js +4 -4
  11. package/dist/cli/analyze-config.d.ts +2 -0
  12. package/dist/cli/analyze-config.js +16 -0
  13. package/dist/cli/analyze-options.d.ts +4 -0
  14. package/dist/cli/analyze.d.ts +17 -0
  15. package/dist/cli/analyze.js +43 -7
  16. package/dist/cli/eval-server.js +22 -5
  17. package/dist/cli/group.js +7 -1
  18. package/dist/cli/help-i18n.js +2 -0
  19. package/dist/cli/i18n/en.d.ts +12 -2
  20. package/dist/cli/i18n/en.js +12 -2
  21. package/dist/cli/i18n/resources.d.ts +22 -2
  22. package/dist/cli/i18n/zh-CN.d.ts +10 -0
  23. package/dist/cli/i18n/zh-CN.js +12 -2
  24. package/dist/cli/index.js +11 -4
  25. package/dist/cli/status.js +91 -6
  26. package/dist/cli/watch-queue.d.ts +41 -0
  27. package/dist/cli/watch-queue.js +184 -0
  28. package/dist/cli/watch.d.ts +20 -0
  29. package/dist/cli/watch.js +372 -0
  30. package/dist/cli/wiki.js +15 -2
  31. package/dist/config/ignore-service.d.ts +11 -0
  32. package/dist/config/ignore-service.js +41 -4
  33. package/dist/config/repo-control-file.d.ts +3 -0
  34. package/dist/config/repo-control-file.js +115 -0
  35. package/dist/core/group/config-parser.js +20 -2
  36. package/dist/core/group/cross-impact.d.ts +2 -1
  37. package/dist/core/group/cross-impact.js +33 -2
  38. package/dist/core/group/extractors/fs-utils.d.ts +2 -0
  39. package/dist/core/group/extractors/fs-utils.js +86 -0
  40. package/dist/core/group/extractors/graphql-extractor.d.ts +17 -0
  41. package/dist/core/group/extractors/graphql-extractor.js +652 -0
  42. package/dist/core/group/extractors/java-workspace-extractor.js +244 -31
  43. package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
  44. package/dist/core/group/extractors/manifest-extractor.js +1 -1
  45. package/dist/core/group/matching.js +8 -1
  46. package/dist/core/group/service.js +5 -1
  47. package/dist/core/group/storage.js +1 -0
  48. package/dist/core/group/sync.d.ts +3 -1
  49. package/dist/core/group/sync.js +43 -5
  50. package/dist/core/group/types.d.ts +19 -5
  51. package/dist/core/incremental/derived-writeback.d.ts +36 -0
  52. package/dist/core/incremental/derived-writeback.js +68 -0
  53. package/dist/core/incremental/subgraph-extract.d.ts +6 -4
  54. package/dist/core/incremental/subgraph-extract.js +7 -5
  55. package/dist/core/index-content-drift.d.ts +54 -0
  56. package/dist/core/index-content-drift.js +127 -0
  57. package/dist/core/ingestion/filesystem-walker.d.ts +15 -5
  58. package/dist/core/ingestion/filesystem-walker.js +20 -3
  59. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.d.ts +22 -0
  60. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.js +124 -0
  61. package/dist/core/ingestion/language-provider.d.ts +43 -0
  62. package/dist/core/ingestion/languages/csharp/razor-view-components.d.ts +62 -0
  63. package/dist/core/ingestion/languages/csharp/razor-view-components.js +954 -0
  64. package/dist/core/ingestion/languages/csharp/resolution-config.d.ts +3 -0
  65. package/dist/core/ingestion/languages/csharp/resolution-config.js +6 -1
  66. package/dist/core/ingestion/languages/csharp/scope-resolver.js +8 -0
  67. package/dist/core/ingestion/languages/java/capture-side-channel.d.ts +4 -0
  68. package/dist/core/ingestion/languages/java/capture-side-channel.js +16 -0
  69. package/dist/core/ingestion/languages/java/captures.js +14 -1
  70. package/dist/core/ingestion/languages/java/lombok-synthesizer.d.ts +42 -0
  71. package/dist/core/ingestion/languages/java/lombok-synthesizer.js +439 -0
  72. package/dist/core/ingestion/languages/java/scope-resolver.js +2 -0
  73. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.d.ts +8 -0
  74. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.js +62 -0
  75. package/dist/core/ingestion/languages/java.js +2 -0
  76. package/dist/core/ingestion/languages/jvm/accessor-synthesis.d.ts +99 -0
  77. package/dist/core/ingestion/languages/jvm/accessor-synthesis.js +173 -0
  78. package/dist/core/ingestion/languages/jvm/beanspec.d.ts +17 -0
  79. package/dist/core/ingestion/languages/jvm/beanspec.js +42 -0
  80. package/dist/core/ingestion/languages/kotlin/capture-side-channel.d.ts +5 -0
  81. package/dist/core/ingestion/languages/kotlin/capture-side-channel.js +16 -0
  82. package/dist/core/ingestion/languages/kotlin/captures.js +14 -1
  83. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.d.ts +26 -0
  84. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.js +417 -0
  85. package/dist/core/ingestion/languages/kotlin/scope-resolver.js +2 -0
  86. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.d.ts +8 -0
  87. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.js +77 -0
  88. package/dist/core/ingestion/languages/kotlin.js +2 -0
  89. package/dist/core/ingestion/pipeline-phases/di.js +47 -14
  90. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -1
  91. package/dist/core/ingestion/pipeline-phases/parse-impl.js +57 -86
  92. package/dist/core/ingestion/pipeline-phases/parse.d.ts +2 -0
  93. package/dist/core/ingestion/pipeline-phases/runner.d.ts +4 -1
  94. package/dist/core/ingestion/pipeline-phases/runner.js +34 -14
  95. package/dist/core/ingestion/pipeline-phases/scan.js +25 -13
  96. package/dist/core/ingestion/pipeline.d.ts +6 -0
  97. package/dist/core/ingestion/pipeline.js +44 -12
  98. package/dist/core/ingestion/scope-extractor.js +1 -0
  99. package/dist/core/ingestion/utils/symbol-labels.d.ts +2 -2
  100. package/dist/core/ingestion/utils/symbol-labels.js +2 -2
  101. package/dist/core/ingestion/workers/parse-worker.js +28 -2
  102. package/dist/core/lbug/lbug-adapter.d.ts +59 -0
  103. package/dist/core/lbug/lbug-adapter.js +154 -1
  104. package/dist/core/run-analyze.d.ts +17 -0
  105. package/dist/core/run-analyze.js +231 -23
  106. package/dist/core/search/fts-indexes.d.ts +19 -1
  107. package/dist/core/search/fts-indexes.js +28 -1
  108. package/dist/core/wiki/generator.js +8 -0
  109. package/dist/core/wiki/grok-client.d.ts +21 -0
  110. package/dist/core/wiki/grok-client.js +287 -0
  111. package/dist/core/wiki/llm-client.d.ts +1 -1
  112. package/dist/core/wiki/llm-client.js +5 -2
  113. package/dist/core/wiki/local-cli-client.d.ts +11 -0
  114. package/dist/core/wiki/local-cli-client.js +22 -9
  115. package/dist/mcp/local/local-backend.d.ts +24 -7
  116. package/dist/mcp/local/local-backend.js +190 -81
  117. package/dist/mcp/local/pdg-impact.d.ts +8 -4
  118. package/dist/mcp/local/pdg-impact.js +7 -2
  119. package/dist/mcp/repository-policy.d.ts +5 -1
  120. package/dist/mcp/repository-policy.js +48 -4
  121. package/dist/mcp/resources.js +2 -1
  122. package/dist/mcp/server.js +6 -5
  123. package/dist/mcp/tools.js +31 -19
  124. package/dist/server/api.js +23 -64
  125. package/dist/server/grep-params.d.ts +18 -0
  126. package/dist/server/grep-params.js +83 -0
  127. package/dist/server/grep-scan.d.ts +23 -0
  128. package/dist/server/grep-scan.js +107 -0
  129. package/dist/server/grep-worker.d.ts +1 -0
  130. package/dist/server/grep-worker.js +12 -0
  131. package/dist/server/mcp-http.d.ts +8 -0
  132. package/dist/server/mcp-http.js +16 -1
  133. package/dist/storage/file-hash.d.ts +5 -0
  134. package/dist/storage/file-hash.js +16 -6
  135. package/dist/storage/fs-atomic.d.ts +24 -0
  136. package/dist/storage/fs-atomic.js +86 -2
  137. package/dist/storage/git.d.ts +19 -8
  138. package/dist/storage/git.js +83 -24
  139. package/dist/storage/gitnexus-managed-paths.d.ts +36 -0
  140. package/dist/storage/gitnexus-managed-paths.js +46 -0
  141. package/dist/storage/parse-cache.d.ts +22 -4
  142. package/dist/storage/parse-cache.js +106 -29
  143. package/dist/storage/parsedfile-store.d.ts +34 -60
  144. package/dist/storage/parsedfile-store.js +177 -171
  145. package/dist/storage/repo-manager.d.ts +11 -1
  146. package/dist/storage/repo-manager.js +23 -2
  147. package/dist/storage/repo-meta.d.ts +12 -0
  148. package/dist/storage/v8-sidecar.d.ts +48 -0
  149. package/dist/storage/v8-sidecar.js +347 -0
  150. package/dist/types/pipeline.d.ts +14 -0
  151. package/package.json +4 -1
  152. package/scripts/cross-platform-shard.ts +4 -2
  153. package/scripts/cross-platform-tests.ts +7 -1
  154. package/skills/gitnexus-cli.md +11 -3
  155. package/skills/gitnexus-impact-analysis.md +9 -0
  156. package/web/assets/{agent-Dr4l5EOp.js → agent-CFqT4hjR.js} +108 -104
  157. package/web/assets/{index-2zdvEdzg.js → index-BMIniRtX.js} +3 -3
  158. package/web/index.html +1 -1
@@ -8,6 +8,7 @@
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import { createHash } from 'crypto';
11
+ import { scoreImpactRisk, unusedAxesForImpactWalk } from '../../_shared/index.js';
11
12
  import { initLbug, executeQuery, executeParameterized, ensureVectorExtension, closeLbug, isLbugReady, statDbIdentity, dbIdentityChanged, } from '../../core/lbug/pool-adapter.js';
12
13
  import { queryClassBeanMetadata } from './bean-metadata.js';
13
14
  import { querySpringAopMetadata } from './aop-metadata.js';
@@ -23,7 +24,7 @@ import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/l
23
24
  // at MCP server startup — crashes on unsupported Node ABI versions (#89)
24
25
  // git utilities available if needed
25
26
  // import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
26
- import { parseDiffHunks, coalesceHunksByPath, hunksOverlapRange, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
27
+ import { parseDiffHunks, coalesceHunksByPath, hunksOverlapRange, findGitRootByDotGit, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
27
28
  import { realpathSync } from 'fs';
28
29
  import { listRegisteredRepos, cleanupOldKuzuFiles, canonicalizePath, getStoragePaths, loadMeta, RegistryAmbiguousTargetError, } from '../../storage/repo-manager.js';
29
30
  import { GroupService, } from '../../core/group/service.js';
@@ -1178,23 +1179,56 @@ export class LocalBackend {
1178
1179
  * - If only 1 repo, use it
1179
1180
  * - If 0 or multiple without param, throw with helpful message
1180
1181
  *
1181
- * On a miss, re-reads the registry once in case a new repo was indexed
1182
- * while the MCP server was running.
1182
+ * Re-reads the registry before an omitted implicit target or after an
1183
+ * explicit miss, so long-running servers see newly indexed repositories.
1183
1184
  */
1184
1185
  async resolveRepo(repoParam, branch) {
1185
- let refreshedAfterAmbiguity = false;
1186
+ return this.selectToolRepository(repoParam, branch);
1187
+ }
1188
+ /**
1189
+ * Internal resolver variant for CLI/MCP tool routing and discovery.
1190
+ * - If repoParam is given, match by name or path
1191
+ * - If only 1 repo, use it
1192
+ * - If multiple repos exist and repoParam is omitted, callers may opt in to
1193
+ * the registered repo containing process.cwd()
1194
+ * - If 0 repos exist, or cwd cannot disambiguate multiple repos, throw
1195
+ *
1196
+ * Omitted-repo resolution re-reads the registry before accepting any
1197
+ * implicit target, including a cached singleton. A caller that just obtained
1198
+ * a fresh registry snapshot may disable that refresh explicitly.
1199
+ */
1200
+ async selectToolRepository(repoParam, branch, options = {}) {
1201
+ const allowCwdDefault = options.allowCwdDefault === true;
1202
+ const mayRefresh = options.refreshRegistry !== false;
1203
+ let refreshed = false;
1204
+ // A cached singleton is also an implicit choice: another process may have
1205
+ // registered a second repo since init, which must not let a repo-less
1206
+ // mutating call bypass the multi-repo ambiguity guard.
1207
+ if (!repoParam && mayRefresh) {
1208
+ await this.refreshRepos();
1209
+ refreshed = true;
1210
+ }
1186
1211
  let result;
1187
1212
  try {
1188
- result = this.resolveRepoFromCache(repoParam);
1213
+ result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
1189
1214
  }
1190
1215
  catch (err) {
1191
1216
  if (!(err instanceof RegistryAmbiguousTargetError))
1192
1217
  throw err;
1218
+ if (!mayRefresh || refreshed)
1219
+ throw err;
1193
1220
  // Stale in-memory duplicate siblings can linger after unregister; refresh
1194
1221
  // once before re-throwing so a resolved registry can disambiguate (#1658).
1195
1222
  await this.refreshRepos();
1196
- refreshedAfterAmbiguity = true;
1197
- result = this.resolveRepoFromCache(repoParam);
1223
+ refreshed = true;
1224
+ result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
1225
+ }
1226
+ // Explicit misses retain the existing one-refresh retry. Omitted targets
1227
+ // already refreshed above unless a same-snapshot caller opted out.
1228
+ if (!result && mayRefresh && !refreshed) {
1229
+ await this.refreshRepos();
1230
+ refreshed = true;
1231
+ result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
1198
1232
  }
1199
1233
  if (result) {
1200
1234
  // Issue: silent graph drift across sibling clones.
@@ -1208,15 +1242,6 @@ export class LocalBackend {
1208
1242
  });
1209
1243
  return this.applyBranchScope(result, branch);
1210
1244
  }
1211
- // Miss — refresh registry and try once more (skip if already refreshed above)
1212
- if (!refreshedAfterAmbiguity) {
1213
- await this.refreshRepos();
1214
- }
1215
- const retried = this.resolveRepoFromCache(repoParam);
1216
- if (retried) {
1217
- this.maybeWarnSiblingDrift(retried).catch(() => { });
1218
- return this.applyBranchScope(retried, branch);
1219
- }
1220
1245
  // Still no match — throw with helpful message
1221
1246
  if (this.repos.size === 0) {
1222
1247
  throw new Error('No indexed repositories. Run: gitnexus analyze');
@@ -1349,7 +1374,7 @@ export class LocalBackend {
1349
1374
  * Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
1350
1375
  * multiple handles by name and cwd cannot disambiguate (#1658).
1351
1376
  */
1352
- resolveRepoFromCache(repoParam) {
1377
+ resolveRepoFromCache(repoParam, allowCwdDefault = false) {
1353
1378
  if (this.repos.size === 0)
1354
1379
  return null;
1355
1380
  if (repoParam) {
@@ -1378,6 +1403,9 @@ export class LocalBackend {
1378
1403
  if (nameMatches.length === 1)
1379
1404
  return nameMatches[0];
1380
1405
  if (nameMatches.length > 1) {
1406
+ // Explicit duplicate aliases retain the legacy fail-closed contract:
1407
+ // only an exact cwd Git-root match may disambiguate them. Deepest path
1408
+ // containment is reserved for an omitted read-only repo (#3073).
1381
1409
  const cwdPick = this.pickRepoHandleForCwd(nameMatches);
1382
1410
  if (cwdPick)
1383
1411
  return cwdPick;
@@ -1402,26 +1430,46 @@ export class LocalBackend {
1402
1430
  if (this.repos.size === 1) {
1403
1431
  return this.repos.values().next().value;
1404
1432
  }
1433
+ if (allowCwdDefault) {
1434
+ const cwdPick = this.pickRepoHandleForCwd([...this.repos.values()], true);
1435
+ if (cwdPick)
1436
+ return cwdPick;
1437
+ }
1405
1438
  return null; // Multiple repos, no param — ambiguous
1406
1439
  }
1407
1440
  /**
1408
- * Prefer the indexed repo whose path matches the git root of process.cwd().
1441
+ * Match process.cwd() against indexed repositories.
1409
1442
  *
1410
- * In MCP stdio server mode, `process.cwd()` is the server's launch directory,
1411
- * not the agent client's cwd. If the server was started from an unrelated
1412
- * directory, `getGitRoot` returns null and duplicate-name resolution throws
1413
- * {@link RegistryAmbiguousTargetError} — callers should pass an absolute path.
1443
+ * Explicit duplicate aliases use exact Git-root matching only. Omitted
1444
+ * read-only calls opt into deepest containing-path selection. In that mode a
1445
+ * candidate must not sit above cwd's Git root, so an unindexed nested checkout
1446
+ * cannot fall through to an indexed ancestor. The `.git` ancestor fallback
1447
+ * preserves that boundary when the git executable is unavailable.
1414
1448
  */
1415
- pickRepoHandleForCwd(candidates) {
1416
- const cwdRoot = getGitRoot(process.cwd());
1417
- if (!cwdRoot)
1449
+ pickRepoHandleForCwd(candidates, allowContaining = false) {
1450
+ const cwd = process.cwd();
1451
+ const normalize = (value) => {
1452
+ const canonical = canonicalizePath(value);
1453
+ return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
1454
+ };
1455
+ const isSameOrDescendant = (parent, child) => child === parent ||
1456
+ child.startsWith(parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`);
1457
+ const canonicalCwd = normalize(cwd);
1458
+ const cwdRoot = getGitRoot(cwd) ?? findGitRootByDotGit(cwd);
1459
+ const canonicalRoot = cwdRoot ? normalize(cwdRoot) : null;
1460
+ if (allowContaining) {
1461
+ const containing = candidates
1462
+ .map((handle) => ({ handle, repoPath: normalize(handle.repoPath) }))
1463
+ .filter(({ repoPath }) => isSameOrDescendant(repoPath, canonicalCwd))
1464
+ .filter(({ repoPath }) => !canonicalRoot || isSameOrDescendant(canonicalRoot, repoPath))
1465
+ .sort((a, b) => b.repoPath.length - a.repoPath.length);
1466
+ if (containing.length > 0)
1467
+ return containing[0].handle;
1468
+ }
1469
+ if (!canonicalRoot)
1418
1470
  return null;
1419
- const canonicalCwd = canonicalizePath(cwdRoot);
1420
1471
  const cwdMatches = candidates.filter((handle) => {
1421
- const stored = canonicalizePath(handle.repoPath);
1422
- return process.platform === 'win32'
1423
- ? stored.toLowerCase() === canonicalCwd.toLowerCase()
1424
- : stored === canonicalCwd;
1472
+ return normalize(handle.repoPath) === canonicalRoot;
1425
1473
  });
1426
1474
  return cwdMatches.length === 1 ? cwdMatches[0] : null;
1427
1475
  }
@@ -1818,7 +1866,7 @@ export class LocalBackend {
1818
1866
  }
1819
1867
  // Resolve repo from optional param (re-reads registry on miss). An optional
1820
1868
  // `branch` param scopes the resolved handle to that branch's index (#2106).
1821
- const repo = await this.resolveRepo(p.repo, p.branch);
1869
+ const repo = await this.selectToolRepository(p.repo, p.branch, { allowCwdDefault: method !== 'rename' });
1822
1870
  switch (method) {
1823
1871
  case 'query':
1824
1872
  return this.withToolStaleness(repo, await this.query(repo, p));
@@ -3000,7 +3048,22 @@ export class LocalBackend {
3000
3048
  else if (isQualified) {
3001
3049
  // Parenthesised because the kind filter below is appended with AND, which
3002
3050
  // binds tighter than OR.
3003
- whereClause = `WHERE (n.id = $symName OR n.name = $symName)`;
3051
+ // #3074: a repo-relative file path (e.g. "supabase/functions/_shared/crypto.ts")
3052
+ // is the most natural way to name a File and is exactly what `target.filePath`
3053
+ // reports, but the old clause only matched `n.id` (= "File:<path>") or basename
3054
+ // `n.name`, so the same path the graph stores never resolved. Also match the
3055
+ // repo-relative `n.filePath` exactly and via an anchored suffix (segment-boundary
3056
+ // "ENDS WITH $suffix" where suffix is "/"+path) so "a.ts" does not spuriously
3057
+ // match "mylib/a.ts" — same anchoring used in detect_changes (#2915).
3058
+ const suffix = pathSuffixOf(name);
3059
+ // File-path terms must be scoped to File nodes — n.filePath is shared by
3060
+ // every symbol in the file, so an unlabeled predicate would turn
3061
+ // "src/actions.ts" into every symbol in that file (bot review #3084 P1).
3062
+ // LadybugDB does not allow label tests in WHERE (n:File), so scope via
3063
+ // id prefix — File nodes are `File:<path>`.
3064
+ whereClause = `WHERE (n.id = $symName OR n.name = $symName OR (n.id STARTS WITH $filePrefix AND (n.filePath = $symName OR n.filePath ENDS WITH $suffix)))`;
3065
+ queryParams.suffix = suffix;
3066
+ queryParams.filePrefix = 'File:';
3004
3067
  }
3005
3068
  else {
3006
3069
  whereClause = `WHERE n.name = $symName`;
@@ -3079,7 +3142,7 @@ export class LocalBackend {
3079
3142
  if (rows.length === 0)
3080
3143
  return { kind: 'not_found' };
3081
3144
  // Normalise row shape across object / tuple returns from LadybugDB.
3082
- const normalized = rows.map((r) => ({
3145
+ let normalized = rows.map((r) => ({
3083
3146
  id: (r.id ?? r[0]),
3084
3147
  name: (r.name ?? r[1]),
3085
3148
  type: (r.type ?? r[2] ?? ''),
@@ -3088,6 +3151,14 @@ export class LocalBackend {
3088
3151
  endLine: (r.endLine ?? r[5]),
3089
3152
  ...(include_content ? { content: (r.content ?? r[6]) } : {}),
3090
3153
  }));
3154
+ // An exact File path wins over anchored suffix candidates. Without this,
3155
+ // `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an
3156
+ // otherwise unambiguous exact target into `ambiguous` (#3084 review P2).
3157
+ if (isQualified) {
3158
+ const exactFiles = normalized.filter((candidate) => candidate.id.startsWith('File:') && candidate.filePath === name);
3159
+ if (exactFiles.length > 0)
3160
+ normalized = exactFiles;
3161
+ }
3091
3162
  // The COUNT can never legitimately be below the page it accompanies, so a
3092
3163
  // value under `normalized.length` means the count leg failed or returned an
3093
3164
  // unreadable shape. Keep the window size as the floor — reporting zero would
@@ -4942,6 +5013,7 @@ export class LocalBackend {
4942
5013
  direction: params.direction,
4943
5014
  suggestion,
4944
5015
  recoverySuggestion,
5016
+ undetermined: true,
4945
5017
  });
4946
5018
  return pdgErr;
4947
5019
  }
@@ -4949,7 +5021,7 @@ export class LocalBackend {
4949
5021
  error: message,
4950
5022
  target: { name: params.target },
4951
5023
  direction: params.direction,
4952
- impactedCount: 0,
5024
+ impactedCount: null,
4953
5025
  risk: 'UNKNOWN',
4954
5026
  suggestion,
4955
5027
  ...(recoverySuggestion ? { recoverySuggestion } : {}),
@@ -5036,6 +5108,7 @@ export class LocalBackend {
5036
5108
  `(single-repo PDG impact). Remove them or use mode:'callgraph' for cross-repo fan-out.`,
5037
5109
  target: crossDepthTarget,
5038
5110
  direction,
5111
+ undetermined: true,
5039
5112
  });
5040
5113
  return pdgErr;
5041
5114
  }
@@ -5091,12 +5164,17 @@ export class LocalBackend {
5091
5164
  error: `Target '${missing}' not found`,
5092
5165
  target: notFoundTarget,
5093
5166
  direction,
5167
+ undetermined: true,
5094
5168
  })
5095
5169
  : {
5096
5170
  error: `Target '${missing}' not found`,
5097
5171
  target: { name: target },
5098
5172
  direction,
5099
- impactedCount: 0,
5173
+ // #3074 follow-up: do not ship a normal-shaped 0/UNKNOWN blast radius
5174
+ // alongside the error — it reads as a real "nothing depends on this"
5175
+ // answer. Null marks UNDETERMINED (same as the ambiguous path) so a
5176
+ // consumer testing `impactedCount === 0` cannot misread a miss as safe.
5177
+ impactedCount: null,
5100
5178
  risk: 'UNKNOWN',
5101
5179
  };
5102
5180
  }
@@ -5200,6 +5278,10 @@ export class LocalBackend {
5200
5278
  score: Number(c.score.toFixed(2)),
5201
5279
  impactedCount: summary?.impactedCount ?? 0,
5202
5280
  risk: summary?.risk ?? 'UNKNOWN',
5281
+ ...(summary?.riskSharedAxes !== undefined
5282
+ ? { riskSharedAxes: summary.riskSharedAxes }
5283
+ : {}),
5284
+ ...(summary?.riskScale !== undefined ? { riskScale: summary.riskScale } : {}),
5203
5285
  direct: summary?.summary?.direct ?? 0,
5204
5286
  ...(summary?.riskNote !== undefined ? { riskNote: summary.riskNote } : {}),
5205
5287
  // Carry the explanation with the verdict. The single-symbol path
@@ -6092,6 +6174,10 @@ export class LocalBackend {
6092
6174
  const rawMaxChunks = process.env.IMPACT_MAX_CHUNKS?.trim();
6093
6175
  const parsedMaxChunks = rawMaxChunks ? Number(rawMaxChunks) : Number.NaN;
6094
6176
  const MAX_CHUNKS = Number.isInteger(parsedMaxChunks) && parsedMaxChunks >= 0 ? parsedMaxChunks : 10;
6177
+ let processQueryFailed = false;
6178
+ let moduleQueryFailed = false;
6179
+ let enrichmentDegraded = false;
6180
+ let moduleClassificationFailed = false;
6095
6181
  // `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
6096
6182
  // process/module aggregation passes entirely — those probes need only the
6097
6183
  // count + a count-based risk, so paying the bounded-but-real enrichment cost
@@ -6125,7 +6211,12 @@ export class LocalBackend {
6125
6211
  p.entryPointId AS entryPointId, hits, minStep, p.stepCount AS stepCount,
6126
6212
  ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath
6127
6213
  ORDER BY pId
6128
- `, { ids }).catch(() => []);
6214
+ `, { ids }).catch((err) => {
6215
+ processQueryFailed = true;
6216
+ enrichmentDegraded = true;
6217
+ logQueryError('impact:process-chunk', err);
6218
+ return [];
6219
+ });
6129
6220
  for (const row of rows) {
6130
6221
  const pId = row.pId ?? row[0];
6131
6222
  const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0];
@@ -6173,6 +6264,8 @@ export class LocalBackend {
6173
6264
  }
6174
6265
  }
6175
6266
  catch (e) {
6267
+ processQueryFailed = true;
6268
+ enrichmentDegraded = true;
6176
6269
  logQueryError('impact:process-chunk', e);
6177
6270
  }
6178
6271
  }
@@ -6187,7 +6280,11 @@ export class LocalBackend {
6187
6280
  MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
6188
6281
  WHERE p.id IN $pIds AND s.id IN $ids
6189
6282
  RETURN p.id AS pid, MIN(r.step) AS minStep
6190
- `, { pIds, ids: allImpactedIds }).catch(() => []);
6283
+ `, { pIds, ids: allImpactedIds }).catch((err) => {
6284
+ enrichmentDegraded = true;
6285
+ logQueryError('impact:process-chunk-backfill', err);
6286
+ return [];
6287
+ });
6191
6288
  for (const mr of missingRows) {
6192
6289
  const pid = mr.pid ?? mr[0];
6193
6290
  const minStep = mr.minStep ?? mr[1];
@@ -6203,6 +6300,7 @@ export class LocalBackend {
6203
6300
  }
6204
6301
  }
6205
6302
  catch (e) {
6303
+ enrichmentDegraded = true;
6206
6304
  logQueryError('impact:process-chunk-backfill', e);
6207
6305
  }
6208
6306
  }
@@ -6250,7 +6348,12 @@ export class LocalBackend {
6250
6348
  RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
6251
6349
  ORDER BY hits DESC, name
6252
6350
  LIMIT 20
6253
- `, { ids: idsChunk }).catch(() => []);
6351
+ `, { ids: idsChunk }).catch((err) => {
6352
+ moduleQueryFailed = true;
6353
+ enrichmentDegraded = true;
6354
+ logQueryError('impact:module-chunk', err);
6355
+ return [];
6356
+ });
6254
6357
  for (const r of rows) {
6255
6358
  const name = r.name ?? r[0] ?? null;
6256
6359
  const hits = (r.hits ?? r[1]) || 0;
@@ -6260,6 +6363,8 @@ export class LocalBackend {
6260
6363
  }
6261
6364
  }
6262
6365
  catch (e) {
6366
+ moduleQueryFailed = true;
6367
+ enrichmentDegraded = true;
6263
6368
  logQueryError('impact:module-chunk', e);
6264
6369
  }
6265
6370
  };
@@ -6280,7 +6385,12 @@ export class LocalBackend {
6280
6385
  MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
6281
6386
  WHERE s.id IN $ids
6282
6387
  RETURN DISTINCT c.heuristicLabel AS name
6283
- `, { ids: idsChunk }).catch(() => []);
6388
+ `, { ids: idsChunk }).catch((err) => {
6389
+ enrichmentDegraded = true;
6390
+ moduleClassificationFailed = true;
6391
+ logQueryError('impact:direct-module-chunk', err);
6392
+ return [];
6393
+ });
6284
6394
  for (const r of rows) {
6285
6395
  const name = r.name ?? r[0] ?? null;
6286
6396
  if (name)
@@ -6288,6 +6398,8 @@ export class LocalBackend {
6288
6398
  }
6289
6399
  }
6290
6400
  catch (e) {
6401
+ enrichmentDegraded = true;
6402
+ moduleClassificationFailed = true;
6291
6403
  logQueryError('impact:direct-module-chunk', e);
6292
6404
  }
6293
6405
  };
@@ -6308,47 +6420,35 @@ export class LocalBackend {
6308
6420
  return {
6309
6421
  name,
6310
6422
  hits,
6311
- impact: directModuleNameSet.has(name) ? 'direct' : 'indirect',
6423
+ impact: moduleClassificationFailed
6424
+ ? 'classification-unavailable'
6425
+ : directModuleNameSet.has(name)
6426
+ ? 'direct'
6427
+ : 'indirect',
6312
6428
  };
6313
6429
  });
6314
6430
  }
6315
6431
  // Risk scoring
6316
6432
  const processCount = affectedProcesses.length;
6317
6433
  const moduleCount = affectedModules.length;
6318
- let risk;
6319
- if (direction === 'upstream' && impacted.length === 0) {
6320
- // An upstream walk that resolved NO callers cannot support `LOW`. "Safe
6321
- // to change" is a claim ABOUT callers, and this walk found none to reason
6322
- // about: the symbol may be genuinely unused, or reached only through a
6323
- // reference class this index does not record — a property access on a
6324
- // plain object, or a bare-identifier read of a module-scope `Const`,
6325
- // neither of which mints a reference site today. Seeding `LOW` from an
6326
- // empty result is the same false-safe signal `anyKnownRisk` refuses to
6327
- // emit on the ambiguous-candidate path, and that #2687 removed by making
6328
- // an undetermined `impactedCount` `null` instead of `0`.
6329
- //
6330
- // Downstream is deliberately untouched: an empty downstream walk reports
6331
- // that this symbol resolved no callees, which is not a safety verdict.
6332
- risk = 'UNKNOWN';
6333
- }
6334
- else if (directCount >= 30 ||
6335
- processCount >= 5 ||
6336
- moduleCount >= 5 ||
6337
- impacted.length >= 200) {
6338
- risk = 'CRITICAL';
6339
- }
6340
- else if (directCount >= 15 ||
6341
- processCount >= 3 ||
6342
- moduleCount >= 3 ||
6343
- impacted.length >= 100) {
6344
- risk = 'HIGH';
6345
- }
6346
- else if (directCount >= 5 || impacted.length >= 30) {
6347
- risk = 'MEDIUM';
6348
- }
6349
- else {
6350
- risk = 'LOW';
6351
- }
6434
+ const isFileTarget = symType === 'File' || String(symId).startsWith('File:');
6435
+ const unusedAxes = unusedAxesForImpactWalk({
6436
+ isFileTarget,
6437
+ skipEnrichment,
6438
+ maxChunks: MAX_CHUNKS,
6439
+ processQueryFailed,
6440
+ moduleQueryFailed,
6441
+ impactedCount: impacted.length,
6442
+ enrichmentTruncated: !skipEnrichment && MAX_CHUNKS > 0 && impacted.length > MAX_CHUNKS * CHUNK_SIZE,
6443
+ });
6444
+ const { risk, riskSharedAxes, riskScale } = scoreImpactRisk({
6445
+ direction,
6446
+ directCount,
6447
+ processCount,
6448
+ moduleCount,
6449
+ impactedCount: impacted.length,
6450
+ unusedAxes,
6451
+ });
6352
6452
  // Build per-depth counts (always included, even in summaryOnly mode)
6353
6453
  const byDepthCounts = {};
6354
6454
  for (const [depth, items] of Object.entries(grouped)) {
@@ -6365,7 +6465,7 @@ export class LocalBackend {
6365
6465
  target: {
6366
6466
  id: symId,
6367
6467
  name: sym.name || sym[1],
6368
- type: symType,
6468
+ type: isFileTarget ? symType || 'File' : symType,
6369
6469
  filePath: sym.filePath || sym[2],
6370
6470
  ...(beanMetadata ? { bean: beanMetadata } : {}),
6371
6471
  ...(aopMetadata ? { aop: aopMetadata } : {}),
@@ -6373,17 +6473,26 @@ export class LocalBackend {
6373
6473
  direction,
6374
6474
  impactedCount: impacted.length,
6375
6475
  risk,
6476
+ riskSharedAxes,
6477
+ riskScale,
6376
6478
  ...(risk === 'UNKNOWN'
6377
6479
  ? {
6378
- riskNote: 'No callers resolved. Absence of edges is not evidence the symbol is unused: ' +
6379
- 'a caller reaching it through a reference class this index does not record — ' +
6380
- 'plain-object property access, a bare-identifier read of a module-scope const — ' +
6381
- 'produces no edge to find. Confirm with a text search before treating the ' +
6382
- 'change as safe.',
6480
+ riskNote: processQueryFailed || moduleQueryFailed
6481
+ ? 'Risk is unresolved because process/module enrichment failed. Observed counts ' +
6482
+ 'are lower bounds; retry impact before treating the change as safe.'
6483
+ : unusedAxes.some((axis) => axis.reason === 'enrichment-truncated')
6484
+ ? 'Risk is unresolved because process/module enrichment was truncated. Observed ' +
6485
+ 'counts are lower bounds; retry with a higher IMPACT_MAX_CHUNKS before ' +
6486
+ 'treating the change as safe.'
6487
+ : 'No callers resolved. Absence of edges is not evidence the symbol is unused: ' +
6488
+ 'a caller reaching it through a reference class this index does not record — ' +
6489
+ 'plain-object property access, a bare-identifier read of a module-scope const — ' +
6490
+ 'produces no edge to find. Confirm with a text search before treating the ' +
6491
+ 'change as safe.',
6383
6492
  }
6384
6493
  : {}),
6385
6494
  ...epistemic,
6386
- ...(!traversalComplete && { partial: true }),
6495
+ ...((!traversalComplete || enrichmentDegraded) && { partial: true }),
6387
6496
  summary: {
6388
6497
  direct: directCount,
6389
6498
  processes_affected: processCount,
@@ -31,8 +31,10 @@ export declare function splitCalleeIds(raw: unknown): string[];
31
31
  * Bump on any breaking change to the PDG result fields.
32
32
  * v2: `startLine` in the result is now 1-based display (#2380), matching the
33
33
  * context/query/impact tools (was 0-based).
34
+ * v3: error envelopes use `impactedCount: null` when a target cannot be resolved
35
+ * (#3074), so consumers cannot read a miss as a measured zero.
34
36
  */
35
- export declare const PDG_RESULT_VERSION: 2;
37
+ export declare const PDG_RESULT_VERSION: 3;
36
38
  /** A reachable dependence block resolved to its source statement. */
37
39
  export interface PdgStatement {
38
40
  /** 1-based source line where the statement's block starts. */
@@ -229,7 +231,7 @@ export interface PdgInterproceduralImpact {
229
231
  export interface PdgImpactBaseResult extends PdgImpactParityFields {
230
232
  mode: 'pdg';
231
233
  /** Contract version of the mode:'pdg' impact result shape; bump on any breaking change to the PDG result fields. */
232
- pdgResultVersion: 2;
234
+ pdgResultVersion: 3;
233
235
  target: PdgImpactTarget;
234
236
  direction: 'upstream' | 'downstream';
235
237
  impactedCount: number;
@@ -296,11 +298,11 @@ export interface PdgImpactDegradedResult extends PdgImpactBaseResult {
296
298
  export interface PdgImpactErrorResult {
297
299
  mode?: 'pdg';
298
300
  /** Contract version of the mode:'pdg' impact result shape; bump on any breaking change to the PDG result fields. */
299
- pdgResultVersion: 2;
301
+ pdgResultVersion: 3;
300
302
  error: string;
301
303
  target: PdgImpactTarget;
302
304
  direction: 'upstream' | 'downstream';
303
- impactedCount: 0;
305
+ impactedCount: number | null;
304
306
  risk: 'UNKNOWN';
305
307
  suggestion?: string;
306
308
  recoverySuggestion?: string;
@@ -313,6 +315,8 @@ export declare function makePdgImpactErrorResult(input: {
313
315
  mode?: 'pdg';
314
316
  suggestion?: string;
315
317
  recoverySuggestion?: string;
318
+ /** True when analysis did not obtain a measured impact count. */
319
+ undetermined?: boolean;
316
320
  }): PdgImpactErrorResult;
317
321
  export declare function isPdgDegradedLayerStatus(layer: PdgLayerStatus): layer is PdgDegradedLayerStatus;
318
322
  export declare function makePdgLayerDegradedResult(input: {
@@ -109,8 +109,10 @@ export function splitCalleeIds(raw) {
109
109
  * Bump on any breaking change to the PDG result fields.
110
110
  * v2: `startLine` in the result is now 1-based display (#2380), matching the
111
111
  * context/query/impact tools (was 0-based).
112
+ * v3: error envelopes use `impactedCount: null` when a target cannot be resolved
113
+ * (#3074), so consumers cannot read a miss as a measured zero.
112
114
  */
113
- export const PDG_RESULT_VERSION = 2;
115
+ export const PDG_RESULT_VERSION = 3;
114
116
  /**
115
117
  * FU-B-2 intra-block def→use line walk. Given a block's self REACHING_DEF
116
118
  * def→use line PAIRS (every `defLine → useLine` step decoded from the edge
@@ -469,7 +471,10 @@ export function makePdgImpactErrorResult(input) {
469
471
  error: input.error,
470
472
  target: input.target,
471
473
  direction: input.direction,
472
- impactedCount: 0,
474
+ // #3074 follow-up + P2 review: an unmeasured PDG result must not ship a
475
+ // confident-looking 0 blast radius. null marks UNDETERMINED, so a consumer
476
+ // testing === 0 cannot misread a miss or failed query as safe.
477
+ impactedCount: input.undetermined ? null : 0,
473
478
  risk: 'UNKNOWN',
474
479
  ...(input.suggestion ? { suggestion: input.suggestion } : {}),
475
480
  ...(input.recoverySuggestion ? { recoverySuggestion: input.recoverySuggestion } : {}),
@@ -20,10 +20,14 @@ export declare class McpRepositoryPolicy {
20
20
  private repoForArgs;
21
21
  private normalizeToolArgs;
22
22
  private listAllowedRepos;
23
- requiresExplicitRepo(backend: LocalBackend): Promise<boolean>;
23
+ toolSchemaRepoRequirements(backend: LocalBackend): Promise<{
24
+ readOnlyRequiresRepo: boolean;
25
+ mutatingRequiresRepo: boolean;
26
+ }>;
24
27
  private listReposPage;
25
28
  private callTool;
26
29
  private resolveRepo;
30
+ private selectToolRepository;
27
31
  assertResourceUri(uri: string): void;
28
32
  resourceTemplateAllowed(uriTemplate: string): boolean;
29
33
  toolAllowed(toolName: string): boolean;
@@ -134,10 +134,40 @@ export class McpRepositoryPolicy {
134
134
  };
135
135
  });
136
136
  }
137
- async requiresExplicitRepo(backend) {
138
- if (this.defaultRepo)
139
- return false;
140
- return (await this.listAllowedRepos(backend)).length > 1;
137
+ async toolSchemaRepoRequirements(backend) {
138
+ if (this.defaultRepo) {
139
+ return { readOnlyRequiresRepo: false, mutatingRequiresRepo: false };
140
+ }
141
+ // Runtime selection is based on the configured allowlist, not on which
142
+ // entries happen to remain visible in a later registry refresh. Keep the
143
+ // advertised schema aligned with repoForArgs() when that listing shrinks.
144
+ if (this.restricted) {
145
+ const requiresRepo = this.allowed.length > 1;
146
+ return {
147
+ readOnlyRequiresRepo: requiresRepo,
148
+ mutatingRequiresRepo: requiresRepo,
149
+ };
150
+ }
151
+ // One fresh listing supplies both schema decisions. Besides keeping the
152
+ // advertised contract internally consistent, this avoids doing two full
153
+ // per-repo staleness fan-outs for every tools/list request.
154
+ const visibleRepos = await this.listAllowedRepos(backend);
155
+ if (visibleRepos.length <= 1) {
156
+ return { readOnlyRequiresRepo: false, mutatingRequiresRepo: false };
157
+ }
158
+ try {
159
+ // listAllowedRepos() refreshed this backend immediately above. Resolve
160
+ // against that exact cache snapshot instead of racing another registry
161
+ // read; only read-only schemas may advertise the cwd-derived default.
162
+ await backend.selectToolRepository(undefined, undefined, {
163
+ allowCwdDefault: true,
164
+ refreshRegistry: false,
165
+ });
166
+ return { readOnlyRequiresRepo: false, mutatingRequiresRepo: true };
167
+ }
168
+ catch {
169
+ return { readOnlyRequiresRepo: true, mutatingRequiresRepo: true };
170
+ }
141
171
  }
142
172
  async listReposPage(backend, params) {
143
173
  const { limit, offset } = parseListReposPagination(params, {
@@ -186,6 +216,17 @@ export class McpRepositoryPolicy {
186
216
  const selected = this.repoForArgs(repo === undefined ? undefined : { repo });
187
217
  return backend.resolveRepo(selected?.path, branch);
188
218
  }
219
+ async selectToolRepository(backend, repo, branch, options) {
220
+ if (!this.configured)
221
+ return backend.selectToolRepository(repo, branch, options);
222
+ if (!this.restricted) {
223
+ return backend.selectToolRepository(repo ?? this.defaultRepo?.path, branch, options);
224
+ }
225
+ const selected = this.repoForArgs(repo === undefined ? undefined : { repo });
226
+ // Restricted policies never allow cwd to select outside the configured
227
+ // set; once policy supplies an explicit path, the public resolver is enough.
228
+ return backend.resolveRepo(selected?.path, branch);
229
+ }
189
230
  assertResourceUri(uri) {
190
231
  if (!this.restricted)
191
232
  return;
@@ -255,6 +296,9 @@ export class McpRepositoryPolicy {
255
296
  if (property === 'resolveRepo') {
256
297
  return (repo, branch) => policy.resolveRepo(target, repo, branch);
257
298
  }
299
+ if (property === 'selectToolRepository') {
300
+ return (repo, branch, options) => policy.selectToolRepository(target, repo, branch, options);
301
+ }
258
302
  if (property === 'getContext' && policy.restricted) {
259
303
  return (repoId) => {
260
304
  if (!repoId || !policy.uniqueAllowedContextNames.has(repoId.toLowerCase()))