/**
 * Structural validator for per-action LLM output (DR3 of the L3 proposal).
 *
 * The L2 convention matcher produces deterministic TypeScript snippets
 * for spec steps it can match. Those pre-baked snippets are passed to the
 * LLM as scaffolding (DR3-A). The LLM emits a coherent method body that
 * weaves the pre-baked snippets together with [WRITE]-step code it
 * authors itself.
 *
 * The structural validator is the trust-but-verify counterpart to that
 * pattern: after the LLM emits, we diff the output against the pre-baked
 * snippets. Every pre-baked snippet must appear in the LLM's body
 * verbatim (modulo whitespace + trailing punctuation). If a snippet is
 * missing — i.e. the LLM rewrote a deterministic piece — the realize
 * pipeline fails loudly with a diff showing what was preserved + what
 * was dropped.
 *
 * Why "verbatim modulo whitespace": pre-baked snippets are already
 * idiomatic TS. Letting the LLM reformat them silently undermines
 * "deterministic" — we'd lose reproducibility across runs. But forcing
 * exact byte-for-byte match would fail on innocuous LLM behaviours
 * (re-indenting a single line of pre-baked code to match the surrounding
 * indentation it adopted). We compare on the canonical form: the
 * snippet's "code essence" stripped of leading whitespace and trailing
 * comment-only lines.
 *
 * This validator is orthogonal to the parse-check (esbuild) and the
 * tsc-check (per-action-llm-emit's surgical retry loop). All three
 * gates compose: parse-fail → surgical retry; structural-fail → loud
 * fail (LLM rewrote a deterministic piece); tsc-fail → fall through to
 * γ stub.
 */
/**
 * Canonicalise a code chunk for comparison: strip leading whitespace per
 * line, drop pure-comment lines (the matcher prepends `// Step N: ...`
 * markers that the LLM is allowed to reword or absorb into prose), and
 * collapse internal whitespace runs to a single space.
 *
 * The result is a fingerprint of the snippet's executable shape — enough
 * to spot rewrites (different identifier, different control flow) but
 * loose enough to tolerate harmless reformatting (alignment, spacing
 * around operators).
 */
function canonicalise(code) {
    return code
        .split('\n')
        .map((line) => line.trim())
        .filter((line) => line.length > 0 && !line.startsWith('//'))
        .join(' ')
        .replace(/\s+/g, ' ')
        .trim();
}
/**
 * Default drop-fraction threshold: an owner may drop up to (and
 * including) HALF of its pre-baked snippets and still be accepted
 * (with the drops surfaced as warnings). Above half → γ-fallback.
 *
 * Rationale (2026-05-14, Phase 5 first target of
 * `2026-05-13-VERIFIER-DIAGNOSTIC-TREATMENT-SPLIT.md`): pre-fix the
 * validator rejected an ENTIRE owner if it dropped a SINGLE snippet.
 * For sonnet this rarely fires; for openai-compatible models (Ollama
 * qwen3-coder:30b, MarrBox) it fired on ~13/36 owners in the idle-meta
 * trial — every paraphrased snippet nuked the whole owner to γ-stub.
 * The post-emit feedback loop then recovered those owners anyway via
 * context-complete reemit (which does NOT run this validator), so the
 * strictness bought an extra LLM round-trip and STILL lost determinism.
 * A threshold keeps the gross-failure guard ("the LLM basically
 * ignored the scaffolding") while letting a minority of paraphrases
 * through — they ride the normal tsc + stub-completeness gates instead.
 *
 * Overridable via `SPECVERSE_REALIZE_STRUCTURAL_DROP_THRESHOLD`.
 */
export const DEFAULT_STRUCTURAL_DROP_THRESHOLD = 0.5;
/**
 * Validate that every pre-baked snippet appears in the LLM-emitted body.
 *
 * The check is substring containment on the canonical form (whitespace +
 * leading-comment normalised). For each missing snippet we surface the
 * step number, step text, and the closest near-match found in the body
 * (or `null` if there's no token-overlap candidate).
 *
 * `dropThreshold` is the fraction of countable snippets that may be
 * dropped before the result flips to `ok: false`. At or below the
 * threshold, the result is `ok: true` with the dropped snippets in
 * `warnings`. Above it, `ok: false` with the full `missing` list.
 *
 * Empty `prebakedSnippets` is a vacuous pass — when no convention
 * matched, there's nothing to preserve.
 */
export function validateLlmOutputPreservesConventions(llmBody, prebakedSnippets, dropThreshold = DEFAULT_STRUCTURAL_DROP_THRESHOLD) {
    if (prebakedSnippets.length === 0) {
        return { ok: true };
    }
    const llmCanonical = canonicalise(llmBody);
    const missing = [];
    // Countable = snippets with a non-empty canonical form. Comment-only
    // snippets are skipped (they're vacuously "preserved") and must NOT
    // inflate the denominator, or the drop fraction would understate.
    let countable = 0;
    for (const snippet of prebakedSnippets) {
        const expected = canonicalise(snippet.snippet);
        if (expected.length === 0)
            continue; // skip comment-only snippets
        countable++;
        if (llmCanonical.includes(expected)) {
            continue;
        }
        // Near-match heuristic: extract a list of progressively-shorter
        // dotted tokens from the snippet (e.g. `prisma.customer.findUnique`,
        // `prisma.customer`, `prisma`) and search the LLM body for any of
        // them. Surfaces the closest line to the reviewer — so when the LLM
        // rewrote `findUnique` to `findFirst` (kept the `prisma.customer`
        // prefix), we find the rewritten line rather than reporting "no
        // near-match found".
        const distinctiveTokens = extractDistinctiveTokens(snippet.snippet);
        let closestMatch = null;
        for (const tok of distinctiveTokens) {
            for (const line of llmBody.split('\n')) {
                if (line.includes(tok)) {
                    closestMatch = line.trim();
                    break;
                }
            }
            if (closestMatch)
                break;
        }
        missing.push({
            stepNum: snippet.stepNum,
            stepText: snippet.stepText,
            expected: snippet.snippet.trim(),
            closestMatch,
        });
    }
    if (missing.length === 0) {
        return { ok: true };
    }
    // Below/at threshold → accept with warnings; above → γ-fallback.
    // countable is guaranteed > 0 here (missing is non-empty, and every
    // entry in missing incremented countable).
    const dropFraction = missing.length / countable;
    if (dropFraction <= dropThreshold) {
        return { ok: true, warnings: missing };
    }
    const reason = formatMissingReport(missing, countable, dropThreshold);
    return { ok: false, missing, reason };
}
/**
 * Extract a list of progressively-shorter distinctive identifier
 * tokens from a snippet, ordered most-specific first. Used by the
 * closest-match heuristic to surface a rewritten line even when the
 * LLM changed the trailing call shape (e.g. `findUnique → findFirst`)
 * but kept the prefix.
 *
 * Returns the empty array when the snippet has no dotted identifiers.
 */
function extractDistinctiveTokens(snippet) {
    // Collect every dotted identifier (foo.bar, prisma.order.create, etc.)
    const rawMatches = snippet.match(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+/g);
    const matches = rawMatches ? Array.from(rawMatches) : [];
    if (matches.length === 0)
        return [];
    const out = [];
    // Start with the longest dotted identifier, then walk back along
    // its prefix segments (one fewer dot each step) so we have a series
    // of fall-back queries to match against the LLM body.
    matches.sort((a, b) => b.length - a.length);
    const longest = matches[0];
    out.push(longest);
    const segments = longest.split('.');
    for (let i = segments.length - 1; i >= 2; i--) {
        out.push(segments.slice(0, i).join('.'));
    }
    // Add any other dotted identifiers from the snippet, in case the
    // longest one was a transient (e.g. variable assignment) and the
    // distinguishing call sits later.
    for (const m of matches.slice(1)) {
        if (!out.includes(m))
            out.push(m);
    }
    return out;
}
/**
 * Format a human-readable diff report for the realize pipeline to print
 * when the structural validator rejects an LLM output (drop fraction
 * exceeded the threshold).
 */
function formatMissingReport(missing, countable, dropThreshold) {
    const lines = [];
    const pct = Math.round((missing.length / countable) * 100);
    lines.push(`LLM output dropped or rewrote ${missing.length}/${countable} pre-baked convention snippet${countable === 1 ? '' : 's'} ` +
        `(${pct}%, over the ${Math.round(dropThreshold * 100)}% threshold):`);
    for (const m of missing) {
        lines.push('');
        lines.push(`  Step ${m.stepNum}: ${m.stepText}`);
        lines.push(`    Expected (verbatim): ${m.expected.replace(/\n/g, '\n      ')}`);
        if (m.closestMatch) {
            lines.push(`    Closest in LLM body: ${m.closestMatch}`);
        }
        else {
            lines.push(`    Closest in LLM body: (no near-match found)`);
        }
    }
    lines.push('');
    lines.push('The convention matcher is deterministic; pre-baked snippets should be preserved verbatim. ' +
        'A minority of drops is tolerated (see SPECVERSE_REALIZE_STRUCTURAL_DROP_THRESHOLD); this owner ' +
        'exceeded that. If you believe the matcher is wrong for this case, fix the convention pattern ' +
        'in step-conventions.ts.');
    return lines.join('\n');
}
//# sourceMappingURL=structural-validator.js.map