// ─── S4 corpus selectionRule predicate (ADR-110 §6) ─────────────────────────
//
// Pure, deterministic re-derivation of the wind-tunnel corpus from offline git
// metadata — NO GitHub-API fields (the §4 offline-derivation invariant). Given
// the same `asOfCommit` + the same frozen config, `resolveSelectionRule` yields
// a byte-identical PR-number set on every run; that determinism is what turns
// the §6 check `resolvedPrs ≡ selectionRule(asOfCommit)` from a tautology into a
// real gate. IO (git enumeration → PrMeta) lives in the cli layer; this module
// is the pure mathematical predicate over PrMeta.
/** Thrown when a merge subject carries a TRAILING `(#…)` that is not a valid PR ref. */
export class SelectionRuleParseError extends Error {
    subject;
    constructor(subject) {
        super(`Malformed PR ref in merge subject (trailing parenthesized ref is not a positive integer): ${subject}`);
        this.subject = subject;
        this.name = 'SelectionRuleParseError';
    }
}
// ─── Glob matching (self-contained; frozen narrow classifier) ────────────────
function escapeRegexChar(ch) {
    return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
}
/**
 * Translate a path glob to an anchored RegExp. Supports:
 *   `**​/`     → zero or more path segments
 *   `**`      → any characters including `/`
 *   `*`       → any characters except `/`
 *   `?`       → a single character except `/`
 *   `{a,b,c}` → brace alternation (e.g. `**​/*.{ts,tsx}`)
 * Sufficient for the frozen code-path classifier; avoids a glob dependency and
 * keeps matching deterministic/offline.
 */
function globToRegExp(glob) {
    const g = glob.replace(/\\/g, '/');
    let re = '';
    let i = 0;
    while (i < g.length) {
        const c = g[i];
        if (c === '*') {
            if (g[i + 1] === '*') {
                if (g[i + 2] === '/') {
                    re += '(?:[^/]+/)*'; // '**/' → zero or more segments (matches the empty case)
                    i += 3;
                }
                else {
                    re += '.*'; // '**' (e.g. trailing) → any chars incl. '/'
                    i += 2;
                }
            }
            else {
                re += '[^/]*'; // '*' → any chars within a segment
                i += 1;
            }
        }
        else if (c === '?') {
            re += '[^/]';
            i += 1;
        }
        else if (c === '{') {
            const end = g.indexOf('}', i);
            if (end > i) {
                const alts = g
                    .slice(i + 1, end)
                    .split(',')
                    .map((alt) => alt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // alternatives are literals
                re += `(?:${alts.join('|')})`; // '{ts,tsx}' → '(?:ts|tsx)'
                i = end + 1;
            }
            else {
                re += escapeRegexChar(c); // unmatched '{' → literal
                i += 1;
            }
        }
        else {
            re += escapeRegexChar(c);
            i += 1;
        }
    }
    return new RegExp(`^${re}$`);
}
const globCache = new Map();
function matchGlob(filePath, glob) {
    let re = globCache.get(glob);
    if (!re) {
        re = globToRegExp(glob);
        globCache.set(glob, re);
    }
    return re.test(filePath);
}
/**
 * A PR is code-touching iff at least one changed file matches ≥1 includeGlob and
 * no excludeGlob. Exclude wins at the FILE level (not the PR level), so a PR
 * with a mix of code + doc files is still code-touching, while a docs/config/
 * generated-only PR is excluded because no file survives the classifier.
 */
export function isCodeTouching(changedFiles, classifier) {
    return changedFiles.some((raw) => {
        const file = raw.replace(/\\/g, '/');
        const included = classifier.includeGlobs.some((g) => matchGlob(file, g));
        if (!included)
            return false;
        const excluded = classifier.excludeGlobs.some((g) => matchGlob(file, g));
        return !excluded;
    });
}
// ─── Git-convention detectors (offline, §4) ──────────────────────────────────
/**
 * Bot iff the author identity is a GitHub bot. `author` is git's `%an <%ae>` —
 * e.g. `dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>` — so
 * checking the whole string's suffix would fail (it ends with `>`). Strip the
 * trailing ` <email>` and test the display NAME's `[bot]` suffix, and also match
 * a `[bot]@` noreply email; robust to the name-only or name+email forms.
 */
export function isBotIdentity(author) {
    const a = author.replace(/\r/g, '').trim().toLowerCase();
    const name = a.replace(/\s*<[^>]*>\s*$/, '');
    return name.endsWith('[bot]') || /\[bot\]@/.test(a);
}
/**
 * The ALLOWLIST of recognized review-FINDING bots (strategy#709 yield-fix β). Their
 * inline review comments are legitimate mining SUBSTRATE for a bot-reviewed cert
 * corpus — UNLIKE generic `[bot]` automation accounts (renovate / dependabot),
 * whose PR-level noise must never count as a substantive review comment. Logins are
 * the GitHub `author.login` form (NO `[bot]` suffix for GCA/CR app comments), so a
 * `[bot]`-suffixed variant of the same tool is matched too. Lowercased identities.
 */
const REVIEW_FINDING_BOT_LOGINS = new Set(['gemini-code-assist', 'coderabbitai']);
/**
 * Recognize an ALLOWLISTED review-finding bot (gemini-code-assist / coderabbitai),
 * with or without a `[bot]` suffix and with or without a trailing ` <email>`.
 *
 * DELIBERATELY SEPARATE from `isBotIdentity` (panel OQ-β1): `isBotIdentity` gates
 * CORPUS MEMBERSHIP (it excludes `[bot]`-AUTHORED PRs) and stays unchanged —
 * coupling review-comment substrate to commit-author exclusion would be the wrong
 * dependency. This predicate is a NEW review-comment classifier used ONLY for
 * `authorKind` tagging, the substantive-comment count, and chrome normalization.
 *
 * ALLOWLIST not denylist (panel OQ-β2): it fails CONSERVATIVE — a not-yet-listed
 * review tool undercounts (drops to "no substrate") rather than laundering every
 * future automation account in as a substantive reviewer. `isBotIdentity('gemini-
 * code-assist')` stays `false` (no `[bot]` suffix); `reviewBotIdentity` returns
 * `true` for it; `reviewBotIdentity('dependabot[bot]')` returns `false`.
 */
export function reviewBotIdentity(author) {
    const a = author.replace(/\r/g, '').trim().toLowerCase();
    // `.trim()` after the `[bot]` strip mirrors `isBotIdentity`: a `'coderabbitai
    // [bot]'` form (space before the suffix) would otherwise leave a trailing space
    // and miss the allowlist (greptile #2242).
    const name = a
        .replace(/\s*<[^>]*>\s*$/, '')
        .replace(/\[bot\]$/, '')
        .trim();
    return REVIEW_FINDING_BOT_LOGINS.has(name);
}
const REVERT_BODY_REGEX = /^This reverts commit ([0-9a-f]{7,40})\b/im;
/** Extract the reverted target SHA from a `This reverts commit <sha>` body line. */
export function parseRevertSha(commitBody) {
    const m = REVERT_BODY_REGEX.exec(commitBody.replace(/\r/g, ''));
    return m ? m[1].toLowerCase() : undefined;
}
// ─── PR-number extraction (lc squash convention) ─────────────────────────────
const TRAILING_PR_REGEX = /\(#(\d+)\)\s*$/;
const TRAILING_PAREN_REF_REGEX = /\(#[^)]*\)\s*$/;
/**
 * Extract the PR number from a squash-merge subject's TRAILING `(#N)`.
 * - No trailing `(#…)` at all → `null` (a direct-to-main non-PR commit; skip).
 * - Trailing `(#N)` with a positive integer → that number. Anchored to the END,
 *   so `…spec (#533) (#534)` → 534 and `…(closes #522) (#524)` → 524 (earlier
 *   refs are issue refs, not the PR).
 * - Trailing parenthesized ref that is NOT a positive integer (`(#abc)`, `(#)`,
 *   `(#0)`, `(#-1)`) → throws `SelectionRuleParseError` (malformed, never silent).
 */
export function parsePrNumber(subject) {
    const s = subject.replace(/\r/g, '');
    const m = TRAILING_PR_REGEX.exec(s);
    if (m) {
        const n = Number(m[1]);
        if (Number.isInteger(n) && n > 0)
            return n;
        throw new SelectionRuleParseError(subject); // e.g. "(#0)"
    }
    if (TRAILING_PAREN_REF_REGEX.test(s)) {
        throw new SelectionRuleParseError(subject); // e.g. "(#abc)", "(#)", "(#-1)"
    }
    return null;
}
// ─── The predicate + two-pass resolver ───────────────────────────────────────
/**
 * Per-PR predicate (pure, single-PrMeta): code-touching AND (not bot, when
 * excluded) AND (not itself a revert, when excluded). The reverted-TARGET
 * exclusion is NOT here — it needs cross-candidate context and lives in the
 * second pass of `resolveSelectionRule`.
 */
export function selectionRulePredicate(meta, config) {
    if (!isCodeTouching(meta.changedFiles, config.codePathClassifier))
        return false;
    if (config.excludeBotPrs && meta.isBotAuthor)
        return false;
    if (config.excludeRevertPairs && meta.revertsSha !== undefined)
        return false;
    return true;
}
/**
 * Resolve the corpus PR set from enumerated PrMetas (already filtered to
 * ancestors of `asOfCommit` by the caller). Two-pass when excluding revert
 * pairs: (1) collect reverted-target SHAs; (2) apply the per-PR predicate, then
 * drop any candidate whose `mergeCommit` is a reverted target. Fail-safe: a
 * target SHA that maps to no in-window candidate (direct-to-main / out-of-
 * ancestry) drops nothing — only the revert PR itself is excluded (by the
 * predicate). Returns sorted unique PR numbers.
 */
export function resolveSelectionRule(metas, config) {
    const revertedTargetShas = [];
    if (config.excludeRevertPairs) {
        for (const m of metas) {
            if (m.revertsSha)
                revertedTargetShas.push(m.revertsSha);
        }
    }
    const isRevertedTarget = (mergeCommit) => revertedTargetShas.some((t) => mergeCommit.toLowerCase().startsWith(t));
    // Qualifying PRs in INPUT order — the caller passes them in `git log --topo-order`
    // order (ancestry newest-first, NOT commit-date; ADR-110 §6 ancestry-not-timestamp),
    // so a bounded window's "most recent N" is the first N here.
    const qualifying = [];
    const seen = new Set();
    for (const m of metas) {
        if (!selectionRulePredicate(m, config))
            continue;
        if (isRevertedTarget(m.mergeCommit))
            continue;
        if (seen.has(m.pr))
            continue;
        seen.add(m.pr);
        qualifying.push(m.pr);
    }
    const windowed = config.window.type === 'bounded' ? qualifying.slice(0, config.window.n) : qualifying;
    return [...windowed].sort((a, b) => a - b);
}
// ─── Deep set-equality (§6) ───────────────────────────────────────────────────
/** Symmetric difference; both sides normalized to unique sets, order-invariant. */
export function diffPrSets(expected, actual) {
    const e = new Set(expected);
    const a = new Set(actual);
    const missing = [...e].filter((x) => !a.has(x)).sort((x, y) => x - y);
    const extra = [...a].filter((x) => !e.has(x)).sort((x, y) => x - y);
    return { missing, extra };
}
/** Order- and duplicate-invariant set-membership equality (both sides deduped). */
export function prSetsEqual(expected, actual) {
    const d = diffPrSets(expected, actual);
    return d.missing.length === 0 && d.extra.length === 0;
}
//# sourceMappingURL=selection-rule.js.map