#!/usr/bin/env python3"""doc_drift.py - リポジトリ直下の *.md を、エージェントに読ませる前に点検する。検出するもの: (1) 実在しないパス参照 (2) テンプレート元の別ドメインが自ドメイン以上に出てくる文書 使い方: python3 -I doc_drift.py <repo> <own-domain> [--siblings DOMAIN ...] [--ignore PATH ...]終了コード: 0=問題なし / 1=要確認あり(CI や起動前フックで止められる)"""import argparse, collections, pathlib, re, sysap = argparse.ArgumentParser()ap.add_argument("repo"); ap.add_argument("own")ap.add_argument("--siblings", nargs="*", default=[], help="同じテンプレート由来の別ドメイン(混入検出用)")ap.add_argument("--ignore", nargs="*", default=[], help="意図して実在しない参照(失敗例・将来の予定など)")a = ap.parse_args()repo = pathlib.Path(a.repo)# 追跡対象の全パス。.git と node_modules は除くfiles = {p.relative_to(repo).as_posix() for p in repo.rglob("*") if p.is_file() and not {".git", "node_modules"} & set(p.parts)}basenames = {f.rsplit("/", 1)[-1] for f in files}# バッククォートで囲まれた「パスらしい」文字列だけを拾うREF = re.compile(r"`([\w@.\[\]{}/-]+/[\w@.\[\]{}-]*|[\w-]+\.(?:mjs|ts|tsx|js|json|toml|md|mdx|txt))`")DOM = re.compile(r"\b([a-z0-9-]+\.(?:net|jp|org|com))\b")def resolve(ref: str): ref = ref.strip("./") if "{" in ref: # テンプレート記法は判定しない return None if "/" not in ref: # 裸のファイル名は basename で探す return ref in basenames if ref in files or any(f.startswith(ref.rstrip("/") + "/") for f in files): return True # [locale] などのブラケットも実在ディレクトリ名として一致する return any(f.endswith("/" + ref) for f in files) # 先頭を省略した相対参照problems = 0for doc in sorted(repo.glob("*.md")): text = doc.read_text(encoding="utf-8") dead, skipped = collections.Counter(), 0 for ref in set(REF.findall(text)): if ref in a.ignore: continue r = resolve(ref) if r is None: skipped += 1 elif not r: dead[ref] = text.count(f"`{ref}`") doms = collections.Counter(DOM.findall(text)) foreign = {d: n for d, n in doms.items() if d in a.siblings} own_n = doms.get(a.own, 0) flag = bool(dead) or sum(foreign.values()) > own_n # 兄弟ドメインの言及が自ドメイン以上なら疑う problems += flag print(f"{'NG' if flag else 'ok'} {doc.name:<20} {len(text.encode()):>6}B " f"dead={sum(dead.values())} skipped={skipped} own={own_n} foreign={sum(foreign.values())}") for ref, n in dead.items(): print(f" dead {ref} ×{n}") for d, n in foreign.items(): print(f" foreign {d} ×{n}")sys.exit(1 if problems else 0)
ok CONTENT_LOG.md 30371B dead=0 skipped=0 own=0 foreign=0NG HANDOVER.md 14317B dead=5 skipped=0 own=7 foreign=9 dead antigravitylab.net/ ×1 dead /home/user/project/ ×1 dead opengraph-image.tsx ×1 dead src/app/[locale]/opengraph-image.tsx ×1 dead BookRecommendation.tsx ×1 foreign sibling-a.example ×8 foreign sibling-b.example ×1NG MIGRATION_GUIDE.md 11406B dead=5 skipped=1 own=1 foreign=4 dead src/components/ui/BookRecommendation.tsx ×1 dead opengraph-image.tsx ×1 dead src/app/[locale]/opengraph-image.tsx ×2 dead BookRecommendation.tsx ×1 foreign sibling-b.example ×2 foreign sibling-a.example ×2ok README.md 100B dead=0 skipped=0 own=1 foreign=0NG REMAINING_TASKS.md 2846B dead=1 skipped=0 own=1 foreign=0 dead src/proxy.ts ×1ok SKILL.md 9903B dead=0 skipped=1 own=2 foreign=0
実行時間は 0.084 秒でした。CONTENT_LOG.md が ok なのは、外部リンクが多いだけで、兄弟ドメインを含まないためです。
次に、意図して実在しない 3 件を除外して走らせます。
ok CONTENT_LOG.md 30371B dead=0 skipped=0 own=0 foreign=0NG HANDOVER.md 14317B dead=3 skipped=0 own=7 foreign=9 dead opengraph-image.tsx ×1 dead BookRecommendation.tsx ×1 dead src/app/[locale]/opengraph-image.tsx ×1 foreign sibling-a.example ×8 foreign sibling-b.example ×1NG MIGRATION_GUIDE.md 11406B dead=5 skipped=1 own=1 foreign=4 dead opengraph-image.tsx ×1 dead src/components/ui/BookRecommendation.tsx ×1 dead BookRecommendation.tsx ×1 dead src/app/[locale]/opengraph-image.tsx ×2 foreign sibling-b.example ×2 foreign sibling-a.example ×2ok README.md 100B dead=0 skipped=0 own=1 foreign=0ok REMAINING_TASKS.md 2846B dead=0 skipped=0 own=1 foreign=0ok SKILL.md 9903B dead=0 skipped=1 own=2 foreign=0
REMAINING_TASKS.md は ok になりました。HANDOVER.md と MIGRATION_GUIDE.md は、依然として NG です。ここが NG のままでよいことが、このスクリプトの役目だと考えています。 参照を直しただけでは通らず、文書が誰のものかを人が決めるまで止まります。