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.
- package/README.md +1 -1
- package/dist/commands/audit.js +6 -2
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/index-cmd.d.ts +49 -0
- package/dist/commands/index-cmd.d.ts.map +1 -0
- package/dist/commands/index-cmd.js +367 -0
- package/dist/commands/index-cmd.js.map +1 -0
- package/dist/commands/index-cmd.test.d.ts +17 -0
- package/dist/commands/index-cmd.test.d.ts.map +1 -0
- package/dist/commands/index-cmd.test.js +535 -0
- package/dist/commands/index-cmd.test.js.map +1 -0
- package/dist/commands/indexer-registry.d.ts +93 -0
- package/dist/commands/indexer-registry.d.ts.map +1 -0
- package/dist/commands/indexer-registry.js +342 -0
- package/dist/commands/indexer-registry.js.map +1 -0
- package/dist/commands/indexer-registry.test.d.ts +10 -0
- package/dist/commands/indexer-registry.test.d.ts.map +1 -0
- package/dist/commands/indexer-registry.test.js +538 -0
- package/dist/commands/indexer-registry.test.js.map +1 -0
- package/dist/commands/review-blast-radius.test.d.ts +13 -0
- package/dist/commands/review-blast-radius.test.d.ts.map +1 -0
- package/dist/commands/review-blast-radius.test.js +179 -0
- package/dist/commands/review-blast-radius.test.js.map +1 -0
- package/dist/commands/review.d.ts +136 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +357 -2
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/review.test.js +527 -1
- package/dist/commands/review.test.js.map +1 -1
- package/dist/commands/triage.d.ts +14 -0
- package/dist/commands/triage.d.ts.map +1 -0
- package/dist/commands/triage.js +248 -0
- package/dist/commands/triage.js.map +1 -0
- package/dist/commands/triage.test.d.ts +8 -0
- package/dist/commands/triage.test.d.ts.map +1 -0
- package/dist/commands/triage.test.js +272 -0
- package/dist/commands/triage.test.js.map +1 -0
- package/dist/index.js +38 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/cli-github-client-port.d.ts +2 -0
- package/dist/lib/cli-github-client-port.d.ts.map +1 -1
- package/dist/lib/cli-github-client-port.js +12 -0
- package/dist/lib/cli-github-client-port.js.map +1 -1
- package/dist/ui/__tests__/theme.test.js +3 -2
- package/dist/ui/__tests__/theme.test.js.map +1 -1
- package/dist/ui/theme.d.ts.map +1 -1
- package/dist/ui/theme.js +1 -0
- package/dist/ui/theme.js.map +1 -1
- 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
|