"""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
from checkers import (
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
from checkers.refs import resolve_canndev
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,
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
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:
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 = "改动文件"
local_mode = a.local or bool(a.ops)
if a.ops:
scope_label = "文件(算子目录全量)"
try:
op_dirs = resolve_ops(repo, a.ops)
except ValueError as e:
print(f"❌ {e}", file=sys.stderr)
return 2
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
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_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")
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}")
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:
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())