import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ─── Mocks ──────────────────────────────────────────────
//
// `retrospect.ts` lazy-imports the adapter, ui, parsers, and core. vi.mock
// is hoisted, so these stubs land in place before the dynamic imports
// inside runRetrospect resolve.
const mockFetchPr = vi.fn();
const mockFetchReviews = vi.fn();
const mockFetchReviewComments = vi.fn();
vi.mock('../adapters/github-cli-pr.js', () => ({
    GitHubCliPrAdapter: class GitHubCliPrAdapter {
        fetchPr(num) {
            return mockFetchPr(num);
        }
        fetchReviews(num) {
            return mockFetchReviews(num);
        }
        fetchReviewComments(num) {
            return mockFetchReviewComments(num);
        }
    },
}));
// Capture every log line so tests can assert on tag invariants.
vi.mock('../ui.js', () => ({
    log: {
        info: vi.fn(),
        success: vi.fn(),
        warn: vi.fn(),
        error: vi.fn(),
        dim: vi.fn(),
    },
}));
// Mock loadConfig + resolveConfigPath to a tmp totemDir; spread the rest
// of utils so constants like `IS_WIN`/`GH_TIMEOUT_MS` stay accessible.
// Per Sonnet review of CR R2: dropping the spread is fragile to future
// drift in the dependency graph.
vi.mock('../utils.js', async () => {
    const actual = await vi.importActual('../utils.js');
    return {
        ...actual,
        loadConfig: vi.fn().mockImplementation(async () => ({ totemDir: '.totem' })),
        resolveConfigPath: vi.fn().mockReturnValue(''),
    };
});
const uiModule = await import('../ui.js');
const mockLog = vi.mocked(uiModule.log);
// ─── Test data factories ───────────────────────────────
function makeReview(overrides) {
    return {
        id: overrides.id,
        user_login: overrides.user_login === undefined ? 'coderabbitai[bot]' : overrides.user_login,
        commit_id: overrides.commit_id ?? 'sha-default',
        submitted_at: overrides.submitted_at ?? '2026-04-29T01:00:00.000Z',
        state: overrides.state ?? 'COMMENTED',
        body: overrides.body ?? '',
    };
}
function makeInlineComment(overrides) {
    return {
        id: overrides.id,
        author: overrides.author ?? 'coderabbitai[bot]',
        body: overrides.body ?? 'Avoid using `any` — prefer `unknown`.',
        path: overrides.filePath ?? 'src/handler.ts',
        diffHunk: `@@ -1,3 +${overrides.line ?? 42},3 @@`,
        inReplyToId: undefined,
        createdAt: overrides.createdAt ?? '2026-04-29T01:00:30.000Z',
        // Default to the first review submission's id so timestamp-free joins
        // still work for the existing fixtures. Tests explicitly exercising
        // round-grouping override this.
        pullRequestReviewId: overrides.pullRequestReviewId === undefined ? 1 : overrides.pullRequestReviewId,
    };
}
function makePr(overrides) {
    return {
        number: overrides.number,
        title: `PR #${overrides.number}`,
        body: '',
        state: overrides.state ?? 'open',
        comments: [],
        reviews: [],
    };
}
// ─── Test setup ────────────────────────────────────────
let tmpDir;
let totemDir;
let originalCwd;
beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'retrospect-'));
    totemDir = path.join(tmpDir, '.totem');
    fs.mkdirSync(totemDir, { recursive: true });
    originalCwd = process.cwd();
    process.chdir(tmpDir);
    mockFetchPr.mockReset();
    mockFetchReviews.mockReset();
    mockFetchReviewComments.mockReset();
    for (const fn of Object.values(mockLog)) {
        fn.mockReset();
    }
});
afterEach(() => {
    process.chdir(originalCwd);
    fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});
// ─── Tests ──────────────────────────────────────────────
describe('runRetrospect — sub-threshold skip', () => {
    it('exits 0 (resolves) when rounds < threshold and --force is not set', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([
            makeReview({
                id: 1,
                commit_id: 'sha-A',
                submitted_at: '2026-04-29T01:00:00.000Z',
            }),
        ]);
        mockFetchReviewComments.mockReturnValue([]);
        const { runRetrospect } = await import('./retrospect.js');
        await expect(runRetrospect({ prNumber: '1713', threshold: 5 })).resolves.toBeUndefined();
        // Skip message logged.
        const skipCall = mockLog.info.mock.calls.find((c) => String(c[1]).includes('below threshold'));
        expect(skipCall).toBeDefined();
    });
    it('renders the report when --force is passed below threshold', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({ id: 100, createdAt: '2026-04-29T01:00:30.000Z' }),
        ]);
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '1713', threshold: 5, force: true });
        // Should NOT log the skip message.
        const skipCall = mockLog.info.mock.calls.find((c) => String(c[1]).includes('below threshold'));
        expect(skipCall).toBeUndefined();
        // Should log the headline summary.
        const headline = mockLog.info.mock.calls.find((c) => String(c[1]).includes('PR #1713'));
        expect(headline).toBeDefined();
    });
});
describe('runRetrospect — substrate graceful degrade', () => {
    it('exits 0 with substrateAvailable: false when recurrence-stats.json is absent', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 200 }));
        // 5 rounds via 5 distinct head SHAs so threshold is met.
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
            makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
            makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
            makeReview({ id: 4, commit_id: 'sha-D', submitted_at: '2026-04-29T04:00:00.000Z' }),
            makeReview({ id: 5, commit_id: 'sha-E', submitted_at: '2026-04-29T05:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
        ]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '200', threshold: 5, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(written.substrateAvailable).toBe(false);
        // Every finding has crossPrRecurrence: 0.
        const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
        for (const f of all)
            expect(f.crossPrRecurrence).toBe(0);
        // Warning emitted naming the missing file.
        const warnCall = mockLog.warn.mock.calls.find((c) => String(c[1]).includes('recurrence-stats.json'));
        expect(warnCall).toBeDefined();
    });
    it('treats malformed recurrence-stats.json as missing and continues', async () => {
        fs.writeFileSync(path.join(totemDir, 'recurrence-stats.json'), 'this is not json', 'utf-8');
        mockFetchPr.mockReturnValue(makePr({ number: 201 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
            makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
            makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
            makeReview({ id: 4, commit_id: 'sha-D', submitted_at: '2026-04-29T04:00:00.000Z' }),
            makeReview({ id: 5, commit_id: 'sha-E', submitted_at: '2026-04-29T05:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
        ]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '201', threshold: 5, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(written.substrateAvailable).toBe(false);
        const warnCall = mockLog.warn.mock.calls.find((c) => String(c[1]).includes('Could not parse'));
        expect(warnCall).toBeDefined();
    });
    it('honors compiled-rules absence with compiledRulesAvailable: false', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 202 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
        ]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '202', threshold: 1, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(written.compiledRulesAvailable).toBe(false);
        const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
        for (const f of all)
            expect(f.coveredByRule).toBe(false);
    });
});
describe('runRetrospect — round grouping invariants', () => {
    it('collapses two reviews on the same head_sha into one round', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 300 }));
        mockFetchReviews.mockReturnValue([
            makeReview({
                id: 1,
                commit_id: 'sha-A',
                submitted_at: '2026-04-29T01:00:00.000Z',
                user_login: 'coderabbitai[bot]',
            }),
            makeReview({
                id: 2,
                commit_id: 'sha-A',
                submitted_at: '2026-04-29T01:30:00.000Z',
                user_login: 'gemini-code-assist[bot]',
            }),
        ]);
        mockFetchReviewComments.mockReturnValue([]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '300', threshold: 1, force: true, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(written.rounds).toHaveLength(1);
        expect(written.rounds[0].headSha).toBe('sha-A');
    });
    it('separates reviews on different head_sha into distinct rounds', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 301 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
            makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
            makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '301', threshold: 1, force: true, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(written.rounds).toHaveLength(3);
        expect(written.rounds.map((r) => r.headSha)).toEqual([
            'sha-A',
            'sha-B',
            'sha-C',
        ]);
    });
});
describe('runRetrospect — cross-PR recurrence excludes target PR', () => {
    it('counts only OTHER PRs in crossPrRecurrence even if target has same signature N times', async () => {
        // Build a recurrence-stats.json substrate where a finding from PR
        // 400 (the target) appears 5x — should yield crossPrRecurrence: 0
        // (not 5) because the target PR is excluded from the count.
        const targetPr = '400';
        // Compute the signature using core's helpers so the substrate matches.
        const { signatureOfBody } = await import('@mmnto/totem');
        const findingBody = 'Avoid using `any` — prefer `unknown`.';
        const sig = signatureOfBody(findingBody);
        fs.writeFileSync(path.join(totemDir, 'recurrence-stats.json'), JSON.stringify({
            version: 1,
            lastUpdated: '2026-04-29T00:00:00.000Z',
            thresholdApplied: 1,
            historyDepth: 50,
            prsScanned: [targetPr],
            patterns: [
                {
                    signature: sig,
                    tool: 'coderabbit',
                    severityBucket: 'medium',
                    occurrences: 5,
                    // All 5 occurrences are on the target PR.
                    prs: [targetPr, targetPr, targetPr, targetPr, targetPr],
                    sampleBodies: [findingBody],
                    firstSeen: 'x',
                    lastSeen: 'y',
                    paths: [],
                    coveredByRule: false,
                },
            ],
            coveredPatterns: [],
        }), 'utf-8');
        mockFetchPr.mockReturnValue(makePr({ number: 400 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({ id: 100, body: findingBody, createdAt: '2026-04-29T01:30:00.000Z' }),
        ]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: targetPr, threshold: 1, force: true, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(written.substrateAvailable).toBe(true);
        const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
        expect(all.length).toBeGreaterThan(0);
        for (const f of all)
            expect(f.crossPrRecurrence).toBe(0);
    });
    it('counts OTHER PRs only when they actually exist on the substrate', async () => {
        const { signatureOfBody } = await import('@mmnto/totem');
        const findingBody = 'Empty catch block — prefer logging the error.';
        const sig = signatureOfBody(findingBody);
        fs.writeFileSync(path.join(totemDir, 'recurrence-stats.json'), JSON.stringify({
            version: 1,
            lastUpdated: '2026-04-29T00:00:00.000Z',
            thresholdApplied: 1,
            historyDepth: 50,
            prsScanned: ['401', '402', '403'],
            patterns: [
                {
                    signature: sig,
                    tool: 'coderabbit',
                    severityBucket: 'medium',
                    occurrences: 4,
                    // PR 401 is the target; 402, 403 are siblings.
                    prs: ['401', '402', '403'],
                    sampleBodies: [findingBody],
                    firstSeen: 'x',
                    lastSeen: 'y',
                    paths: [],
                    coveredByRule: false,
                },
            ],
            coveredPatterns: [],
        }), 'utf-8');
        mockFetchPr.mockReturnValue(makePr({ number: 401 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({ id: 100, body: findingBody, createdAt: '2026-04-29T01:30:00.000Z' }),
        ]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '401', threshold: 1, force: true, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        const all = [...written.routeOutCandidates, ...written.inPrFixes, ...written.undetermined];
        // Find the matched finding by signature.
        const matched = all.find((f) => f.signature === sig);
        expect(matched).toBeDefined();
        expect(matched.crossPrRecurrence).toBe(2); // 402 and 403, NOT 401
    });
});
describe('runRetrospect — zero-bot edge case', () => {
    it('emits an empty-finding report when the PR has no bot comments', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 500 }));
        mockFetchReviews.mockReturnValue([]);
        mockFetchReviewComments.mockReturnValue([]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '500', threshold: 1, force: true, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(written.totalFindings).toBe(0);
        expect(written.rounds).toHaveLength(0);
        expect(written.routeOutCandidates).toEqual([]);
        expect(written.inPrFixes).toEqual([]);
        expect(written.undetermined).toEqual([]);
    });
});
describe('runRetrospect — --out write semantics', () => {
    it('writes a deterministic two-space-indented JSON file when --out is set', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 600 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([]);
        const outPath = path.join(tmpDir, 'sub', 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '600', threshold: 1, force: true, out: outPath });
        expect(fs.existsSync(outPath)).toBe(true);
        const raw = fs.readFileSync(outPath, 'utf-8');
        expect(raw.endsWith('\n')).toBe(true);
        // Two-space indent = `  "version"`.
        expect(raw).toMatch(/\n {2}"version": 1/);
    });
    it('does NOT create any file under .totem/ by default (no --out)', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 601 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([]);
        // Snapshot mtimes of every file currently under .totem/.
        const before = fs.readdirSync(totemDir).map((name) => {
            const p = path.join(totemDir, name);
            return { name, mtimeMs: fs.statSync(p).mtimeMs };
        });
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '601', threshold: 1, force: true });
        const after = fs.readdirSync(totemDir).map((name) => {
            const p = path.join(totemDir, name);
            return { name, mtimeMs: fs.statSync(p).mtimeMs };
        });
        // Same set of files, same mtimes — no writes.
        expect(after).toEqual(before);
    });
});
describe('runRetrospect — no-LLM invariant', () => {
    // ─── Static-source guard ─────────────────────────────
    // Mirrors the shield-estimate.ts no-LLM guard. A static-source grep
    // catches a future drift where someone adds an orchestrator import.
    it('never imports the orchestrator or LLM-path modules from retrospect.ts', async () => {
        const cmdPath = path.join(__dirname, 'retrospect.ts');
        const source = fs.readFileSync(cmdPath, 'utf-8');
        expect(source).not.toMatch(/from ['"]\.\.\/orchestrators\//);
        expect(source).not.toMatch(/from ['"]@mmnto\/totem-orchestrator['"]/);
        expect(source).not.toMatch(/getOrchestrator/);
        expect(source).not.toMatch(/createOrchestrator/);
        expect(source).not.toMatch(/runOrchestrator/);
        expect(source).not.toMatch(/Anthropic/);
        expect(source).not.toMatch(/OpenAI/);
        expect(source).not.toMatch(/\bgemini\b/i);
        expect(source).not.toMatch(/createEmbedder/);
        expect(source).not.toMatch(/LanceStore/);
    });
    it('never imports the orchestrator or LLM-path modules from core/retrospect.ts', async () => {
        const corePath = path.join(__dirname, '..', '..', '..', 'core', 'src', 'retrospect.ts');
        const source = fs.readFileSync(corePath, 'utf-8');
        expect(source).not.toMatch(/from ['"]@mmnto\/totem-orchestrator['"]/);
        expect(source).not.toMatch(/getOrchestrator/);
        expect(source).not.toMatch(/createOrchestrator/);
        expect(source).not.toMatch(/runOrchestrator/);
        expect(source).not.toMatch(/Anthropic/);
        expect(source).not.toMatch(/OpenAI/);
        expect(source).not.toMatch(/\bgemini\b/i);
        expect(source).not.toMatch(/createEmbedder/);
        expect(source).not.toMatch(/LanceStore/);
    });
    // ─── Dynamic-import allowlist ────────────────────────
    // Every `await import(...)` in retrospect.ts must resolve to a known
    // non-LLM module. Closes the static-source-grep gap where a transitive
    // orchestrator import via a benign-looking path would be missed.
    it('keeps every dynamic import sourced from non-LLM modules', async () => {
        const cmdSource = fs.readFileSync(path.join(__dirname, 'retrospect.ts'), 'utf-8');
        const allowedDynamicImports = [
            "await import('node:fs')",
            "await import('node:path')",
            "await import('node:crypto')",
            "await import('zod')",
            "await import('../adapters/github-cli-pr.js')",
            "await import('../ui.js')",
            "await import('../parsers/bot-review-parser.js')",
            "await import('@mmnto/totem')",
            "await import('../utils.js')",
        ];
        // Strip allowed imports; what's left should contain NO stray imports.
        let residual = cmdSource;
        for (const expected of allowedDynamicImports) {
            // 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.
            residual = residual.split(expected).join('');
        }
        // Any remaining `await import(...)` is a guard violation.
        expect(residual).not.toMatch(/await import\(['"][^'"]+['"]\)/);
    });
});
// ─── Runtime orchestrator spy guard ────────────────────────
// Per CR mmnto-ai/totem#1734 round-2: source-text inspection alone can
// be fooled by a transitive import or an aliased dynamic import. Mock the
// orchestrator factory module at runtime; if anything in retrospect's
// import chain reaches it, the spy fires. We require zero invocations.
//
// vi.mock is hoisted, so this block lives at module scope.
const orchestratorSpy = vi.fn();
vi.mock('../orchestrators/orchestrator.js', () => ({
    createOrchestrator: (...args) => {
        orchestratorSpy(...args);
        return () => {
            throw new Error('createOrchestrator must NEVER be called from runRetrospect');
        };
    },
    resolveOrchestrator: (...args) => {
        orchestratorSpy(...args);
        throw new Error('resolveOrchestrator must NEVER be called from runRetrospect');
    },
}));
describe('runRetrospect — runtime orchestrator spy', () => {
    it('never invokes createOrchestrator or resolveOrchestrator across a real run', async () => {
        orchestratorSpy.mockClear();
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
            makeReview({ id: 2, commit_id: 'sha-B', submitted_at: '2026-04-29T02:00:00.000Z' }),
            makeReview({ id: 3, commit_id: 'sha-C', submitted_at: '2026-04-29T03:00:00.000Z' }),
            makeReview({ id: 4, commit_id: 'sha-D', submitted_at: '2026-04-29T04:00:00.000Z' }),
            makeReview({ id: 5, commit_id: 'sha-E', submitted_at: '2026-04-29T05:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({ id: 100, createdAt: '2026-04-29T01:30:00.000Z' }),
        ]);
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '1713', threshold: 5, force: true });
        expect(orchestratorSpy).toHaveBeenCalledTimes(0);
    });
});
describe('runRetrospect — classification fan-out', () => {
    // High-level shape check: feed a synthetic finding for each severity
    // bucket and assert that the classification verdicts land in the
    // expected report buckets. Detailed table coverage lives in the
    // core-package retrospect.test.ts; this is the integration shape.
    it.each([
        // CodeRabbit: critical → in-pr-fix
        [
            'crit',
            '🔴 Critical: Avoid using `any` — prefer `unknown`.',
            'critical',
            'inPrFixes',
        ],
        // CodeRabbit: major → high → in-pr-fix
        ['major', '🟠 Major: bigger problem.', 'high', 'inPrFixes'],
        // CodeRabbit: minor → medium → in-pr-fix at early round
        ['minor', '🟡 Minor: small thing.', 'medium', 'inPrFixes'],
    ])('classifies a CR %s finding into bucket %s with severity %s', async (_label, body, expectedSeverity, expectedBucket) => {
        mockFetchPr.mockReturnValue(makePr({ number: 700 }));
        mockFetchReviews.mockReturnValue([
            makeReview({ id: 1, commit_id: 'sha-A', submitted_at: '2026-04-29T01:00:00.000Z' }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({
                id: 100,
                body,
                createdAt: '2026-04-29T01:30:00.000Z',
            }),
        ]);
        const outPath = path.join(tmpDir, 'report.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '700', threshold: 1, force: true, out: outPath });
        const written = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        const target = written[expectedBucket];
        expect(target.length).toBeGreaterThan(0);
        expect(target[0].severityBucket).toBe(expectedSeverity);
    });
});
describe('runRetrospect — input validation (mmnto-ai/totem#1734 review-1)', () => {
    it('rejects PR number with trailing non-numerics (Number.isInteger guard)', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([]);
        mockFetchReviewComments.mockReturnValue([]);
        const { runRetrospect } = await import('./retrospect.js');
        await expect(runRetrospect({ prNumber: '1713foo', force: true })).rejects.toThrow(/Invalid PR number/);
    });
    it('rejects fractional PR number (e.g. "5.2")', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([]);
        mockFetchReviewComments.mockReturnValue([]);
        const { runRetrospect } = await import('./retrospect.js');
        await expect(runRetrospect({ prNumber: '5.2', force: true })).rejects.toThrow(/Invalid PR number/);
    });
    it('rejects non-positive PR number', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([]);
        mockFetchReviewComments.mockReturnValue([]);
        const { runRetrospect } = await import('./retrospect.js');
        await expect(runRetrospect({ prNumber: '0', force: true })).rejects.toThrow(/Invalid PR number/);
    });
});
describe('runRetrospect — pull_request_review_id round-grouping (CR mmnto-ai/totem#1734 R2)', () => {
    it('buckets each finding via review_id even when comment.created_at predates submitted_at (draft review)', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        // Two reviews: review-1 SUBMITTED much later than its comments were CREATED
        // (comments were drafted while the review was pending). A timestamp join
        // would mis-attribute review-1's comments to review-2 because comment
        // createdAt < review-1 submittedAt < review-2 submittedAt is FALSE here.
        mockFetchReviews.mockReturnValue([
            makeReview({
                id: 1,
                commit_id: 'sha-A',
                submitted_at: '2026-04-29T05:00:00.000Z',
                user_login: 'coderabbitai[bot]',
            }),
            makeReview({
                id: 2,
                commit_id: 'sha-B',
                submitted_at: '2026-04-29T05:30:00.000Z',
                user_login: 'coderabbitai[bot]',
            }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            // Comment drafted at 04:30 (BEFORE review-1's submitted_at 05:00)
            // — pending review behavior. Foreign-key join puts it into review-1
            // (sha-A); a naive timestamp join would put it nowhere (no SHA
            // satisfies submitted_at <= 04:30).
            makeInlineComment({
                id: 100,
                author: 'coderabbitai[bot]',
                createdAt: '2026-04-29T04:30:00.000Z',
                pullRequestReviewId: 1,
            }),
            // Comment drafted at 05:25 (BETWEEN review-1 and review-2's
            // submitted_at) but belongs to review-2 (foreign key). A timestamp
            // join would mis-attribute it to review-1 (sha-A).
            makeInlineComment({
                id: 101,
                author: 'coderabbitai[bot]',
                createdAt: '2026-04-29T05:25:00.000Z',
                pullRequestReviewId: 2,
            }),
        ]);
        const outPath = path.join(tmpDir, 'out.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '1713', threshold: 1, force: true, out: outPath });
        const report = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        expect(report.totalFindings).toBe(2);
        // Two distinct rounds (one per SHA) confirm the foreign-key join.
        expect(report.rounds.length).toBe(2);
        expect(new Set(report.rounds.map((r) => r.headSha))).toEqual(new Set(['sha-A', 'sha-B']));
    });
});
describe('runRetrospect — terminal-output sanitization (CR mmnto-ai/totem#1734 R2)', () => {
    it('strips ANSI/control bytes from bodyExcerpt before logging', async () => {
        // Set up enough rounds to clear the threshold so the renderer fires.
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([
            makeReview({
                id: 1,
                commit_id: 'sha-A',
                submitted_at: '2026-04-29T01:00:00.000Z',
                user_login: 'coderabbitai[bot]',
            }),
            makeReview({
                id: 2,
                commit_id: 'sha-B',
                submitted_at: '2026-04-29T02:00:00.000Z',
                user_login: 'coderabbitai[bot]',
            }),
        ]);
        // Hostile body: ANSI red-fg + cursor-up + bell + raw control bytes.
        const hostile = '\x1b[31mFAKE CRITICAL\x1b[0m\x1b[2A\x07 should look fine after sanitize';
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({
                id: 100,
                body: hostile,
                author: 'coderabbitai[bot]',
                pullRequestReviewId: 1,
            }),
        ]);
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '1713', threshold: 1, force: true });
        // Every log call's args[1] must contain no escape or control bytes.
        const allLogs = [
            ...mockLog.info.mock.calls,
            ...mockLog.dim.mock.calls,
            ...mockLog.warn.mock.calls,
        ];
        for (const call of allLogs) {
            const text = String(call[1] ?? '');
            // eslint-disable-next-line no-control-regex
            expect(text).not.toMatch(/\x1b\[/);
            // eslint-disable-next-line no-control-regex
            expect(text).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/);
        }
    });
});
describe('runRetrospect — null user_login (deleted/ghost accounts)', () => {
    it('skips review submissions whose user_login is null without inflating round count', async () => {
        mockFetchPr.mockReturnValue(makePr({ number: 1713 }));
        mockFetchReviews.mockReturnValue([
            makeReview({
                id: 1,
                commit_id: 'sha-A',
                submitted_at: '2026-04-29T01:00:00.000Z',
                user_login: 'coderabbitai[bot]',
            }),
            // GitHub API returns null user for deleted/ghost accounts.
            makeReview({
                id: 2,
                commit_id: 'sha-B',
                submitted_at: '2026-04-29T01:30:00.000Z',
                user_login: null,
            }),
        ]);
        mockFetchReviewComments.mockReturnValue([
            makeInlineComment({
                id: 100,
                author: 'coderabbitai[bot]',
                createdAt: '2026-04-29T01:05:00.000Z',
            }),
            makeInlineComment({
                id: 101,
                // Inline comment from a deleted account — author would be 'ghost' or empty
                // in practice; here we model it via the parser path via isBotComment('').
                author: 'ghost',
                createdAt: '2026-04-29T01:35:00.000Z',
            }),
        ]);
        const outPath = path.join(tmpDir, 'out.json');
        const { runRetrospect } = await import('./retrospect.js');
        await runRetrospect({ prNumber: '1713', force: true, out: outPath });
        const report = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
        // Only the bot-authored finding survives; the ghost-authored one drops.
        expect(report.totalFindings).toBe(1);
        // Round count reflects only bot rounds (sha-A); sha-B is skipped because
        // its review submission's user_login is null.
        expect(report.rounds.length).toBe(1);
        expect(report.rounds[0].headSha).toBe('sha-A');
    });
});
//# sourceMappingURL=retrospect.test.js.map