ghagga 3.2.0 → 3.4.0

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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/audit.js +6 -2
  3. package/dist/commands/audit.js.map +1 -1
  4. package/dist/commands/index-cmd.d.ts +49 -0
  5. package/dist/commands/index-cmd.d.ts.map +1 -0
  6. package/dist/commands/index-cmd.js +367 -0
  7. package/dist/commands/index-cmd.js.map +1 -0
  8. package/dist/commands/index-cmd.test.d.ts +17 -0
  9. package/dist/commands/index-cmd.test.d.ts.map +1 -0
  10. package/dist/commands/index-cmd.test.js +535 -0
  11. package/dist/commands/index-cmd.test.js.map +1 -0
  12. package/dist/commands/indexer-registry.d.ts +93 -0
  13. package/dist/commands/indexer-registry.d.ts.map +1 -0
  14. package/dist/commands/indexer-registry.js +342 -0
  15. package/dist/commands/indexer-registry.js.map +1 -0
  16. package/dist/commands/indexer-registry.test.d.ts +10 -0
  17. package/dist/commands/indexer-registry.test.d.ts.map +1 -0
  18. package/dist/commands/indexer-registry.test.js +538 -0
  19. package/dist/commands/indexer-registry.test.js.map +1 -0
  20. package/dist/commands/review-blast-radius.test.d.ts +13 -0
  21. package/dist/commands/review-blast-radius.test.d.ts.map +1 -0
  22. package/dist/commands/review-blast-radius.test.js +179 -0
  23. package/dist/commands/review-blast-radius.test.js.map +1 -0
  24. package/dist/commands/review.d.ts +136 -1
  25. package/dist/commands/review.d.ts.map +1 -1
  26. package/dist/commands/review.js +357 -2
  27. package/dist/commands/review.js.map +1 -1
  28. package/dist/commands/review.test.js +527 -1
  29. package/dist/commands/review.test.js.map +1 -1
  30. package/dist/commands/triage.d.ts +14 -0
  31. package/dist/commands/triage.d.ts.map +1 -0
  32. package/dist/commands/triage.js +248 -0
  33. package/dist/commands/triage.js.map +1 -0
  34. package/dist/commands/triage.test.d.ts +8 -0
  35. package/dist/commands/triage.test.d.ts.map +1 -0
  36. package/dist/commands/triage.test.js +272 -0
  37. package/dist/commands/triage.test.js.map +1 -0
  38. package/dist/index.js +38 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/lib/cli-github-client-port.d.ts +2 -0
  41. package/dist/lib/cli-github-client-port.d.ts.map +1 -1
  42. package/dist/lib/cli-github-client-port.js +12 -0
  43. package/dist/lib/cli-github-client-port.js.map +1 -1
  44. package/dist/ui/__tests__/theme.test.js +3 -2
  45. package/dist/ui/__tests__/theme.test.js.map +1 -1
  46. package/dist/ui/theme.d.ts.map +1 -1
  47. package/dist/ui/theme.js +1 -0
  48. package/dist/ui/theme.js.map +1 -1
  49. package/package.json +11 -10
@@ -6,6 +6,24 @@
6
6
  * needing an actual LLM API key.
7
7
  */
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ // Mutable control cell for the mocked `FilesystemGraphLoader` — lets
10
+ // individual tests (blast-radius integration coverage, R4-002) drive the
11
+ // graph/metadata `load()` returns without needing a real `.ghagga/graph.json`
12
+ // on disk. Declared with `vi.hoisted` so it's initialized before the
13
+ // `vi.mock('ghagga-core', ...)` factory below runs (which is itself hoisted).
14
+ const graphLoaderControl = vi.hoisted(() => ({
15
+ // biome-ignore lint/suspicious/noExplicitAny: test control cell, shape mirrors DependencyGraph
16
+ graph: null,
17
+ // biome-ignore lint/suspicious/noExplicitAny: test control cell, shape mirrors GraphMetadata
18
+ metadata: null,
19
+ // When set, `load()` invokes the constructor's `onOversize`/`onMalformed`
20
+ // callback (mirroring the REAL FilesystemGraphLoader contract) and
21
+ // resolves to null — lets tests drive the oversize/malformed degrade path
22
+ // without a real oversized/corrupt graph.json on disk.
23
+ oversize: null,
24
+ malformed: null,
25
+ loadCallCount: 0,
26
+ }));
9
27
  // ─── Mock ghagga-core to prevent actual LLM calls ───────────────
10
28
  vi.mock('ghagga-core', () => {
11
29
  const mockTools = [
@@ -117,6 +135,62 @@ vi.mock('ghagga-core', () => {
117
135
  ];
118
136
  return {
119
137
  reviewPipeline: vi.fn(),
138
+ // Blast-radius wire-up (sdd/scip-graph-wireup): a real class so
139
+ // `new FilesystemGraphLoader(...)` never throws; `load()` defaults to
140
+ // null (no graph) unless an individual test overrides the instance.
141
+ FilesystemGraphLoader: class {
142
+ opts;
143
+ constructor(_repoRoot,
144
+ // biome-ignore lint/suspicious/noExplicitAny: mirrors real FilesystemGraphLoaderOptions
145
+ opts) {
146
+ this.opts = opts;
147
+ }
148
+ async load() {
149
+ graphLoaderControl.loadCallCount++;
150
+ if (graphLoaderControl.oversize) {
151
+ this.opts?.onOversize?.(graphLoaderControl.oversize.bytes, graphLoaderControl.oversize.max);
152
+ return null;
153
+ }
154
+ if (graphLoaderControl.malformed) {
155
+ this.opts?.onMalformed?.(graphLoaderControl.malformed);
156
+ return null;
157
+ }
158
+ return graphLoaderControl.graph;
159
+ }
160
+ async loadMetadata() {
161
+ return graphLoaderControl.metadata;
162
+ }
163
+ },
164
+ // Wired in alongside FilesystemGraphLoader (R4-001 fix): reviewCommand
165
+ // wraps the already-loaded graph in one of these instead of handing the
166
+ // pipeline the same FilesystemGraphLoader instance (which would re-read
167
+ // graph.json a second time).
168
+ NullGraphLoader: class {
169
+ async load() {
170
+ return null;
171
+ }
172
+ async loadMetadata() {
173
+ return null;
174
+ }
175
+ },
176
+ PreloadedGraphLoader: class {
177
+ graph;
178
+ metadata;
179
+ constructor(graph, metadata) {
180
+ this.graph = graph;
181
+ this.metadata = metadata;
182
+ }
183
+ async load() {
184
+ return this.graph;
185
+ }
186
+ async loadMetadata() {
187
+ return this.metadata ?? null;
188
+ }
189
+ },
190
+ detectLanguage: vi.fn().mockReturnValue(undefined),
191
+ GRAPH_VERSION: 1,
192
+ isGraphStale: vi.fn().mockReturnValue(false),
193
+ parseDiffFiles: vi.fn().mockReturnValue([]),
120
194
  formatReviewComment: vi.fn().mockReturnValue('<!-- ghagga-review -->\nbody'),
121
195
  formatBoxSummary: vi.fn().mockReturnValue([]),
122
196
  REVIEW_COMMENT_MARKER: '<!-- ghagga-review -->',
@@ -196,19 +270,21 @@ describe('CLI review module', () => {
196
270
  });
197
271
  describe('CLI output formatting and exit codes', () => {
198
272
  // We test the internal formatting/exit code logic by checking types/contracts
199
- it('defines valid exit codes: PASSED=0, SKIPPED=0, FAILED=1, NEEDS_HUMAN_REVIEW=1, PARTIAL=1', () => {
273
+ it('defines valid exit codes: PASSED=0, SKIPPED=0, FAILED=1, NEEDS_HUMAN_REVIEW=1, PARTIAL=1, INCONCLUSIVE=1', () => {
200
274
  const exitCodes = {
201
275
  PASSED: 0,
202
276
  SKIPPED: 0,
203
277
  FAILED: 1,
204
278
  NEEDS_HUMAN_REVIEW: 1,
205
279
  PARTIAL: 1,
280
+ INCONCLUSIVE: 1,
206
281
  };
207
282
  expect(exitCodes.PASSED).toBe(0);
208
283
  expect(exitCodes.SKIPPED).toBe(0);
209
284
  expect(exitCodes.FAILED).toBe(1);
210
285
  expect(exitCodes.NEEDS_HUMAN_REVIEW).toBe(1);
211
286
  expect(exitCodes.PARTIAL).toBe(1);
287
+ expect(exitCodes.INCONCLUSIVE).toBe(1);
212
288
  });
213
289
  it('ReviewResult findings have expected structure', () => {
214
290
  const finding = {
@@ -396,6 +472,16 @@ describe('reviewCommand — functional tests', () => {
396
472
  }));
397
473
  expect(exitSpy).toHaveBeenCalledWith(0);
398
474
  });
475
+ it('should call reviewPipeline with hybrid-4r mode', async () => {
476
+ const diff = 'diff --git a/file.ts b/file.ts\n+console.log("hello");';
477
+ mockExecSync.mockReturnValue(diff);
478
+ mockReviewPipeline.mockResolvedValue(makeReviewResult({ status: 'PASSED' }));
479
+ const { reviewCommand } = await import('./review.js');
480
+ await reviewCommand('.', defaultOptions({ mode: 'hybrid-4r' }));
481
+ expect(mockReviewPipeline).toHaveBeenCalledWith(expect.objectContaining({
482
+ mode: 'hybrid-4r',
483
+ }));
484
+ });
399
485
  it('should exit 1 when review status is FAILED', async () => {
400
486
  mockExecSync.mockReturnValue('diff content');
401
487
  mockReviewPipeline.mockResolvedValue(makeReviewResult({ status: 'FAILED' }));
@@ -547,6 +633,205 @@ describe('reviewCommand — functional tests', () => {
547
633
  expect(settings.reviewLevel).toBe('strict');
548
634
  expect(settings.customRules).toEqual(['/rules/custom.yml']);
549
635
  });
636
+ it('populates ReviewInput.providerChain from .ghagga.json providerChain (cross-engine routing)', async () => {
637
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
638
+ mockExecSync.mockReturnValue(diff);
639
+ mockExistsSync.mockReturnValue(true);
640
+ mockReadFileSync.mockReturnValue(JSON.stringify({
641
+ providerChain: [
642
+ {
643
+ provider: 'gateway',
644
+ model: 'claude-sonnet-4-6',
645
+ targetProvider: 'cli-claude',
646
+ },
647
+ {
648
+ provider: 'gateway',
649
+ model: 'claude-opus-4-6',
650
+ targetProvider: 'cli-claude',
651
+ },
652
+ {
653
+ provider: 'gateway',
654
+ model: 'gpt-5.5',
655
+ targetProvider: 'codex-cli',
656
+ gatewayUrl: 'http://localhost:3001',
657
+ },
658
+ ],
659
+ }));
660
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
661
+ const { reviewCommand } = await import('./review.js');
662
+ await reviewCommand('.', defaultOptions({ apiKey: 'gw-token' }));
663
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
664
+ const providerChain = callArgs.providerChain;
665
+ expect(providerChain).toHaveLength(3);
666
+ expect(providerChain[0]).toMatchObject({
667
+ provider: 'gateway',
668
+ model: 'claude-sonnet-4-6',
669
+ targetProvider: 'cli-claude',
670
+ apiKey: 'gw-token',
671
+ });
672
+ expect(providerChain[1]).toMatchObject({
673
+ provider: 'gateway',
674
+ model: 'claude-opus-4-6',
675
+ targetProvider: 'cli-claude',
676
+ apiKey: 'gw-token',
677
+ });
678
+ expect(providerChain[2]).toMatchObject({
679
+ provider: 'gateway',
680
+ model: 'gpt-5.5',
681
+ targetProvider: 'codex-cli',
682
+ gatewayUrl: 'http://localhost:3001',
683
+ apiKey: 'gw-token',
684
+ });
685
+ });
686
+ it('leaves ReviewInput.providerChain undefined when .ghagga.json has no providerChain (regression-safe)', async () => {
687
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
688
+ mockExecSync.mockReturnValue(diff);
689
+ mockExistsSync.mockReturnValue(true);
690
+ mockReadFileSync.mockReturnValue(JSON.stringify({ reviewLevel: 'strict' }));
691
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
692
+ const { reviewCommand } = await import('./review.js');
693
+ await reviewCommand('.', defaultOptions());
694
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
695
+ expect(callArgs.providerChain).toBeUndefined();
696
+ });
697
+ it('rejects a malformed providerChain entry with a clear error and exits 1', async () => {
698
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
699
+ mockExecSync.mockReturnValue(diff);
700
+ mockExistsSync.mockReturnValue(true);
701
+ mockReadFileSync.mockReturnValue(JSON.stringify({
702
+ providerChain: [{ provider: 'anthropic', model: 'claude-sonnet-4-6' }],
703
+ }));
704
+ const { reviewCommand } = await import('./review.js');
705
+ await reviewCommand('.', defaultOptions());
706
+ expect(exitSpy).toHaveBeenCalledWith(1);
707
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('providerChain'));
708
+ });
709
+ it('rejects a providerChain entry missing model with a clear error and exits 1', async () => {
710
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
711
+ mockExecSync.mockReturnValue(diff);
712
+ mockExistsSync.mockReturnValue(true);
713
+ mockReadFileSync.mockReturnValue(JSON.stringify({
714
+ providerChain: [{ provider: 'gateway' }],
715
+ }));
716
+ const { reviewCommand } = await import('./review.js');
717
+ await reviewCommand('.', defaultOptions());
718
+ expect(exitSpy).toHaveBeenCalledWith(1);
719
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('providerChain'));
720
+ });
721
+ it('sets settings.pinLensesToFirst when .ghagga.json pinLensesToFirst is true', async () => {
722
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
723
+ mockExecSync.mockReturnValue(diff);
724
+ mockExistsSync.mockReturnValue(true);
725
+ mockReadFileSync.mockReturnValue(JSON.stringify({ pinLensesToFirst: true }));
726
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
727
+ const { reviewCommand } = await import('./review.js');
728
+ await reviewCommand('.', defaultOptions());
729
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
730
+ const settings = callArgs.settings;
731
+ expect(settings.pinLensesToFirst).toBe(true);
732
+ });
733
+ it('leaves settings.pinLensesToFirst undefined when .ghagga.json omits pinLensesToFirst', async () => {
734
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
735
+ mockExecSync.mockReturnValue(diff);
736
+ mockExistsSync.mockReturnValue(true);
737
+ mockReadFileSync.mockReturnValue(JSON.stringify({ reviewLevel: 'strict' }));
738
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
739
+ const { reviewCommand } = await import('./review.js');
740
+ await reviewCommand('.', defaultOptions());
741
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
742
+ const settings = callArgs.settings;
743
+ expect(settings.pinLensesToFirst).toBeUndefined();
744
+ });
745
+ it('rejects a non-boolean pinLensesToFirst with a clear error and exits 1', async () => {
746
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
747
+ mockExecSync.mockReturnValue(diff);
748
+ mockExistsSync.mockReturnValue(true);
749
+ mockReadFileSync.mockReturnValue(JSON.stringify({ pinLensesToFirst: 'yes' }));
750
+ const { reviewCommand } = await import('./review.js');
751
+ await reviewCommand('.', defaultOptions());
752
+ expect(exitSpy).toHaveBeenCalledWith(1);
753
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('pinLensesToFirst'));
754
+ });
755
+ it('sets settings.contrarianCount when .ghagga.json contrarianCount is 1', async () => {
756
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
757
+ mockExecSync.mockReturnValue(diff);
758
+ mockExistsSync.mockReturnValue(true);
759
+ mockReadFileSync.mockReturnValue(JSON.stringify({ contrarianCount: 1 }));
760
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
761
+ const { reviewCommand } = await import('./review.js');
762
+ await reviewCommand('.', defaultOptions());
763
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
764
+ const settings = callArgs.settings;
765
+ expect(settings.contrarianCount).toBe(1);
766
+ });
767
+ it('leaves settings.contrarianCount undefined when .ghagga.json omits contrarianCount', async () => {
768
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
769
+ mockExecSync.mockReturnValue(diff);
770
+ mockExistsSync.mockReturnValue(true);
771
+ mockReadFileSync.mockReturnValue(JSON.stringify({ reviewLevel: 'strict' }));
772
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
773
+ const { reviewCommand } = await import('./review.js');
774
+ await reviewCommand('.', defaultOptions());
775
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
776
+ const settings = callArgs.settings;
777
+ expect(settings.contrarianCount).toBeUndefined();
778
+ });
779
+ it('rejects a non-integer or zero contrarianCount with a clear error and exits 1', async () => {
780
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
781
+ mockExecSync.mockReturnValue(diff);
782
+ mockExistsSync.mockReturnValue(true);
783
+ mockReadFileSync.mockReturnValue(JSON.stringify({ contrarianCount: 'yes' }));
784
+ const { reviewCommand } = await import('./review.js');
785
+ await reviewCommand('.', defaultOptions());
786
+ expect(exitSpy).toHaveBeenCalledWith(1);
787
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('contrarianCount'));
788
+ exitSpy.mockClear();
789
+ errorSpy.mockClear();
790
+ mockReadFileSync.mockReturnValue(JSON.stringify({ contrarianCount: 0 }));
791
+ await reviewCommand('.', defaultOptions());
792
+ expect(exitSpy).toHaveBeenCalledWith(1);
793
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('contrarianCount'));
794
+ });
795
+ it('sets settings.refuterCount when .ghagga.json refuterCount is 2', async () => {
796
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
797
+ mockExecSync.mockReturnValue(diff);
798
+ mockExistsSync.mockReturnValue(true);
799
+ mockReadFileSync.mockReturnValue(JSON.stringify({ refuterCount: 2 }));
800
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
801
+ const { reviewCommand } = await import('./review.js');
802
+ await reviewCommand('.', defaultOptions());
803
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
804
+ const settings = callArgs.settings;
805
+ expect(settings.refuterCount).toBe(2);
806
+ });
807
+ it('leaves settings.refuterCount undefined when .ghagga.json omits refuterCount', async () => {
808
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
809
+ mockExecSync.mockReturnValue(diff);
810
+ mockExistsSync.mockReturnValue(true);
811
+ mockReadFileSync.mockReturnValue(JSON.stringify({ reviewLevel: 'strict' }));
812
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
813
+ const { reviewCommand } = await import('./review.js');
814
+ await reviewCommand('.', defaultOptions());
815
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
816
+ const settings = callArgs.settings;
817
+ expect(settings.refuterCount).toBeUndefined();
818
+ });
819
+ it('rejects a non-integer or below-2 refuterCount with a clear error and exits 1', async () => {
820
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
821
+ mockExecSync.mockReturnValue(diff);
822
+ mockExistsSync.mockReturnValue(true);
823
+ mockReadFileSync.mockReturnValue(JSON.stringify({ refuterCount: 'yes' }));
824
+ const { reviewCommand } = await import('./review.js');
825
+ await reviewCommand('.', defaultOptions());
826
+ expect(exitSpy).toHaveBeenCalledWith(1);
827
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refuterCount'));
828
+ exitSpy.mockClear();
829
+ errorSpy.mockClear();
830
+ mockReadFileSync.mockReturnValue(JSON.stringify({ refuterCount: 1 }));
831
+ await reviewCommand('.', defaultOptions());
832
+ expect(exitSpy).toHaveBeenCalledWith(1);
833
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refuterCount'));
834
+ });
550
835
  it('should handle non-Error thrown objects in catch block', async () => {
551
836
  mockExecSync.mockReturnValue('diff');
552
837
  mockReviewPipeline.mockRejectedValue('string error');
@@ -896,4 +1181,245 @@ describe('Phase 7: config file with new tool fields', () => {
896
1181
  expect(settings.disabledTools).toContain('cpd');
897
1182
  });
898
1183
  });
1184
+ // ═══════════════════════════════════════════════════════════════
1185
+ // Blast-radius glue integration coverage (R4-002)
1186
+ //
1187
+ // The pure seams (`resolveBlastRadiusEnabled`, `checkGraphStaleness`,
1188
+ // `computeUncoveredLanguages`) are unit-tested in
1189
+ // `review-blast-radius.test.ts`. This block exercises the side-effecting
1190
+ // GLUE in `reviewCommand` itself — the `execSync('git rev-parse HEAD')`
1191
+ // try/catch, the outer try/catch around the staleness/coverage block, and
1192
+ // the warn ordering — with a present graph, an oversize/malformed graph,
1193
+ // and a failing `git rev-parse HEAD`. It also locks in the R4-001 fix: the
1194
+ // graph is loaded exactly once and the oversize/malformed warning fires
1195
+ // exactly once.
1196
+ // ═══════════════════════════════════════════════════════════════
1197
+ describe('reviewCommand — blast-radius glue integration (R4-002)', () => {
1198
+ // biome-ignore lint/suspicious/noExplicitAny: mock spy type
1199
+ let logSpy;
1200
+ // biome-ignore lint/suspicious/noExplicitAny: mock spy type
1201
+ let errorSpy;
1202
+ // biome-ignore lint/suspicious/noExplicitAny: mock spy type
1203
+ let exitSpy;
1204
+ const diff = 'diff --git a/file.ts b/file.ts\n+line';
1205
+ function makeGraph() {
1206
+ return {
1207
+ version: 1,
1208
+ rootDir: '.',
1209
+ nodes: {
1210
+ 'src/main.go': {
1211
+ hash: 'h',
1212
+ language: 'go',
1213
+ imports: [],
1214
+ exports: [],
1215
+ calls: [],
1216
+ isTest: false,
1217
+ },
1218
+ },
1219
+ };
1220
+ }
1221
+ function makeMetadata() {
1222
+ return {
1223
+ lastIndexedCommit: 'abc123',
1224
+ lastIndexedAt: new Date().toISOString(),
1225
+ schemaVersion: 1,
1226
+ fileCount: 1,
1227
+ languages: ['go'],
1228
+ indexDurationMs: 10,
1229
+ };
1230
+ }
1231
+ beforeEach(() => {
1232
+ vi.clearAllMocks();
1233
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
1234
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
1235
+ // biome-ignore lint/suspicious/noExplicitAny: mock cast
1236
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { }));
1237
+ // Auto-enable blast-radius: existsSync(<repo>/.ghagga/graph.json) → true,
1238
+ // existsSync(<repo>/.ghagga.json) → false (no config file to load).
1239
+ mockExistsSync.mockImplementation((p) => String(p).includes('graph.json'));
1240
+ graphLoaderControl.graph = null;
1241
+ graphLoaderControl.metadata = null;
1242
+ graphLoaderControl.oversize = null;
1243
+ graphLoaderControl.malformed = null;
1244
+ graphLoaderControl.loadCallCount = 0;
1245
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
1246
+ });
1247
+ afterEach(() => {
1248
+ logSpy.mockRestore();
1249
+ errorSpy.mockRestore();
1250
+ exitSpy.mockRestore();
1251
+ graphLoaderControl.graph = null;
1252
+ graphLoaderControl.metadata = null;
1253
+ graphLoaderControl.oversize = null;
1254
+ graphLoaderControl.malformed = null;
1255
+ graphLoaderControl.loadCallCount = 0;
1256
+ });
1257
+ it('present valid graph: blast-radius enables, no crash, review continues (PASSED)', async () => {
1258
+ graphLoaderControl.graph = makeGraph();
1259
+ graphLoaderControl.metadata = makeMetadata();
1260
+ mockExecSync.mockReturnValue(diff);
1261
+ const { reviewCommand } = await import('./review.js');
1262
+ await reviewCommand('.', defaultOptions());
1263
+ expect(graphLoaderControl.loadCallCount).toBe(1);
1264
+ expect(mockReviewPipeline).toHaveBeenCalledTimes(1);
1265
+ const callArgs = mockReviewPipeline.mock.calls[0]?.[0];
1266
+ // The pipeline must receive a loader wrapping the already-loaded graph
1267
+ // (Preloaded/Null), NEVER the same FilesystemGraphLoader instance that
1268
+ // review.ts already called `.load()` on — that identity is what would
1269
+ // cause a second filesystem read (R4-001).
1270
+ expect(callArgs.graphLoader.constructor.name).not.toBe('FilesystemGraphLoader');
1271
+ expect(exitSpy).toHaveBeenCalledWith(0);
1272
+ });
1273
+ it('oversize graph: degrades gracefully, review continues, warning fires exactly once (R4-001)', async () => {
1274
+ graphLoaderControl.oversize = { bytes: 25_000_000, max: 20_000_000 };
1275
+ mockExecSync.mockReturnValue(diff);
1276
+ const { reviewCommand } = await import('./review.js');
1277
+ await reviewCommand('.', defaultOptions());
1278
+ expect(graphLoaderControl.loadCallCount).toBe(1);
1279
+ const oversizeWarnings = logSpy.mock.calls.filter((call) => String(call[0]).includes('Dependency graph exceeds'));
1280
+ expect(oversizeWarnings).toHaveLength(1);
1281
+ // Review continues (degrades) rather than crashing or aborting.
1282
+ expect(mockReviewPipeline).toHaveBeenCalledTimes(1);
1283
+ expect(exitSpy).toHaveBeenCalledWith(0);
1284
+ });
1285
+ it('malformed graph: degrades gracefully, review continues, warning fires exactly once (R4-001)', async () => {
1286
+ graphLoaderControl.malformed = 'invalid JSON';
1287
+ mockExecSync.mockReturnValue(diff);
1288
+ const { reviewCommand } = await import('./review.js');
1289
+ await reviewCommand('.', defaultOptions());
1290
+ expect(graphLoaderControl.loadCallCount).toBe(1);
1291
+ const malformedWarnings = logSpy.mock.calls.filter((call) => String(call[0]).includes('stale/corrupt'));
1292
+ expect(malformedWarnings).toHaveLength(1);
1293
+ expect(mockReviewPipeline).toHaveBeenCalledTimes(1);
1294
+ expect(exitSpy).toHaveBeenCalledWith(0);
1295
+ });
1296
+ it('git rev-parse HEAD throws: staleness check skipped, review continues, no crash', async () => {
1297
+ graphLoaderControl.graph = makeGraph();
1298
+ graphLoaderControl.metadata = makeMetadata();
1299
+ mockExecSync.mockImplementation((cmd) => {
1300
+ if (String(cmd).includes('rev-parse HEAD')) {
1301
+ throw new Error('fatal: not a git repository');
1302
+ }
1303
+ return diff;
1304
+ });
1305
+ const { reviewCommand } = await import('./review.js');
1306
+ await reviewCommand('.', defaultOptions());
1307
+ // The staleness check is skipped (currentHead falls back to '') but the
1308
+ // review is NOT aborted by the failing git call — it must degrade, not
1309
+ // propagate the throw.
1310
+ expect(mockReviewPipeline).toHaveBeenCalledTimes(1);
1311
+ expect(exitSpy).toHaveBeenCalledWith(0);
1312
+ expect(errorSpy).not.toHaveBeenCalled();
1313
+ });
1314
+ });
1315
+ // ═══════════════════════════════════════════════════════════════
1316
+ // Fail-closed --anchor (hybrid-4r-anchor)
1317
+ // process.exit is mocked as a no-op — every fail-closed path MUST
1318
+ // return after exit(1) so reviewPipeline is never reached.
1319
+ // ═══════════════════════════════════════════════════════════════
1320
+ describe('reviewCommand — fail-closed --anchor', () => {
1321
+ // biome-ignore lint/suspicious/noExplicitAny: mock spy type
1322
+ let logSpy;
1323
+ // biome-ignore lint/suspicious/noExplicitAny: mock spy type
1324
+ let errorSpy;
1325
+ // biome-ignore lint/suspicious/noExplicitAny: mock spy type
1326
+ let exitSpy;
1327
+ const DIFF = 'diff --git a/file.ts b/file.ts\n+line';
1328
+ const MATCHING_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
1329
+ const OTHER_SHA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
1330
+ beforeEach(() => {
1331
+ vi.clearAllMocks();
1332
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
1333
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
1334
+ // biome-ignore lint/suspicious/noExplicitAny: mock cast
1335
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { }));
1336
+ mockExistsSync.mockReturnValue(false);
1337
+ mockReviewPipeline.mockResolvedValue(makeReviewResult());
1338
+ });
1339
+ afterEach(() => {
1340
+ logSpy.mockRestore();
1341
+ errorSpy.mockRestore();
1342
+ exitSpy.mockRestore();
1343
+ });
1344
+ function mockGitDiffAndRevs(headSha, verifySha) {
1345
+ mockExecSync.mockImplementation((cmd) => {
1346
+ const c = String(cmd);
1347
+ if (c.includes('--verify')) {
1348
+ return `${verifySha}\n`;
1349
+ }
1350
+ if (c.includes('rev-parse HEAD')) {
1351
+ return `${headSha}\n`;
1352
+ }
1353
+ return DIFF;
1354
+ });
1355
+ }
1356
+ it('calls reviewPipeline when options.anchor matches HEAD', async () => {
1357
+ mockGitDiffAndRevs(MATCHING_SHA, MATCHING_SHA);
1358
+ const { reviewCommand } = await import('./review.js');
1359
+ await reviewCommand('.', defaultOptions({ anchor: 'aaaaaaaa' }));
1360
+ expect(mockReviewPipeline).toHaveBeenCalledTimes(1);
1361
+ });
1362
+ it('exits 1 on HEAD/anchor mismatch and does not run the pipeline', async () => {
1363
+ mockGitDiffAndRevs(MATCHING_SHA, OTHER_SHA);
1364
+ const { reviewCommand } = await import('./review.js');
1365
+ await reviewCommand('.', defaultOptions({ anchor: 'aaaaaaaa' }));
1366
+ expect(exitSpy).toHaveBeenCalledWith(1);
1367
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('anchor'));
1368
+ expect(mockReviewPipeline).not.toHaveBeenCalled();
1369
+ });
1370
+ it('skips the mismatch check when fileConfig.anchor is "auto"', async () => {
1371
+ mockExecSync.mockReturnValue(DIFF);
1372
+ mockExistsSync.mockReturnValue(true);
1373
+ mockReadFileSync.mockReturnValue(JSON.stringify({ anchor: 'auto' }));
1374
+ const { reviewCommand } = await import('./review.js');
1375
+ await reviewCommand('.', defaultOptions());
1376
+ expect(mockReviewPipeline).toHaveBeenCalledTimes(1);
1377
+ });
1378
+ it('exits 1 on invalid anchor values and does not run the pipeline', async () => {
1379
+ mockExecSync.mockReturnValue(DIFF);
1380
+ const { reviewCommand } = await import('./review.js');
1381
+ await reviewCommand('.', defaultOptions({ anchor: '../x' }));
1382
+ expect(exitSpy).toHaveBeenCalledWith(1);
1383
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('anchor'));
1384
+ expect(mockReviewPipeline).not.toHaveBeenCalled();
1385
+ exitSpy.mockClear();
1386
+ errorSpy.mockClear();
1387
+ mockReviewPipeline.mockClear();
1388
+ await reviewCommand('.', defaultOptions({ anchor: 'main' }));
1389
+ expect(exitSpy).toHaveBeenCalledWith(1);
1390
+ expect(mockReviewPipeline).not.toHaveBeenCalled();
1391
+ });
1392
+ it('lets options.anchor override config auto and still mismatch-fails', async () => {
1393
+ mockGitDiffAndRevs(MATCHING_SHA, OTHER_SHA);
1394
+ mockExistsSync.mockReturnValue(true);
1395
+ mockReadFileSync.mockReturnValue(JSON.stringify({ anchor: 'auto' }));
1396
+ const { reviewCommand } = await import('./review.js');
1397
+ await reviewCommand('.', defaultOptions({ anchor: 'aaaaaaaa' }));
1398
+ expect(exitSpy).toHaveBeenCalledWith(1);
1399
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('anchor'));
1400
+ expect(mockReviewPipeline).not.toHaveBeenCalled();
1401
+ });
1402
+ it('exits 1 when git rev-parse throws and does not run the pipeline', async () => {
1403
+ mockExecSync.mockImplementation((cmd) => {
1404
+ const c = String(cmd);
1405
+ if (c.includes('rev-parse')) {
1406
+ throw new Error('fatal: not a git repository');
1407
+ }
1408
+ return DIFF;
1409
+ });
1410
+ const { reviewCommand } = await import('./review.js');
1411
+ await reviewCommand('.', defaultOptions({ anchor: 'aaaaaaaa' }));
1412
+ expect(exitSpy).toHaveBeenCalledWith(1);
1413
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('anchor'));
1414
+ expect(mockReviewPipeline).not.toHaveBeenCalled();
1415
+ });
1416
+ it('exits 1 when resolved HEAD or verify SHA is empty and does not run the pipeline', async () => {
1417
+ mockGitDiffAndRevs('', MATCHING_SHA);
1418
+ const { reviewCommand } = await import('./review.js');
1419
+ await reviewCommand('.', defaultOptions({ anchor: 'aaaaaaaa' }));
1420
+ expect(exitSpy).toHaveBeenCalledWith(1);
1421
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('anchor'));
1422
+ expect(mockReviewPipeline).not.toHaveBeenCalled();
1423
+ });
1424
+ });
899
1425
  //# sourceMappingURL=review.test.js.map