feat: 新增 diag_refs — source_ref↔manifest 對帳診斷
lint 的「source_ref 不在 manifest」只報對不上、不辨成因。diag_refs 把每個 對不上的 source_ref 分成五類(hash 不符/路徑字串岔開/檔名 hash 後綴岔開/ 檔在磁碟未登記/完全無對應),各附修法,讓「補帳本(§14.3)」與「修規則 (§12)」的相反處置不再混為一談。純唯讀、結束碼 0/1,可當搬機或改 manifest 後的閘門。附 selfcheck 驗五型別分類、結束碼與零寫檔。README §3.3/§6/§7/§8 同步。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
114
tools/selfcheck_diag_refs.py
Normal file
114
tools/selfcheck_diag_refs.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Phase 4 自檢:沙盒植入五種 source_ref 岔開型別(hash 不符、路徑正規化、
|
||||
檔名 hash 後綴岔開、檔在磁碟未登記、完全無對應)+一個正常解得開的 ref,
|
||||
驗證 diag_refs 逐一分類正確、報告涵蓋各型別、結束碼 1、且不修改任何檔案。
|
||||
另驗全部解得開時結束碼 0。
|
||||
|
||||
執行:.venv/Scripts/python tools/selfcheck_diag_refs.py
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||||
import kb
|
||||
import diag_refs
|
||||
|
||||
SHA_A = "abcd1234" + "0" * 56
|
||||
SHA_B = "beef5678" + "0" * 56
|
||||
# 帳本 key 一律 posix 正斜線+中文/連字號檔名(真實 convert 產出的形狀)
|
||||
K_OK = "raw/converted/www-pluspay-faq-06b02fd9.md"
|
||||
K_STEM = "raw/converted/www-pluspay-terms-11112222.md" # 同幹、不同 hash 後綴
|
||||
|
||||
FILES = {
|
||||
K_OK: {"kind": "converted", "sha256": SHA_A, "original_path": "raw/originals/faq.html",
|
||||
"original_sha256": SHA_A, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
|
||||
K_STEM: {"kind": "converted", "sha256": SHA_B, "original_path": "raw/originals/terms.html",
|
||||
"original_sha256": SHA_B, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
|
||||
}
|
||||
|
||||
# ref → 預期分類。unregistered 的檔會實際落地;absent 的不落地。
|
||||
CASES = {
|
||||
"resolved": f"{K_OK}#abcd1234",
|
||||
"hash-mismatch": f"{K_OK}#deadbeef",
|
||||
"normalize": r"raw\converted\www-pluspay-faq-06b02fd9.md#abcd1234", # 反斜線
|
||||
"stem": "raw/converted/www-pluspay-terms-99998888.md#cccccccc", # 同幹、異後綴、非 key
|
||||
"unregistered": "raw/converted/www-pluspay-instructions-77776666.md#77777777",
|
||||
"absent": "raw/converted/www-pluspay-ghost-00001111.md#cafebabe",
|
||||
}
|
||||
|
||||
|
||||
def unit():
|
||||
"""classify_ref 純函式逐型別驗證。"""
|
||||
on_disk = {"raw/converted/www-pluspay-instructions-77776666.md"}
|
||||
exists = lambda p: p in on_disk
|
||||
for expect, ref in CASES.items():
|
||||
cat, detail = diag_refs.classify_ref(ref, FILES, exists)
|
||||
assert cat == expect, f"{ref!r} 應分類為 {expect},實得 {cat}({detail})"
|
||||
print("PASS: classify_ref 六型別(含 resolved)分類正確")
|
||||
|
||||
|
||||
def build(root):
|
||||
for sub in ("raw/converted", "wiki/summaries", "wiki/entities", "wiki/concepts"):
|
||||
(root / sub).mkdir(parents=True)
|
||||
(root / "raw/manifest.json").write_text(
|
||||
json.dumps({"version": 1, "files": FILES}), encoding="utf-8")
|
||||
# unregistered 的檔要真的存在於磁碟,才會被判為「檔在、帳本無」而非 absent
|
||||
(root / "raw/converted/www-pluspay-instructions-77776666.md").write_text(
|
||||
"# 未登記\n", encoding="utf-8")
|
||||
today = "2026-07-01"
|
||||
for i, (name, ref) in enumerate(CASES.items()):
|
||||
meta = {"type": "entity", "title": f"頁{name}", "description": "測試頁",
|
||||
"tags": ["t"], "timestamp": today, "sources": [ref], "status": "draft"}
|
||||
(root / f"wiki/entities/p{i}.md").write_text(kb.dump_page(meta, "內文。"), encoding="utf-8")
|
||||
|
||||
|
||||
def run(argv):
|
||||
buf = io.StringIO()
|
||||
code = 0
|
||||
try:
|
||||
with redirect_stdout(buf):
|
||||
diag_refs.main(argv)
|
||||
except SystemExit as e:
|
||||
code = e.code
|
||||
return code, buf.getvalue()
|
||||
|
||||
|
||||
def main():
|
||||
unit()
|
||||
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-diag-"))
|
||||
try:
|
||||
root = tmp / "repo"
|
||||
build(root)
|
||||
before = {p: p.read_bytes() for p in root.rglob("*")
|
||||
if p.is_file()}
|
||||
|
||||
code, out = run(["--root", str(root)])
|
||||
assert code == 1, f"有岔開應以結束碼 1,實得 {code}\n{out}"
|
||||
for cat in ("hash-mismatch", "normalize", "stem", "unregistered", "absent"):
|
||||
assert diag_refs.CAT_TITLE[cat].split("(")[0] in out, f"報告缺型別 {cat}\n{out}"
|
||||
assert "對不上:5" in out, out
|
||||
after = {p: p.read_bytes() for p in root.rglob("*") if p.is_file()}
|
||||
assert before == after, "diag_refs 修改了檔案!"
|
||||
print("PASS: 整合——五型別全報、結束碼 1、零寫檔")
|
||||
|
||||
# 全部解得開 → 結束碼 0
|
||||
good = {"type": "entity", "title": "好頁", "description": "d", "tags": ["t"],
|
||||
"timestamp": "2026-07-01", "sources": [f"{K_OK}#abcd1234"], "status": "draft"}
|
||||
for p in (root / "wiki/entities").glob("*.md"):
|
||||
p.unlink()
|
||||
(root / "wiki/entities/ok.md").write_text(kb.dump_page(good, "內文。"), encoding="utf-8")
|
||||
code, out = run(["--root", str(root)])
|
||||
assert code == 0, f"全部解得開應以結束碼 0,實得 {code}\n{out}"
|
||||
assert "全部解得開" in out, out
|
||||
print("PASS: 全部解得開時結束碼 0")
|
||||
print("ALL PASS")
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user