#!/usr/bin/env python3
"""changwei-code-check 主入口。

统一契约:只吃 (repo, base, head),脚本内不含"本地还是远程"的判断。
本地模式渲染成终端报告;远程模式取 --json 喂给评论生成。

    python3 check.py --repo <路径> --base <ref> --head <ref> [--groups 1,3,6] [--json]
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from checkers import base as B  # noqa: E402
from checkers import (  # noqa: E402
    g01_int_width, g02_memory_safety, g03_validation, g04_a2a5_isolation,
    g05_support_scope, g06_proto_consistency, g08_declared_unused, g09_deliverables,
    g10_precommit, g11_cleancode, g12_doc_style, g13_commit_hygiene, g14_dfx_special,
    g15_handover, g16_triangle, g17_test_quality, g18_a2_diff, g19_semantic, g20_handover_content,
    g21_test_prediction, g22_perf_prediction, g23_freshness,
)
from checkers.closure import closure_note, implementation_closure  # noqa: E402
from checkers.refs import resolve_canndev  # noqa: E402

CHECKERS = {
    1: g01_int_width,
    2: g02_memory_safety,
    3: g03_validation,
    4: g04_a2a5_isolation,
    10: g10_precommit,
    11: g11_cleancode,
    13: g13_commit_hygiene,
    14: g14_dfx_special,
    15: g15_handover,
    16: g16_triangle,
    17: g17_test_quality,
    18: g18_a2_diff,
    19: g19_semantic,
    20: g20_handover_content,
    21: g21_test_prediction,
    22: g22_perf_prediction,
    23: g23_freshness,
    5: g05_support_scope,
    7: g05_support_scope,   # 同一模块产出组 5 与组 7 的 finding
    6: g06_proto_consistency,
    8: g08_declared_unused,
    9: g09_deliverables,
    12: g12_doc_style,
}

SEV_ICON = {"high": "🔴", "mid": "🟡", "low": "🟢"}
SEV_ORDER = {"high": 0, "mid": 1, "low": 2}


def _render_pack(asks: list[dict], repo: Path) -> str:
    """把待判题渲染成可直接派单的 md:一个人(或一个 agent)读了就能开答。

    每题自带取材(相关行、diff 片段、已收集的线索),不要求答题者从零开始找。
    这是脚本在这套分工里的正职:**定位 + 取证**,判断不归它。
    """
    L = [f"# 待判定题包 —— {repo.name}", "",
         f"共 {len(asks)} 题。脚本只负责定位与取材,**结论必须由读得懂上下文的人/agent 给出**。",
         "", "每题请回答:", "",
         "- `verdict`:问题 / 没问题 / 存疑",
         "- `reason`:判断依据,引用你实际读到的代码或文件",
         "- `impact`:若是问题,给**具体触发条件与后果**(什么输入下会怎样)",
         "- `fix`:若是问题,给最小修法", "",
         "最后以 JSON 数组输出:`[{\"id\":1,\"verdict\":\"...\",\"reason\":\"...\",\"impact\":\"...\",\"fix\":\"...\"}]`",
         "", f"仓库根目录:`{repo}`", "", "---", ""]
    for i, q in enumerate(asks, 1):
        loc = f"{q['file']}:{q['line']}" if q["line"] else q["file"]
        L += [f"## {i}. {q['topic']}", "",
              f"位置:`{loc}`", "",
              "```", q["anchor"], "```", "",
              q["question"], "",
              f"> 脚本判不了的原因:{q['why_not_script']}", "", "---", ""]
    return "\n".join(L)


def load_suppressions(path: Path) -> list[dict]:
    """读误报白名单。文件不在就是空表——白名单是可选的,不是必需的。"""
    if not path.is_file():
        return []
    try:
        return [x for x in json.loads(path.read_text(encoding="utf-8")).get("items", [])
                if x.get("rule")]
    except (OSError, ValueError):
        return []


def apply_suppressions(findings: list, rules: list[dict]) -> tuple[list, list]:
    """按白名单过滤。返回 (保留, 被压掉)。

    **被压掉的不丢**——报告末尾单列条数与规则,让"压掉了"和"没查出来"能分开(纪律一)。
    """
    kept, dropped = [], []
    for f in findings:
        hit = next((r for r in rules
                    if r["rule"] == f.rule
                    and (("op" not in r) or r["op"] in f.file)
                    and (("file" not in r) or r["file"] in f.file)), None)
        (dropped if hit else kept).append(f)
    return kept, dropped


def backfill_answers(asks: list[dict], path: Path) -> tuple[list, list[dict]]:
    """把 agent 答完的判定回填。返回 (转成 finding 的, 仍未答的)。

    **绑定用 topic + file,不用位置序号。** 第一版按 `id` 当下标绑,换个仓重跑一次
    题目集就变了(5 题 → 30 题),性能那道题的答案被挂到"C 风格 cast"上——
    静默错配比不回填更危险:它看起来是有结论的。
    序号只作兜底,且**只有在题目数与答案数完全一致时**才敢用。

    答案格式(题包里已写明,另可带 `topic` 提高绑定精度):
        [{"id":1,"topic":"...","verdict":"问题|没问题|存疑","reason":"...","impact":"...","fix":"..."}]
    """
    try:
        answers = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as e:
        print(f"❌ 读不了答案文件 {path}:{e}", file=sys.stderr)
        return [], asks

    by_topic = {str(a.get("topic", "")).strip(): a for a in answers if a.get("topic")}
    by_id = {int(a["id"]): a for a in answers if str(a.get("id", "")).isdigit()}
    positional_ok = len(answers) == len(asks) and not by_topic
    if by_id and not by_topic and not positional_ok:
        print(f"⚠️ 答案只给了序号,但题目数({len(asks)}) 与答案数({len(answers)}) 不一致——"
              "序号绑定不可靠,本次不回填。请在答案里带上 topic 字段。", file=sys.stderr)
        return [], asks

    found, left, used = [], [], set()
    for i, q in enumerate(asks, 1):
        a = by_topic.get(q["topic"].strip()) or (by_id.get(i) if positional_ok else None)
        if a is None:
            left.append(q)
            continue
        used.add(id(a))
        v = str(a.get("verdict", "")).strip()
        if v.startswith("没问题"):
            continue                      # 判过了,销账
        found.append(B.Finding(
            file=q["file"], line=q["line"], group=q["group"],
            rule=f"G{q['group']}-judged-" + ("issue" if v.startswith("问题") else "unsure"),
            message=f"[{q['topic']}] {a.get('reason','')} {a.get('impact','')}"
                    + (f" 修法:{a.get('fix')}" if a.get("fix") else ""),
            evidence=q["anchor"][:160]))
    orphan = [a for a in answers if id(a) not in used]
    if orphan:
        print(f"⚠️ {len(orphan)} 条答案没匹配到题目(topic 对不上,可能题目集变了):"
              + "、".join(str(a.get("topic") or a.get("id"))[:30] for a in orphan[:3]), file=sys.stderr)
    return found, left


def resolve_ops(repo: Path, spec: str) -> list[Path]:
    """把用户随口给的算子名解析成算子目录。

    三种写法都收:仓内相对路径 `norm/xxx`、目录名 `xxx`、驼峰名 `Xxx`。
    算子目录的判据与 base.op_dir_of 一致:含 op_host/ 或 op_kernel/。

    ⚠️ 找不到就**报错退出**,不静默跳过。名字打错却当成"这个算子没问题",
    是纪律一里最典型的那种假通过。
    """
    def norm(s: str) -> str:
        return s.lower().replace("_", "").replace("-", "")

    all_ops = {p.parent for pat in ("*/op_host", "*/op_kernel")
               for p in repo.glob(f"*/{pat}")} | \
              {p.parent for pat in ("*/op_host", "*/op_kernel")
               for p in repo.glob(f"*/*/{pat}")}
    out, missing = [], []
    for raw in (x.strip() for x in spec.split(",") if x.strip()):
        direct = (repo / raw).resolve()
        if direct.is_dir() and ((direct / "op_host").is_dir() or (direct / "op_kernel").is_dir()):
            out.append(direct)
            continue
        hit = [d for d in all_ops if norm(d.name) == norm(raw)]
        if len(hit) == 1:
            out.append(hit[0])
        elif not hit:
            missing.append(f"{raw}(仓里没找到同名算子目录)")
        else:
            missing.append(f"{raw}(有 {len(hit)} 个同名:{'、'.join(B.rel(repo, h) for h in hit)},请给全路径)")
    if missing:
        raise ValueError("这些算子解析不了:" + ";".join(missing))
    return sorted(set(out), key=str)


FOLD_THRESHOLD = 5   # 与 render_comments 同阈值:同文件同规则超过这么多条就折叠


def _fold(findings: list, limit: int) -> list[tuple]:
    """按 (文件, 规则) 折叠,只影响**展示**,不动 findings 本体与 --json。

    同一条规则在一个文件里刷十几条,读的人看到的是噪音、真信号被淹(实测一个
    fusion_pass 里 30 条 🔴 全来自两条规则)。他检通路早就折叠了,本地报告没折——
    而本地报告恰恰是"要我去改"的那条路。返回 [(代表 finding, 其余行号)]。
    """
    from collections import OrderedDict
    buckets: OrderedDict = OrderedDict()
    for f in findings:
        buckets.setdefault((f.file, f.rule), []).append(f)
    out = []
    for grp in buckets.values():
        grp.sort(key=lambda x: x.line)
        if len(grp) > limit:
            out.append((grp[0], [g.line for g in grp[1:]]))
        else:
            # 没超阈值的**逐条照出**,不能只留代表——那是静默丢弃(纪律一)
            out += [(g, []) for g in grp]
    return sorted(out, key=lambda t: (SEV_ORDER[t[0].severity], t[0].group, t[0].file, t[0].line))


def _brief_low(findings: list) -> tuple[list, list[str]]:
    """把 🟢 收起来:只留每条规则一行汇总,不逐条列。

    大算子上 🟢 会淹掉真信号——mat_mul_v3 实测 358 条里 290 条是 🟢(格式 + CleanCode),
    🔴13 / 🟡55 被埋在后面。这不是"报得多"的问题,是排版问题:
    🟢 大多是 pre-commit 一把能清的,不需要逐条读。
    **收起来不等于不报**——条数、规则、涉及文件数都在,`--brief` 关掉就全展开。
    """
    from collections import Counter, defaultdict
    keep = [f for f in findings if f.severity != "low"]
    low = [f for f in findings if f.severity == "low"]
    if not low:
        return keep, []
    cnt = Counter(f.rule for f in low)
    files = defaultdict(set)
    for f in low:
        files[f.rule].add(f.file)
    lines = [f"  {n:>4} 条  {r:<34} 涉及 {len(files[r])} 个文件"
             for r, n in cnt.most_common()]
    return keep, lines


def _render_markdown(repo: Path, want: set[int], files: list[str], op_dirs: list,
                     findings: list, skipped: list[str], stamp: str,
                     scope_label: str = "改动文件", fold: int = FOLD_THRESHOLD,
                     brief: bool = True) -> str:
    """问题报告(Markdown)。**四节缺一不可**,这是纪律一的载体:

    问题 / ⏳待判定(未下结论)/ 执行记录(真跑过的)/ 基线与范围(含没跑的组)。
    只留第一节的话,一份 0 问题的报告读起来像"全查过且没事",而真相可能是半数组没跑。
    """
    L = [f"# 代码检查报告 —— {repo.name}", "",
         f"- 生成时间:{stamp or '(未给 --stamp)'}",
         f"- 扫描范围:{len(files)} 个{scope_label},{len(op_dirs)} 个算子目录"
         f"({', '.join(B.rel(repo, d) for d in op_dirs) or '—'})",
         f"- 检查组:{'、'.join(str(g) for g in sorted(want))}", ""]

    n = {s: sum(1 for f in findings if f.severity == s) for s in ("high", "mid", "low")}
    L += [f"## 结论:{len(findings)} 条问题"
          f"(🔴 {n['high']} / 🟡 {n['mid']} / 🟢 {n['low']})", ""]
    if not findings:
        L += ["本次未发现问题。**注意**:这不等于全部检查都跑过了——"
              "下面「基线与范围」里标了「未执行 / 跳过」的组没有结论。", ""]

    shown, low_summary = (_brief_low(findings) if brief else (findings, []))

    cur = None
    for f, folded in _fold(shown, fold):
        if f.group != cur:
            cur = f.group
            L += ["", f"### {SEV_ICON[f.severity]} 组 {f.group} · {B.GROUP_TITLES[f.group]}", ""]
        flag = ""
        if f.needs_human:
            flag += " `需人工复核`"
        if f.out_of_diff:
            flag += " `diff 外`"
        loc = f"{f.file}:{f.line}" if f.line else f.file
        L.append(f"- **`{loc}`**{flag}")
        L.append(f"  - `{f.rule}` {f.message}")
        if folded:
            shown = "、".join(str(n) for n in folded[:15])
            more = "……" if len(folded) > 15 else ""
            L.append(f"  - 同文件同类还有 **{len(folded)} 处**:行 {shown}{more}")
        if f.evidence:
            L.append(f"  - 证据:`{f.evidence}`")

    if low_summary:
        L += ["", f"### 🟢 规范与卫生 {sum(1 for f in findings if f.severity == 'low')} 条(已收起)",
              "", "多数跑一遍 pre-commit 能统一清掉。", "",
              "| 条数 | 规则 | 涉及文件 |", "|---|---|---|"]
        for ln in low_summary:
            n, rule, rest = ln.split(None, 2)
            L.append(f"| {n} | `{rule.replace('条', '').strip()}` | {rest.split('涉及')[-1].strip()} |")

    if B.JUDGMENT_ASKS:
        L += ["", f"## ⏳ 待判定 {len(B.JUDGMENT_ASKS)} 条 —— 脚本**未下结论**", "",
              "这些点脚本只能定位、判不了(可达性要跨函数追数据流,"
              "「是迁移还是 hack」要读懂意图)。**没人答 = 没有结论,"
              "既不是有问题也不是没问题,不许当成通过。**", ""]
        for i, q in enumerate(B.JUDGMENT_ASKS, 1):
            loc = f"{q['file']}:{q['line']}" if q["line"] else q["file"]
            L.append(f"{i}. **[{q['topic']}]** `{loc}` {q['anchor'][:110]}")

    if B.PROBES:
        L += ["", "## 执行记录(真跑过的检查,过与不过都在这)", "",
              "| 组 | 查了什么 | 结果 |", "|---|---|---|"]
        for gid, what, outcome in B.PROBES:
            # 竖线要转义,否则表格散架。注意别写进 f-string 表达式里——
            # 表达式内的反斜杠要到 py3.12 才允许,会把最低版本抬到 3.12。
            cell = outcome.replace("|", "\\|")
            L.append(f"| {gid} | {what} | {cell} |")

    L += ["", "## 基线与范围(哪组按什么权威源判、哪组没跑)", "",
          "| 组 | 基线 |", "|---|---|"]
    for gid in sorted(want):
        if gid not in CHECKERS:
            L.append(f"| {gid} | ⚠️ **未实现 —— 没查,不等于没问题** |")
        else:
            L.append(f"| {gid} | {B.BASELINES.get(gid, '基线未声明(仅查本仓,全称否定不成立)')} |")
    if skipped:
        L += ["", "## 跳过说明(这些组没有结论)", ""]
        L += [f"- {s}" for s in skipped]
    return "\n".join(L) + "\n"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--repo", required=True)
    ap.add_argument("--ops", default="",
                    help="逗号分隔的算子(目录名/驼峰名/仓内相对路径均可)。"
                         "给了就按算子目录全量扫,不需要 --base/--head,也不需要先 commit")
    ap.add_argument("--base", default="")
    ap.add_argument("--head", default="")
    ap.add_argument("--groups", default="", help="逗号分隔的组号;默认全跑")
    ap.add_argument("--fold", type=int, default=FOLD_THRESHOLD,
                    help=f"同文件同规则超过 N 条折叠成一条(默认 {FOLD_THRESHOLD});"
                         "只影响展示,--json 始终是全量")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--deliverables", default=None,
                    help="转测交付件根路径;也可用环境变量 CHANGWEI_DELIVERABLES_ROOT。"
                         "都不给则组 15/20 跳过(不猜路径)")
    ap.add_argument("--local", action="store_true",
                    help="本地自检模式:额外查作者流程卫生(单 commit / squash 基线)")
    ap.add_argument("--report", default=None,
                    help="把问题报告写成 Markdown 到该路径(终端输出照旧)")
    ap.add_argument("--stamp", default="", help="报告里的生成时间;脚本内不取当前时间,由调用方传")
    ap.add_argument("--answers", default=None,
                    help="待判题的答案 JSON(agent 答完回填);未答的仍留在「⏳ 待判定」")
    ap.add_argument("--vendor", default=None,
                    help="部署 vendor 根(含 kernel .o);给了才查「结果旧于 binary」那一侧")
    ap.add_argument("--full", action="store_true",
                    help="逐条展开 🟢 规范与卫生(默认收起,只给每规则条数,突出 🔴🟡)")
    ap.add_argument("--suppressions", default=None,
                    help="误报白名单 JSON;默认取 skill 根目录的 suppressions.json")
    ap.add_argument("--canndev", default=None,
                    help="canndev 仓路径(A2 权威基线);也可用环境变量 CHANGWEI_CANNDEV。"
                         "不给则组 4/5/7/18 的 A2 对照未执行(会在报告里显式说明)")
    a = ap.parse_args()

    repo = Path(a.repo).resolve()
    try:
        canndev = resolve_canndev(a.canndev)
    except ValueError as e:
        print(f"❌ {e}", file=sys.stderr)
        return 2
    # 默认跑**全部**组,不是"全部已实现的组"——后者会让未实现的组在报告里消失,
    # 报告看着像跑全了,与"没查≠没问题"自相矛盾。未实现的组必须每次现形。
    want = {int(x) for x in a.groups.split(",") if x.strip()} or set(B.GROUP_TITLES)

    findings: list[B.Finding] = []
    skipped: list[str] = []

    scope_label = "改动文件"
    # --ops 天然就是"我在本地查我自己的算子",远程他检拿不到本地算子目录,
    # 不隐含 local 的话组 15 会输出一句"远程他检不查"——文不对题。
    local_mode = a.local or bool(a.ops)
    if a.ops:
        # 算子模式:主语是"这几个算子",不是"这段 diff"。不需要先 commit。
        scope_label = "文件(算子目录全量)"
        try:
            op_dirs = resolve_ops(repo, a.ops)
        except ValueError as e:
            print(f"❌ {e}", file=sys.stderr)
            return 2
        # 实现闭包:跟 #include 走到仓内其它目录(scatter_max 的 tiling 就委托给
        # scatter_reduce_common),否则整段实现读不到却输出 0 条 🔴——静默通过。
        allf: set = set()
        for d in op_dirs:
            closure, _ = implementation_closure(repo, d)
            allf |= closure | {p for p in d.rglob("*") if p.is_file()}
            B.record_probe(0, f"{d.name} 扫描定义域", closure_note(repo, d))
        files = sorted(B.rel(repo, p) for p in allf)
        # 判据是"本次改了什么"的组,在算子模式下没有输入——**显式跳过,不静默**。
        for gid in (4, 13):
            if gid in want:
                want.discard(gid)
                skipped.append(
                    f"组 {gid}({B.GROUP_TITLES[gid]}):算子模式按目录全量扫,"
                    "没有「本次改了什么」这个输入 → 本组跳过(要查请改用 --base/--head)")
    else:
        if not (a.base and a.head):
            print("❌ 要么给 --ops <算子>,要么给 --base/--head <版本区间>", file=sys.stderr)
            return 2
        files = B.changed_files(repo, a.base, a.head)
        op_dirs = B.touched_op_dirs(repo, files)

    for gid in sorted(want):
        mod = CHECKERS.get(gid)
        if mod is None:
            skipped.append(f"组 {gid}({B.GROUP_TITLES.get(gid, '?')}):本版未实现")
            continue
        try:
            if gid == 7 and 5 in want:
                continue   # g05 模块已一并产出,避免重复
            handover = Path(a.deliverables) if a.deliverables else None
            if gid == 18:
                findings.extend(mod.run(repo, files, op_dirs,
                                        handover_root=handover, canndev=canndev))
            elif gid == 20:
                findings.extend(mod.run(repo, files, op_dirs, handover_root=handover))
            elif gid == 8:
                findings.extend(mod.run(repo, files, op_dirs, base=a.base, head=a.head))
            elif gid == 4:
                findings.extend(mod.run(repo, files, op_dirs, base=a.base, head=a.head,
                                        canndev=canndev))
            elif gid in (5, 7):
                findings.extend(mod.run(repo, files, op_dirs, canndev=canndev))
            elif gid == 23:
                findings.extend(mod.run(repo, files, op_dirs, remote_mode=not local_mode,
                                        deliverables=a.deliverables, vendor=a.vendor))
            elif gid in (15, 22):
                findings.extend(mod.run(repo, files, op_dirs, remote_mode=not local_mode,
                                        deliverables=a.deliverables))
            elif gid == 13:
                findings.extend(mod.run(repo, files, op_dirs, base=a.base, head=a.head,
                                        remote_mode=not local_mode))
            else:
                findings.extend(mod.run(repo, files, op_dirs))
        except B.Skipped as e:
            skipped.append(f"组 {gid}({B.GROUP_TITLES[gid]}):{e}")

    # 标注锚点是否落在本次 diff 内。不是过滤掉——只是打标,让提评论那一步能收敛落点。
    #
    # ⚠️ 只标**存在的文件**。「缺失」类 finding 的锚点是"本该存在却不存在的路径"
    # (G9-golden-missing 指向 tests/assets/、G6-proto-not-found 指向算子目录、
    # G9-deliverable-missing 指向没建的 tests/ut/op_host/)——不存在的东西当然不在 diff 里,
    # 按"不在 diff 就不报"处理会把整个缺失类静默丢掉。而缺失类恰恰是本 skill 里
    # **最难查也最值钱**的一类(#1168 盲审逃逸的 21 类里 16 类是"该有而没有")。
    # 夹具上实测:14 条 diff 外里 12 条是缺失类,一刀切就是拿丢召回换精确率。
    diff_set = set(files)
    for f in findings:
        if not f.file or f.file.startswith("/") or f.file in diff_set:
            continue
        if (repo / f.file).is_file():        # 文件在、但作者本次没碰 → 才是真·越界
            f.out_of_diff = True
    n_ood = sum(1 for f in findings if f.out_of_diff)

    if a.answers:
        judged, B.JUDGMENT_ASKS[:] = backfill_answers(B.JUDGMENT_ASKS, Path(a.answers).expanduser())
        findings.extend(judged)
        print(f"📥 已回填判定:{len(judged)} 条转为问题,剩 {len(B.JUDGMENT_ASKS)} 条未答")

    supp = load_suppressions(Path(a.suppressions).expanduser() if a.suppressions
                             else Path(__file__).parent.parent / "suppressions.json")
    findings, suppressed = apply_suppressions(findings, supp)

    findings.sort(key=lambda f: (SEV_ORDER[f.severity], f.group, f.file, f.line))

    if a.json:
        print(json.dumps({
            "findings": [f.to_dict() for f in findings],
            "skipped": skipped,
            "probes": [{"group": g, "what": w, "outcome": o} for g, w, o in B.PROBES],
            "judgment_asks": B.JUDGMENT_ASKS,
            "changed_files": len(files),
            "op_dirs": [B.rel(repo, d) for d in op_dirs],
        }, ensure_ascii=False, indent=2))
        return 0

    print(f"扫描范围:{len(files)} 个{scope_label},{len(op_dirs)} 个算子目录 "
          f"({', '.join(B.rel(repo, d) for d in op_dirs) or '—'})")
    print(f"检查组:{'、'.join(str(g) for g in sorted(want))}\n")

    # 🟢 默认收起:大算子上它会淹掉真信号(mat_mul_v3 实测 358 条里 291 条是 🟢)。
    shown, low_summary = ((findings, []) if a.full else _brief_low(findings))

    cur = None
    for f, folded in _fold(shown, a.fold):
        if f.group != cur:
            cur = f.group
            print(f"\n{'─' * 72}\n{SEV_ICON[f.severity]} 组 {f.group} · {B.GROUP_TITLES[f.group]}\n")
        flag = " [需人工复核]" if f.needs_human else ""
        flag += " [diff 外·不提 PR 评论]" if f.out_of_diff else ""
        loc = f"{f.file}:{f.line}" if f.line else f.file
        print(f"  {loc}{flag}\n    [{f.rule}] {f.message}")
        if folded:
            shown = "、".join(str(n) for n in folded[:15])
            print(f"    ↳ 同文件同类还有 {len(folded)} 处:行 {shown}"
                  + ("……" if len(folded) > 15 else ""))
        if f.evidence:
            print(f"    > {f.evidence}")
        print()

    if low_summary:
        print(f"\n{'─' * 72}\n🟢 规范与卫生 {sum(1 for f in findings if f.severity == 'low')} 条"
              "(已收起;`--full` 逐条展开。多数跑一遍 pre-commit 能统一清掉)\n")
        for ln in low_summary:
            print(ln)
        print()

    n_hi = sum(1 for f in findings if f.severity == "high")
    n_mid = sum(1 for f in findings if f.severity == "mid")
    n_low = sum(1 for f in findings if f.severity == "low")
    print(f"{'═' * 72}")
    print(f"合计 {len(findings)} 条:🔴高 {n_hi} / 🟡中 {n_mid} / 🟢低 {n_low}"
          f" (其中 {sum(1 for f in findings if f.needs_human)} 条需人工复核)")
    if n_ood:
        print(f"   ↳ 其中 {n_ood} 条锚点在本次 diff **之外**:留作上下文,他检时不生成 PR 评论"
              "(不在别人 PR 上评 ta 没碰过的行)")
    for s in skipped:
        print(f"⚠️  跳过 {s}")

    # 待判定:脚本能定位、但**判不了**的点,不下结论,交给读得懂上下文的去判。
    #
    # 这一节必须显眼、必须默认输出、必须把题目本体写到文件里。
    # 理由:整个 skill 里唯一能回答"功能对不对"的就是这条路,而它此前被做成了
    # opt-in 的三步 CLI(--emit → 贴给 agent → --answers 回填),谁只跑 check.py
    # 就只能拿到一个格式检查器。招牌能力不能是可选项。
    if B.JUDGMENT_ASKS:
        pack = repo.parent / f"judgment_pack_{repo.name}.md"
        try:
            pack.write_text(_render_pack(B.JUDGMENT_ASKS, repo), encoding="utf-8")
            where = f"已写出题目本体:{pack}"
        except OSError as e:      # noqa: BLE001
            where = f"题目本体写出失败({e}),用 semantic_probe.py --emit 取"
        print(f"\n{'━' * 72}")
        print(f"⏳ 待判定 {len(B.JUDGMENT_ASKS)} 条 —— **脚本未下结论**")
        print("   这些点脚本只能定位、判不了:可达性要跨函数追数据流,"
              "「是迁移还是 hack」要读懂意图。\n"
              "   历史教训:这批原来按 🔴 直报,5 个 PR 实测**精确率 0**。")
        for i, q in enumerate(B.JUDGMENT_ASKS, 1):
            loc = f"{q['file']}:{q['line']}" if q["line"] else q["file"]
            print(f"\n  {i}. [{q['topic']}] {loc}")
            print(f"     > {q['anchor'][:110]}")
        print(f"\n   {where}")
        print(f"   ⚠️ 没人答 = 这 {len(B.JUDGMENT_ASKS)} 条**没有结论**,"
              "既不是有问题也不是没问题。报告里不许当成通过。")

    if B.PROBES:
        print(f"\n{'─' * 72}\n执行记录(真跑过的检查,过与不过都在这)")
        for gid, what, outcome in B.PROBES:
            print(f"  组 {gid:<3} {what}\n         {outcome}")

    # 基线与范围:让"不适用/跳过"永远留痕,不能静默发生
    print(f"\n{'─' * 72}\n基线与范围")
    for gid in sorted(want):
        if gid not in CHECKERS:
            print(f"  组 {gid:<3} 未实现 —— 没查,不等于没问题")
        else:
            print(f"  组 {gid:<3} {B.BASELINES.get(gid, '基线未声明(仅查本仓,全称否定不成立)')}")

    if suppressed:
        import collections as _c
        cnt = _c.Counter(f.rule for f in suppressed)
        print(f"\n🔇 已按白名单过滤 {len(suppressed)} 条:"
              + "、".join(f"{k}×{v}" for k, v in cnt.most_common())
              + " (被压掉≠没问题,理由见 suppressions.json)")

    if a.report:
        rp = Path(a.report).expanduser()
        rp.parent.mkdir(parents=True, exist_ok=True)
        rp.write_text(_render_markdown(repo, want, files, op_dirs, findings, skipped,
                                       a.stamp, scope_label, a.fold, not a.full),
                      encoding="utf-8")
        print(f"\n📄 问题报告已写出:{rp}")
    return 0


if __name__ == "__main__":
    sys.exit(main())