/**
 * commands/pr-review-audit-fixer.mjs — deterministic hallucination gate
 * for /apt:pr-review fix agents.
 *
 * On 2026-04-20 during a live /apt:pr-review run, a fix agent reported
 * `FIXED: 2 | FAILED: 0 | SKIPPED: 0 | VERIFICATION: PASS` and wrote a
 * convincing 140-line fixes-applied.md — but the worktree had zero edits
 * matching the claimed files. The orchestrator trusted the self-reported
 * status line and nearly shipped two fabricated fixes. This gate closes
 * that hole: after a fixer returns, the orchestrator invokes this CLI
 * which cross-checks the fixer's claims against the actual git state.
 *
 * Structurally modeled on validate-evidence.mjs — same JSON envelope,
 * same fail-closed ethos, same output() helper. No LLM calls.
 *
 * Verdict rules (first-match wins):
 *
 *   1. `skipped` — status line is FIXED: 0 | FAILED: 0 | SKIPPED: N | PASS
 *      AND the report has ≥ 1 `## Skipped > ### <id>` entry per claimed
 *      skip. No diff required. Not a hallucination.
 *
 *   2. `hallucinated` — FIXED > 0 AND VERIFICATION: PASS AND the
 *      intersection of the claimed files (from the report) with the
 *      worktree diff is empty. The diff window is the UNION of
 *      (a) staged + unstaged + untracked in the worktree AND
 *      (b) `git diff ${sinceSha}..HEAD --name-only` when a starting SHA
 *      is known (either `--since-sha <sha>` or the resolved default
 *      `git merge-base HEAD origin/main`). The committed-diff branch
 *      exists because a fixer that commits its edits before the audit
 *      runs would otherwise present an empty working tree and trip a
 *      false-positive hallucination verdict (bug-5, 2026-04-23).
 *
 *   3. `hallucinated` — fixes-applied.md is missing, empty, or has no
 *      `## Fixes Applied > ### <id>` block backing up a claimed FIXED
 *      count ≥ 1.
 *
 *   4. `hallucinated` — status line claims VERIFICATION: PASS but the
 *      report marks any individual fix with `- **Verification**: FAIL`.
 *
 *   5. `verified` — status line, report, and git diff are internally
 *      consistent.
 *
 * Fail-closed on parse/IO errors — always return verdict 'hallucinated'
 * with a human-readable `reason` explaining what couldn't be verified.
 * When rule 2 fires and no since-sha was supplied or resolvable, the CLI
 * emits a single-line `HINT: …` on stderr pointing at `--since-sha` so
 * orchestrators investigating a false positive see the escape hatch
 * without parsing audit.json.
 *
 * Usage:
 *   apt-tools pr-review audit-fixer <project-dir> \
 *     --review-dir <path> \
 *     --iteration <N> \
 *     --fixer <fixer-1|fixer-2|fixer-3|self-reviewer> \
 *     --worktree <path> \
 *     --status-line "FIXED: X | FAILED: Y | SKIPPED: Z | VERIFICATION: PASS" \
 *     [--since-sha <sha>]
 *
 * `--since-sha` is optional. When supplied, the audit also unions
 * `git diff ${sha}..HEAD --name-only` into the diff window so fixers that
 * commit their edits before returning still clear the gate. When omitted,
 * the CLI tries to resolve a default via `git merge-base HEAD origin/main`
 * (falling back to `origin/master`) and silently expands the window if
 * that succeeds; otherwise it stays on the legacy uncommitted-only window
 * and emits the stderr hint on empty-diff hallucinations.
 */
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { parseFlags } from '../util/args.mjs';
import { withFileLock } from '../util/fs-lock.mjs';
import { err, ok } from '../util/result.mjs';
const STATUS_LINE_RX = /FIXED:\s*(\d+)\s*\|\s*FAILED:\s*(\d+)\s*\|\s*SKIPPED:\s*(\d+)\s*\|\s*VERIFICATION:\s*(PASS|FAIL)/i;
const KNOWN_FIXERS = new Set(['fixer-1', 'fixer-2', 'fixer-3', 'self-reviewer']);
/**
 * Parse the terse status line fix agents emit. Case-insensitive on the
 * PASS/FAIL token; all other fields must be integers. Malformed lines
 * return null — callers should treat null as a hallucination signal
 * (fail-closed).
 */
export function parseFixerStatusLine(line) {
    if (!line || typeof line !== 'string')
        return null;
    const m = STATUS_LINE_RX.exec(line);
    if (!m)
        return null;
    const fixed = Number(m[1]);
    const failed = Number(m[2]);
    const skipped = Number(m[3]);
    if (!Number.isFinite(fixed) || !Number.isFinite(failed) || !Number.isFinite(skipped)) {
        return null;
    }
    return {
        fixed,
        failed,
        skipped,
        verification: m[4].toUpperCase(),
    };
}
/**
 * Extract the body of a top-level section (`## {heading}`) up to the next
 * `## ` or EOF. Returns '' when the section is missing. We use '' rather
 * than null so callers can `.includes` without null-guarding.
 */
function extractTopLevelSection(markdown, heading) {
    const rx = new RegExp(`^##\\s+${heading}\\s*$`, 'im');
    const m = rx.exec(markdown);
    if (!m)
        return '';
    const rest = markdown.slice(m.index + m[0].length);
    const nextTopRx = /\n##\s+/m;
    const nxt = nextTopRx.exec(rest);
    return (nxt ? rest.slice(0, nxt.index) : rest).trim();
}
/**
 * Parse a fixes-applied.md produced by apt-pr-review-fixer OR a
 * self-review.md produced by apt-pr-review-self-reviewer. Returns
 * `{ fixes: Array<{id, title, file, verification}>, skipped: Array<{id}>, exists: true }`.
 * Non-existent or empty report returns `{ exists: false, fixes: [], skipped: [] }`.
 *
 * "File" is extracted from `- **File**: path[:line]` anywhere inside the
 * `### <id>` block. We strip any trailing `(created)` / `(modify)` etc
 * annotation the fixer template shows as free-form text so the intersect
 * step can match against `git diff --name-only` output cleanly.
 *
 * Dual-schema support (PR #95 Defect 2):
 *  - Fixer reports use `## Fixes Applied` with `### <ID> — title` headers
 *    (e.g. `### QUA-001 — title`). Real finding ids carry through.
 *  - Self-reviewer reports use `## Fixes Applied by Self-Reviewer` with
 *    `### Fix N: description` headers. These have no real finding id, so
 *    we synthesize `self-reviewer-fix-${N}` to satisfy the
 *    `report.fixes.length > 0` guard in decideVerdict rule 3 while
 *    letting the diff-intersect step (the real check) decide the verdict.
 *
 * The two heading texts are disjoint — fixers emit one, self-reviewers
 * emit the other. If both somehow appear in the same file (not expected
 * in practice), both are parsed and fixer entries come first.
 */
export function parseFixesAppliedMarkdown(md) {
    if (!md || typeof md !== 'string' || !md.trim()) {
        return { exists: false, fixes: [], skipped: [], schema: 'fixer' };
    }
    const fixesSection = extractTopLevelSection(md, 'Fixes Applied');
    const selfReviewerSection = extractTopLevelSection(md, 'Fixes Applied by Self-Reviewer');
    const skippedSection = extractTopLevelSection(md, 'Skipped');
    const legacyFixerEntries = parseFixEntries(fixesSection);
    const selfReviewerEntries = parseSelfReviewerFixEntries(selfReviewerSection);
    // FRAMEWORK-BUG-035: additive `## Fix N` parser path. Scans the whole
    // document — NOT a `## Fixes Applied` body — so it catches fixer reports
    // shaped as numbered top-level blocks. Union, not substitution, per spec
    // ID-03 so legacy `### {ID}` reports keep parsing unchanged.
    const fixNEntries = parseFixNEntries(md);
    const fixes = [...legacyFixerEntries, ...selfReviewerEntries, ...fixNEntries];
    const skipped = parseSkippedEntries(skippedSection);
    // Schema discriminator: when the ONLY entries came from the `## Fix N`
    // parser path, label it `'fix-n'` so downstream code / debugging can
    // tell which shape was on disk. Fall back to existing fixer / self-
    // reviewer labels when those sections were present.
    let schema;
    if (fixesSection)
        schema = 'fixer';
    else if (selfReviewerSection)
        schema = 'self-reviewer';
    else if (fixNEntries.length > 0)
        schema = 'fix-n';
    else
        schema = 'fixer';
    return { exists: true, fixes, skipped, schema };
}
/**
 * Parse the body of a `## Fixes Applied by Self-Reviewer` section into
 * entries shaped like fixer entries (id, title, file, files, verification).
 *
 * The self-reviewer agent emits `### Fix N: description` per fix instead
 * of `### ID — title`, because the reviewer doesn't carry the original
 * finding ids forward. We synthesize id `self-reviewer-fix-${N}` so the
 * rest of the audit pipeline (decideVerdict rule 3 guard, audit.json
 * envelope) stays schema-agnostic. The body shape — `- **File**:` /
 * `- **Change**:` — is identical to fixer entries, so we reuse
 * extractFileFields verbatim. Verification is not part of the self-
 * reviewer schema (their PASS/FAIL signal lives in `## Verification
 * Results` and is already covered by parseFixerStatusLine), so we leave
 * `verification: null` and let the gate's diff-intersect step decide.
 */
function parseSelfReviewerFixEntries(section) {
    if (!section)
        return [];
    // `### Fix 1: description` / `### Fix 2: description`.
    const entries = [];
    const headerRx = /^###\s+Fix\s+(\d+)(?:\s*:\s*(.+))?$/gm;
    const matches = [...section.matchAll(headerRx)];
    for (let i = 0; i < matches.length; i++) {
        const m = matches[i];
        const start = m.index;
        const nextStart = i + 1 < matches.length ? matches[i + 1].index : section.length;
        const body = section.slice(start, nextStart);
        const files = extractFileFields(body);
        entries.push({
            id: `self-reviewer-fix-${m[1]}`,
            title: m[2] ? m[2].trim() : null,
            file: files[0] ?? null,
            files,
            verification: extractVerificationField(body),
        });
    }
    return entries;
}
function parseFixEntries(section) {
    if (!section)
        return [];
    // `### <id> — <title>` OR `### <id> -- <title>` OR `### <id>` alone.
    // The fixer template uses em-dash + space; we tolerate either.
    //
    // PR #95 (2026-05-12) widened the capture to accept combined-id headers
    // like `### QUA-003 + FIT-001 — title` where one body explains multiple
    // findings. The locked separator alphabet is `+`, `,`, and ` and ` —
    // the exact set observed in live fixer reports. We do NOT widen to a
    // generic `\W+` splitter; the alphabet stays narrow so a typo like
    // `### QUA-003-foo` cannot be mis-parsed as id `QUA-003`. Combined
    // headers produce one entry per id, all sharing the same body fields
    // (files, verification, title).
    const entries = [];
    const headerRx = /^###\s+([A-Z]+-\d+(?:\s*(?:\+|,|\s+and\s+)\s*[A-Z]+-\d+)*)(?:\s*[—-]{1,2}\s*(.+))?$/gm;
    const idSplitRx = /\s*(?:\+|,|\s+and\s+)\s*/;
    const matches = [...section.matchAll(headerRx)];
    for (let i = 0; i < matches.length; i++) {
        const m = matches[i];
        const start = m.index;
        const nextStart = i + 1 < matches.length ? matches[i + 1].index : section.length;
        const body = section.slice(start, nextStart);
        const files = extractFileFields(body);
        const title = m[2] ? m[2].trim() : null;
        const verification = extractVerificationField(body);
        const ids = m[1].split(idSplitRx);
        for (const id of ids) {
            entries.push({
                id,
                title,
                // Back-compat: single-path consumers + tests still see `file`
                // (the first extracted path). New code should prefer `files`.
                file: files[0] ?? null,
                files,
                verification,
            });
        }
    }
    return entries;
}
/**
 * FRAMEWORK-BUG-035 — parse `## Fix N` top-level blocks into entries shaped
 * like `parseFixEntries` output. The bug narrative documents `## Fix N`
 * as the canonical shape going forward for fixes-applied-fixer-N.md, but
 * the legacy `### {ID} — title` parser silently dropped every entry on
 * those reports, surfacing `<none parsed>` and false-positive flipping
 * the gate to `hallucinated`.
 *
 * This parser scans the WHOLE document (not a `## Fixes Applied` body)
 * because the new shape uses `## Fix N` directly as top-level headers.
 * Finding-ID extraction priority chain (per spec ID-04):
 *   1. `- **Finding ID**:` field line
 *   2. `- **Fix**:`        field line
 *   3. `- **ID**:`         field line
 *   4. synthetic `fix-N`   fallback (matches `parseSelfReviewerFixEntries`'s
 *      `self-reviewer-fix-N` convention; the diff-intersect step in
 *      `decideVerdict` is the real check, not the id)
 *
 * Returns the same `{id, title, file, files, verification}` shape as
 * `parseFixEntries` so downstream consumers see a uniform array.
 */
function parseFixNEntries(markdown) {
    if (!markdown)
        return [];
    const entries = [];
    // `^## Fix N$` — top-level heading with the literal word "Fix" + a
    // number. No trailing description in the header (the canonical shape
    // per the BUG-034 narrative puts the description inside the body).
    const headerRx = /^##\s+Fix\s+(\d+)\s*$/gm;
    const matches = [...markdown.matchAll(headerRx)];
    for (let i = 0; i < matches.length; i++) {
        const m = matches[i];
        const start = m.index + m[0].length;
        // Terminate at the next top-level `## ` heading OR EOF. Mirrors
        // `extractTopLevelSection`'s body slicing.
        const rest = markdown.slice(start);
        const nextTopRx = /\n##\s+/m;
        const nxt = nextTopRx.exec(rest);
        const body = (nxt ? rest.slice(0, nxt.index) : rest).trim();
        const findingId = extractFindingIdField(body) ?? `fix-${m[1]}`;
        const files = extractFileFields(body);
        entries.push({
            id: findingId,
            title: null,
            file: files[0] ?? null,
            files,
            verification: extractVerificationField(body),
        });
    }
    return entries;
}
/**
 * Extract the finding ID from a `## Fix N` block body via the priority
 * chain documented in `parseFixNEntries`. Returns `null` if no field
 * matched — caller falls back to the synthetic `fix-N` id.
 */
function extractFindingIdField(body) {
    for (const key of ['Finding ID', 'Fix', 'ID']) {
        const rx = new RegExp(`-\\s*\\*\\*${key}\\*\\*:\\s*([^\\n]+)`, 'i');
        const m = rx.exec(body);
        if (m)
            return m[1].trim();
    }
    return null;
}
function parseSkippedEntries(section) {
    if (!section)
        return [];
    const entries = [];
    const headerRx = /^###\s+([A-Z]+-\d+)(?:\s*[—-]{1,2}\s*(.+))?$/gm;
    for (const m of section.matchAll(headerRx)) {
        entries.push({ id: m[1], title: m[2] ? m[2].trim() : null });
    }
    return entries;
}
/**
 * Strip cosmetic annotations from a single path-like fragment:
 *  - parenthetical asides like `(original)`, `(created)`, `(modify)`
 *  - trailing `:N` line numbers
 *  - leading/trailing backticks + whitespace
 *
 * Returns null for fragments that don't look like a file path after
 * cleanup (must contain a `.ext` suffix; bare words like
 * `now lines 164–170` are dropped).
 */
function cleanFilePathFragment(fragment) {
    if (!fragment || typeof fragment !== 'string')
        return null;
    let s = fragment.trim().replace(/^`|`$/g, '').trim();
    // Strip ALL parenthetical annotations anywhere in the fragment, not just
    // trailing ones — fixers in the wild produce `users.ts:162 (original)`
    // and the `(original)` is mid-string when the fragment came from a
    // comma-split.
    s = s.replace(/\s*\([^)]*\)\s*/g, ' ').trim();
    // Drop trailing `:N` line suffix.
    s = s.replace(/:\d+(?:[-–]\d+)?$/, '').trim();
    // Path-shape gate: must contain a file extension (alphanumerics after a
    // dot anchored at end). Drops free-form fragments like `now lines 164-170`.
    if (!/\.[A-Za-z0-9]+$/.test(s))
        return null;
    // Sanity: the fragment shouldn't contain whitespace at this point —
    // real paths don't have spaces. Reject if it does.
    if (/\s/.test(s))
        return null;
    return s || null;
}
/**
 * Extract every file path the fixer claimed to have edited from a single
 * `- **File**: ...` line.
 *
 * The fixer agent template shows ONE `**File**` field per fix entry, but
 * fixers in the wild routinely produce compound claims when they edited
 * multiple paths to address one finding:
 *   - `users.ts:162 (original), now lines 164-170` — annotation noise after
 *     a single path. The path is fine, the annotation is conversational.
 *   - `invitations.ts:90 and packages/backend/convex/teams.ts` — TWO real
 *     paths joined with " and ". Both must be matched.
 *   - `path-a.ts, path-b.ts` — comma-separated multi-path.
 *
 * Splits on `, ` and ` and `, then runs each fragment through
 * `cleanFilePathFragment`. Returns the deduped list. Empty array when no
 * path-shaped fragment is found.
 */
function extractFileFields(body) {
    // `- **File**: apps/desktop/...ts:12 (created)` — capture everything to
    // end-of-line; we'll split + clean the captured text below.
    const m = /-\s*\*\*File\*\*:\s*([^\n]+)$/im.exec(body);
    if (!m)
        return [];
    const raw = m[1].trim();
    // Split on `, ` (comma + space) OR ` and ` (whitespace-bounded `and`).
    // The `and` case is bounded by `\s+` so `s.replace(...)` doesn't fire
    // on words containing "and" like `command.ts`.
    const fragments = raw
        .split(/\s+and\s+|,\s+/)
        .map(cleanFilePathFragment)
        .filter((s) => s !== null);
    // Dedup while preserving order.
    const seen = new Set();
    const out = [];
    for (const p of fragments) {
        if (!seen.has(p)) {
            seen.add(p);
            out.push(p);
        }
    }
    return out;
}
/**
 * Back-compat shim — preserves `entry.file` (single string | null) for
 * existing consumers + tests that assert on the first extracted path.
 * New consumers should read `entry.files` instead, which is always an
 * array (possibly empty) and captures compound claims.
 */
function extractFileField(body) {
    const all = extractFileFields(body);
    return all[0] ?? null;
}
function extractVerificationField(body) {
    const m = /-\s*\*\*Verification\*\*:\s*(PASS|FAIL)/i.exec(body);
    return m ? m[1].toUpperCase() : null;
}
/**
 * Run `git diff ${sinceSha}..HEAD --name-only` to enumerate files touched
 * by commits landed between the iteration's starting SHA and the current
 * HEAD. This is the "committed-diff" half of the audit window — it
 * closes the bug-5 false positive where a fixer commits before the audit
 * runs and presents an empty working tree.
 *
 * Fails closed: on any git error (unknown sha, invalid range, binary
 * unavailable) returns `{ ok: false, error }` with an empty list so the
 * caller can either fall back to the uncommitted-only window or annotate
 * the audit envelope with the git error.
 */
function readCommittedDiffSince(worktree, sinceSha) {
    if (!sinceSha || typeof sinceSha !== 'string') {
        return { ok: true, files: [] };
    }
    try {
        const out = execFileSync('git', ['diff', `${sinceSha}..HEAD`, '--name-only'], {
            cwd: worktree,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'pipe'],
        });
        const files = out
            .split(/\r?\n/)
            .map((s) => s.trim())
            .filter(Boolean);
        return { ok: true, files };
    }
    catch (err) {
        return { ok: false, error: err?.message ?? String(err), files: [] };
    }
}
/**
 * Try to resolve a default iteration-start SHA when the orchestrator did
 * not pass `--since-sha` explicitly. We use `git merge-base HEAD origin/main`
 * (then `origin/master` as the fallback for legacy-named repos). Returns
 * the sha on success and `null` on any failure — callers should treat
 * `null` as "stay on the legacy uncommitted-only window."
 *
 * Semantic drift vs. `--since-sha` (IMPORTANT — LOG-001, PR #64 review):
 * The sha returned here is the PR-branchpoint SHA, NOT the current
 * iteration's starting SHA. The audit window therefore widens to
 * `[merge-base..HEAD]` = "everything since this PR branched off main",
 * covering ALL prior fixer iterations' committed edits, not just the
 * current iteration. A no-op fixer in iteration N can still be stamped
 * `verified` if iteration N-1 legitimately committed matching files.
 * This is best-effort by design — `--since-sha` from the orchestrator is
 * the only path that bounds the committed window to the current
 * iteration and is the ONLY path with per-iteration verification
 * strictness. Callers (orchestrators / SKILL.md Phase 6 Step 4b) MUST
 * pass `--since-sha "${ITERATION_START_SHA}"` to avoid this degradation.
 *
 * Return-value / stderr-HINT contract — the four cases:
 *   1. `--since-sha` supplied (authoritative):
 *      never reaches this helper; audit window bounded to the iteration.
 *   2. `origin/main` resolves and `merge-base HEAD origin/main` returns
 *      a SHA matching `^[0-9a-f]{7,40}$` (success): return that SHA;
 *      `observed.since_sha_source = "merge-base"`; NO stderr hint.
 *   3. `origin/main` is not fetched locally (no `refs/remotes/origin/main`
 *      ref exists): `merge-base` exits non-zero with "Not a valid object
 *      name origin/main" or similar, we try `origin/master`, fail again,
 *      return `null`; audit stays on the legacy uncommitted-only window;
 *      stderr HINT fires on empty-diff hallucination verdicts pointing
 *      at `--since-sha` as the escape hatch.
 *   4. Unrelated histories / HEAD is NOT a descendant of origin/main
 *      (e.g. an orphan branch, or HEAD has been rewritten to not share
 *      history): `merge-base` exits with "fatal: no merge base found";
 *      our try/catch swallows it, `origin/master` also fails the same
 *      way, we return `null`; same behavior as case (3) — legacy window
 *      + stderr hint on empty-diff verdicts.
 *
 * We intentionally do NOT invent fancier heuristics (e.g. look at
 * `pr-state.json`, walk reflog) because the default is best-effort —
 * the authoritative path is `--since-sha` from the orchestrator. This
 * helper just prevents the gate from flipping to hallucinated on every
 * standard-branch-workflow repo where the orchestrator forgot the flag.
 */
function tryResolveDefaultSinceSha(worktree) {
    for (const ref of ['origin/main', 'origin/master']) {
        try {
            const out = execFileSync('git', ['merge-base', 'HEAD', ref], {
                cwd: worktree,
                encoding: 'utf-8',
                stdio: ['ignore', 'pipe', 'pipe'],
            }).trim();
            if (/^[0-9a-f]{7,40}$/i.test(out))
                return out;
        }
        catch {
            // ref missing (case 3) or unrelated histories (case 4) —
            // try the next ref. Empty stdout / non-SHA match falls
            // through the regex gate and also moves to the next ref.
        }
    }
    return null;
}
/**
 * Read the worktree's staged + unstaged file lists via `git diff`. Fails
 * closed: on any git error, returns `{ ok: false, error }` so the caller
 * can stamp `hallucinated` with a useful reason instead of crashing.
 */
function readWorktreeDiff(worktree) {
    try {
        const cached = execFileSync('git', ['diff', '--cached', '--name-only'], {
            cwd: worktree,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'pipe'],
        })
            .split(/\r?\n/)
            .map((s) => s.trim())
            .filter(Boolean);
        const unstaged = execFileSync('git', ['diff', '--name-only'], {
            cwd: worktree,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'pipe'],
        })
            .split(/\r?\n/)
            .map((s) => s.trim())
            .filter(Boolean);
        const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
            cwd: worktree,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'pipe'],
        })
            .split(/\r?\n/)
            .map((s) => s.trim())
            .filter(Boolean);
        return { ok: true, staged: cached, unstaged, untracked };
    }
    catch (err) {
        return {
            ok: false,
            error: err?.message ?? String(err),
            staged: [],
            unstaged: [],
            untracked: [],
        };
    }
}
/**
 * Set-intersect claimed files with the worktree diff. Path matching is
 * done by exact equality after trimming; we also accept a suffix match
 * (`diffPath.endsWith('/' + claimed)` or vice versa) to tolerate the
 * fixer reporting `apps/desktop/...` while `git diff` from a nested
 * worktree root emits the same path, or the reverse.
 *
 * Returns `{ matched: string[], unmatched: string[] }` where `matched`
 * is the subset of `claimedFiles` that appear in `diffFiles`.
 */
export function intersectClaimedAndStaged(claimedFiles, diffFiles) {
    const matched = [];
    const unmatched = [];
    const diffSet = new Set(diffFiles);
    for (const claimed of claimedFiles) {
        if (!claimed)
            continue;
        if (diffSet.has(claimed)) {
            matched.push(claimed);
            continue;
        }
        const hit = diffFiles.find((d) => d.endsWith(`/${claimed}`) || claimed.endsWith(`/${d}`) || d === claimed);
        if (hit) {
            matched.push(claimed);
        }
        else {
            unmatched.push(claimed);
        }
    }
    return { matched, unmatched };
}
/**
 * Apply the verdict rules to the already-parsed inputs. Pure function —
 * no I/O. Callers compose it over the raw inputs so tests can feed
 * hand-crafted objects.
 */
export function decideVerdict({ statusLine, fixesApplied, diffFiles }) {
    // Fail-closed on malformed status line.
    const claimed = parseFixerStatusLine(statusLine);
    if (!claimed) {
        return {
            verdict: 'hallucinated',
            reason: `status line could not be parsed (got ${JSON.stringify(statusLine)})`,
            claimed: null,
            matched: [],
            unmatched: [],
            claimedFiles: [],
        };
    }
    const report = fixesApplied ?? { exists: false, fixes: [], skipped: [] };
    // Rule 1: skipped verdict.
    // Status says FIXED=0, FAILED=0, SKIPPED>=1, PASS — AND the report has
    // at least as many skipped entries as claimed. Report missing but
    // status says SKIPPED>0 is NOT a skipped verdict (we can't confirm)
    // → falls through to hallucinated.
    if (claimed.fixed === 0 &&
        claimed.failed === 0 &&
        claimed.skipped > 0 &&
        claimed.verification === 'PASS') {
        if (report.exists && report.skipped.length >= claimed.skipped) {
            return {
                verdict: 'skipped',
                reason: null,
                claimed,
                matched: [],
                unmatched: [],
                claimedFiles: [],
            };
        }
        return {
            verdict: 'hallucinated',
            reason: `status line claims SKIPPED=${claimed.skipped} but report has ${report.skipped.length} skipped entries${report.exists ? '' : ' (report missing)'}`,
            claimed,
            matched: [],
            unmatched: [],
            claimedFiles: [],
        };
    }
    // Trivial FIXED=0, FAILED=0, SKIPPED=0, PASS — nothing claimed,
    // nothing to audit. Treat as skipped (no-op fixer). Callers still log
    // it in audit.json for observability.
    if (claimed.fixed === 0 &&
        claimed.failed === 0 &&
        claimed.skipped === 0 &&
        claimed.verification === 'PASS') {
        return {
            verdict: 'skipped',
            reason: null,
            claimed,
            matched: [],
            unmatched: [],
            claimedFiles: [],
        };
    }
    // From here on, FIXED > 0 OR VERIFICATION == FAIL. Any of those demand
    // a matching report to verify; missing report = hallucinated.
    if (claimed.fixed > 0 && !report.exists) {
        return {
            verdict: 'hallucinated',
            reason: `fixer claimed FIXED=${claimed.fixed} but fixes-applied.md is missing or empty`,
            claimed,
            matched: [],
            unmatched: [],
            claimedFiles: [],
        };
    }
    // Report exists but has no fix entries while FIXED > 0 — another
    // fail-closed trap. The report could be scaffolded with headers but
    // no actual entries.
    if (claimed.fixed > 0 && report.fixes.length === 0) {
        const heading = report.schema === 'self-reviewer' ? '## Fixes Applied by Self-Reviewer' : '## Fixes Applied';
        return {
            verdict: 'hallucinated',
            reason: `fixer claimed FIXED=${claimed.fixed} but fixes-applied.md has zero ` +
                `### <id>` +
                ` entries under ${heading}`,
            claimed,
            matched: [],
            unmatched: [],
            claimedFiles: [],
        };
    }
    // Status claims PASS but any listed fix self-reported FAIL.
    if (claimed.verification === 'PASS') {
        const failedFix = report.fixes.find((f) => f.verification === 'FAIL');
        if (failedFix) {
            return {
                verdict: 'hallucinated',
                reason: `status line claims VERIFICATION=PASS but fix ${failedFix.id} is marked Verification: FAIL in the report`,
                claimed,
                matched: [],
                unmatched: [],
                claimedFiles: report.fixes.flatMap((f) => Array.isArray(f.files) ? f.files : f.file ? [f.file] : []),
            };
        }
    }
    // Main rule: FIXED > 0 AND PASS — intersect claimed files with the
    // worktree diff. Empty intersection = hallucination.
    if (claimed.fixed > 0 && claimed.verification === 'PASS') {
        const claimedFiles = [
            ...new Set(report.fixes.flatMap((f) => (Array.isArray(f.files) ? f.files : f.file ? [f.file] : []))),
        ];
        const { matched: rawMatched, unmatched } = intersectClaimedAndStaged(claimedFiles, diffFiles);
        const matched = [...new Set(rawMatched)];
        if (matched.length === 0) {
            return {
                verdict: 'hallucinated',
                reason: `fixer claimed FIXED=${claimed.fixed} but worktree has zero edits matching the assigned files (claimed: ${claimedFiles.join(', ') || '<none parsed>'})`,
                claimed,
                matched,
                unmatched,
                claimedFiles,
            };
        }
        return {
            verdict: 'verified',
            reason: null,
            claimed,
            matched,
            unmatched,
            claimedFiles,
        };
    }
    // Everything else: FIXED == 0 but FAILED > 0 (legitimate failure) or
    // VERIFICATION: FAIL on the status line — these are not hallucinations
    // (the fixer is telling the truth about not fixing things). Mark as
    // verified so the gate doesn't re-spawn, but carry the claim through.
    return {
        verdict: 'verified',
        reason: null,
        claimed,
        matched: [],
        unmatched: [],
        claimedFiles: report.fixes.map((f) => f.file).filter(Boolean),
    };
}
export function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
    // BUG-030: strict-ordered candidate list. Parallel fixers write to
    // distinct fixer-named markdowns (fixes-applied-fixer-{N}.md AND/OR
    // fixes-applied-{fixer}.md); the legacy group-named form remains for
    // backward-compat with reviews started before this fix. fixes-applied.md
    // (unscoped) stays last as the legacy single-fixer fallback. NO broad
    // glob — false-positives on the wrong fixer's file are what caused the
    // `hallucinated` flake (4 sightings in 3 days per spec-gaps).
    const iterDir = join(reviewDir, 'iterations', String(iteration));
    const candidates = [];
    if (fixer === 'self-reviewer') {
        candidates.push(join(reviewDir, 'self-review.md'));
        candidates.push(join(iterDir, 'self-review.md'));
    }
    else {
        const match = /^fixer-(\d+)$/.exec(fixer);
        if (match) {
            candidates.push(join(iterDir, `fixes-applied-group${match[1]}.md`));
            candidates.push(join(iterDir, `fixes-applied-fixer-${match[1]}.md`));
        }
        candidates.push(join(iterDir, `fixes-applied-${fixer}.md`));
        candidates.push(join(iterDir, 'fixes-applied.md'));
    }
    for (const c of candidates) {
        if (existsSync(c)) {
            try {
                const md = readFileSync(c, 'utf-8');
                return { path: c, md };
            }
            catch (err) {
                return { path: c, md: null, error: err?.message ?? String(err) };
            }
        }
    }
    return { path: null, md: null };
}
function mergeAuditJson(auditPath, entry) {
    let audit = { version: 1, fixer_verdicts: [] };
    if (existsSync(auditPath)) {
        try {
            const raw = readFileSync(auditPath, 'utf-8');
            const parsed = raw.trim() ? JSON.parse(raw) : null;
            if (parsed && Array.isArray(parsed.fixer_verdicts)) {
                audit = parsed;
            }
        }
        catch {
            // Malformed audit.json — fail closed by overwriting, since the
            // file is ours to maintain.
            audit = { version: 1, fixer_verdicts: [] };
        }
    }
    // De-dupe by (fixer, iteration, timestamp) isn't necessary — a fresh
    // run after a re-spawn should append; the orchestrator decides
    // one-strike tracking by counting entries.
    audit.fixer_verdicts.push(entry);
    writeFileSync(auditPath, `${JSON.stringify(audit, null, 2)}\n`, 'utf-8');
}
function mergeMetadataJson(metadataPath, entry) {
    let metadata = null;
    if (existsSync(metadataPath)) {
        try {
            const raw = readFileSync(metadataPath, 'utf-8');
            metadata = raw.trim() ? JSON.parse(raw) : null;
        }
        catch {
            metadata = null;
        }
    }
    if (!metadata || typeof metadata !== 'object') {
        // Metadata.json doesn't exist yet (fixture bootstrapping). Create a
        // minimal shell so we don't crash; the pr-review init path will
        // normally have created a fuller record.
        metadata = { review_id: null, status: 'in-progress' };
    }
    if (!Array.isArray(metadata.fixer_verdicts))
        metadata.fixer_verdicts = [];
    if (typeof metadata.fixer_hallucinations_total !== 'number') {
        metadata.fixer_hallucinations_total = 0;
    }
    metadata.fixer_verdicts.push(entry);
    if (entry.verdict === 'hallucinated') {
        metadata.fixer_hallucinations_total += 1;
    }
    writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf-8');
}
/**
 * Main command entry point. Wired into the pr-review dispatcher.
 */
export function cmdPrReviewAuditFixer(projectDir, extraArgs) {
    // --help short-circuit. Accept `--help` in either the projectDir slot
    // (when users invoke `pr-review audit-fixer --help`) OR as a flag in
    // extraArgs. Always emits JSON so the wire-up test can grep for
    // `review-dir` on stdout.
    const helpAsProjectDir = projectDir === '--help' || projectDir === '-h';
    const extraFlags = parseFlags(extraArgs ?? []);
    if (helpAsProjectDir || extraFlags.get('help') === true) {
        return ok({
            status: 'ok',
            command: 'pr-review-audit-fixer',
            usage: 'apt-tools pr-review audit-fixer <project-dir> --review-dir <path> --iteration <N> --fixer <fixer-1|fixer-2|fixer-3|self-reviewer> --worktree <path> --status-line <str> [--since-sha <sha>]',
            flags: [
                '--review-dir',
                '--iteration',
                '--fixer',
                '--worktree',
                '--status-line',
                '--since-sha',
            ],
        });
    }
    if (!projectDir) {
        return err('Usage: apt-tools pr-review audit-fixer <project-dir> --review-dir <path> --iteration <N> --fixer <name> --worktree <path> --status-line <str> [--since-sha <sha>]');
    }
    const root = resolve(projectDir);
    if (!existsSync(root))
        return err(`project-dir not found: ${root}`);
    const flags = extraFlags;
    const reviewDirArg = flags.get('review-dir');
    if (!reviewDirArg)
        return err('Required: --review-dir <path>');
    const reviewDir = resolve(reviewDirArg);
    const iterationArg = flags.get('iteration');
    if (!iterationArg || !/^\d+$/.test(String(iterationArg))) {
        return err('Required: --iteration <N>');
    }
    const iteration = Number(iterationArg);
    const fixer = String(flags.get('fixer') ?? '').trim();
    if (!KNOWN_FIXERS.has(fixer)) {
        return err(`Required: --fixer <fixer-1|fixer-2|fixer-3|self-reviewer> (got ${JSON.stringify(fixer)})`);
    }
    const worktreeArg = flags.get('worktree');
    if (!worktreeArg)
        return err('Required: --worktree <path>');
    // BUG-015: resolve relative to the passed projectDir (not process.cwd()).
    // Callers invoke audit-fixer from a non-worktree cwd (the main repo) with
    // a relative --worktree like `../worktrees/foo`; resolving against cwd
    // returned a bogus path that didn't exist, causing the audit to see zero
    // staged files and false-positive `hallucinated`. The existing `cwd:
    // worktree` at the spawn site (line 437-473) already scopes git
    // invocations correctly, so no `git -C` change is needed.
    const worktree = resolve(projectDir, worktreeArg);
    const statusLineArg = flags.get('status-line');
    // Missing / empty status line is fail-closed to hallucinated, not a
    // CLI fatal — the whole point is to catch a misbehaving fixer.
    const statusLine = typeof statusLineArg === 'string' ? statusLineArg : null;
    // --since-sha <sha>: optional iteration-start SHA that widens the
    // audit window to include committed fixes (bug-5 fix, 2026-04-23). A
    // bare `--since-sha` without a value lands as `true` — ignore that
    // and treat it as unset so we don't try to `git diff true..HEAD`.
    const sinceShaArg = flags.get('since-sha');
    const sinceShaExplicit = typeof sinceShaArg === 'string' && sinceShaArg.trim() ? sinceShaArg.trim() : null;
    // Read fixes-applied.md for this fixer. Missing file is not fatal.
    const { path: reportPath, md: reportMd, error: reportErr, } = readFixesAppliedForFixer(reviewDir, iteration, fixer);
    let fixesApplied = { exists: false, fixes: [], skipped: [] };
    if (reportMd) {
        try {
            fixesApplied = parseFixesAppliedMarkdown(reportMd);
        }
        catch {
            fixesApplied = { exists: false, fixes: [], skipped: [] };
        }
    }
    // Read worktree diff. On git error the diff lists are empty and the
    // decision will likely flip to hallucinated (correctly — we can't
    // prove the fixer did anything).
    let uncommittedFiles = [];
    let committedFiles = [];
    let gitError = null;
    let sinceShaResolved = null;
    let sinceShaSource = null;
    let committedDiffError = null;
    if (existsSync(worktree)) {
        const diff = readWorktreeDiff(worktree);
        if (diff.ok) {
            // Union of staged + unstaged + untracked. Untracked covers the
            // NEW-file case where the fixer created a file but didn't stage
            // it yet; staged covers the stage-but-not-commit case.
            uncommittedFiles = [...new Set([...diff.staged, ...diff.unstaged, ...diff.untracked])];
        }
        else {
            gitError = diff.error;
        }
        // Committed-diff branch — bug-5 fix. An explicit --since-sha always
        // wins. If none was supplied, best-effort resolve a default via
        // `git merge-base HEAD origin/main` so standard PR workflows clear
        // the gate without the orchestrator having to pass the flag. If
        // neither succeeds, we stay on the legacy uncommitted-only window
        // (and the stderr hint below will fire on hallucination verdicts).
        if (sinceShaExplicit) {
            sinceShaResolved = sinceShaExplicit;
            sinceShaSource = 'flag';
        }
        else {
            const def = tryResolveDefaultSinceSha(worktree);
            if (def) {
                sinceShaResolved = def;
                sinceShaSource = 'merge-base';
            }
        }
        if (sinceShaResolved) {
            const committed = readCommittedDiffSince(worktree, sinceShaResolved);
            if (committed.ok) {
                committedFiles = committed.files;
            }
            else {
                committedDiffError = committed.error;
            }
        }
    }
    else {
        gitError = `worktree not found: ${worktree}`;
    }
    const diffFiles = [...new Set([...uncommittedFiles, ...committedFiles])];
    const decision = decideVerdict({ statusLine, fixesApplied, diffFiles });
    // If we had a git error AND the decision came out 'verified', demote
    // to hallucinated — we couldn't prove anything.
    let finalVerdict = decision.verdict;
    let finalReason = decision.reason;
    if (gitError && finalVerdict === 'verified') {
        finalVerdict = 'hallucinated';
        finalReason = `git diff could not be read: ${gitError}`;
    }
    if (reportErr && finalVerdict === 'verified') {
        finalVerdict = 'hallucinated';
        finalReason = `fixes-applied.md could not be read: ${reportErr}`;
    }
    const timestamp = new Date().toISOString();
    const auditEntry = {
        fixer,
        iteration,
        verdict: finalVerdict,
        reason: finalReason,
        claimed: decision.claimed,
        observed: {
            worktree_changed_files: diffFiles,
            uncommitted_changed_files: uncommittedFiles,
            committed_changed_files: committedFiles,
            claimed_files: decision.claimedFiles,
            matched_files: decision.matched,
            unmatched_claimed_files: decision.unmatched,
            since_sha: sinceShaResolved,
            since_sha_source: sinceShaSource,
            committed_diff_error: committedDiffError,
        },
        report_path: reportPath,
        timestamp,
    };
    // stderr hint — only when the gate landed on hallucinated with an
    // empty match set AND no --since-sha / resolvable default was in play.
    // This is the user-visible breadcrumb for PR #63-class false positives:
    // the fixer committed its work before the audit ran, leaving an empty
    // working tree. The hint goes to stderr so the stdout JSON envelope
    // consumed by callers stays byte-for-byte identical.
    if (finalVerdict === 'hallucinated' &&
        decision.matched.length === 0 &&
        decision.claimedFiles.length > 0 &&
        !sinceShaResolved) {
        process.stderr.write('HINT: audit saw an empty diff and marked this fixer hallucinated. ' +
            'If the fixer committed its fixes before the audit ran, re-invoke with ' +
            '--since-sha <iteration-start-sha> so the audit window includes ' +
            'committed changes.\n');
    }
    // Write audit.json and metadata.json under lock so concurrent fixer
    // audits can't stomp each other. audit.json lives per-iteration;
    // metadata.json is per-review.
    const iterDir = join(reviewDir, 'iterations', String(iteration));
    mkdirSync(iterDir, { recursive: true });
    const auditPath = join(iterDir, 'audit.json');
    withFileLock(auditPath, () => {
        mergeAuditJson(auditPath, auditEntry);
    });
    const metadataPath = join(reviewDir, 'metadata.json');
    if (existsSync(reviewDir)) {
        withFileLock(metadataPath, () => {
            mergeMetadataJson(metadataPath, auditEntry);
        });
    }
    return ok({
        status: 'ok',
        command: 'pr-review-audit-fixer',
        fixer,
        iteration,
        verdict: finalVerdict,
        reason: finalReason,
        claimed: decision.claimed,
        observed: auditEntry.observed,
        audit_path: auditPath,
        metadata_path: existsSync(metadataPath) ? metadataPath : null,
    });
}
//# sourceMappingURL=pr-review-audit-fixer.mjs.map