#!/usr/bin/env python3
"""security-gate orchestrator — thin wiring over two VALIDATED legs.

Legs (proven, n>=3, git-pinned — see RESULT_coverage_status.md):
  - oracle  : bun oracle2.ts <file>           (deterministic; C02/C09 complete-mediation only)
  - llm     : claude -p --model sonnet --effort medium  < v2_prompt(file)   (k>=3 rolls, UNIONED)

Orchestrator's own (thin) logic ONLY: cross-file SCOPE (inline first-party dependency
bodies into ONE review so an imported insecure default is visible — the proven band-2
channel), k-roll union, merge/report. Everything else calls a leg.

Oracle reaches cross-file by being run PER in-scope file (target + each inlined dep), NOT by
reading the bundle: the oracle's sink enumeration is IN-FILE (extractSinks walks one file's AST;
it resolves imported PREDICATE bodies for guards but NOT imported sink constructions), so a
money-move sink built inside an imported helper is invisible when scanning only the target.
C02/C09 have NO LLM backstop -> that would be a silent FALSE CLEAN. The LLM keeps the bundle.

Usage:
  gate.py <target-file> [--alias '@/=<root>'] [--k 3] [--depth 1]
          [--config-dir DIR] [--template PATH] [--oracle PATH] [--report PATH]
"""
import argparse, json, os, re, subprocess, sys, tempfile, concurrent.futures as cf
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from semantic_merge import merge_groups  # #17a: collapse paraphrased restatements of one finding
from resolver import find_repo_root, build_workspace_aliases, collect_deps  # cross-file scope (extracted to resolver.py)
from llm_runner import run_llm

# finding header in v2 output: "**1. Title**" / "1. **Title**" / "### 1. Title"
FINDING = re.compile(r"^\s*(?:#+\s*)?(?:\*\*)?(\d+)[.)]\s*(?:\*\*)?\s*(.+?)\s*\**\s*$", re.M)
SEV = re.compile(r"\b(critical|high|medium|low)\b", re.I)
IMPERATIVE = re.compile(
    r"^(add|use|make|implement|throw|set|move|replace|ensure|consider|wrap|validate|"
    r"introduce|change|apply|prefer|document|guard|switch)\b",
    re.I,
)


def oracle_status(oflags):
    """Classify an oracle output. Returns (silent, unreliable, label).
    unreliable = a guard may hide in an import the oracle could not load -> a SILENT result here is a
    possible FALSE CLEAN and must NOT be read as a clean pass (same discipline as the LLM xfile flag)."""
    silent = "none" in oflags.lower() and "silent" in oflags.lower()
    unreliable = "unresolved imports" in oflags.lower()
    if silent and unreliable:
        label = ("SILENT but UNRELIABLE — unresolved predicate imports; do NOT read as clean "
                 "(run from the full repo / fix aliases). See block.")
    elif silent:
        label = "SILENT"
    else:
        label = "FLAGS — see below"
    return silent, unreliable, label


def run_oracle(oracle, runtime, path):
    try:
        r = subprocess.run([runtime, oracle, path], capture_output=True, text=True, timeout=120)
    except Exception as e:  # noqa: BLE001
        return f"(oracle error: {e})"
    out = r.stdout
    m = re.search(r"=== FLAGS.*", out, re.S)
    return (m.group(0).strip() if m else out.strip()) or "(oracle silent)"


def run_oracle_set(oracle, runtime, files):
    """Run the deterministic oracle on EACH in-scope file (target + each inlined dep) -> [(file, out)].

    The oracle's sink enumeration is IN-FILE: extractSinks walks one file's AST. findDefBody resolves
    imported PREDICATE bodies (guards), but a money-move object-literal sink CONSTRUCTED inside an
    imported helper is invisible when scanning only the target. Since C02/C09 are oracle-ONLY (no LLM
    backstop) and the deps now live in the LLM bundle (which the oracle never reads), scanning only the
    target would be a silent FALSE CLEAN. Running per file restores the original gate's per-in-scope-file
    reach. Asymmetry is deliberate: oracle = deterministic/precise/cheap -> run on every file; LLM =
    expensive/noisy -> one bundle. Single-file targets (no deps) collapse to one run = no regression."""
    return [(f, run_oracle(oracle, runtime, f)) for f in files]


def one_roll(path, template, config_dir, idx, cwd=None, model="sonnet", effort="medium"):
    prompt = template.replace("{{MODULE_PATH}}", path)
    try:
        r = run_llm(prompt, model=model, effort=effort, config_dir=config_dir,
                    cwd=cwd or os.path.dirname(path))
        return r.stdout
    except Exception as e:  # noqa: BLE001
        return f"(roll {idx} error: {e})"


# anchor extraction (finding-citation spine). Real v2 output (MEASURED, /tmp/sg_fmt_roll_B.txt) renders the
# OUTPUT-CONTRACT sub-fields as BULLETS, the Code field as a FENCED block (often multi-line):
#   - **Location:** `vuln.ts:91-92`
#   - **Code:**
#     ```ts
#     const passwordHash = await hashPassword(password)
#     ...
#     ```
# snippet = first non-empty line INSIDE the Code fence, STRIPPED (a multi-line span can never be a substring
# of one file line -> _resolve_line would always fall through to the hint; a single distinctive line is a
# stable anchor and survives re-indentation). Fallback: an inline-backtick span on the Code bullet line.
# line_hint = first integer on the Location bullet line (Location renders `file:91-92`; the bare-colon form
# defeats an "after file:" regex, and the Trigger line carries "Pass 2" integers -> scope to the Location line).
_CODE_BULLET = re.compile(r"^\s*[-*]?\s*\*{0,2}\s*Code\s*\*{0,2}\s*:?\s*(.*)$", re.I)
_LOCATION_BULLET = re.compile(r"^\s*[-*]?\s*\*{0,2}\s*Location\s*\*{0,2}\s*:?\s*(.*)$", re.I)
_FENCE = re.compile(r"^\s*```")
_INLINE_BACKTICK = re.compile(r"`([^`]+)`")


def _extract_anchor(body):
    """-> (snippet, line_hint). snippet from the Code field (fenced block first line, else inline backtick);
    line_hint = first int on the Location line. Missing/unparseable -> ("", 0). Tolerant: never raises."""
    lines = body.splitlines()
    snippet, line_hint = "", 0
    for i, ln in enumerate(lines):
        cm = _CODE_BULLET.match(ln)
        if cm and not snippet:
            inline = cm.group(1).strip()
            mib = _INLINE_BACKTICK.search(inline)
            if mib:  # inline `code` directly on the Code bullet line
                snippet = mib.group(1).strip()
            else:  # look ahead for a fenced block; take its first non-empty content line
                j = i + 1
                while j < len(lines) and not _FENCE.match(lines[j]) and not lines[j].strip():
                    j += 1
                if j < len(lines) and _FENCE.match(lines[j]):
                    j += 1
                    while j < len(lines) and not _FENCE.match(lines[j]):
                        if lines[j].strip():
                            snippet = lines[j].strip()
                            break
                        j += 1
        lm = _LOCATION_BULLET.match(ln)
        if lm and not line_hint:
            mint = re.search(r"\d+", lm.group(1))
            if mint:
                line_hint = int(mint.group(0))
    return snippet, line_hint


def parse_findings(text):
    """-> list of (title, severity, body, snippet, line_hint). tolerant of v2 header variants.
    snippet/line_hint anchor the finding (finding-citation spine); missing -> ("", 0), finding KEPT."""
    hits = list(FINDING.finditer(text))
    out = []
    for i, m in enumerate(hits):
        title = m.group(2).strip()
        if len(title) < 6 or title[0].islower() and " " not in title:
            continue  # skip stray list items / sub-numbered lines
        body = text[m.end(): hits[i + 1].start() if i + 1 < len(hits) else len(text)]
        sev = (SEV.search(body[:400]) or SEV.search(title))
        sev_l = sev.group(1).lower() if sev else "unrated"
        # parser FP guard: a numbered item with no severity AND an imperative-verb opener is a
        # Fix/recommendation sub-item (e.g. "Add a guard…", "Use an allowlist…"), not a finding.
        if sev_l == "unrated" and IMPERATIVE.match(title):
            continue
        snippet, line_hint = _extract_anchor(body)
        out.append((title, sev_l, body, snippet, line_hint))
    return out


def norm(title):
    return " ".join(w for w in re.sub(r"[^a-z0-9 ]", " ", title.lower()).split())[:90]


def union_rolls(rolls):
    """union findings across k rolls; dedupe by normalized title; count rolls that found each."""
    groups = {}  # key -> {title, sev, rolls:set, snippet, line_hint}
    for ri, text in enumerate(rolls):
        for title, sev, _, snippet, line_hint in parse_findings(text):
            key = norm(title)
            sig_words = set(key.split())
            merged = None
            for k, g in groups.items():
                kw = set(k.split())
                if sig_words & kw and len(sig_words & kw) >= max(2, min(len(sig_words), len(kw)) // 2):
                    merged = g
                    break
            if merged is None:
                groups[key] = {"title": title, "sev": sev, "rolls": {ri},
                               "snippet": snippet, "line_hint": line_hint}
            else:
                merged["rolls"].add(ri)
                # first non-empty anchor wins (a later roll may quote the line an earlier one omitted)
                if not merged.get("snippet") and snippet:
                    merged["snippet"] = snippet
                if not merged.get("line_hint") and line_hint:
                    merged["line_hint"] = line_hint
                if SEV_RANK.get(sev, 9) < SEV_RANK.get(merged["sev"], 9):
                    merged["sev"], merged["title"] = sev, title
    return sorted(groups.values(), key=lambda g: (SEV_RANK.get(g["sev"], 9), -len(g["rolls"])))


SEV_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unrated": 4}

LEVEL = {"critical": "error", "high": "error", "medium": "warning", "low": "note", "unrated": "note"}


def _load_contract():
    """Path-load prevent/contract.py LAZILY (only when --emit) so the no-emit path stays dep-free."""
    import importlib.util
    p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "prevent", "contract.py")
    spec = importlib.util.spec_from_file_location("sg_contract", p)
    m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m


def _resolve_line(target, snippet, line_hint):
    """Real target line = where the verbatim snippet appears in the target FILE (1-based, first match).
    SNIPPET IS PRIMARY: in bundle mode the LLM's line counts from the concatenated bundle top (header +
    dep sections), so it is offset-wrong as the target's line; locating the snippet in the actual target
    is correct. The LLM's line_hint is FALLBACK only (snippet absent / not found / whitespace-drifted).
    -> (line:int, resolved:bool). Tolerant: a missing/unreadable file degrades to the hint, never raises."""
    if snippet:
        try:
            with open(target, encoding="utf-8", errors="replace") as fh:
                for i, ln in enumerate(fh, 1):
                    if snippet in ln:
                        return i, True
        except OSError:
            pass
    return (line_hint or 0), False


def build_report(target, deps, dropped, oracle_results, groups, k, model, effort, no_merge, merge_degraded):
    """The gate's markdown report (extracted verbatim from main() so --emit cannot alter it; golden-locked)."""
    lines = ["# Security-gate report", "", "## Target reviewed", f"- `{os.path.relpath(target)}`"]
    if deps:
        lines.append("")
        lines.append("## Dependency bodies inlined as cross-file context (#36)")
        for dep_f, sym, importer in deps:
            lines.append(f"- `{os.path.relpath(dep_f)}` — import `{sym}` from {importer}")
    if dropped:
        lines.append("")
        lines.append("## Dropped — NOT inlined, NOT clean (raise --max-scope to cover)")
        for dep_f, reason in dropped.items():
            lines.append(f"- `{os.path.relpath(dep_f)}` — {reason}")
    lines.append("")
    lines.append("**Oracle (complete-mediation) — run per in-scope file (target + inlined deps):**")
    for f, out in oracle_results:
        silent, unreliable, status = oracle_status(out)
        role = "target" if f == target else "dep"
        lines.append(f"- `{os.path.relpath(f)}` ({role}): {status}")
        if not silent or unreliable:
            lines.append("")
            lines.append("```\n" + out + "\n```")
            lines.append("")
    merge_note = " (raw lexical union, --no-merge)" if no_merge else " + semantic-merge"
    scope_note = f", {len(deps)} dep bodies inlined" if deps else ", single-file"
    lines.append(f"**LLM leg (v2@{model}/{effort.upper()}, {k}-roll union{merge_note}{scope_note}):**")
    if merge_degraded:
        lines.append("- ⚠️ semantic-merge DEGRADED — findings shown un-deduped; "
                     "paraphrases of one bug may appear as separate low-roll entries (recall undersold).")
    for g in groups:
        m = g.get("members", 1)
        mtag = f", {m} paraphrases merged" if m > 1 else ""
        lines.append(f"- [{g['sev']}] {g['title']}  _(found by {len(g['rolls'])}/{k} rolls{mtag})_")
    lines.append("")
    return "\n".join(lines)


def build_emit_dict(target, detector_id, covers, k, groups, oracle_results, dropped, merge_degraded):
    """Serialize gate findings into the prevent/contract.py shape (a FILE, never contract.emit()'s stdout).
    Additive keys sev/rolls/of ride each finding (contract consumers ignore unknown keys). no-false-clean:
    dropped deps + merge-degraded + unreliable oracle ⇒ coverage.unresolved ⇒ status='degraded'."""
    contract = _load_contract()
    cls = ",".join(covers) if covers else "security"
    findings, unresolved = [], []
    for g in groups:
        snippet = g.get("snippet", "")
        line, resolved = _resolve_line(target, snippet, g.get("line_hint", 0))
        f = contract.finding(rule_id=detector_id, level=LEVEL.get(g["sev"], "note"), cls=cls,
                             message=g["title"], file=os.path.relpath(target), line=line, symbol="")
        # citation-spine: snippet rides a DEDICATED `code` field (NOT symbol — PREVENT expects a symbol NAME
        # there). anchor=resolved when the verbatim snippet was located in the target file, else unverified.
        f["code"] = snippet
        f["anchor"] = "resolved" if resolved else "unverified"
        f["sev"] = g["sev"]; f["rolls"] = len(g["rolls"]); f["of"] = k
        findings.append(f)
    for dep_f, reason in dropped.items():
        unresolved.append(f"{os.path.relpath(dep_f)}: {reason}")
    if merge_degraded:
        unresolved.append("semantic-merge degraded")
    scanned = []
    for f, out in oracle_results:
        rel = os.path.relpath(f)
        if rel not in scanned:
            scanned.append(rel)
        silent, unreliable, label = oracle_status(out)
        if unreliable:
            unresolved.append(f"{rel}: oracle unreliable (unresolved predicate imports)")
        if not silent:
            of = contract.finding(rule_id="oracle", level="error", cls="S9",
                                  message=f"oracle FLAGS in {rel}: {label}", file=rel, line=0, symbol="")
            of["sev"] = "critical"; of["rolls"] = 1; of["of"] = k
            findings.append(of)
    status = "degraded" if unresolved else "ok"
    return {"detector": detector_id, "status": status, "findings": findings,
            "coverage": {"scanned": scanned, "unresolved": unresolved}}


# #36 DELIVERY: a Shape-B bug (an imported insecure DEFAULT) is a band-1 single-file SILENT miss (0/3)
# but is CAUGHT once the dependency body sits in the SAME review as the caller (band-2 spike: 3/3 with
# caller+callee concatenated into one prompt). So the gate inlines first-party dependency BODIES into the
# target's review as context — it does NOT scan them as separate isolated files (an isolated dep scan
# cannot diff the caller's assumption against the callee's body).
BUNDLE_HEADER = (
    "// ============================================================================\n"
    "// REVIEW BUNDLE — security-gate cross-file scope (#36)\n"
    "// First section = the FILE UNDER REVIEW. Sections marked '===== DEPENDENCY ... ====='\n"
    "// are the ACTUAL BODIES of its first-party imports, inlined so a bug hiding in an\n"
    "// imported helper (e.g. an insecure default) is visible in the same review.\n"
    "// ============================================================================\n"
)
# Appended to the prompt ONLY when deps are inlined. Placed LAST so recency overrides the template's
# "single module / black box" framing (line ~6). Single-file reviews (no deps) keep the template
# byte-for-byte -> zero recall regression.
DEP_OVERRIDE = """

## CROSS-FILE SCOPE OVERRIDE — read LAST; this SUPERSEDES the "single module / treat imports as a black box" framing above
The file at {{MODULE_PATH}} is a REVIEW BUNDLE. The section marked "===== REVIEW TARGET: <path> ====="
is the file under review; each section marked "===== DEPENDENCY ... =====" is the ACTUAL SOURCE BODY of a
first-party import. Those dependencies are NO LONGER black boxes — their source is right here.
- Review the REVIEW TARGET for defects.
- CHECK THE TARGET'S CROSS-FILE ASSUMPTIONS AGAINST THE PROVIDED DEPENDENCY BODIES. A correct-LOOKING call
  in the target to an imported helper whose BODY contains the bug (an insecure default, a missing check, a
  fail-open branch, a trusted-but-unvalidated return) IS A FINDING in the target.
- Report findings in the REVIEW TARGET (or in how it uses a dependency). Do not separately audit a
  dependency's internals unrelated to how the target uses it.
"""


def _read_text(p):
    try:
        return open(p, encoding="utf-8", errors="replace").read()
    except OSError as e:  # noqa: BLE001
        return f"// (security-gate: could not read {p}: {e})"


def _neutral_label(target):
    """generic REVIEW TARGET name (extension preserved) — strips the defect-revealing corpus dir/basename."""
    return "review_target" + (os.path.splitext(target)[1] or ".ts")


def write_neutral_file(target):
    """copy the target's CONTENT to a generically-named temp file so a defect-revealing corpus dir
    (e.g. S4-kb-spaceids-sqli/) cannot prime the LLM via the injected REVIEW TARGET path (path-leak).
    Measurement-only (--neutral-path); the OS reclaims /tmp on SIGKILL; caller deletes in a finally."""
    fd, path = tempfile.mkstemp(prefix="review_target_", suffix=os.path.splitext(target)[1] or ".ts")
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        fh.write(_read_text(target))
    return path


def build_bundle(target, deps, neutral=False):
    """concatenate the review target + its dependency bodies into ONE review unit (the proven band-2
    channel — the spike caught the Shape-B omission 3/3 only with caller+callee in one prompt).
    neutral=True blinds the TARGET header (path-leak) for corpus measurement; deps keep real import names."""
    parts = [BUNDLE_HEADER,
             f"// ===== REVIEW TARGET: {_neutral_label(target) if neutral else os.path.relpath(target)} =====",
             _read_text(target)]
    for dep_f, sym, importer in deps:
        parts.append(f"\n// ===== DEPENDENCY (first-party import `{sym}` from {importer}; "
                     f"body provided — NOT a black box): {os.path.relpath(dep_f)} =====")
        parts.append(_read_text(dep_f))
    return "\n".join(parts)


def write_bundle_file(content):
    """write the bundle to the SYSTEM tempdir, NOT the source tree. A kill mid-run (rate-limit/cron)
    must never leak a compilable `.sgbundle.ts` into a real repo (a running tsc --watch / dev server
    would trip on it). The LLM reads it by absolute path; its cwd is set to the repo dir separately
    (decoupled from bundle location) so repo-relative exploration still behaves like the validated spike.
    Caller deletes it in a finally; the OS reclaims /tmp even on SIGKILL."""
    fd, path = tempfile.mkstemp(suffix=".sgbundle.ts")
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        fh.write(content)
    return path


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("target")
    ap.add_argument("--alias", action="append", default=[], help="'@/=<root>' (repeatable)")
    ap.add_argument("--k", type=int, default=3)
    ap.add_argument("--depth", type=int, default=1,
                    help="hops of first-party dependency BODIES to inline (1=direct imports, the VALIDATED "
                         "band-2 shape; >1 inlines deps-of-deps, budget-bounded but NOT rate-validated)")
    ap.add_argument("--max-scope", type=int, default=60,
                    help="cap on dependency bodies INLINED into the review; excess is DROPPED and reported, NEVER read as clean")
    ap.add_argument("--dry-run", action="store_true",
                    help="deterministic preview: print the review bundle (target + dep bodies) the gate WOULD build (no oracle/LLM)")
    ap.add_argument("--config-dir", default=None,
                    help="override CLAUDE_CONFIG_DIR (blind catch-test only; omit for real runs)")
    ap.add_argument("--no-merge", action="store_true",
                    help="skip the #17a semantic-merge pass (A/B against raw lexical union)")
    ap.add_argument("--no-workspace-alias", action="store_true",
                    help="disable auto pnpm/npm workspace alias discovery (default ON)")
    ap.add_argument("--repo-root", default=None,
                    help="workspace root for alias discovery (default: walk up from target to pnpm-workspace.yaml/.git)")
    _here = os.path.dirname(os.path.abspath(__file__))
    _root = os.path.dirname(_here)
    ap.add_argument("--template", default=os.path.join(_root, "domains", "security", "detectors", "baseline", "baseline.prompt.txt"),
                    help="LLM prompt template path (default: the always-on baseline detector). Overridden by --detector.")
    ap.add_argument("--detector", default=None,
                    help="select an LLM detector by id under domains/security/detectors/<id>/ (loads its manifest prompt; overrides --template)")
    ap.add_argument("--oracle", default=os.path.join(_root, "domains", "security", "detectors", "oracle", "oracle2.ts"))
    ap.add_argument("--runtime", default="bun")
    ap.add_argument("--model", default="sonnet",
                    help="LLM-leg model alias for `claude -p --model` (default sonnet; e.g. opus for tier-escalation tests)")
    ap.add_argument("--effort", default="medium",
                    help="LLM-leg reasoning effort for `claude -p --effort` (default medium; e.g. high/xhigh for escalation tests)")
    ap.add_argument("--report", default=None)
    ap.add_argument("--emit", default=None,
                    help="write the prevent/contract.py finding JSON here (additive; report path unchanged)")
    ap.add_argument("--neutral-path", action="store_true",
                    help="MEASUREMENT-ONLY: copy the target to a generically-named temp file so a defect-revealing "
                         "corpus dir (e.g. S4-kb-spaceids-sqli/) cannot prime the LLM via the injected REVIEW TARGET "
                         "path (path-leak). Default OFF — production keeps real repo paths as legit review context.")
    a = ap.parse_args()

    detector_id, covers = "baseline", []
    if a.detector:  # modular detector selection: resolve <id>/detector.json -> its prompt (overrides --template)
        man_path = os.path.join(_root, "domains", "security", "detectors", a.detector, "detector.json")
        man = json.load(open(man_path, encoding="utf-8"))
        if man.get("kind") != "llm":
            ap.error(f"detector '{a.detector}' is kind={man.get('kind')!r}, not an llm prompt detector")
        a.template = os.path.join(os.path.dirname(man_path), man["prompt"])
        detector_id, covers = a.detector, man.get("covers", [])

    aliases = []
    for al in a.alias:                       # hand-passed FIRST so they OVERRIDE auto-discovered aliases
        pre, _, root = al.partition("=")
        aliases.append((pre, os.path.abspath(root)))
    target = os.path.abspath(a.target)
    if not a.no_workspace_alias:             # auto workspace aliases appended AFTER (resolve() returns first match)
        repo_root = os.path.abspath(a.repo_root) if a.repo_root else find_repo_root(target)
        aliases += build_workspace_aliases(repo_root)
    template = open(a.template, encoding="utf-8").read()
    assert "{{MODULE_PATH}}" in template, "template missing {{MODULE_PATH}}"

    # #36 DELIVERY: the target is reviewed TOGETHER WITH its first-party dependency BODIES in ONE prompt
    # (the proven band-2 channel). Deps are CONTEXT for the target's review, not separate isolated scans.
    deps, dropped = collect_deps(target, aliases, a.depth, a.max_scope)

    if a.dry_run:  # deterministic preview of the review bundle — no oracle/LLM
        print("# DRY RUN — review bundle the gate WOULD build (depth %d, max-scope %d)" % (a.depth, a.max_scope))
        print(f"[target] {os.path.relpath(target)}")
        for dep_f, sym, importer in deps:
            print(f"[dep]     {os.path.relpath(dep_f)} — import `{sym}` from {importer}")
        for dep_f, reason in dropped.items():
            print(f"[dropped] {os.path.relpath(dep_f)} — {reason}")
        return

    # oracle PER in-scope file (target + each inlined dep) — each self-parsed as REAL TS (never the
    # bundle, which would confuse it). Restores cross-file reach for the in-file-sink limitation that
    # the LLM bundle alone does not cover for the oracle-only C02/C09 classes. See run_oracle_set.
    oracle_results = run_oracle_set(a.oracle, a.runtime, [target] + [d[0] for d in deps])

    # LLM leg: review target (+ inlined dep bodies when present) in ONE prompt, k rolls unioned.
    # No deps -> byte-identical single-file behavior (original template, original file path) = no regression.
    tmp_bundle = tmp_neutral = None
    try:
        if deps:
            tmp_bundle = write_bundle_file(build_bundle(target, deps, neutral=a.neutral_path))
            module_path, this_template, roll_cwd = tmp_bundle, template + DEP_OVERRIDE, os.path.dirname(target)
        elif a.neutral_path:                     # single-file measurement: review a neutrally-named copy, cwd=/tmp
            tmp_neutral = write_neutral_file(target)
            module_path, this_template, roll_cwd = tmp_neutral, template, os.path.dirname(tmp_neutral)
        else:
            module_path, this_template, roll_cwd = target, template, None
        with cf.ThreadPoolExecutor(max_workers=a.k) as ex:
            rolls = list(ex.map(lambda i: one_roll(module_path, this_template, a.config_dir, i, roll_cwd, a.model, a.effort), range(a.k)))
    finally:
        for _t in (tmp_bundle, tmp_neutral):
            if _t and os.path.isfile(_t):
                os.unlink(_t)

    groups = union_rolls(rolls)
    merge_degraded = False
    if not a.no_merge and len(groups) >= 2:  # #17a: semantic merge restores the roll-count signal
        groups, merge_degraded = merge_groups(groups, config_dir=a.config_dir)

    report = build_report(target, deps, dropped, oracle_results, groups, a.k, a.model, a.effort,
                          a.no_merge, merge_degraded)
    print(report)
    if a.report:
        open(a.report, "w", encoding="utf-8").write(report)
        print(f"\n[written] {a.report}", file=sys.stderr)
    if a.emit:
        with open(a.emit, "w", encoding="utf-8") as fh:
            json.dump(build_emit_dict(target, detector_id, covers, a.k, groups, oracle_results,
                                      dropped, merge_degraded), fh)
        print(f"\n[emit] {a.emit}", file=sys.stderr)


if __name__ == "__main__":
    main()
