#!/usr/bin/env python3 """wiki-compress — 讀不完的必讀檔,等於沒有(inkstone/ISEP#89) ━━ 這支解的問題 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ wiki 一直長大,沒有人會回頭整理。`mistakes.md` 是「必讀」等級的檔案, 實測 **7,680 行、168 條**——**沒有人真的每次都從頭讀。** leo 2026-08-27 說那天「失去記憶」。東西還在,只是**沒人讀得完**。 已經有的三支管的是別的事:`wiki-secret-scan`(寫進去安不安全)、 `wiki-first-search`/`wiki-first-police`(有沒有先查)。**沒有任何一支管「太大了」。** ━━ 🔴 最重要的設計決定:**只搬不改** ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 票上第 2 條驗收是「**不能弄丟任何一條教訓**(要能逐條對帳)」。 ⇒ 所以本支**不改寫任何一條的正文**,一個字都不動。它只做三件事: ① 產生**目錄**(每條一行:標題+日期+票號+去哪了)放回原檔開頭 ② 把舊的整條**原封不動搬**到 `<檔名>-archive-YYYY-MM.md` ③ 把「同一件事的第 N 次」在目錄裡標成 `×N`,**而不是把它們合併掉** 「合併同類、濃縮成一行」聽起來更漂亮,但那要重寫正文—— **而重寫的當下沒有人會發現弄丟了什麼**(票上原話)。 只搬不改的好處是:`verify` 可以用**內文雜湊**逐條對帳, 「沒弄丟」不是宣稱,是算出來的。 ━━ 五個動詞 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ wiki-compress audit [...] 哪幾個檔太大了、大在哪 wiki-compress plan <檔> 要怎麼壓:同類幾群、可搬幾條 wiki-compress apply <檔> --ticket owner/repo#N 真的壓(只搬不改),並留交付紀錄 wiki-compress verify <壓縮前的檔> <壓縮後的檔> [搬去的檔...] 逐條對帳:有沒有弄丟(票上驗收 2) wiki-compress bench <壓縮前的檔> <壓縮後的檔> [搬去的檔...] 量「查得比壓之前快」(票上驗收 1) 🔴 `apply` **一定要 --ticket**(票上驗收 3:壓縮這件事本身要有票、有交付紀錄, 不准順手做完沒人知道)。它會把「哪一版壓掉了什麼」寫進 `.compress-log.md`。 """ import argparse import datetime as dt import hashlib import os import re import sys THRESHOLD = int(os.environ.get("WIKI_COMPRESS_THRESHOLD") or 1200) # 行 LOG_NAME = ".compress-log.md" FENCE = re.compile(r"^\s*(```|~~~)") H2 = re.compile(r"^##\s+(?!#)(.+?)\s*$") DATE = re.compile(r"(20\d\d)[-/](\d\d)[-/]?(\d\d)?") TICKET = re.compile(r"\b([\w.-]+/[\w.-]+#\d+)\b") # ══ 一、把一個 wiki 檔切成「條」═══════════════════════════════════════════ # # 🔴 一定要跳過 code fence。`mistakes.md` 第 534 行是 ```` # 兩邊各跑一次 ```` # ——那是**程式碼註解**,不是標題。照抄 grep '^#' 會把它當成一條, # 然後 verify 會拿一個不存在的東西去對帳,全盤失準。 def split_entries(text): """回 (前言, [條...])。每條 = {"title","body","line","date","tickets","hash"}。""" lines = text.split("\n") fenced = False marks = [] for i, ln in enumerate(lines): if FENCE.match(ln): fenced = not fenced continue if fenced: continue m = H2.match(ln) if m: marks.append((i, m.group(1).strip())) pre = "\n".join(lines[:marks[0][0]]) if marks else text out = [] for n, (i, title) in enumerate(marks): end = marks[n + 1][0] if n + 1 < len(marks) else len(lines) body = "\n".join(lines[i:end]).rstrip() out.append({ "title": title, "body": body, "line": i + 1, "lines": end - i, "date": entry_date(title, body), "tickets": sorted(set(TICKET.findall(body))), "hash": body_hash(body), }) return pre, out def body_hash(body): """對帳用的指紋:把空白正規化之後雜湊。 正規化空白(而不是逐字元比對)是為了讓「搬檔時尾端多/少一個換行」 這種**無意義的差異**不要被報成「弄丟了」——誤報會讓對帳表沒人看。 真的改了字,雜湊一定不同。 """ return hashlib.sha256(re.sub(r"\s+", " ", body).strip().encode()).hexdigest()[:16] def entry_date(title, body): """條的日期:標題裡的優先(那是人寫的),沒有才往內文找第一個。""" for src in (title, body[:1500]): m = DATE.search(src or "") if m: y, mo, d = m.group(1), m.group(2), m.group(3) or "01" try: return dt.date(int(y), int(mo), int(d)).isoformat() except ValueError: return "%s-%s-01" % (y, mo) return "" # ══ 二之〇、自承重複:**這一條自己說它是同一件事的第 N 次** ══════════════ # # 為什麼另外做這一格(實測數字,不是設計偏好): # `mistakes.md` 260 條,用**標題相似度**分群只找得到 1 對—— # 因為同一個病每次都被寫成完全不同的句子(那正是它一犯再犯的原因)。 # 但其中 **68 條的內文自己寫著「同款第三次」「又犯一次」「同一個病」**。 # ⇒ 人下的判斷比機器算的相似度準得多。**照抄人已經寫下的結論,不要重新猜一遍。** # # 這 68 條就是「合併成一條、記發生過幾次」最該下手的地方, # 但**合併要人來做**(要重寫正文,機器一動就會弄丟東西)⇒ 本支只把它們點名出來。 SELF_DUP = re.compile(r"同款|第[一二三四五六七八九十百0-9]+次|又犯|再犯|又一次|" r"同一個病|同一形狀|同樣的錯|重蹈") def self_declared_repeat(e): """回它自己講的那句話(沒有就回 None)。**引它的原話,不要自己下結論。**""" for src in (e["title"], e["body"][:900]): m = SELF_DUP.search(src or "") if m: i = max(0, m.start() - 12) return re.sub(r"\s+", " ", (src[i:m.end() + 10]).strip()) return None # ══ 二、同類分群(標 ×N,不合併)═════════════════════════════════════════ STOP = set("的了是在有和與及對於把被就都也還很更最一個這那我你他它們不沒要會能" "MISTAKE mistake 方法 為什麼 怎麼".split()) def tokens(title): t = re.sub(r"[((].*?[))]", " ", title) t = re.sub(r"[^\w一-鿿]+", " ", t) out = set() for w in t.split(): if re.fullmatch(r"[A-Za-z0-9_.-]+", w): if len(w) > 2 and w not in STOP: out.add(w.lower()) else: for i in range(len(w) - 1): # 中文用 bigram bg = w[i:i + 2] if bg not in STOP: out.add(bg) return out def cluster(entries, thresh=0.42): """同一件事的第 N 次 ⇒ 分到同一群。回 [[索引...]],單獨一條的也算一群。""" toks = [tokens(e["title"]) for e in entries] parent = list(range(len(entries))) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x for a in range(len(entries)): for b in range(a + 1, len(entries)): ta, tb = toks[a], toks[b] if not ta or not tb: continue j = len(ta & tb) / float(len(ta | tb)) # 共用票號=同一條線,門檻放寬(票號是人下的判斷,比字面相似可靠) same_ticket = bool(set(entries[a]["tickets"]) & set(entries[b]["tickets"])) if j >= thresh or (same_ticket and j >= thresh * 0.6): parent[find(a)] = find(b) groups = {} for i in range(len(entries)): groups.setdefault(find(i), []).append(i) return sorted(groups.values(), key=lambda g: (-len(g), g[0])) # ══ 三、audit ════════════════════════════════════════════════════════════ def iter_wiki_files(paths): for p in paths: if os.path.isdir(p): for name in sorted(os.listdir(p)): if name.endswith(".md") and not name.startswith("."): yield os.path.join(p, name) elif os.path.isfile(p): yield p def cmd_audit(args): rows = [] for f in iter_wiki_files(args.paths): text = open(f, encoding="utf-8", errors="replace").read() _, entries = split_entries(text) groups = [g for g in cluster(entries) if len(g) > 1] if entries else [] rows.append({ "file": f, "lines": text.count("\n") + 1, "entries": len(entries), "dup_groups": len(groups), "dup_entries": sum(len(g) for g in groups), "self_dup": sum(1 for e in entries if self_declared_repeat(e)), }) rows.sort(key=lambda r: -r["lines"]) over = [r for r in rows if r["lines"] > THRESHOLD] print("📚 wiki 體檢(門檻 %d 行——超過就沒有人會從頭讀)" % THRESHOLD) print() for r in rows: flag = "🔴 太長" if r["lines"] > THRESHOLD else " " print("%s %-46s %6d 行 / %4d 條%s" % (flag, os.path.basename(r["file"]), r["lines"], r["entries"], ",%d 條自承是同款的第 N 次" % r["self_dup"] if r["self_dup"] else "")) print() if not over: print("✅ 查過了,沒有超過門檻的檔。") return 0 print("⇒ %d 個檔超過門檻。**這不是風格問題**——一個沒有人讀得完的必讀檔," "跟沒有那個檔的差別只在於它讓人以為有。" % len(over)) print("⇒ 下一步:`wiki-compress plan <檔>` 看怎麼壓;壓之前先開票(票上驗收 3)。") return 1 # ══ 四、plan ═════════════════════════════════════════════════════════════ def plan(path, before=None, keep=None, threshold=None): """算出「哪幾條留下、哪幾條搬走」。 🔴 預設政策=**搬到原檔掉到門檻以下為止**(最新的留下、最舊的先搬)。 為什麼不是「早於某個日期就搬」(第一版是那樣寫的,實測就被打臉): `mistakes.md` 260 條裡有 205 條是最近一個月的 ⇒ 用 30 天當線只搬得動 40 條, 壓完還是 6,000 行 ⇒ **一樣沒有人讀得完,等於白壓。** 而這件事的目的是「讓必讀檔可讀」,那就該讓**可讀**本身當判準。 """ text = open(path, encoding="utf-8", errors="replace").read() pre, entries = split_entries(text) groups = cluster(entries) limit = threshold or THRESHOLD # 新舊排序:有日期的照日期,沒日期的照它在檔裡的位置(這批 wiki 都是新的在上面) order = sorted(range(len(entries)), key=lambda i: (entries[i]["date"] or "0000-00-00", -i), reverse=True) if before: stay = {i for i in range(len(entries)) if not entries[i]["date"] or entries[i]["date"] >= before} else: n_keep = keep if keep is not None else None if n_keep is None: # 目錄一條一行 + 檔頭固定幾行;一直加到再加就超過門檻為止 budget = limit - (len(entries) + 14) n_keep, used = 0, 0 for i in order: if used + entries[i]["lines"] > budget: break used += entries[i]["lines"] n_keep += 1 stay = set(order[:max(0, n_keep)]) movable = [entries[i] for i in range(len(entries)) if i not in stay] return {"text": text, "pre": pre, "entries": entries, "groups": groups, "stay": stay, "movable": movable, "limit": limit, "cutoff": before or "(門檻政策:壓到 %d 行以下)" % limit} def cmd_plan(args): p = plan(args.file, args.before, args.keep) entries, groups = p["entries"], p["groups"] dups = [g for g in groups if len(g) > 1] selfs = [e for e in entries if self_declared_repeat(e)] stay_lines = sum(entries[i]["lines"] for i in p["stay"]) print("📐 壓縮計畫:%s" % args.file) print(" 現況:%d 行 / %d 條" % (p["text"].count("\n") + 1, len(entries))) print(" 政策:%s" % p["cutoff"]) print(" 搬走:%d 條 → <檔名>-archive-YYYY-MM.md(**原封不動,一個字都不改**)" % len(p["movable"])) print(" 留下:%d 條、%d 行 + 目錄 %d 行 = 約 %d 行" % (len(p["stay"]), stay_lines, len(entries) + 14, stay_lines + len(entries) + 14)) print() print(" ↻ **自承是同款第 N 次的有 %d 條**(占 %.0f%%)——" % (len(selfs), 100.0 * len(selfs) / max(1, len(entries)))) print(" 這批是「合併成一條、記發生過幾次」最該下手的地方,") print(" 但**合併要人來做**:機器一改正文就會弄丟東西,所以本支只點名。") for e in selfs[:6]: print(" · %s" % e["title"][:54]) print(" 它自己說:…%s…" % (self_declared_repeat(e) or "")[:52]) if len(selfs) > 6: print(" · …另外還有 %d 條" % (len(selfs) - 6)) if dups: print() print(" ×N 標題就長得像的(%d 群):" % len(dups)) for g in dups[:5]: print(" ×%d %s" % (len(g), entries[g[0]]["title"][:52])) print() print("⇒ 真的要壓:wiki-compress apply %s --ticket owner/repo#N" % args.file) return 0 # ══ 五、apply(只搬不改)═════════════════════════════════════════════════ def cmd_apply(args): p = plan(args.file, args.before, args.keep) entries, groups = p["entries"], p["groups"] if not entries: print("🔴 這個檔切不出任何一條(沒有 `## ` 標題),不動它。", file=sys.stderr) return 2 where = {} # 條的索引 → 它搬去哪個檔(留下的是 None) buckets = {} stem, _ = os.path.splitext(args.file) for idx, e in enumerate(entries): if idx not in p["stay"]: month = (e["date"] or "")[:7] or "undated" target = "%s-archive-%s.md" % (stem, month) buckets.setdefault(target, []).append(idx) where[idx] = target else: where[idx] = None gid = {} for n, g in enumerate(groups): for i in g: gid[i] = n # ── 目錄:每條一行。這一行就是票上說的「原位置留一份目錄」────────── idx_lines = [] for n, g in enumerate(groups): head = entries[g[0]] mark = "×%d " % len(g) if len(g) > 1 else "" for k, i in enumerate(g): e = entries[i] dest = where[i] loc = "本檔" if dest is None else "→ `%s`" % os.path.basename(dest) tick = (" " + "/".join(e["tickets"][:2])) if e["tickets"] else "" rep = "↻ " if self_declared_repeat(e) else "" pre = mark if k == 0 else "  ↳ " idx_lines.append("- %s%s`%s` %s(%s,%d 行)%s%s" % (pre, rep, e["hash"], e["title"], e["date"] or "沒寫日期", e["lines"], loc, tick)) del head stamp = dt.datetime.now().strftime("%Y-%m-%d") header = [ p["pre"].rstrip(), "", "---", "", "## 📇 目錄(%s 壓縮,票:%s)" % (stamp, args.ticket), "", "> 這份目錄是機器產生的(`scripts/wiki-compress`)。**每一條的正文一個字都沒改**——", "> 舊的整條原封不動搬到 archive,留下的還在本檔。要對帳跑:", "> `wiki-compress verify <壓縮前> <壓縮後> `", ">", "> `×N` = 標題就長得像的同一群。`↻` = **這一條自己寫著它是同款第 N 次**", "> ——那是它自己下的判斷,不是機器猜的。要合併就從 `↻` 這批開始,**由人合併**。", "", ] + idx_lines + ["", "---", ""] kept = [entries[i]["body"] for i in range(len(entries)) if where[i] is None] new_text = "\n".join(header) + "\n\n".join(kept) + "\n" if args.dry_run: print("🧪 --dry-run,什麼都沒寫。壓完會是:") print(" %s:%d 行 → %d 行(目錄 %d 條)" % (args.file, p["text"].count("\n") + 1, new_text.count("\n") + 1, len(idx_lines))) for t, ids in sorted(buckets.items()): print(" %s:+%d 條" % (os.path.basename(t), len(ids))) return 0 # ── 先把搬走的寫出去,再改原檔(順序不能反:中途死掉也不會弄丟)────── for target, ids in sorted(buckets.items()): chunk = ["# %s(自 %s 搬出,%s)" % (os.path.basename(target), os.path.basename(args.file), stamp), "", "> 原封不動搬過來的,**一個字都沒改**。票:%s" % args.ticket, "> 回原檔看目錄:`%s`" % os.path.basename(args.file), ""] chunk += [entries[i]["body"] for i in ids] old = "" if os.path.exists(target): old = open(target, encoding="utf-8").read().rstrip() + "\n\n" with open(target, "w", encoding="utf-8") as f: f.write(old + "\n\n".join(chunk) + "\n") backup = args.file + ".before-compress" with open(backup, "w", encoding="utf-8") as f: f.write(p["text"]) with open(args.file, "w", encoding="utf-8") as f: f.write(new_text) # ── 交付紀錄(票上驗收 3:不准順手做完沒人知道)──────────────────── log = os.path.join(os.path.dirname(os.path.abspath(args.file)) or ".", LOG_NAME) with open(log, "a", encoding="utf-8") as f: f.write("\n## %s — %s(票:%s)\n\n" % (stamp, os.path.basename(args.file), args.ticket)) f.write("- 壓縮前:%d 行 / %d 條\n" % (p["text"].count("\n") + 1, len(entries))) f.write("- 壓縮後:%d 行(目錄 %d 條 + 留下 %d 條)\n" % (new_text.count("\n") + 1, len(idx_lines), len(kept))) for t, ids in sorted(buckets.items()): f.write("- 搬出 %d 條 → `%s`\n" % (len(ids), os.path.basename(t))) f.write("- 對帳指令:`wiki-compress verify %s %s %s`\n" % (os.path.basename(backup), os.path.basename(args.file), " ".join(os.path.basename(t) for t in sorted(buckets)))) f.write("- 🔴 正文未改動(只搬不改);逐條對帳結果見上面那條指令\n") print("✅ 壓完了。%d 行 → %d 行" % (p["text"].count("\n") + 1, new_text.count("\n") + 1)) for t, ids in sorted(buckets.items()): print(" 搬出 %d 條 → %s" % (len(ids), t)) print(" 壓縮前的原檔留在:%s" % backup) print(" 交付紀錄:%s" % log) print() print("⇒ 現在跑對帳(**沒跑過就不算壓完**):") print(" wiki-compress verify %s %s %s" % (backup, args.file, " ".join(sorted(buckets)))) return 0 # ══ 六、verify(票上驗收 2:不能弄丟任何一條)════════════════════════════ def cmd_verify(args): before = open(args.before, encoding="utf-8", errors="replace").read() _, b_entries = split_entries(before) after_files = [args.after] + list(args.also) found = {} for f in after_files: if not os.path.exists(f): print("🔴 找不到 %s" % f, file=sys.stderr) return 2 _, es = split_entries(open(f, encoding="utf-8", errors="replace").read()) for e in es: found.setdefault(e["hash"], []).append((f, e["title"])) missing = [e for e in b_entries if e["hash"] not in found] dupes = {h: v for h, v in found.items() if len(v) > 1} print("🧾 逐條對帳:%s → %s" % (args.before, "+".join(after_files))) print(" 壓縮前 %d 條 / 壓縮後(含搬出去的)%d 條" % (len(b_entries), sum(len(v) for v in found.values()))) print() if missing: print("🔴 **弄丟了 %d 條**(下面每一條在壓縮後找不到內文一模一樣的):" % len(missing)) for e in missing[:40]: print(" · 第 %d 行 `%s` %s" % (e["line"], e["hash"], e["title"][:60])) if len(missing) > 40: print(" · …另外還有 %d 條" % (len(missing) - 40)) print() print("⇒ **這次壓縮不合格。** 票上第 2 條驗收就是這件事。") print("⇒ 原檔還在 `%s.before-compress`,把它還原回去再查。" % args.after) return 1 print("✅ **一條都沒弄丟。** %d 條全部在壓縮後的檔裡找得到內文一模一樣的。" % len(b_entries)) if dupes: print(" ⚠️ 有 %d 條同時出現在兩個檔(搬了但原檔沒清):" % len(dupes)) for h, v in list(dupes.items())[:8]: print(" · %s:%s" % (v[0][1][:40], "、".join(f for f, _ in v))) return 0 # ══ 七、bench(票上驗收 1:查得比壓之前快)═══════════════════════════════ # # 「快」要能量,不然它只是感覺。這裡量的是**定位成本**: # 從檔頭讀到「確定答案在哪一條」為止,你要掃過幾行。 # 壓縮前:沒有目錄 ⇒ 只能一路掃到命中的那一行 ⇒ 成本 = 命中行號 # 壓縮後:檔頭有目錄 ⇒ 在目錄裡命中就停 ⇒ 成本 = 目錄裡的行號 # 同時量**命中率**——「查得到」是「查得快」的前提,快而查不到是更糟的結果。 def probes(entries, cap=80): """從每條的標題挑一個**在全檔獨一無二**的詞當查詢。挑不到就跳過那條。 🔴 **要在整份檔上均勻取樣**,不能只取前 cap 條。 第一版就是取前 80 條——而那批正好是最新的、壓縮後留在原檔最上面的, **等於只考它最擅長的題目**,量出來的倍數是假的。 """ titles = [e["title"] for e in entries] step = max(1, len(entries) // cap) sample = entries[::step] out = [] for e in sample: best = None cands = re.findall(r"[一-鿿]{4,10}|[A-Za-z][\w.-]{4,}", e["title"]) for c in sorted(cands, key=len, reverse=True): if sum(1 for t in titles if c in t) == 1: best = c break if best: out.append((best, e["hash"])) if len(out) >= cap: break return out def first_hit_line(path, needle): with open(path, encoding="utf-8", errors="replace") as f: for n, ln in enumerate(f, 1): if needle in ln: return n return None def cmd_bench(args): before = open(args.before, encoding="utf-8", errors="replace").read() _, b_entries = split_entries(before) qs = probes(b_entries) if not qs: print("🔴 這個檔挑不出可以拿來查的詞(標題太相似)。", file=sys.stderr) return 2 files = [args.after] + list(args.also) hit_b = hit_a = 0 cost_b = cost_a = 0 worse = [] for q, _h in qs: cb = first_hit_line(args.before, q) ca = None for f in files: c = first_hit_line(f, q) if c is not None and (ca is None or c < ca): ca = c if cb: hit_b += 1; cost_b += cb if ca: hit_a += 1; cost_a += ca if cb and ca and ca > cb: worse.append((q, cb, ca)) n = len(qs) print("⏱ 查得比壓之前快嗎?(%d 個查詢,每條各出一題)" % n) print() print(" 命中率 壓縮前 %d/%d → 壓縮後 %d/%d" % (hit_b, n, hit_a, n)) print(" 定位成本(要從檔頭掃過幾行才確定答案在哪):") print(" 壓縮前 平均 %.0f 行" % (cost_b / max(1, hit_b))) print(" 壓縮後 平均 %.0f 行" % (cost_a / max(1, hit_a))) if hit_a and hit_b: ratio = (cost_b / hit_b) / max(1e-9, cost_a / hit_a) print(" ⇒ **快 %.1f 倍**" % ratio) print() ok = True if hit_a < hit_b: print("🔴 **有 %d 個查詢壓縮後查不到了。** 查得快但查不到=更糟。" % (hit_b - hit_a)) ok = False if worse: print(" ⚠️ 有 %d 個查詢變慢了(前 5 個):" % len(worse)) for q, cb, ca in worse[:5]: print(" · 「%s」 %d → %d 行" % (q, cb, ca)) if ok and hit_a == hit_b: print("✅ **同一件事查得到,而且查得更快**(票上驗收 1)。") return 0 if ok else 1 def main(argv): ap = argparse.ArgumentParser(prog="wiki-compress", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) a = sub.add_parser("audit"); a.add_argument("paths", nargs="*", default=["."]) a.set_defaults(fn=cmd_audit) p = sub.add_parser("plan"); p.add_argument("file") p.add_argument("--before", help="早於這個日期的搬走(預設:壓到門檻以下為止)") p.add_argument("--keep", type=int, help="最新的幾條留在原檔") p.set_defaults(fn=cmd_plan) ap2 = sub.add_parser("apply"); ap2.add_argument("file") ap2.add_argument("--ticket", required=True, help="owner/repo#N —— 壓縮這件事本身要有票(票上驗收 3)") ap2.add_argument("--before") ap2.add_argument("--keep", type=int) ap2.add_argument("--dry-run", action="store_true") ap2.set_defaults(fn=cmd_apply) v = sub.add_parser("verify"); v.add_argument("before"); v.add_argument("after") v.add_argument("also", nargs="*") v.set_defaults(fn=cmd_verify) b = sub.add_parser("bench"); b.add_argument("before"); b.add_argument("after") b.add_argument("also", nargs="*") b.set_defaults(fn=cmd_bench) args = ap.parse_args(argv) if args.cmd == "audit" and not args.paths: args.paths = ["."] if args.cmd == "apply" and not re.match(r"^[\w.-]+/[\w.-]+#\d+$", args.ticket): print("🔴 --ticket 的寫法是 owner/repo#N(裸號跨 repo 會撞號)", file=sys.stderr) return 2 return args.fn(args) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))