gitnexus 1.6.11-rc.6 → 1.6.11-rc.8

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 (35) hide show
  1. package/dist/_shared/impact-risk.d.ts +4 -2
  2. package/dist/_shared/impact-risk.d.ts.map +1 -1
  3. package/dist/_shared/impact-risk.js +35 -21
  4. package/dist/_shared/impact-risk.js.map +1 -1
  5. package/dist/cli/ai-context.js +4 -4
  6. package/dist/cli/eval-server.js +22 -5
  7. package/dist/cli/i18n/en.d.ts +8 -0
  8. package/dist/cli/i18n/en.js +8 -0
  9. package/dist/cli/i18n/resources.d.ts +16 -0
  10. package/dist/cli/i18n/zh-CN.d.ts +8 -0
  11. package/dist/cli/i18n/zh-CN.js +8 -0
  12. package/dist/cli/status.js +91 -6
  13. package/dist/core/group/cross-impact.d.ts +2 -1
  14. package/dist/core/group/cross-impact.js +23 -2
  15. package/dist/core/group/types.d.ts +12 -1
  16. package/dist/core/index-content-drift.d.ts +54 -0
  17. package/dist/core/index-content-drift.js +127 -0
  18. package/dist/core/ingestion/filesystem-walker.d.ts +15 -5
  19. package/dist/core/ingestion/filesystem-walker.js +20 -3
  20. package/dist/core/ingestion/pipeline-phases/scan.js +25 -13
  21. package/dist/core/run-analyze.js +9 -1
  22. package/dist/mcp/local/local-backend.js +78 -46
  23. package/dist/mcp/tools.js +4 -2
  24. package/dist/storage/file-hash.d.ts +5 -0
  25. package/dist/storage/file-hash.js +16 -6
  26. package/dist/storage/git.d.ts +19 -8
  27. package/dist/storage/git.js +83 -24
  28. package/dist/storage/gitnexus-managed-paths.d.ts +36 -0
  29. package/dist/storage/gitnexus-managed-paths.js +46 -0
  30. package/dist/storage/repo-meta.d.ts +12 -0
  31. package/package.json +1 -1
  32. package/skills/gitnexus-impact-analysis.md +9 -0
  33. package/web/assets/{agent-Dr4l5EOp.js → agent-BWAXPjC5.js} +106 -104
  34. package/web/assets/{index-2zdvEdzg.js → index-DDRie6_8.js} +3 -3
  35. package/web/index.html +1 -1
@@ -1,6 +1,6 @@
1
1
  export type ImpactRisk = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'UNKNOWN';
2
2
  export type ImpactRiskAxis = 'processes' | 'modules';
3
- export type UnusedImpactRiskReason = 'file-nodes-have-no-process-or-community-membership' | 'enrichment-skipped' | 'enrichment-budget-exhausted' | 'enrichment-query-failed';
3
+ export type UnusedImpactRiskReason = 'file-nodes-have-no-process-or-community-membership' | 'enrichment-skipped' | 'enrichment-budget-exhausted' | 'enrichment-truncated' | 'enrichment-query-failed';
4
4
  export interface UnusedImpactRiskAxis {
5
5
  axis: ImpactRiskAxis;
6
6
  reason: UnusedImpactRiskReason;
@@ -29,7 +29,9 @@ export declare function unusedAxesForImpactWalk(input: {
29
29
  processQueryFailed: boolean;
30
30
  moduleQueryFailed: boolean;
31
31
  /** When 0, a zero chunk budget is not an unused-axis event — there was nothing to enrich. */
32
- impactedCount?: number;
32
+ impactedCount: number;
33
+ /** True when process/module queries ran on a strict subset of impacted symbols. */
34
+ enrichmentTruncated?: boolean;
33
35
  }): UnusedImpactRiskAxis[];
34
36
  export declare function scoreImpactRisk(input: ImpactRiskInput): ImpactRiskResult;
35
37
  //# sourceMappingURL=impact-risk.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"impact-risk.d.ts","sourceRoot":"","sources":["../src/impact-risk.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;AAE5E,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,SAAS,CAAC;AAErD,MAAM,MAAM,sBAAsB,GAC9B,oDAAoD,GACpD,oBAAoB,GACpB,6BAA6B,GAC7B,yBAAyB,CAAC;AAE9B,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,cAAc,CAAC;IACrB,MAAM,EAAE,sBAAsB,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,UAAU,GAAG,YAAY,CAAC;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,SAAS,oBAAoB,EAAE,CAAC;CAC9C;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,UAAU,CAAC;IACjB,cAAc,EAAE,UAAU,CAAC;IAC3B,SAAS,EAAE;QACT,qBAAqB,EAAE,OAAO,CAAC;QAC/B,UAAU,EAAE,SAAS,oBAAoB,EAAE,CAAC;KAC7C,CAAC;CACH;AA0CD,iGAAiG;AACjG,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,6FAA6F;IAC7F,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,GAAG,oBAAoB,EAAE,CAiCzB;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,gBAAgB,CAWxE"}
1
+ {"version":3,"file":"impact-risk.d.ts","sourceRoot":"","sources":["../src/impact-risk.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;AAE5E,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,SAAS,CAAC;AAErD,MAAM,MAAM,sBAAsB,GAC9B,oDAAoD,GACpD,oBAAoB,GACpB,6BAA6B,GAC7B,sBAAsB,GACtB,yBAAyB,CAAC;AAE9B,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,cAAc,CAAC;IACrB,MAAM,EAAE,sBAAsB,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,UAAU,GAAG,YAAY,CAAC;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,SAAS,oBAAoB,EAAE,CAAC;CAC9C;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,UAAU,CAAC;IACjB,cAAc,EAAE,UAAU,CAAC;IAC3B,SAAS,EAAE;QACT,qBAAqB,EAAE,OAAO,CAAC;QAC/B,UAAU,EAAE,SAAS,oBAAoB,EAAE,CAAC;KAC7C,CAAC;CACH;AAwDD,iGAAiG;AACjG,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,6FAA6F;IAC7F,aAAa,EAAE,MAAM,CAAC;IACtB,mFAAmF;IACnF,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,GAAG,oBAAoB,EAAE,CAqBzB;AAOD,wBAAgB,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,gBAAgB,CAuBxE"}
@@ -12,10 +12,23 @@ function score(input) {
12
12
  return 'MEDIUM';
13
13
  return 'LOW';
14
14
  }
15
- function countsWithUnusedAxesZeroed(input) {
15
+ const UNMEASURED_REASONS = new Set([
16
+ 'file-nodes-have-no-process-or-community-membership',
17
+ 'enrichment-skipped',
18
+ 'enrichment-budget-exhausted',
19
+ ]);
20
+ function unusedPair(reason) {
21
+ return [
22
+ { axis: 'processes', reason },
23
+ { axis: 'modules', reason },
24
+ ];
25
+ }
26
+ function countsWithUnmeasuredAxesZeroed(input) {
16
27
  let processCount = input.processCount;
17
28
  let moduleCount = input.moduleCount;
18
29
  for (const unused of input.unusedAxes ?? []) {
30
+ if (!UNMEASURED_REASONS.has(unused.reason))
31
+ continue;
19
32
  if (unused.axis === 'processes')
20
33
  processCount = 0;
21
34
  if (unused.axis === 'modules')
@@ -32,30 +45,18 @@ function countsWithUnusedAxesZeroed(input) {
32
45
  /** Map walk outcomes to unused process/module axes so comparability matches what was sampled. */
33
46
  export function unusedAxesForImpactWalk(input) {
34
47
  if (input.isFileTarget) {
35
- return [
36
- {
37
- axis: 'processes',
38
- reason: 'file-nodes-have-no-process-or-community-membership',
39
- },
40
- {
41
- axis: 'modules',
42
- reason: 'file-nodes-have-no-process-or-community-membership',
43
- },
44
- ];
48
+ return unusedPair('file-nodes-have-no-process-or-community-membership');
45
49
  }
46
50
  if (input.skipEnrichment) {
47
- return [
48
- { axis: 'processes', reason: 'enrichment-skipped' },
49
- { axis: 'modules', reason: 'enrichment-skipped' },
50
- ];
51
+ return unusedPair('enrichment-skipped');
51
52
  }
52
- if (input.maxChunks === 0 && (input.impactedCount ?? 1) > 0) {
53
- return [
54
- { axis: 'processes', reason: 'enrichment-budget-exhausted' },
55
- { axis: 'modules', reason: 'enrichment-budget-exhausted' },
56
- ];
53
+ if (input.maxChunks === 0 && input.impactedCount > 0) {
54
+ return unusedPair('enrichment-budget-exhausted');
57
55
  }
58
56
  const unused = [];
57
+ if (input.enrichmentTruncated) {
58
+ unused.push(...unusedPair('enrichment-truncated'));
59
+ }
59
60
  if (input.processQueryFailed) {
60
61
  unused.push({ axis: 'processes', reason: 'enrichment-query-failed' });
61
62
  }
@@ -64,10 +65,23 @@ export function unusedAxesForImpactWalk(input) {
64
65
  }
65
66
  return unused;
66
67
  }
68
+ const INCOMPLETE_SAMPLE_REASONS = new Set([
69
+ 'enrichment-query-failed',
70
+ 'enrichment-truncated',
71
+ ]);
67
72
  export function scoreImpactRisk(input) {
68
73
  const unusedAxes = input.unusedAxes ?? [];
74
+ const observedRisk = score(countsWithUnmeasuredAxesZeroed(input));
75
+ const incompleteSample = unusedAxes.some((unused) => INCOMPLETE_SAMPLE_REASONS.has(unused.reason));
76
+ // Failed queries and truncated samples make observed process/module counts
77
+ // lower bounds. Preserve any HIGH/CRITICAL warning already proved by those
78
+ // counts, but never emit a confident LOW/MEDIUM edit gate from an incomplete
79
+ // enrichment pass.
80
+ const risk = incompleteSample && (observedRisk === 'LOW' || observedRisk === 'MEDIUM')
81
+ ? 'UNKNOWN'
82
+ : observedRisk;
69
83
  return {
70
- risk: score(countsWithUnusedAxesZeroed(input)),
84
+ risk,
71
85
  riskSharedAxes: score({ ...input, processCount: 0, moduleCount: 0 }),
72
86
  riskScale: {
73
87
  comparableAcrossKinds: unusedAxes.length === 0,
@@ -1 +1 @@
1
- {"version":3,"file":"impact-risk.js","sourceRoot":"","sources":["../src/impact-risk.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"impact-risk.js","sourceRoot":"","sources":["../src/impact-risk.ts"],"names":[],"mappings":"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"}
@@ -139,14 +139,14 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
139
139
 
140
140
  ## Always Do
141
141
 
142
- - **MUST run impact analysis before editing.** Use \`impact({target: "symbolName", direction: "upstream"})\` (MCP) or \`${runner} impact "symbolName" --direction upstream --repo .\` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis.${hasPdg
142
+ - **MUST run impact before editing.** Use \`impact({target: "symbolName", direction: "upstream"})\` or \`${runner} impact "symbolName" --direction upstream --repo .\`; report callers, processes, and risk. Never substitute grep for graph analysis.${hasPdg
143
143
  ? ` For unified PDG impact, add \`mode: "pdg"\` with optional \`line: <N>\` — it returns statement-level \`affectedStatements\` over CDG + REACHING_DEF and inter-procedural symbols in \`interproceduralByDepth\`/\`byDepth\`; no-layer/degraded PDG results are UNKNOWN-risk notes (\`--pdg\` layer). CLI equivalent: \`${runner} impact "symbolName" --direction upstream --mode pdg --line <N> --repo .\`.`
144
144
  : ''}
145
145
  - **MUST analyze graph changes before committing.** Use \`detect_changes({scope: "all"})\` (MCP) or \`${runner} detect-changes --scope all --repo .\` (CLI fallback). \`partial: true\` or \`truncated: true\` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: \`detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\` or \`${runner} detect-changes --scope compare --base-ref ${JSON.stringify(markdownSafeBranch(defaultBranch))} --repo .\`.
146
- - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
146
+ - MUST warn on HIGH/CRITICAL \`risk\` pre-edit; never use \`riskSharedAxes\` to waive a HIGH/CRITICAL \`risk\` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
147
147
  - **MUST treat \`risk: UNKNOWN\` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). \`impact\` pairs \`UNKNOWN\` with a \`riskNote\` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
148
- - When exploring unfamiliar code, use \`query({search_query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
149
- - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`context({name: "symbolName"})\`.
148
+ - Explore with \`query({search_query: "concept"})\` for process-grouped flows.
149
+ - Use \`context({name: "symbolName"})\` for callers, callees, and flows.
150
150
  - For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).${hasPdg
151
151
  ? `\n- For control/data dependence, \`pdg_query({mode: "controls", target: "fileOrSymbol"})\` answers "under what condition does X run?" (CDG, incl. guard clauses) and \`pdg_query({mode: "flows", target, variable})\` traces "where does variable Y flow?" (REACHING_DEF). \`--pdg\` layer.`
152
152
  : ''}
@@ -240,6 +240,17 @@ function formatTruncationSuffix(result) {
240
240
  : '';
241
241
  return label ? ` (by ${label})` : '';
242
242
  }
243
+ function pushCallgraphRiskLines(lines, result) {
244
+ if (result.risk) {
245
+ lines.push(`Risk: ${result.risk}`);
246
+ }
247
+ if (result.riskNote) {
248
+ lines.push(String(result.riskNote));
249
+ }
250
+ if (result.riskScale?.comparableAcrossKinds === false && result.riskSharedAxes) {
251
+ lines.push(`Shared-axes risk: ${result.riskSharedAxes} (process/module axes are unavailable — compare File vs symbol only; do not use this to waive a HIGH/CRITICAL risk warning)`);
252
+ }
253
+ }
243
254
  export function formatImpactResult(result) {
244
255
  if (result.error) {
245
256
  const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
@@ -467,15 +478,20 @@ export function formatImpactResult(result) {
467
478
  // #1858 — "isolated" is a confident claim. If an interface / indirection
468
479
  // boundary is on the path, the true count is a lower bound, not zero;
469
480
  // callers binding via DI / dynamic dispatch were not traced. Say so instead.
481
+ const lines = [];
470
482
  if (result.epistemic === 'lower-bound') {
471
- const lines = [
472
- `${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
473
- ];
483
+ lines.push(`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`);
474
484
  for (const b of result.boundaries || [])
475
485
  lines.push(` • ${b}`);
476
- return lines.join('\n');
477
486
  }
478
- return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
487
+ else if (direction === 'upstream') {
488
+ lines.push(`${target?.name || '?'}: No ${direction} callers resolved. This is not evidence the symbol is unused or isolated.`);
489
+ }
490
+ else {
491
+ lines.push(`${target?.name || '?'}: No ${direction} dependencies found.`);
492
+ }
493
+ pushCallgraphRiskLines(lines, result);
494
+ return lines.join('\n');
479
495
  }
480
496
  const lines = [];
481
497
  const dirLabel = direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
@@ -490,6 +506,7 @@ export function formatImpactResult(result) {
490
506
  for (const b of result.boundaries || [])
491
507
  lines.push(` • ${b}`);
492
508
  }
509
+ pushCallgraphRiskLines(lines, result);
493
510
  lines.push('');
494
511
  const depthLabels = {
495
512
  1: 'WILL BREAK (direct)',
@@ -32,6 +32,14 @@ export declare const en: {
32
32
  readonly 'status.detached': "(detached HEAD)";
33
33
  readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
34
34
  readonly 'status.status': "Status";
35
+ readonly 'status.indexContentCurrent': "Index content: matches all {{count}} covered file(s)";
36
+ readonly 'status.indexContentDrifted': "Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted";
37
+ readonly 'status.indexContentMore': " ...and {{count}} more {{label}}";
38
+ readonly 'status.indexContentUnmeasurable': "Index content: not comparable ({{reason}}); fell back to the working-tree check";
39
+ readonly 'status.indexContentScanFailed': "Index content: coverage scan failed; treating the index as stale";
40
+ readonly 'status.driftChanged': "changed";
41
+ readonly 'status.driftAdded': "added";
42
+ readonly 'status.driftDeleted': "deleted";
35
43
  readonly 'status.upToDate': "✅ up-to-date";
36
44
  readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
37
45
  readonly 'clean.deleteAll': "This will delete GitNexus indexes for {{count}} repo(s):";
@@ -32,6 +32,14 @@ export const en = {
32
32
  'status.detached': '(detached HEAD)',
33
33
  'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
34
34
  'status.status': 'Status',
35
+ 'status.indexContentCurrent': 'Index content: matches all {{count}} covered file(s)',
36
+ 'status.indexContentDrifted': 'Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted',
37
+ 'status.indexContentMore': ' ...and {{count}} more {{label}}',
38
+ 'status.indexContentUnmeasurable': 'Index content: not comparable ({{reason}}); fell back to the working-tree check',
39
+ 'status.indexContentScanFailed': 'Index content: coverage scan failed; treating the index as stale',
40
+ 'status.driftChanged': 'changed',
41
+ 'status.driftAdded': 'added',
42
+ 'status.driftDeleted': 'deleted',
35
43
  'status.upToDate': '✅ up-to-date',
36
44
  'status.stale': '⚠️ stale (re-run gitnexus analyze)',
37
45
  'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
@@ -33,6 +33,14 @@ export declare const cliResources: {
33
33
  readonly 'status.detached': "(detached HEAD)";
34
34
  readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
35
35
  readonly 'status.status': "Status";
36
+ readonly 'status.indexContentCurrent': "Index content: matches all {{count}} covered file(s)";
37
+ readonly 'status.indexContentDrifted': "Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted";
38
+ readonly 'status.indexContentMore': " ...and {{count}} more {{label}}";
39
+ readonly 'status.indexContentUnmeasurable': "Index content: not comparable ({{reason}}); fell back to the working-tree check";
40
+ readonly 'status.indexContentScanFailed': "Index content: coverage scan failed; treating the index as stale";
41
+ readonly 'status.driftChanged': "changed";
42
+ readonly 'status.driftAdded': "added";
43
+ readonly 'status.driftDeleted': "deleted";
36
44
  readonly 'status.upToDate': "✅ up-to-date";
37
45
  readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
38
46
  readonly 'clean.deleteAll': "This will delete GitNexus indexes for {{count}} repo(s):";
@@ -289,6 +297,14 @@ export declare const cliResources: {
289
297
  'status.detached': string;
290
298
  'status.workspaceIndexLabel': string;
291
299
  'status.status': string;
300
+ 'status.indexContentCurrent': string;
301
+ 'status.indexContentDrifted': string;
302
+ 'status.indexContentMore': string;
303
+ 'status.indexContentUnmeasurable': string;
304
+ 'status.indexContentScanFailed': string;
305
+ 'status.driftChanged': string;
306
+ 'status.driftAdded': string;
307
+ 'status.driftDeleted': string;
292
308
  'status.upToDate': string;
293
309
  'status.stale': string;
294
310
  'clean.deleteAll': string;
@@ -32,6 +32,14 @@ export declare const zhCN: {
32
32
  'status.detached': string;
33
33
  'status.workspaceIndexLabel': string;
34
34
  'status.status': string;
35
+ 'status.indexContentCurrent': string;
36
+ 'status.indexContentDrifted': string;
37
+ 'status.indexContentMore': string;
38
+ 'status.indexContentUnmeasurable': string;
39
+ 'status.indexContentScanFailed': string;
40
+ 'status.driftChanged': string;
41
+ 'status.driftAdded': string;
42
+ 'status.driftDeleted': string;
35
43
  'status.upToDate': string;
36
44
  'status.stale': string;
37
45
  'clean.deleteAll': string;
@@ -32,6 +32,14 @@ export const zhCN = {
32
32
  'status.detached': '(分离 HEAD)',
33
33
  'status.workspaceIndexLabel': "工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
34
34
  'status.status': '状态',
35
+ 'status.indexContentCurrent': '索引内容:与覆盖的全部 {{count}} 个文件一致',
36
+ 'status.indexContentDrifted': '索引内容:{{changed}} 个已修改,{{added}} 个新增,{{deleted}} 个已删除',
37
+ 'status.indexContentMore': ' ……另有 {{count}} 个 {{label}}',
38
+ 'status.indexContentUnmeasurable': '索引内容:无法比对({{reason}}),已回退到工作区检查',
39
+ 'status.indexContentScanFailed': '索引内容:覆盖扫描失败,按过期处理',
40
+ 'status.driftChanged': '已修改',
41
+ 'status.driftAdded': '新增',
42
+ 'status.driftDeleted': '已删除',
35
43
  'status.upToDate': '✅ 已是最新',
36
44
  'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
37
45
  'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
@@ -8,7 +8,64 @@ import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/re
8
8
  import { getCurrentCommit, getCurrentBranch, isGitRepo, getGitRoot, isWorkingTreeDirty, } from '../storage/git.js';
9
9
  import { analyzerRunnerIdentitiesEqual, resolveAnalyzerRunnerIdentity, } from '../core/analyzer-identity.js';
10
10
  import { getIndexIncompleteReasons } from '../core/index-freshness.js';
11
+ import { detectIndexContentDrift } from '../core/index-content-drift.js';
11
12
  import { t } from './i18n/index.js';
13
+ /** How many drifted paths the report names before summarizing the rest. */
14
+ const DRIFT_SAMPLE_LIMIT = 10;
15
+ /**
16
+ * Machine-readable form of the per-file comparison. `'not-checked'` is its own
17
+ * value rather than a silent omission: it says the index was already stale on
18
+ * metadata alone, so the scan was skipped, which is not the same claim as a
19
+ * scan that ran and found nothing.
20
+ */
21
+ const describeContentDrift = (drift) => {
22
+ if (!drift)
23
+ return { status: 'not-checked' };
24
+ if (drift.kind === 'current') {
25
+ return { status: 'current', coveredFiles: drift.coveredFileCount };
26
+ }
27
+ if (drift.kind === 'unmeasurable') {
28
+ return { status: 'unmeasurable', reason: drift.reason };
29
+ }
30
+ return {
31
+ status: 'drifted',
32
+ counts: {
33
+ changed: drift.changed.length,
34
+ added: drift.added.length,
35
+ deleted: drift.deleted.length,
36
+ },
37
+ changed: drift.changed.slice(0, DRIFT_SAMPLE_LIMIT),
38
+ added: drift.added.slice(0, DRIFT_SAMPLE_LIMIT),
39
+ deleted: drift.deleted.slice(0, DRIFT_SAMPLE_LIMIT),
40
+ truncated: {
41
+ changed: drift.changed.length > DRIFT_SAMPLE_LIMIT,
42
+ added: drift.added.length > DRIFT_SAMPLE_LIMIT,
43
+ deleted: drift.deleted.length > DRIFT_SAMPLE_LIMIT,
44
+ },
45
+ };
46
+ };
47
+ /** Escape control characters in repo-relative paths before printing. */
48
+ const formatDriftPath = (rel) => /[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
49
+ const printDriftDetail = (drift) => {
50
+ console.log(t('status.indexContentDrifted', {
51
+ changed: drift.changed.length,
52
+ added: drift.added.length,
53
+ deleted: drift.deleted.length,
54
+ }));
55
+ const labelled = [
56
+ [t('status.driftChanged'), drift.changed],
57
+ [t('status.driftAdded'), drift.added],
58
+ [t('status.driftDeleted'), drift.deleted],
59
+ ];
60
+ for (const [label, paths] of labelled) {
61
+ for (const p of paths.slice(0, DRIFT_SAMPLE_LIMIT)) {
62
+ console.log(` ${label}: ${formatDriftPath(p)}`);
63
+ }
64
+ const remaining = paths.length - DRIFT_SAMPLE_LIMIT;
65
+ if (remaining > 0)
66
+ console.log(t('status.indexContentMore', { count: remaining, label }));
67
+ }
68
+ };
12
69
  export const statusCommand = async (options = {}) => {
13
70
  const cwd = process.cwd();
14
71
  if (!isGitRepo(cwd)) {
@@ -63,13 +120,26 @@ export const statusCommand = async (options = {}) => {
63
120
  const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url);
64
121
  const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual(activeMeta.runnerIdentity, currentRunnerIdentity);
65
122
  const incompleteReasons = getIndexIncompleteReasons(activeMeta);
66
- // A matching HEAD is not enough: `analyze` re-indexes a dirty working tree,
67
- // so a repo with uncommitted source changes is stale even at the same commit.
68
- // Skip the check for non-git folders (currentCommit === '') to match analyze.
69
- const isUpToDate = currentCommit === activeMeta.lastCommit &&
123
+ const metadataIsCurrent = currentCommit === activeMeta.lastCommit &&
70
124
  runnerIdentityIsCurrent &&
71
- incompleteReasons.length === 0 &&
72
- (currentCommit === '' || !isWorkingTreeDirty(repo.repoPath));
125
+ incompleteReasons.length === 0;
126
+ // A matching HEAD is not enough: `analyze` re-indexes changed content at the
127
+ // same commit, so the files the index covers must still be compared against
128
+ // disk. Only worth the scan once the cheap metadata checks agree, and skipped
129
+ // for non-git folders (currentCommit === '') to match analyze.
130
+ const contentDrift = metadataIsCurrent && currentCommit !== ''
131
+ ? await detectIndexContentDrift(repo.repoPath, activeMeta.fileHashes, activeMeta.indexCoverage)
132
+ : undefined;
133
+ // The repo-wide dirty flag survives only as the fallback for metadata written
134
+ // before `fileHashes` existed. Where the per-file comparison can run it
135
+ // decides, so a file the index does not cover no longer pins a byte-current
136
+ // index to a "stale" verdict that `analyze` is powerless to clear (#3077).
137
+ const contentIsCurrent = contentDrift === undefined ||
138
+ contentDrift.kind === 'current' ||
139
+ (contentDrift.kind === 'unmeasurable' &&
140
+ contentDrift.reason === 'no-file-hashes' &&
141
+ !isWorkingTreeDirty(repo.repoPath));
142
+ const isUpToDate = metadataIsCurrent && contentIsCurrent;
73
143
  if (options.json) {
74
144
  console.log(JSON.stringify({
75
145
  schemaVersion: 1,
@@ -87,6 +157,7 @@ export const statusCommand = async (options = {}) => {
87
157
  commit: currentCommit,
88
158
  runnerIdentity: currentRunnerIdentity,
89
159
  },
160
+ contentDrift: describeContentDrift(contentDrift),
90
161
  status: isUpToDate ? 'up-to-date' : 'stale',
91
162
  }));
92
163
  return;
@@ -107,5 +178,19 @@ export const statusCommand = async (options = {}) => {
107
178
  console.log(`Index incomplete reasons: ${JSON.stringify(incompleteReasons)}`);
108
179
  }
109
180
  console.log(`${t('status.currentRunnerIdentity')}: ${JSON.stringify(currentRunnerIdentity)}`);
181
+ if (contentDrift?.kind === 'current') {
182
+ console.log(t('status.indexContentCurrent', { count: contentDrift.coveredFileCount }));
183
+ }
184
+ else if (contentDrift?.kind === 'drifted') {
185
+ printDriftDetail(contentDrift);
186
+ }
187
+ else if (contentDrift?.kind === 'unmeasurable') {
188
+ if (contentDrift.reason === 'scan-failed') {
189
+ console.log(t('status.indexContentScanFailed'));
190
+ }
191
+ else if (!isUpToDate) {
192
+ console.log(t('status.indexContentUnmeasurable', { reason: contentDrift.reason }));
193
+ }
194
+ }
110
195
  console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
111
196
  };
@@ -2,6 +2,7 @@
2
2
  * Cross-repo impact (Phase 1 local walk + Phase 2 bridge fan-out).
3
3
  * All bridge Cypher for this feature lives in this module.
4
4
  */
5
+ import type { ImpactRisk } from '../../_shared/index.js';
5
6
  import type { BridgeHandle, BridgeMeta, CrossRepoImpact, GroupImpactResult } from './types.js';
6
7
  import type { GroupToolPort } from './service.js';
7
8
  export { truncationFields, crossRepoCompleteness, type TruncationFields, type CrossRepoCompleteness, type CrossRepoCompletenessInput, } from './completeness.js';
@@ -109,7 +110,7 @@ export declare function collectImpactSymbolUids(local: unknown, servicePrefix: s
109
110
  uids: string[];
110
111
  targetFilePath?: string;
111
112
  };
112
- export declare function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string;
113
+ export declare function mergeRisk(localRisk: ImpactRisk, cross: CrossRepoImpact[]): ImpactRisk;
113
114
  /**
114
115
  * Is this bridge's metadata unable to say where its contents came from?
115
116
  *
@@ -279,6 +279,15 @@ function extractProcessNames(impact) {
279
279
  // permanently that a PDG `risk:'UNKNOWN'` never coalesces to a confident `LOW`.
280
280
  // No behavior change — `'UNKNOWN'` was already handled correctly at the
281
281
  // `(localRisk === 'LOW' || localRisk === 'UNKNOWN')` branch below.
282
+ function asImpactRisk(value, fallback = 'LOW') {
283
+ return value === 'LOW' ||
284
+ value === 'MEDIUM' ||
285
+ value === 'HIGH' ||
286
+ value === 'CRITICAL' ||
287
+ value === 'UNKNOWN'
288
+ ? value
289
+ : fallback;
290
+ }
282
291
  export function mergeRisk(localRisk, cross) {
283
292
  const traversed = cross.filter((c) => c.fanout_status !== 'not_attempted');
284
293
  const highConf = traversed.some((c) => c.contract.confidence >= 0.85);
@@ -292,6 +301,15 @@ export function mergeRisk(localRisk, cross) {
292
301
  return 'MEDIUM';
293
302
  return localRisk;
294
303
  }
304
+ function liftLocalRiskMeta(local, cross) {
305
+ const { riskSharedAxes, riskScale } = local;
306
+ return {
307
+ ...(riskSharedAxes !== undefined
308
+ ? { riskSharedAxes: mergeRisk(asImpactRisk(riskSharedAxes), cross) }
309
+ : {}),
310
+ ...(riskScale !== undefined ? { riskScale } : {}),
311
+ };
312
+ }
295
313
  /**
296
314
  * Is this bridge's metadata unable to say where its contents came from?
297
315
  *
@@ -461,6 +479,7 @@ export async function runGroupImpact(deps, params) {
461
479
  cross_repo_hits: 0,
462
480
  },
463
481
  risk: 'UNKNOWN',
482
+ ...liftLocalRiskMeta(local, []),
464
483
  timeoutMs,
465
484
  crossDepthWarning,
466
485
  };
@@ -513,7 +532,8 @@ export async function runGroupImpact(deps, params) {
513
532
  modules_affected: s.modules_affected ?? 0,
514
533
  cross_repo_hits: 0,
515
534
  },
516
- risk: String(local.risk ?? 'LOW'),
535
+ risk: asImpactRisk(local.risk),
536
+ ...liftLocalRiskMeta(local, []),
517
537
  timeoutMs,
518
538
  crossDepthWarning,
519
539
  };
@@ -666,7 +686,7 @@ export async function runGroupImpact(deps, params) {
666
686
  await closeBridgeDb(handle);
667
687
  }
668
688
  const localSum = local?.summary || {};
669
- const localRisk = String(local.risk ?? 'LOW');
689
+ const localRisk = asImpactRisk(local.risk);
670
690
  const localPartial = Boolean(local.partial);
671
691
  // The bridge's own incompleteness, in the shared vocabulary, read through
672
692
  // what this query DECLARED. The fan-out above already drops every neighbour
@@ -740,6 +760,7 @@ export async function runGroupImpact(deps, params) {
740
760
  cross_repo_hits: cross.length,
741
761
  },
742
762
  risk: mergeRisk(localRisk, cross),
763
+ ...liftLocalRiskMeta(local, cross),
743
764
  timeoutMs,
744
765
  crossDepthWarning,
745
766
  };
@@ -1,3 +1,4 @@
1
+ import type { ImpactRisk, ImpactRiskResult } from '../../_shared/index.js';
1
2
  export type ContractType = 'http' | 'graphql' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include';
2
3
  export type ManifestContractType = Exclude<ContractType, 'graphql'>;
3
4
  export type MatchType = 'exact' | 'manifest' | 'wildcard';
@@ -166,7 +167,17 @@ export interface GroupImpactResult {
166
167
  modules_affected: number;
167
168
  cross_repo_hits: number;
168
169
  };
169
- risk: string;
170
+ risk: ImpactRisk;
171
+ /**
172
+ * Two-axis (direct + total) risk from the local leg, then `mergeRisk` with
173
+ * crossings — compare File vs symbol here, not via top-level `risk`.
174
+ */
175
+ riskSharedAxes?: ImpactRisk;
176
+ /**
177
+ * Local-leg scale metadata (File / skipped enrichment). Crossings do not
178
+ * invent process/module membership for File nodes.
179
+ */
180
+ riskScale?: ImpactRiskResult['riskScale'];
170
181
  /**
171
182
  * `'lower-bound'` when the fan-out was cut short, so `risk` is a FLOOR, not a
172
183
  * verdict. Same vocabulary as single-repo `impact`'s `epistemic` field.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Does the index still reflect the files it actually covers?
3
+ *
4
+ * `status` used to answer this with a repo-wide `git status --porcelain`
5
+ * boolean, which says something different: whether the working tree differs
6
+ * from HEAD. Those two questions diverge in both directions. A scratch file,
7
+ * a build artifact, or a tracked file under a tool directory the indexer
8
+ * never reads makes the tree dirty while every indexed file is byte-current —
9
+ * and because `analyze` cannot commit or delete that file, the resulting
10
+ * "stale (re-run gitnexus analyze)" verdict was unclearable (#3077). It also
11
+ * misses the reverse case: reverting a file that was indexed while dirty
12
+ * leaves a clean tree over an index holding the pre-revert content.
13
+ *
14
+ * `meta.fileHashes` already records the exact set of files the last run
15
+ * covered, so the question can be answered directly. This module recomputes
16
+ * the coverage set with the same `walkRepositoryPaths` scan (ignore rules and
17
+ * dotfile handling stay shared) and the large-file cap recorded in
18
+ * `meta.indexCoverage`, hashes only the paths that can actually have changed
19
+ * since that run, and diffs against what was recorded.
20
+ */
21
+ import type { RepoMeta } from '../storage/repo-meta.js';
22
+ /** Why the recorded coverage set could not be compared against disk at all. */
23
+ export type IndexContentUnmeasurableReason =
24
+ /** Metadata predates per-file hashes, or the run recorded none (non-git). */
25
+ 'no-file-hashes'
26
+ /** The repository scan or hashing pass threw. */
27
+ | 'scan-failed';
28
+ /**
29
+ * A three-way verdict. `'unmeasurable'` is kept apart from `'current'` on
30
+ * purpose: it means the comparison never ran, which is not evidence the index
31
+ * is fresh. Legacy metadata without hashes still falls back to the working-tree
32
+ * check; a failed scan must not.
33
+ */
34
+ export type IndexContentDrift = {
35
+ kind: 'current';
36
+ coveredFileCount: number;
37
+ } | {
38
+ kind: 'drifted';
39
+ changed: string[];
40
+ added: string[];
41
+ deleted: string[];
42
+ } | {
43
+ kind: 'unmeasurable';
44
+ reason: IndexContentUnmeasurableReason;
45
+ };
46
+ export type IndexCoveragePolicy = NonNullable<RepoMeta['indexCoverage']>;
47
+ /**
48
+ * Compare the files recorded in `fileHashes` against the current working tree.
49
+ *
50
+ * `added` covers files the index would pick up but has never seen, so a new
51
+ * source file still reports stale — the index is genuinely incomplete then,
52
+ * and comparing only the recorded entries would wave that through.
53
+ */
54
+ export declare const detectIndexContentDrift: (repoPath: string, fileHashes: Readonly<Record<string, string>> | undefined, coverage?: IndexCoveragePolicy) => Promise<IndexContentDrift>;