@mmnto/cli 1.16.1 → 1.17.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.
@@ -0,0 +1,696 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ // ─── Mocks ──────────────────────────────────────────────
8
+ //
9
+ // `retrospect.ts` lazy-imports the adapter, ui, parsers, and core. vi.mock
10
+ // is hoisted, so these stubs land in place before the dynamic imports
11
+ // inside runRetrospect resolve.
12
+ const mockFetchPr = vi.fn();
13
+ const mockFetchReviews = vi.fn();
14
+ const mockFetchReviewComments = vi.fn();
15
+ vi.mock('../adapters/github-cli-pr.js', () => ({
16
+ GitHubCliPrAdapter: class GitHubCliPrAdapter {
17
+ fetchPr(num) {
18
+ return mockFetchPr(num);
19
+ }
20
+ fetchReviews(num) {
21
+ return mockFetchReviews(num);
22
+ }
23
+ fetchReviewComments(num) {
24
+ return mockFetchReviewComments(num);
25
+ }
26
+ },
27
+ }));
28
+ // Capture every log line so tests can assert on tag invariants.
29
+ vi.mock('../ui.js', () => ({
30
+ log: {
31
+ info: vi.fn(),
32
+ success: vi.fn(),
33
+ warn: vi.fn(),
34
+ error: vi.fn(),
35
+ dim: vi.fn(),
36
+ },
37
+ }));
38
+ // Mock loadConfig + resolveConfigPath to a tmp totemDir.
39
+ vi.mock('../utils.js', () => ({
40
+ loadConfig: vi.fn().mockImplementation(async () => ({ totemDir: '.totem' })),
41
+ resolveConfigPath: vi.fn().mockReturnValue(''),
42
+ }));
43
+ const uiModule = await import('../ui.js');
44
+ const mockLog = vi.mocked(uiModule.log);
45
+ // ─── Test data factories ───────────────────────────────
46
+ function makeReview(overrides) {
47
+ return {
48
+ id: overrides.id,
49
+ user_login: overrides.user_login === undefined ? 'coderabbitai[bot]' : overrides.user_login,
50
+ commit_id: overrides.commit_id ?? 'sha-default',
51
+ submitted_at: overrides.submitted_at ?? '2026-04-29T01:00:00.000Z',
52
+ state: overrides.state ?? 'COMMENTED',
53
+ body: overrides.body ?? '',
54
+ };
55
+ }
56
+ function makeInlineComment(overrides) {
57
+ return {
58
+ id: overrides.id,
59
+ author: overrides.author ?? 'coderabbitai[bot]',
60
+ body: overrides.body ?? 'Avoid using `any` — prefer `unknown`.',
61
+ path: overrides.filePath ?? 'src/handler.ts',
62
+ diffHunk: `@@ -1,3 +${overrides.line ?? 42},3 @@`,
63
+ inReplyToId: undefined,
64
+ createdAt: overrides.createdAt ?? '2026-04-29T01:00:30.000Z',
65
+ // Default to the first review submission's id so timestamp-free joins
66
+ // still work for the existing fixtures. Tests explicitly exercising
67
+ // round-grouping override this.
68
+ pullRequestReviewId: overrides.pullRequestReviewId === undefined ? 1 : overrides.pullRequestReviewId,
69
+ };
70
+ }
71
+ function makePr(overrides) {
72
+ return {
73
+ number: overrides.number,
74
+ title: `PR #${overrides.number}`,
75
+ body: '',
76
+ state: overrides.state ?? 'open',
77
+ comments: [],
78
+ reviews: [],
79
+ };
80
+ }
81
+ // ─── Test setup ────────────────────────────────────────
82
+ let tmpDir;
83
+ let totemDir;
84
+ let originalCwd;
85
+ beforeEach(() => {
86
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'retrospect-'));
87
+ totemDir = path.join(tmpDir, '.totem');
88
+ fs.mkdirSync(totemDir, { recursive: true });
89
+ originalCwd = process.cwd();
90
+ process.chdir(tmpDir);
91
+ mockFetchPr.mockReset();
92
+ mockFetchReviews.mockReset();
93
+ mockFetchReviewComments.mockReset();
94
+ for (const fn of Object.values(mockLog)) {
95
+ fn.mockReset();
96
+ }
97
+ });
98
+ afterEach(() => {
99
+ process.chdir(originalCwd);
100
+ fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
101
+ });
102
+ // ─── Tests ──────────────────────────────────────────────
103
+ describe('runRetrospect — sub-threshold skip', () => {
104
+ it('exits 0 (resolves) when rounds < threshold and --force is not set', async () => {
105
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
106
+ mockFetchReviews.mockReturnValue([
107
+ makeReview({
108
+ id: 1,
109
+ commit_id: 'sha-A',
110
+ submitted_at: '2026-04-29T01:00:00.000Z',
111
+ }),
112
+ ]);
113
+ mockFetchReviewComments.mockReturnValue([]);
114
+ const { runRetrospect } = await import('./retrospect.js');
115
+ await expect(runRetrospect({ prNumber: '1713', threshold: 5 })).resolves.toBeUndefined();
116
+ // Skip message logged.
117
+ const skipCall = mockLog.info.mock.calls.find((c) => String(c[1]).includes('below threshold'));
118
+ expect(skipCall).toBeDefined();
119
+ });
120
+ it('renders the report when --force is passed below threshold', async () => {
121
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
122
+ mockFetchReviews.mockReturnValue([
123
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
124
+ ]);
125
+ mockFetchReviewComments.mockReturnValue([
126
+ makeInlineComment({ id: 100, createdAt: '2026-04-29T01:00:30.000Z' }),
127
+ ]);
128
+ const { runRetrospect } = await import('./retrospect.js');
129
+ await runRetrospect({ prNumber: '1713', threshold: 5, force: true });
130
+ // Should NOT log the skip message.
131
+ const skipCall = mockLog.info.mock.calls.find((c) => String(c[1]).includes('below threshold'));
132
+ expect(skipCall).toBeUndefined();
133
+ // Should log the headline summary.
134
+ const headline = mockLog.info.mock.calls.find((c) => String(c[1]).includes('PR #1713'));
135
+ expect(headline).toBeDefined();
136
+ });
137
+ });
138
+ describe('runRetrospect — substrate graceful degrade', () => {
139
+ it('exits 0 with substrateAvailable: false when recurrence-stats.json is absent', async () => {
140
+ mockFetchPr.mockReturnValue(makePr({ number: 200 }));
141
+ // 5 rounds via 5 distinct head SHAs so threshold is met.
142
+ mockFetchReviews.mockReturnValue([
143
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
144
+ makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
145
+ makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
146
+ makeReview({ id: 4, commit_id: 'sha-D', submitted_at: '2026-04-29T04:00:00.000Z' }),
147
+ makeReview({ id: 5, commit_id: 'sha-E', submitted_at: '2026-04-29T05:00:00.000Z' }),
148
+ ]);
149
+ mockFetchReviewComments.mockReturnValue([
150
+ makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
151
+ ]);
152
+ const outPath = path.join(tmpDir, 'report.json');
153
+ const { runRetrospect } = await import('./retrospect.js');
154
+ await runRetrospect({ prNumber: '200', threshold: 5, out: outPath });
155
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
156
+ expect(written.substrateAvailable).toBe(false);
157
+ // Every finding has crossPrRecurrence: 0.
158
+ const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
159
+ for (const f of all)
160
+ expect(f.crossPrRecurrence).toBe(0);
161
+ // Warning emitted naming the missing file.
162
+ const warnCall = mockLog.warn.mock.calls.find((c) => String(c[1]).includes('recurrence-stats.json'));
163
+ expect(warnCall).toBeDefined();
164
+ });
165
+ it('treats malformed recurrence-stats.json as missing and continues', async () => {
166
+ fs.writeFileSync(path.join(totemDir, 'recurrence-stats.json'), 'this is not json', 'utf-8');
167
+ mockFetchPr.mockReturnValue(makePr({ number: 201 }));
168
+ mockFetchReviews.mockReturnValue([
169
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
170
+ makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
171
+ makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
172
+ makeReview({ id: 4, commit_id: 'sha-D', submitted_at: '2026-04-29T04:00:00.000Z' }),
173
+ makeReview({ id: 5, commit_id: 'sha-E', submitted_at: '2026-04-29T05:00:00.000Z' }),
174
+ ]);
175
+ mockFetchReviewComments.mockReturnValue([
176
+ makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
177
+ ]);
178
+ const outPath = path.join(tmpDir, 'report.json');
179
+ const { runRetrospect } = await import('./retrospect.js');
180
+ await runRetrospect({ prNumber: '201', threshold: 5, out: outPath });
181
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
182
+ expect(written.substrateAvailable).toBe(false);
183
+ const warnCall = mockLog.warn.mock.calls.find((c) => String(c[1]).includes('Could not parse'));
184
+ expect(warnCall).toBeDefined();
185
+ });
186
+ it('honors compiled-rules absence with compiledRulesAvailable: false', async () => {
187
+ mockFetchPr.mockReturnValue(makePr({ number: 202 }));
188
+ mockFetchReviews.mockReturnValue([
189
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
190
+ ]);
191
+ mockFetchReviewComments.mockReturnValue([
192
+ makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
193
+ ]);
194
+ const outPath = path.join(tmpDir, 'report.json');
195
+ const { runRetrospect } = await import('./retrospect.js');
196
+ await runRetrospect({ prNumber: '202', threshold: 1, out: outPath });
197
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
198
+ expect(written.compiledRulesAvailable).toBe(false);
199
+ const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
200
+ for (const f of all)
201
+ expect(f.coveredByRule).toBe(false);
202
+ });
203
+ });
204
+ describe('runRetrospect — round grouping invariants', () => {
205
+ it('collapses two reviews on the same head_sha into one round', async () => {
206
+ mockFetchPr.mockReturnValue(makePr({ number: 300 }));
207
+ mockFetchReviews.mockReturnValue([
208
+ makeReview({
209
+ id: 1,
210
+ commit_id: 'sha-A',
211
+ submitted_at: '2026-04-29T01:00:00.000Z',
212
+ user_login: 'coderabbitai[bot]',
213
+ }),
214
+ makeReview({
215
+ id: 2,
216
+ commit_id: 'sha-A',
217
+ submitted_at: '2026-04-29T01:30:00.000Z',
218
+ user_login: 'gemini-code-assist[bot]',
219
+ }),
220
+ ]);
221
+ mockFetchReviewComments.mockReturnValue([]);
222
+ const outPath = path.join(tmpDir, 'report.json');
223
+ const { runRetrospect } = await import('./retrospect.js');
224
+ await runRetrospect({ prNumber: '300', threshold: 1, force: true, out: outPath });
225
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
226
+ expect(written.rounds).toHaveLength(1);
227
+ expect(written.rounds[0].headSha).toBe('sha-A');
228
+ });
229
+ it('separates reviews on different head_sha into distinct rounds', async () => {
230
+ mockFetchPr.mockReturnValue(makePr({ number: 301 }));
231
+ mockFetchReviews.mockReturnValue([
232
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
233
+ makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
234
+ makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
235
+ ]);
236
+ mockFetchReviewComments.mockReturnValue([]);
237
+ const outPath = path.join(tmpDir, 'report.json');
238
+ const { runRetrospect } = await import('./retrospect.js');
239
+ await runRetrospect({ prNumber: '301', threshold: 1, force: true, out: outPath });
240
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
241
+ expect(written.rounds).toHaveLength(3);
242
+ expect(written.rounds.map((r) => r.headSha)).toEqual([
243
+ 'sha-A',
244
+ 'sha-B',
245
+ 'sha-C',
246
+ ]);
247
+ });
248
+ });
249
+ describe('runRetrospect — cross-PR recurrence excludes target PR', () => {
250
+ it('counts only OTHER PRs in crossPrRecurrence even if target has same signature N times', async () => {
251
+ // Build a recurrence-stats.json substrate where a finding from PR
252
+ // 400 (the target) appears 5x — should yield crossPrRecurrence: 0
253
+ // (not 5) because the target PR is excluded from the count.
254
+ const targetPr = '400';
255
+ // Compute the signature using core's helpers so the substrate matches.
256
+ const { signatureOfBody } = await import('@mmnto/totem');
257
+ const findingBody = 'Avoid using `any` — prefer `unknown`.';
258
+ const sig = signatureOfBody(findingBody);
259
+ fs.writeFileSync(path.join(totemDir, 'recurrence-stats.json'), JSON.stringify({
260
+ version: 1,
261
+ lastUpdated: '2026-04-29T00:00:00.000Z',
262
+ thresholdApplied: 1,
263
+ historyDepth: 50,
264
+ prsScanned: [targetPr],
265
+ patterns: [
266
+ {
267
+ signature: sig,
268
+ tool: 'coderabbit',
269
+ severityBucket: 'medium',
270
+ occurrences: 5,
271
+ // All 5 occurrences are on the target PR.
272
+ prs: [targetPr, targetPr, targetPr, targetPr, targetPr],
273
+ sampleBodies: [findingBody],
274
+ firstSeen: 'x',
275
+ lastSeen: 'y',
276
+ paths: [],
277
+ coveredByRule: false,
278
+ },
279
+ ],
280
+ coveredPatterns: [],
281
+ }), 'utf-8');
282
+ mockFetchPr.mockReturnValue(makePr({ number: 400 }));
283
+ mockFetchReviews.mockReturnValue([
284
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
285
+ ]);
286
+ mockFetchReviewComments.mockReturnValue([
287
+ makeInlineComment({ id: 100, body: findingBody, createdAt: '2026-04-29T01:30:00.000Z' }),
288
+ ]);
289
+ const outPath = path.join(tmpDir, 'report.json');
290
+ const { runRetrospect } = await import('./retrospect.js');
291
+ await runRetrospect({ prNumber: targetPr, threshold: 1, force: true, out: outPath });
292
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
293
+ expect(written.substrateAvailable).toBe(true);
294
+ const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
295
+ expect(all.length).toBeGreaterThan(0);
296
+ for (const f of all)
297
+ expect(f.crossPrRecurrence).toBe(0);
298
+ });
299
+ it('counts OTHER PRs only when they actually exist on the substrate', async () => {
300
+ const { signatureOfBody } = await import('@mmnto/totem');
301
+ const findingBody = 'Empty catch block — prefer logging the error.';
302
+ const sig = signatureOfBody(findingBody);
303
+ fs.writeFileSync(path.join(totemDir, 'recurrence-stats.json'), JSON.stringify({
304
+ version: 1,
305
+ lastUpdated: '2026-04-29T00:00:00.000Z',
306
+ thresholdApplied: 1,
307
+ historyDepth: 50,
308
+ prsScanned: ['401', '402', '403'],
309
+ patterns: [
310
+ {
311
+ signature: sig,
312
+ tool: 'coderabbit',
313
+ severityBucket: 'medium',
314
+ occurrences: 4,
315
+ // PR 401 is the target; 402, 403 are siblings.
316
+ prs: ['401', '402', '403'],
317
+ sampleBodies: [findingBody],
318
+ firstSeen: 'x',
319
+ lastSeen: 'y',
320
+ paths: [],
321
+ coveredByRule: false,
322
+ },
323
+ ],
324
+ coveredPatterns: [],
325
+ }), 'utf-8');
326
+ mockFetchPr.mockReturnValue(makePr({ number: 401 }));
327
+ mockFetchReviews.mockReturnValue([
328
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
329
+ ]);
330
+ mockFetchReviewComments.mockReturnValue([
331
+ makeInlineComment({ id: 100, body: findingBody, createdAt: '2026-04-29T01:30:00.000Z' }),
332
+ ]);
333
+ const outPath = path.join(tmpDir, 'report.json');
334
+ const { runRetrospect } = await import('./retrospect.js');
335
+ await runRetrospect({ prNumber: '401', threshold: 1, force: true, out: outPath });
336
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
337
+ const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
338
+ // Find the matched finding by signature.
339
+ const matched = all.find((f) => f.signature === sig);
340
+ expect(matched).toBeDefined();
341
+ expect(matched.crossPrRecurrence).toBe(2); // 402 and 403, NOT 401
342
+ });
343
+ });
344
+ describe('runRetrospect — zero-bot edge case', () => {
345
+ it('emits an empty-finding report when the PR has no bot comments', async () => {
346
+ mockFetchPr.mockReturnValue(makePr({ number: 500 }));
347
+ mockFetchReviews.mockReturnValue([]);
348
+ mockFetchReviewComments.mockReturnValue([]);
349
+ const outPath = path.join(tmpDir, 'report.json');
350
+ const { runRetrospect } = await import('./retrospect.js');
351
+ await runRetrospect({ prNumber: '500', threshold: 1, force: true, out: outPath });
352
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
353
+ expect(written.totalFindings).toBe(0);
354
+ expect(written.rounds).toHaveLength(0);
355
+ expect(written.routeOutCandidates).toEqual([]);
356
+ expect(written.inPrFixes).toEqual([]);
357
+ expect(written.undetermined).toEqual([]);
358
+ });
359
+ });
360
+ describe('runRetrospect — --out write semantics', () => {
361
+ it('writes a deterministic two-space-indented JSON file when --out is set', async () => {
362
+ mockFetchPr.mockReturnValue(makePr({ number: 600 }));
363
+ mockFetchReviews.mockReturnValue([
364
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
365
+ ]);
366
+ mockFetchReviewComments.mockReturnValue([]);
367
+ const outPath = path.join(tmpDir, 'sub', 'report.json');
368
+ const { runRetrospect } = await import('./retrospect.js');
369
+ await runRetrospect({ prNumber: '600', threshold: 1, force: true, out: outPath });
370
+ expect(fs.existsSync(outPath)).toBe(true);
371
+ const raw = fs.readFileSync(outPath, 'utf-8');
372
+ expect(raw.endsWith('\n')).toBe(true);
373
+ // Two-space indent = ` "version"`.
374
+ expect(raw).toMatch(/\n {2}"version": 1/);
375
+ });
376
+ it('does NOT create any file under .totem/ by default (no --out)', async () => {
377
+ mockFetchPr.mockReturnValue(makePr({ number: 601 }));
378
+ mockFetchReviews.mockReturnValue([
379
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
380
+ ]);
381
+ mockFetchReviewComments.mockReturnValue([]);
382
+ // Snapshot mtimes of every file currently under .totem/.
383
+ const before = fs.readdirSync(totemDir).map((name) => {
384
+ const p = path.join(totemDir, name);
385
+ return { name, mtimeMs: fs.statSync(p).mtimeMs };
386
+ });
387
+ const { runRetrospect } = await import('./retrospect.js');
388
+ await runRetrospect({ prNumber: '601', threshold: 1, force: true });
389
+ const after = fs.readdirSync(totemDir).map((name) => {
390
+ const p = path.join(totemDir, name);
391
+ return { name, mtimeMs: fs.statSync(p).mtimeMs };
392
+ });
393
+ // Same set of files, same mtimes — no writes.
394
+ expect(after).toEqual(before);
395
+ });
396
+ });
397
+ describe('runRetrospect — no-LLM invariant', () => {
398
+ // ─── Static-source guard ─────────────────────────────
399
+ // Mirrors the shield-estimate.ts no-LLM guard. A static-source grep
400
+ // catches a future drift where someone adds an orchestrator import.
401
+ it('never imports the orchestrator or LLM-path modules from retrospect.ts', async () => {
402
+ const cmdPath = path.join(__dirname, 'retrospect.ts');
403
+ const source = fs.readFileSync(cmdPath, 'utf-8');
404
+ expect(source).not.toMatch(/from ['"]\.\.\/orchestrators\//);
405
+ expect(source).not.toMatch(/from ['"]@mmnto\/totem-orchestrator['"]/);
406
+ expect(source).not.toMatch(/getOrchestrator/);
407
+ expect(source).not.toMatch(/createOrchestrator/);
408
+ expect(source).not.toMatch(/runOrchestrator/);
409
+ expect(source).not.toMatch(/Anthropic/);
410
+ expect(source).not.toMatch(/OpenAI/);
411
+ expect(source).not.toMatch(/\bgemini\b/i);
412
+ expect(source).not.toMatch(/createEmbedder/);
413
+ expect(source).not.toMatch(/LanceStore/);
414
+ });
415
+ it('never imports the orchestrator or LLM-path modules from core/retrospect.ts', async () => {
416
+ const corePath = path.join(__dirname, '..', '..', '..', 'core', 'src', 'retrospect.ts');
417
+ const source = fs.readFileSync(corePath, 'utf-8');
418
+ expect(source).not.toMatch(/from ['"]@mmnto\/totem-orchestrator['"]/);
419
+ expect(source).not.toMatch(/getOrchestrator/);
420
+ expect(source).not.toMatch(/createOrchestrator/);
421
+ expect(source).not.toMatch(/runOrchestrator/);
422
+ expect(source).not.toMatch(/Anthropic/);
423
+ expect(source).not.toMatch(/OpenAI/);
424
+ expect(source).not.toMatch(/\bgemini\b/i);
425
+ expect(source).not.toMatch(/createEmbedder/);
426
+ expect(source).not.toMatch(/LanceStore/);
427
+ });
428
+ // ─── Dynamic-import allowlist ────────────────────────
429
+ // Every `await import(...)` in retrospect.ts must resolve to a known
430
+ // non-LLM module. Closes the static-source-grep gap where a transitive
431
+ // orchestrator import via a benign-looking path would be missed.
432
+ it('keeps every dynamic import sourced from non-LLM modules', async () => {
433
+ const cmdSource = fs.readFileSync(path.join(__dirname, 'retrospect.ts'), 'utf-8');
434
+ const allowedDynamicImports = [
435
+ "await import('node:fs')",
436
+ "await import('node:path')",
437
+ "await import('node:crypto')",
438
+ "await import('zod')",
439
+ "await import('../adapters/github-cli-pr.js')",
440
+ "await import('../ui.js')",
441
+ "await import('../parsers/bot-review-parser.js')",
442
+ "await import('@mmnto/totem')",
443
+ "await import('../utils.js')",
444
+ ];
445
+ // Strip allowed imports; what's left should contain NO stray imports.
446
+ let residual = cmdSource;
447
+ for (const expected of allowedDynamicImports) {
448
+ // totem-context: we're stripping allowed dynamic-import substrings, not concatenating tokens. The empty separator is the correct semantics for the static-source-grep guard.
449
+ residual = residual.split(expected).join('');
450
+ }
451
+ // Any remaining `await import(...)` is a guard violation.
452
+ expect(residual).not.toMatch(/await import\(['"][^'"]+['"]\)/);
453
+ });
454
+ });
455
+ // ─── Runtime orchestrator spy guard ────────────────────────
456
+ // Per CR mmnto-ai/totem#1734 round-2: source-text inspection alone can
457
+ // be fooled by a transitive import or an aliased dynamic import. Mock the
458
+ // orchestrator factory module at runtime; if anything in retrospect's
459
+ // import chain reaches it, the spy fires. We require zero invocations.
460
+ //
461
+ // vi.mock is hoisted, so this block lives at module scope.
462
+ const orchestratorSpy = vi.fn();
463
+ vi.mock('../orchestrators/orchestrator.js', () => ({
464
+ createOrchestrator: (...args) => {
465
+ orchestratorSpy(...args);
466
+ return () => {
467
+ throw new Error('createOrchestrator must NEVER be called from runRetrospect');
468
+ };
469
+ },
470
+ resolveOrchestrator: (...args) => {
471
+ orchestratorSpy(...args);
472
+ throw new Error('resolveOrchestrator must NEVER be called from runRetrospect');
473
+ },
474
+ }));
475
+ describe('runRetrospect — runtime orchestrator spy', () => {
476
+ it('never invokes createOrchestrator or resolveOrchestrator across a real run', async () => {
477
+ orchestratorSpy.mockClear();
478
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
479
+ mockFetchReviews.mockReturnValue([
480
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
481
+ makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
482
+ makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
483
+ makeReview({ id: 4, commit_id: 'sha-D', submitted_at: '2026-04-29T04:00:00.000Z' }),
484
+ makeReview({ id: 5, commit_id: 'sha-E', submitted_at: '2026-04-29T05:00:00.000Z' }),
485
+ ]);
486
+ mockFetchReviewComments.mockReturnValue([
487
+ makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
488
+ ]);
489
+ const { runRetrospect } = await import('./retrospect.js');
490
+ await runRetrospect({ prNumber: '1713', threshold: 5, force: true });
491
+ expect(orchestratorSpy).toHaveBeenCalledTimes(0);
492
+ });
493
+ });
494
+ describe('runRetrospect — classification fan-out', () => {
495
+ // High-level shape check: feed a synthetic finding for each severity
496
+ // bucket and assert that the classification verdicts land in the
497
+ // expected report buckets. Detailed table coverage lives in the
498
+ // core-package retrospect.test.ts; this is the integration shape.
499
+ it.each([
500
+ // CodeRabbit: critical → in-pr-fix
501
+ [
502
+ 'crit',
503
+ '🔴 Critical: Avoid using `any` — prefer `unknown`.',
504
+ 'critical',
505
+ 'inPrFixes',
506
+ ],
507
+ // CodeRabbit: major → high → in-pr-fix
508
+ ['major', '🟠 Major: bigger problem.', 'high', 'inPrFixes'],
509
+ // CodeRabbit: minor → medium → in-pr-fix at early round
510
+ ['minor', '🟡 Minor: small thing.', 'medium', 'inPrFixes'],
511
+ ])('classifies a CR %s finding into bucket %s with severity %s', async (_label, body, expectedSeverity, expectedBucket) => {
512
+ mockFetchPr.mockReturnValue(makePr({ number: 700 }));
513
+ mockFetchReviews.mockReturnValue([
514
+ makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
515
+ ]);
516
+ mockFetchReviewComments.mockReturnValue([
517
+ makeInlineComment({
518
+ id: 100,
519
+ body,
520
+ createdAt: '2026-04-29T01:30:00.000Z',
521
+ }),
522
+ ]);
523
+ const outPath = path.join(tmpDir, 'report.json');
524
+ const { runRetrospect } = await import('./retrospect.js');
525
+ await runRetrospect({ prNumber: '700', threshold: 1, force: true, out: outPath });
526
+ const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
527
+ const target = written[expectedBucket];
528
+ expect(target.length).toBeGreaterThan(0);
529
+ expect(target[0].severityBucket).toBe(expectedSeverity);
530
+ });
531
+ });
532
+ describe('runRetrospect — input validation (mmnto-ai/totem#1734 review-1)', () => {
533
+ it('rejects PR number with trailing non-numerics (Number.isInteger guard)', async () => {
534
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
535
+ mockFetchReviews.mockReturnValue([]);
536
+ mockFetchReviewComments.mockReturnValue([]);
537
+ const { runRetrospect } = await import('./retrospect.js');
538
+ await expect(runRetrospect({ prNumber: '1713foo', force: true })).rejects.toThrow(/Invalid PR number/);
539
+ });
540
+ it('rejects fractional PR number (e.g. "5.2")', async () => {
541
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
542
+ mockFetchReviews.mockReturnValue([]);
543
+ mockFetchReviewComments.mockReturnValue([]);
544
+ const { runRetrospect } = await import('./retrospect.js');
545
+ await expect(runRetrospect({ prNumber: '5.2', force: true })).rejects.toThrow(/Invalid PR number/);
546
+ });
547
+ it('rejects non-positive PR number', async () => {
548
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
549
+ mockFetchReviews.mockReturnValue([]);
550
+ mockFetchReviewComments.mockReturnValue([]);
551
+ const { runRetrospect } = await import('./retrospect.js');
552
+ await expect(runRetrospect({ prNumber: '0', force: true })).rejects.toThrow(/Invalid PR number/);
553
+ });
554
+ });
555
+ describe('runRetrospect — pull_request_review_id round-grouping (CR mmnto-ai/totem#1734 R2)', () => {
556
+ it('buckets each finding via review_id even when comment.created_at predates submitted_at (draft review)', async () => {
557
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
558
+ // Two reviews: review-1 SUBMITTED much later than its comments were CREATED
559
+ // (comments were drafted while the review was pending). A timestamp join
560
+ // would mis-attribute review-1's comments to review-2 because comment
561
+ // createdAt < review-1 submittedAt < review-2 submittedAt is FALSE here.
562
+ mockFetchReviews.mockReturnValue([
563
+ makeReview({
564
+ id: 1,
565
+ commit_id: 'sha-A',
566
+ submitted_at: '2026-04-29T05:00:00.000Z',
567
+ user_login: 'coderabbitai[bot]',
568
+ }),
569
+ makeReview({
570
+ id: 2,
571
+ commit_id: 'sha-B',
572
+ submitted_at: '2026-04-29T05:30:00.000Z',
573
+ user_login: 'coderabbitai[bot]',
574
+ }),
575
+ ]);
576
+ mockFetchReviewComments.mockReturnValue([
577
+ // Comment drafted at 04:30 (BEFORE review-1's submitted_at 05:00)
578
+ // — pending review behavior. Foreign-key join puts it into review-1
579
+ // (sha-A); a naive timestamp join would put it nowhere (no SHA
580
+ // satisfies submitted_at <= 04:30).
581
+ makeInlineComment({
582
+ id: 100,
583
+ author: 'coderabbitai[bot]',
584
+ createdAt: '2026-04-29T04:30:00.000Z',
585
+ pullRequestReviewId: 1,
586
+ }),
587
+ // Comment drafted at 05:25 (BETWEEN review-1 and review-2's
588
+ // submitted_at) but belongs to review-2 (foreign key). A timestamp
589
+ // join would mis-attribute it to review-1 (sha-A).
590
+ makeInlineComment({
591
+ id: 101,
592
+ author: 'coderabbitai[bot]',
593
+ createdAt: '2026-04-29T05:25:00.000Z',
594
+ pullRequestReviewId: 2,
595
+ }),
596
+ ]);
597
+ const outPath = path.join(tmpDir, 'out.json');
598
+ const { runRetrospect } = await import('./retrospect.js');
599
+ await runRetrospect({ prNumber: '1713', threshold: 1, force: true, out: outPath });
600
+ const report = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
601
+ expect(report.totalFindings).toBe(2);
602
+ // Two distinct rounds (one per SHA) confirm the foreign-key join.
603
+ expect(report.rounds.length).toBe(2);
604
+ expect(new Set(report.rounds.map((r) => r.headSha))).toEqual(new Set(['sha-A', 'sha-B']));
605
+ });
606
+ });
607
+ describe('runRetrospect — terminal-output sanitization (CR mmnto-ai/totem#1734 R2)', () => {
608
+ it('strips ANSI/control bytes from bodyExcerpt before logging', async () => {
609
+ // Set up enough rounds to clear the threshold so the renderer fires.
610
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
611
+ mockFetchReviews.mockReturnValue([
612
+ makeReview({
613
+ id: 1,
614
+ commit_id: 'sha-A',
615
+ submitted_at: '2026-04-29T01:00:00.000Z',
616
+ user_login: 'coderabbitai[bot]',
617
+ }),
618
+ makeReview({
619
+ id: 2,
620
+ commit_id: 'sha-B',
621
+ submitted_at: '2026-04-29T02:00:00.000Z',
622
+ user_login: 'coderabbitai[bot]',
623
+ }),
624
+ ]);
625
+ // Hostile body: ANSI red-fg + cursor-up + bell + raw control bytes.
626
+ const hostile = '\x1b[31mFAKE CRITICAL\x1b[0m\x1b[2A\x07 should look fine after sanitize';
627
+ mockFetchReviewComments.mockReturnValue([
628
+ makeInlineComment({
629
+ id: 100,
630
+ body: hostile,
631
+ author: 'coderabbitai[bot]',
632
+ pullRequestReviewId: 1,
633
+ }),
634
+ ]);
635
+ const { runRetrospect } = await import('./retrospect.js');
636
+ await runRetrospect({ prNumber: '1713', threshold: 1, force: true });
637
+ // Every log call's args[1] must contain no escape or control bytes.
638
+ const allLogs = [
639
+ ...mockLog.info.mock.calls,
640
+ ...mockLog.dim.mock.calls,
641
+ ...mockLog.warn.mock.calls,
642
+ ];
643
+ for (const call of allLogs) {
644
+ const text = String(call[1] ?? '');
645
+ // eslint-disable-next-line no-control-regex
646
+ expect(text).not.toMatch(/\x1b\[/);
647
+ // eslint-disable-next-line no-control-regex
648
+ expect(text).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/);
649
+ }
650
+ });
651
+ });
652
+ describe('runRetrospect — null user_login (deleted/ghost accounts)', () => {
653
+ it('skips review submissions whose user_login is null without inflating round count', async () => {
654
+ mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
655
+ mockFetchReviews.mockReturnValue([
656
+ makeReview({
657
+ id: 1,
658
+ commit_id: 'sha-A',
659
+ submitted_at: '2026-04-29T01:00:00.000Z',
660
+ user_login: 'coderabbitai[bot]',
661
+ }),
662
+ // GitHub API returns null user for deleted/ghost accounts.
663
+ makeReview({
664
+ id: 2,
665
+ commit_id: 'sha-B',
666
+ submitted_at: '2026-04-29T01:30:00.000Z',
667
+ user_login: null,
668
+ }),
669
+ ]);
670
+ mockFetchReviewComments.mockReturnValue([
671
+ makeInlineComment({
672
+ id: 100,
673
+ author: 'coderabbitai[bot]',
674
+ createdAt: '2026-04-29T01:05:00.000Z',
675
+ }),
676
+ makeInlineComment({
677
+ id: 101,
678
+ // Inline comment from a deleted account — author would be 'ghost' or empty
679
+ // in practice; here we model it via the parser path via isBotComment('').
680
+ author: 'ghost',
681
+ createdAt: '2026-04-29T01:35:00.000Z',
682
+ }),
683
+ ]);
684
+ const outPath = path.join(tmpDir, 'out.json');
685
+ const { runRetrospect } = await import('./retrospect.js');
686
+ await runRetrospect({ prNumber: '1713', force: true, out: outPath });
687
+ const report = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
688
+ // Only the bot-authored finding survives; the ghost-authored one drops.
689
+ expect(report.totalFindings).toBe(1);
690
+ // Round count reflects only bot rounds (sha-A); sha-B is skipped because
691
+ // its review submission's user_login is null.
692
+ expect(report.rounds.length).toBe(1);
693
+ expect(report.rounds[0].headSha).toBe('sha-A');
694
+ });
695
+ });
696
+ //# sourceMappingURL=retrospect.test.js.map