/**
 * commands/validate-evidence.mjs — deterministic Evidence Rule enforcement.
 *
 * R6 of /apt:pr-review hardening. Walks a findings directory (markdown
 * files produced by specialist reviewers, per SCHEMA.md section 3),
 * parses each `## Finding:` block, and decides whether the claimed
 * evidence is actually present in the cited file range.
 *
 * Rules applied (in order, first-match wins):
 *
 *   1. dismissed_evidence_not_literal
 *      The `evidence` code block must be a literal substring of the
 *      cited file range (whitespace-normalized). Prose paraphrases and
 *      search terms get rejected here — no matter how plausible.
 *
 *   2. dismissed_multi_file_evidence_missing
 *      If the description or title references more than one filename
 *      (regex on file-extension tokens: .ts .tsx .js .jsx .mjs .json
 *      .md), the finding must also supply an `### Cross-File Evidence`
 *      block with a literal snippet from each referenced file. Missing
 *      → dismiss.
 *
 * Mutations are written back in place — the findings markdown gets a
 * trailing `### Validation Metadata` section per finding carrying
 * `- **Status**: …` and (optionally) `- **Note**: …` fields (the legacy
 * field-form `- **Validation Status**:` + `### Validation Note` shape is
 * still READ for back-compat but never re-emitted — see
 * FRAMEWORK-BUG-034 in `docs/frameworks/spec-gaps.md`). The command
 * always exits 0; the caller checks the JSON summary on stdout.
 *
 * Usage:
 *   apt-tools validate-evidence <findings-dir> --worktree <path>
 */
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { parseFlags } from '../util/args.mjs';
import { err, ok } from '../util/result.mjs';
// Regex of file-extension tokens we use to detect multi-file references
// inside the structured evidence blocks. Deliberately conservative — the
// point is to catch findings whose Evidence/Suggested-Fix snippets cite
// more than one file without supplying a matching `### Cross-File
// Evidence` entry. Over-detection is NOT OK (bug-4): filenames mentioned
// in prose fields (title / description / impact / Evidence Context) are
// just narrative — they must NOT trip the multi-file rule. Only code
// actually cited as evidence counts. We still strip URLs and fenced code
// fences from the in-scope sections before matching (see
// stripNoiseForFileTokens) so that OWASP/RFC links and outer fence
// markers don't bleed through.
const FILE_TOKEN_RX = /\b[\w./-]+\.(?:ts|tsx|js|jsx|mjs|json|md)\b/g;
// Strip fenced code blocks (```…```) and URL-like substrings (anything
// after http:// or https:// up to whitespace, closing parens, or
// markdown-link brackets) BEFORE matching FILE_TOKEN_RX. Inline backtick
// spans are preserved — those usually wrap real file paths the author
// wants us to detect (e.g. `auth.ts and session.ts`).
export function stripNoiseForFileTokens(text) {
    if (!text)
        return '';
    let out = String(text);
    // 1. Drop fenced code blocks. The opening fence may carry an info
    //    string (```ts, ```bash, etc.); the closing fence is a bare ```
    //    on its own (we tolerate trailing whitespace).
    out = out.replace(/```[^\n]*\n[\s\S]*?```/g, ' ');
    // 2. Drop URLs. The terminator set covers whitespace, closing parens
    //    (markdown link form `(https://…)`), brackets, and angle braces.
    out = out.replace(/https?:\/\/[^\s)\]>]+/g, ' ');
    return out;
}
/**
 * Collapse runs of whitespace to a single space and strip leading/
 * trailing whitespace. We use this on BOTH the claimed evidence and
 * the target file range so indentation changes / CRLF don't cause a
 * false mismatch. Keep the normalization identical on both sides.
 */
export function normalizeForLiteralMatch(text) {
    return String(text ?? '')
        .replace(/\r\n/g, '\n')
        .replace(/\s+/g, ' ')
        .trim();
}
// Annotation marker patterns — line content (after the comment marker is
// stripped) that indicates the line is a reviewer note rather than source.
// Examples this matches:
//   (verified) | (approx) | (approximate) | (elided)
//   ... N more | ... [N] more identical-pattern setters
//   rest of … | remaining … | snip | truncated | omitted
//   lines 106-153 | line 42
//   bare ellipsis: ... | …
const ANNOTATION_PHRASE_RX = /\(verified\)|\(approx(?:imate)?\)|\(elided\)|\.\.\.\s*\[?\d+\]?\s*more\b|\brest of\b|\bremaining\b|\bsnip\b|\btruncated\b|\bomitted\b|\blines?\s+\d+(?:\s*-\s*\d+)?\b/i;
// A line is "comment-only" if, after trim, it begins with one of the
// recognized comment markers and contains no code afterwards we'd care
// about. We deliberately keep the marker set small + language-agnostic;
// reviewers in this codebase use `//`, `#`, `--`, and `/* … */`.
const COMMENT_LINE_RX = /^\s*(?:\/\/|#|--|\/\*.*\*\/\s*$|\/\*|\*)/;
/**
 * Strip a single layer of code-fence markers from an evidence string.
 * The markdown parser already extracts content between the first opening
 * `` ``` `` fence and the next `` ``` ``, so the "outer" fences are gone
 * by the time we get here. But reviewers occasionally paste source that
 * itself contains fence markers (e.g. `` ```bash `` inside the snippet),
 * leaving stray fence lines behind. This helper removes any line whose
 * trimmed content is exactly a fence marker — never source code.
 */
export function stripCodeFenceLines(text) {
    if (!text)
        return '';
    return String(text)
        .split(/\r?\n/)
        .filter((line) => !/^\s*```/.test(line))
        .join('\n');
}
/**
 * Decide whether a single evidence line is a reviewer annotation rather
 * than source code copied from the cited file.
 *
 * A line is treated as an annotation when ALL of the following hold:
 *   - It is a "comment-only" line (starts with `//`, `#`, `--`, `/*` …).
 *   - Either it matches one of the well-known annotation phrases (e.g.
 *     `(verified)`, `... 10 more`, `// rest of`, `// ...`, `lines N-M`),
 *     OR — fallback rule — its trimmed content does not appear as a
 *     literal substring of `sourceWindow` (the cited file range plus a
 *     small ±5-line buffer for reviewer rounding).
 *
 * The "appears in sourceWindow" fallback is what lets us accept genuine
 * comments that happen to be part of the cited source (e.g. a `// TODO`
 * the reviewer pasted) — those are NOT annotations and should still be
 * required to match. Only commentary the reviewer added on top is
 * filtered out.
 *
 * Conservative by design: code lines (anything that doesn't start with a
 * comment marker) are NEVER classified as annotations, so a paraphrased
 * code line still gets through to the literal-match step and fails.
 */
export function isAnnotationLine(line, sourceWindow) {
    const trimmed = line.trim();
    if (!trimmed)
        return false;
    if (!COMMENT_LINE_RX.test(trimmed))
        return false;
    // Bare ellipsis-only lines are always annotations: `// ...`, `# ...`,
    // `-- ...`, or just `...` would have been excluded by COMMENT_LINE_RX
    // since it requires a marker; but a marker followed only by dots /
    // whitespace is the canonical truncation cue.
    if (/^\s*(?:\/\/|#|--|\*)\s*\.{2,}\s*$/.test(trimmed))
        return true;
    if (ANNOTATION_PHRASE_RX.test(trimmed))
        return true;
    // Fallback: comment doesn't appear in the source window → annotation.
    const normLine = normalizeForLiteralMatch(trimmed);
    if (!normLine)
        return false;
    const normWindow = normalizeForLiteralMatch(sourceWindow);
    return !normWindow.includes(normLine);
}
/**
 * Parse a findings markdown file into an array of findings. Each
 * finding is a `## Finding: {id} -- {title}` block terminated by the
 * next `---` (exclusive) or EOF.
 *
 * Returns records with:
 *   { id, title, start, end, raw, fields: {file, line, severity, ...},
 *     evidence, description, lineRange, crossFileEvidence, hasValidationStatus }
 *
 * Mutations re-serialize the raw block, so preserving `start`/`end`
 * offsets on the original text lets us edit surgically without
 * rewriting the file's framing.
 */
export function parseFindingsMarkdown(markdown) {
    const findings = [];
    // BUG-032: widen to accept H2|H3, optional `Finding:` prefix, and any of
    // ASCII `--`, em-dash `—`, or en-dash `–` as the id/title separator. The
    // validator-agent's consolidated `validated.md` shape uses H3 + em-dash
    // with no `Finding:` prefix; the specialist shape uses H2 + `Finding:` +
    // ASCII `--`. Both must round-trip through the same parser.
    const headerRx = /^#{2,3}\s+(?:Finding:\s*)?([A-Z]+-\d+)\s*(?:--|—|–)\s*(.+)$/gm;
    const matches = [...markdown.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 : markdown.length;
        // Each block ends at the first standalone `---` after its header,
        // OR at the next header. Be generous — treat either as terminator.
        const slice = markdown.slice(start, nextStart);
        const terminator = slice.search(/\n---\s*(?:\n|$)/);
        const end = terminator >= 0 ? start + terminator : nextStart;
        const body = markdown.slice(start, end);
        findings.push(parseFindingBlock(body, m[1], m[2], start, end));
    }
    return findings;
}
function parseFindingBlock(body, id, title, start, end) {
    // FRAMEWORK-BUG-034: strip any stamper-emitted Validation Status /
    // Validation Note / Validation Metadata content from the body BEFORE
    // extracting fields and evidence. The legacy field-form
    // `- **Validation Status**: ...` line was bleeding into `fields.evidence`
    // because the `fieldRx` `\s*` after the empty-value `- **Evidence**:`
    // marker consumed the newline and grabbed the next line as the field's
    // value. Stripping pre-parse keeps the parser inputs clean regardless of
    // whether the file was stamped under the old or new shape. The original
    // body is preserved for `raw` and for the dual-shape `hasValidationStatus`
    // detector below.
    const cleanBody = body
        .replace(/^-\s*\*\*Validation Status\*\*:[^\n]*\n?/gim, '')
        .replace(/\n?###\s*Validation Note\s*\n[\s\S]*?(?=\n###|$)/i, '')
        .replace(/\n?###\s*Validation Metadata\s*\n[\s\S]*?(?=\n###|$)/i, '');
    const fields = {};
    // `- **File**: \`path:line\`` etc.
    const fieldRx = /^-\s*\*\*([^*]+)\*\*:\s*(.+)$/gm;
    for (const m of cleanBody.matchAll(fieldRx)) {
        const key = m[1].trim().toLowerCase();
        fields[key] = m[2].trim();
    }
    // File + line from the "File" field. Accepted shapes:
    //   `path/to/foo.ts`
    //   `path/to/foo.ts:42`
    //   `path/to/foo.ts:42-58`     ← line range
    // The regex anchors with ^ (without it, lazy [^`:]+? matched the END
    // of `path:30-32` and captured `30-32` as the file, then validate-evidence
    // tried to read <worktree>/30-32 and silently dismissed every finding —
    // FRAMEWORK-BUG-001 surfaced by /apt:pr-review on PR #68 in 2026-04).
    // `(?:-\d+)?` consumes the trailing range so it doesn't leak into the
    // file capture.
    // BUG-032: the validator-agent's `validated.md` carries the cited path
    // in `- **Original**:` instead of `- **File**:`. Map either spelling
    // onto `file`/`line`; specialist shape continues to write to `file`.
    const fileField = fields.file ?? fields.original;
    let file = null;
    let line = null;
    let lineRangeFromField = null;
    if (fileField) {
        const fileMatch = /^`?([^`:]+?)(?::(\d+)(?:-(\d+))?)?`?\s*$/.exec(fileField);
        if (fileMatch) {
            file = fileMatch[1].trim();
            line = fileMatch[2] ? Number(fileMatch[2]) : null;
            if (fileMatch[2] && fileMatch[3]) {
                lineRangeFromField = [Number(fileMatch[2]), Number(fileMatch[3])];
            }
        }
    }
    // Description block: everything between `### Description` and the next `###`.
    const description = extractSection(cleanBody, 'Description') ?? '';
    // Evidence code block: first triple-backticked block after `### Evidence`
    // (specialist shape). BUG-032: validator-consolidated `validated.md`
    // shape carries evidence as a `- **Evidence**:` field whose value is the
    // fenced block immediately following the field line. Fall back to the
    // first fenced block after that field marker when the section heading
    // form yields nothing.
    //
    // FRAMEWORK-BUG-034: the `fieldEvidenceRx` fallback below is safe as-is
    // because the writer no longer emits `- **Validation Status**` field-form
    // lines (see stampFindings comment) AND `parseFindingBlock` strips any
    // legacy Status / Validation Note text from `cleanBody` above, so the
    // regex can no longer trip on the stamper's own output.
    const evidenceSection = extractSection(cleanBody, 'Evidence') ?? '';
    let evidence;
    const fenced = /```[^\n]*\n([\s\S]*?)```/.exec(evidenceSection);
    if (fenced) {
        evidence = fenced[1];
    }
    else if (evidenceSection) {
        evidence = evidenceSection;
    }
    else {
        const fieldEvidenceRx = /-\s*\*\*Evidence\*\*:\s*(?:\n+)```[^\n]*\n([\s\S]*?)```/i;
        const fieldFenced = fieldEvidenceRx.exec(cleanBody);
        evidence = fieldFenced ? fieldFenced[1] : (fields.evidence ?? '');
    }
    // Suggested Fix section (prose + optional fenced snippets). Unlike
    // Evidence we keep the whole section — snippets AND surrounding
    // sentences both live here — because file tokens in either form are
    // legitimate evidence that the fix crosses files.
    const suggestedFix = extractSection(cleanBody, 'Suggested Fix') ?? '';
    // Line range: Evidence Context -> "Lines examined: a-b"
    const contextSection = extractSection(cleanBody, 'Evidence Context') ?? '';
    const rangeMatch = /Lines? examined[^\d]*(\d+)\s*-\s*(\d+)/i.exec(contextSection);
    const lineRange = rangeMatch
        ? [Number(rangeMatch[1]), Number(rangeMatch[2])]
        : lineRangeFromField
            ? lineRangeFromField
            : line
                ? [line, line]
                : null;
    // Cross-file evidence (optional): `### Cross-File Evidence` block.
    // Each file gets a `- **File**: path` + fenced snippet.
    const crossFileSection = extractSection(cleanBody, 'Cross-File Evidence') ?? '';
    const crossFileEvidence = [];
    const crossFileRx = /-\s*\*\*File\*\*:\s*`?([^`\n]+?)`?\s*\n+```[^\n]*\n([\s\S]*?)```/g;
    for (const m of crossFileSection.matchAll(crossFileRx)) {
        crossFileEvidence.push({ file: m[1].trim(), snippet: m[2] });
    }
    return {
        id,
        title: title.trim(),
        start,
        end,
        raw: body,
        fields,
        file,
        line,
        lineRange,
        evidence,
        description,
        suggestedFix,
        crossFileEvidence,
        // FRAMEWORK-BUG-034: detection broadened to recognize BOTH the new
        // `### Validation Metadata` section (the post-fix writer shape) AND
        // the legacy `- **Validation Status**:` field-form (back-compat for
        // any validated.md on disk written before the fix). The flag name
        // stays `hasValidationStatus` for callers; see spec.md ID-01 for the
        // "renamed conceptually" rationale.
        hasValidationStatus: /###\s*Validation Metadata/i.test(body) || /\*\*Validation Status\*\*/i.test(body),
    };
}
function extractSection(body, heading) {
    const rx = new RegExp(`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n###\\s|$)`, 'i');
    const m = rx.exec(body);
    return m ? m[1].trim() : null;
}
/**
 * Detect that a finding references more than one filename inside its
 * structured evidence blocks (`### Evidence` + `### Suggested Fix`).
 * Prose fields (title / description / impact / Evidence Context) are
 * deliberately NOT scanned — bug-4 proved those routinely mention
 * filenames in narrative (e.g. "per CLAUDE.md", "see src/foo.ts") that
 * have nothing to do with whether the evidence itself spans files.
 *
 * The Evidence block is usually a pasted code snippet; the Suggested Fix
 * may be prose, code, or both. We strip fenced-code fences and URL noise
 * the same way on both sides so outer backticks and OWASP/RFC links
 * don't bleed through. Returns the sorted unique list of referenced
 * files — caller decides whether to require cross-file evidence based
 * on length.
 */
export function detectReferencedFiles(finding) {
    const tokens = new Set();
    for (const text of [finding.evidence, finding.suggestedFix]) {
        const cleaned = stripNoiseForFileTokens(text);
        for (const m of cleaned.matchAll(FILE_TOKEN_RX)) {
            tokens.add(m[0]);
        }
    }
    return [...tokens].sort();
}
/**
 * BUG-014 fuzzy-evidence helpers — token-set Jaccard + line-anchor count.
 *
 * Tokenize on word characters so whitespace, punctuation, and operator
 * spacing don't perturb similarity. Same tokenizer runs on both sides so
 * the ratio is symmetric.
 */
export function tokenizeForJaccard(text) {
    if (!text)
        return [];
    return (String(text)
        .toLowerCase()
        .match(/[a-z0-9_$]+/g) ?? []);
}
export function jaccardSimilarity(a, b) {
    const setA = new Set(tokenizeForJaccard(a));
    const setB = new Set(tokenizeForJaccard(b));
    if (setA.size === 0 && setB.size === 0)
        return 1;
    if (setA.size === 0 || setB.size === 0)
        return 0;
    let intersection = 0;
    for (const t of setA)
        if (setB.has(t))
            intersection++;
    const union = setA.size + setB.size - intersection;
    return intersection / union;
}
/**
 * Count evidence lines that appear as literal substrings of the cited
 * file range (after `normalizeForLiteralMatch`). Used as the
 * anchor-count mitigation against token-stuffing under fuzzy mode.
 */
export function countExactLineAnchors(evidenceLines, target) {
    const normTarget = normalizeForLiteralMatch(target);
    let count = 0;
    for (const line of evidenceLines) {
        const normLine = normalizeForLiteralMatch(line);
        if (!normLine)
            continue;
        if (normTarget.includes(normLine))
            count++;
    }
    return count;
}
/**
 * Check a single finding against the literal-substring rule. Returns
 * a decision object; caller applies it to the markdown.
 *
 * Optional `opts.allowApprox` enables BUG-014 opt-in fuzzy fallback —
 * when the literal check fails, compute Jaccard ≥ `approxFloor` on the
 * cited range AND require ≥2 exact line anchors in the cited range
 * (token-stuffing mitigation). On success, emit downgraded status
 * `dismissed_evidence_approx_confirmed` unless `opts.llmValidatorConfirmed`
 * is also set, in which case status becomes `confirmed_valid_approx`.
 */
export function checkFinding(finding, worktreeRoot, opts = {}) {
    const allowApprox = Boolean(opts.allowApprox);
    const approxFloor = typeof opts.approxFloor === 'number' ? opts.approxFloor : 0.85;
    const llmValidatorConfirmed = Boolean(opts.llmValidatorConfirmed);
    // Rule 1: evidence must be a literal substring of the cited file range.
    if (!finding.file) {
        return {
            status: 'dismissed_evidence_not_literal',
            note: 'Finding has no `file` field — cannot verify evidence literal-match.',
        };
    }
    const abs = resolve(worktreeRoot, finding.file);
    if (!existsSync(abs)) {
        return {
            status: 'dismissed_evidence_not_literal',
            note: `Cited file not found at ${finding.file} (resolved ${abs}).`,
        };
    }
    const all = readFileSync(abs, 'utf-8').split(/\r?\n/);
    const [startLine, endLine] = finding.lineRange ?? [1, all.length];
    // Clamp to file bounds (reviewers sometimes cite past EOF).
    const safeStart = Math.max(1, Math.min(all.length, startLine));
    const safeEnd = Math.max(safeStart, Math.min(all.length, endLine));
    const target = all.slice(safeStart - 1, safeEnd).join('\n');
    // Buffer ±5 lines around the cited range so the "comment exists in
    // source?" annotation-detection fallback tolerates small reviewer
    // rounding errors (e.g. they cite 106-153 but the comment they
    // quoted is on line 105 or 154). Only the matcher's annotation
    // detector reads this widened window — the literal-match step still
    // uses the strict cited range.
    const bufferedStart = Math.max(1, safeStart - 5);
    const bufferedEnd = Math.min(all.length, safeEnd + 5);
    const sourceWindow = all.slice(bufferedStart - 1, bufferedEnd).join('\n');
    const rawEvidence = finding.evidence ?? '';
    if (!normalizeForLiteralMatch(rawEvidence)) {
        return {
            status: 'dismissed_evidence_not_literal',
            note: 'Finding has empty evidence block.',
        };
    }
    // Strip any stray code-fence markers the parser left behind (rare —
    // the parser already extracts the FIRST fenced block, but reviewers
    // sometimes nest fences inside the snippet) and split into lines.
    const evidenceLines = stripCodeFenceLines(rawEvidence).split(/\r?\n/);
    // Keep only "code-bearing" lines: drop blank lines (whitespace-only
    // adds no signal) and reviewer annotation comments (per
    // isAnnotationLine — see helper for the contract).
    const keptLines = evidenceLines.filter((line) => {
        if (!line.trim())
            return false;
        return !isAnnotationLine(line, sourceWindow);
    });
    if (keptLines.length === 0) {
        // Evidence reduced to nothing matchable after stripping fences +
        // annotations. Treat as missing evidence — same outcome as an
        // empty Evidence block.
        return {
            status: 'dismissed_evidence_not_literal',
            note: 'Evidence consists only of fence markers and reviewer annotations — no source content to verify.',
        };
    }
    const normTarget = normalizeForLiteralMatch(target);
    const normWholeEvidence = normalizeForLiteralMatch(keptLines.join('\n'));
    // Capture the first literal-mismatch reason so the fuzzy fallback can
    // reuse it as the rejection note when fuzzy ALSO fails.
    let literalRejectNote = null;
    if (normTarget.includes(normWholeEvidence)) {
        // Fast path: the kept evidence (joined back) is still a literal
        // contiguous substring of the cited range. This is the common
        // case — a clean copy-paste with no annotations interleaved.
    }
    else {
        // Slow path: the reviewer interleaved annotations with source
        // (e.g. `code line A\n// ... 10 more\ncode line B`). Match each
        // kept line individually against the cited range. Every kept
        // line must appear; any miss is a paraphrase / fabrication and
        // the whole finding is rejected — unless the caller opted into
        // fuzzy fallback (BUG-014).
        for (const line of keptLines) {
            const normLine = normalizeForLiteralMatch(line);
            if (!normLine)
                continue;
            if (!normTarget.includes(normLine)) {
                literalRejectNote = `Evidence not found as literal substring of ${finding.file} lines ${safeStart}-${safeEnd}: line "${normLine.slice(0, 120)}" missing from cited range.`;
                break;
            }
        }
    }
    // BUG-014: opt-in fuzzy fallback. Default behaviour (allowApprox=false)
    // preserves the anti-fabrication guarantee — literal mismatch returns
    // `dismissed_evidence_not_literal` unchanged. When the caller passes
    // `--allow-approx-evidence`, attempt Jaccard ≥ floor AND ≥2 exact
    // line anchors in the cited range (mitigates token-stuffing). On
    // success, downgrade status to `dismissed_evidence_approx_confirmed`
    // unless the caller also passed `--llm-validator-confirmed`, in
    // which case promote to `confirmed_valid_approx`.
    if (literalRejectNote) {
        if (!allowApprox) {
            return { status: 'dismissed_evidence_not_literal', note: literalRejectNote };
        }
        const similarity = jaccardSimilarity(keptLines.join('\n'), target);
        const anchors = countExactLineAnchors(keptLines, target);
        if (similarity < approxFloor || anchors < 2) {
            return {
                status: 'dismissed_evidence_not_literal',
                note: `${literalRejectNote} Fuzzy fallback also rejected (jaccard=${similarity.toFixed(3)} floor=${approxFloor}, anchors=${anchors}/2).`,
            };
        }
        const approxNote = `Approximate match accepted under --allow-approx-evidence (jaccard=${similarity.toFixed(3)} ≥ ${approxFloor}, anchors=${anchors} ≥ 2).`;
        if (llmValidatorConfirmed) {
            return { status: 'confirmed_valid_approx', note: approxNote };
        }
        return { status: 'dismissed_evidence_approx_confirmed', note: approxNote };
    }
    // Rule 2: multi-file references require Cross-File Evidence.
    const referenced = detectReferencedFiles(finding);
    if (referenced.length > 1) {
        if (finding.crossFileEvidence.length < referenced.length) {
            return {
                status: 'dismissed_multi_file_evidence_missing',
                note: `Evidence/Suggested Fix reference ${referenced.length} files (${referenced.join(', ')}) but only provides cross-file evidence for ${finding.crossFileEvidence.length}.`,
            };
        }
        // Every referenced file must have a corresponding cross-file
        // evidence entry whose snippet is a literal substring of that
        // file. Any miss → dismiss.
        for (const referencedFile of referenced) {
            const entry = finding.crossFileEvidence.find((e) => e.file.endsWith(referencedFile));
            if (!entry) {
                return {
                    status: 'dismissed_multi_file_evidence_missing',
                    note: `No cross-file evidence entry for referenced file ${referencedFile}.`,
                };
            }
            const crossPath = resolve(worktreeRoot, entry.file);
            if (!existsSync(crossPath)) {
                return {
                    status: 'dismissed_multi_file_evidence_missing',
                    note: `Cross-file evidence cites ${entry.file}, which does not exist in the worktree.`,
                };
            }
            const crossTargetNorm = normalizeForLiteralMatch(readFileSync(crossPath, 'utf-8'));
            const crossEvidenceNorm = normalizeForLiteralMatch(entry.snippet);
            if (!crossTargetNorm.includes(crossEvidenceNorm)) {
                return {
                    status: 'dismissed_multi_file_evidence_missing',
                    note: `Cross-file snippet for ${entry.file} is not a literal substring of the file.`,
                };
            }
        }
    }
    return { status: 'confirmed_valid', note: null };
}
/**
 * Rewrite a findings markdown in place, stamping each finding with a
 * trailing `### Validation Metadata` section carrying Status + Note.
 *
 * FRAMEWORK-BUG-034 (2026-05-19, PR #208): the previous writer emitted
 * `- **Validation Status**:` as a field-form line. The fieldRx in
 * `parseFindingBlock` (`/^-\s*\*\*([^*]+)\*\*:\s*(.+)$/gm`) lets `\s*`
 * consume the newline after the empty-value `- **Evidence**:` field,
 * so on a re-read the inserted Status line is captured as
 * `fields.evidence` and the literal-substring step dismisses 28/32
 * findings citing the writer's own stamp as the missing source line.
 *
 * Fix shape (per spec.md ID-01): WRITER always emits ONLY the new
 * `### Validation Metadata` heading shape — the `###` shape can't fall
 * into the field-cluster regex, so the self-reference loop can't form.
 * READER side (`parseFindingBlock`) supports BOTH the new section AND
 * the legacy field-form so any older validated.md on disk still
 * round-trips. See spec.md ID-01 + ID-02 for the dual-shape rationale.
 */
function stampFindings(markdown, decisions) {
    // Work from the end so earlier edits don't shift later offsets.
    let out = markdown;
    for (let i = decisions.length - 1; i >= 0; i--) {
        const { finding, decision } = decisions[i];
        let block = out.slice(finding.start, finding.end);
        // Strip any legacy field-form Validation Status line. Multiple lines
        // possible if a previous buggy re-run left several stamps; replaceAll-
        // style global match keeps this idempotent.
        block = block.replace(/^-\s*\*\*Validation Status\*\*:[^\n]*\n?/gim, '');
        // Strip any legacy `### Validation Note` block.
        block = block.replace(/\n?###\s*Validation Note\s*\n[\s\S]*?(?=\n###|$)/i, '');
        // Strip any prior `### Validation Metadata` section so we can
        // re-emit fresh values (idempotency across re-runs, spec ID-02).
        block = block.replace(/\n?###\s*Validation Metadata\s*\n[\s\S]*?(?=\n###|$)/i, '');
        const lines = [`### Validation Metadata`, `- **Status**: ${decision.status}`];
        if (decision.note)
            lines.push(`- **Note**: ${decision.note}`);
        const section = `${lines.join('\n')}\n`;
        const newBlock = `${block.replace(/\s*$/, '')}\n\n${section}`;
        out = out.slice(0, finding.start) + newBlock + out.slice(finding.end);
    }
    return out;
}
/**
 * Main command entry point.
 */
export function cmdValidateEvidence(findingsDir, extraArgs) {
    if (!findingsDir)
        return err('Usage: apt-tools validate-evidence <findings-dir> --worktree <path>');
    const dir = resolve(findingsDir);
    if (!existsSync(dir))
        return err(`Findings directory not found: ${dir}`);
    const flags = parseFlags(extraArgs);
    const worktree = flags.get('worktree');
    if (!worktree)
        return err('Required: --worktree <path>');
    const worktreeRoot = resolve(worktree);
    if (!existsSync(worktreeRoot))
        return err(`Worktree not found: ${worktreeRoot}`);
    // BUG-014: opt-in fuzzy-evidence flags. Defaults preserve the
    // anti-fabrication guarantee — literal substring is still the only
    // matcher when no flag is set.
    const allowApprox = flags.get('allow-approx-evidence') === true;
    const approxFloorRaw = flags.get('approx-floor');
    const approxFloor = typeof approxFloorRaw === 'string' ? Number(approxFloorRaw) : 0.85;
    if (allowApprox && (!Number.isFinite(approxFloor) || approxFloor <= 0 || approxFloor > 1)) {
        return err(`Invalid --approx-floor value: ${approxFloorRaw} (expected 0 < n ≤ 1)`);
    }
    const llmValidatorConfirmed = flags.get('llm-validator-confirmed') === true;
    const checkOpts = { allowApprox, approxFloor, llmValidatorConfirmed };
    const files = readdirSync(dir).filter((f) => f.endsWith('.md'));
    const summary = {
        status: 'ok',
        command: 'validate-evidence',
        findings_dir: dir,
        worktree: worktreeRoot,
        validated: 0,
        rejected: 0,
        rejections: [],
        processed_files: [],
    };
    for (const f of files) {
        const full = join(dir, f);
        const md = readFileSync(full, 'utf-8');
        const findings = parseFindingsMarkdown(md);
        // BUG-032: always record the file in `processed_files`. The
        // previous `continue` on empty findings caused validated.md to
        // silently no-op when the header regex didn't match, leaving
        // downstream gates with `processed_files: []` and no audit trail
        // that the file was even read.
        summary.processed_files.push({ file: f, findings_count: findings.length });
        if (findings.length === 0)
            continue;
        const decisions = [];
        for (const finding of findings) {
            const decision = checkFinding(finding, worktreeRoot, checkOpts);
            decisions.push({ finding, decision });
            if (decision.status === 'confirmed_valid' || decision.status === 'confirmed_valid_approx') {
                summary.validated++;
            }
            else {
                summary.rejected++;
                summary.rejections.push({
                    id: finding.id,
                    file: f,
                    finding_file: finding.file,
                    status: decision.status,
                    reason: decision.note,
                });
            }
        }
        const rewritten = stampFindings(md, decisions);
        if (rewritten !== md) {
            writeFileSync(full, rewritten, 'utf-8');
        }
    }
    // BUG-032: surface parser-shape drift early. If we read N markdown
    // files but extracted zero findings, warn on stderr so future schema
    // changes don't silently no-op the gate.
    const totalFindings = summary.processed_files.reduce((acc, e) => acc + (e.findings_count ?? 0), 0);
    if (files.length > 0 && totalFindings === 0) {
        process.stderr.write(`WARNING: validate-evidence read ${files.length} markdown file(s) but extracted 0 findings — parser shape mismatch?\n`);
    }
    return ok(summary);
}
//# sourceMappingURL=validate-evidence.mjs.map