"""source_ref ↔ manifest 對帳診斷(AGENTS.md §7 schema / §14 對帳的輔助工具)。 lint.py 對「source_ref 不在 manifest」只報一句,無法區分成因;本工具把每個 對不上的 source_ref 分類成可據以修復的型別(見 CAT_TITLE / FIX),讓「帳本掉 條目」「路徑字串岔開」「檔名 hash 後綴岔開」「檔真的遺失」彼此不再混為一談。 純唯讀——不改任何檔(含 manifest),有對不上以結束碼 1 表示(可當搬機/改 manifest 後的閘門)。分類邏輯與 lint 一致:以最後一個 '#' 切出 path 與 sha8。 用法:python tools/diag_refs.py [--root DIR] 結束碼:0 = 全部解得開,1 = 有對不上。 """ import argparse import json import pathlib import re import sys sys.path.insert(0, str(pathlib.Path(__file__).parent)) import kb CAT_TITLE = { "hash-mismatch": "Hash 不符(帳本有此路徑,但 sha256 與 ref 不同)", "normalize": "路徑字串岔開(正規化後與帳本 key 相同:斜線/前綴/大小寫)", "stem": "檔名 hash 後綴岔開(帳本有同檔名幹、hash 後綴不同的 key)", "unregistered": "檔在磁碟、帳本無此條目", "absent": "完全無對應(帳本、磁碟、檔名幹皆無)", } FIX = { "hash-mismatch": "raw 被就地改動或重轉(違反 §1.4);還原檔案,或連同 source_ref 走 PR(§14.2)。", "normalize": "修產生岔開字串的那一個環節(路徑正規化),別逐頁改 source_ref(§12 change the ruler)。", "stem": "source_ref 指到不同 hash 後綴的檔名;核對正確檔名,別改帳本去遷就(§14.2)。", "unregistered": "帳本掉了條目、檔還在;走 branch+PR 補登 manifest(§14.3),別動 wiki 頁。", "absent": "source_ref 可能攝入時寫歪,或原始檔真的遺失;上報 HITL 據實記錄(§14.2)。", } # 分類輸出順序(愈可機械修復的排愈前) ORDER = ["hash-mismatch", "normalize", "stem", "unregistered", "absent"] def _norm(path): """正規化路徑字串以偵測「同一檔、不同寫法」:反斜線→斜線、去開頭 ./、轉小寫。""" s = path.replace("\\", "/") if s.startswith("./"): s = s[2:] return s.lower() def _stem(path): """取檔名幹:去目錄、去 .md、去結尾的 -<8 碼 hex>(convert 的檔名 hash 後綴)。""" name = path.replace("\\", "/").rsplit("/", 1)[-1] name = re.sub(r"\.md$", "", name, flags=re.I) return re.sub(r"-[0-9a-f]{8}$", "", name, flags=re.I).lower() def classify_ref(source_ref, files, exists): """把單一 source_ref 分類。files:manifest['files'](path→entry); exists(path)→bool 判磁碟是否有該檔。回傳 (category, detail)。 category 為 "resolved" 或 CAT_TITLE 的任一鍵。""" path, _, sha8 = str(source_ref).rpartition("#") entry = files.get(path) if entry is not None: if entry["sha256"].startswith(sha8): return "resolved", path return "hash-mismatch", f"帳本 sha256={entry['sha256'][:8]} ≠ ref #{sha8}" norm = _norm(path) for k in files: if _norm(k) == norm: return "normalize", f"帳本 key = '{k}'" stem = _stem(path) for k, e in files.items(): if e.get("kind") == "converted" and _stem(k) == stem: return "stem", f"帳本 key = '{k}'" if exists(path): return "unregistered", f"磁碟有 '{path}',帳本無此 key" return "absent", f"'{path}' 帳本無、磁碟無、無同名幹 key" def collect(root, files, exists): """掃全庫 wiki 頁,回傳 (findings, n_refs)。findings:category→[(page, ref, detail)]。""" findings = {c: [] for c in ORDER} n_refs = 0 for p in kb.iter_pages(root): rel = p.relative_to(root).as_posix() try: meta, _ = kb.parse_page(p.read_text(encoding="utf-8")) except Exception as e: findings["absent"].append((rel, "(無法解析頁面)", str(e))) continue for s in meta.get("sources") or []: n_refs += 1 cat, detail = classify_ref(s, files, exists) if cat != "resolved": findings[cat].append((rel, str(s), detail)) return findings, n_refs def format_report(findings, n_refs): total = sum(len(v) for v in findings.values()) lines = [f"檢查 source_ref:{n_refs},對不上:{total}", ""] if total == 0: lines.append("全部解得開(source_ref 與 manifest 一致)。") return "\n".join(lines) for cat in ORDER: items = findings[cat] if not items: continue lines.append(f"## {CAT_TITLE[cat]}({len(items)})") lines.append(f"→ 修法:{FIX[cat]}") for page, ref, detail in items: lines.append(f" - [{page}] {ref}") lines.append(f" {detail}") lines.append("") return "\n".join(lines) def main(argv=None): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--root", default=str(kb.ROOT)) a = ap.parse_args(argv) root = pathlib.Path(a.root).resolve() mpath = root / "raw" / "manifest.json" try: files = json.loads(mpath.read_text(encoding="utf-8"))["files"] except (OSError, KeyError, json.JSONDecodeError) as e: raise SystemExit(f"讀不到 / 解析不了 manifest:{mpath}({e})") findings, n_refs = collect(root, files, lambda p: (root / p).is_file()) print(format_report(findings, n_refs)) if sum(len(v) for v in findings.values()): raise SystemExit(1) if __name__ == "__main__": main()