#!/usr/bin/env python3 # -*- coding: utf-8 -*- """CLAUDE.md の運用ルール1〜4のうち、機械的に判定できる項目を検査する。 使い方: python .claude/skills/doc-lint/doc-lint.py [ファイル...] 引数なしなら リポジトリ直下の *.md と journal/*.md を対象にする。 ERROR が1件でもあれば終了コード 1。 """ import sys, re, os, io, glob if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") TAG = re.compile(r"\[推測:(P-\d+)\]") ENTRY = re.compile(r"^###\s+(P-\d+)\b") H2 = re.compile(r"^##\s+(.*?)\s*$") MDLINK = re.compile(r"\]\(([^)]+\.md[^)]*)\)") JOURNALFN= re.compile(r"^\d{4}-\d{2}-\d{2}-.+\.md$") PROMOTE = re.compile(r"^\s*[-*]\s*格上げ条件\s*[::]\s*\S") # ルールブック自身は、アンチパターンを例として引用するため散文ヒューリスティックの対象外 PROSE_EXEMPT = {"CLAUDE.md"} issues = [] def add(sev, path, line, msg): issues.append((sev, path, line, msg)) def load(path): """コードフェンス内を空行に潰した (行番号, 本文) の列を返す。""" raw = io.open(path, encoding="utf-8").read().splitlines() out, fence = [], False for i, l in enumerate(raw, 1): if l.lstrip().startswith("```"): fence = not fence out.append((i, "")) continue out.append((i, "" if fence else l)) return out def section(lines, title): """## 配下の (行番号, 本文) を返す。無ければ None。""" start = None for idx, (n, l) in enumerate(lines): m = H2.match(l) if m and m.group(1) == title: start = idx + 1 break if start is None: return None body = [] for n, l in lines[start:]: if H2.match(l): break body.append((n, l)) return body def check(path): rel = path.replace(os.sep, "/") base = os.path.basename(rel) is_journal = rel.startswith("journal/") lines = load(path) unconf = section(lines, "未確認事項") rejected = section(lines, "検討済み・非採用") unconf_span = set(n for n, _ in unconf) if unconf else set() # --- ルール4: 推測タグと未確認事項の対応 --- entries = set() if unconf is not None: blocks = [] # [(pid, 行番号, [本文行])] 重複IDも個別に検査する for n, l in unconf: m = ENTRY.match(l) if m: pid = m.group(1) if pid in entries: add("ERROR", rel, n, "未確認事項のID %s が重複している" % pid) entries.add(pid) blocks.append((pid, n, [])) elif blocks: blocks[-1][2].append(l) for pid, ln, body in blocks: if not any(PROMOTE.match(x) for x in body): add("ERROR", rel, ln, "%s に「- 格上げ条件:」の行がない(何を確認すれば事実に格上げできるか)" % pid) for n, l in lines: if n in unconf_span: continue for pid in TAG.findall(l): if unconf is None or pid not in entries: add("ERROR", rel, n, "本文の [推測:%s] に対応する項目が ## 未確認事項 にない" % pid) # 逆向き(本文タグのない未確認事項)は「未解決の問い」として正当なのでエラーにしない # --- ルール3: journal への/からのリンク禁止 --- for n, l in lines: for target in MDLINK.findall(l): if target.lstrip("./").startswith("journal/"): add("ERROR", rel, n, "journal へのリンクが張られている: %s" % target) elif is_journal: add("ERROR", rel, n, "journal から他ファイルへのリンクが張られている: %s" % target) # --- ルール1: 検討済み・非採用 --- if rejected is not None: items = [(n, l) for n, l in rejected if l.strip().startswith(("-", "*"))] for n, l in items: if not re.search(r"\(journal/[^)]+\.md\)", l): add("ERROR", rel, n, "検討済み・非採用の項目が journal ファイル名を参照していない") if len(items) > 5: add("WARN", rel, items[5][0], "検討済み・非採用が %d 件(目安5件)。古いものから落とす" % len(items)) # --- journal 固有 --- if is_journal: if not JOURNALFN.match(base): add("ERROR", rel, 1, "journal のファイル名が YYYY-MM-DD-<topic>.md 形式でない") head = " ".join(l for n, l in lines[:20]) if "結論" not in head: add("WARN", rel, 1, "冒頭20行に結論が見当たらない(結論を先頭に置く)") # --- ルール1: 調査・比較の混入(要目視確認) --- if not is_journal and base not in PROSE_EXEMPT: # 検討済み・非採用 は却下案を列挙する場所なので対象外 skip = set(n for n, _ in rejected) if rejected else set() for n, l in lines: if n in skip: continue if "メリット" in l and "デメリット" in l: add("WARN", rel, n, "比較表らしき記述(メリット/デメリット)。journal 行きでは?") elif re.search(r"却下|不採用|見送っ|も検討したが|案[A-Z]|[A-Z]案", l): add("WARN", rel, n, "却下案の検討過程らしき記述。制約文に書き換えるか journal へ") elif re.search(r"優先度", l) and l.lstrip().startswith("#"): add("WARN", rel, n, "優先度評価は journal 行きでは?") def main(argv): targets = argv[1:] if not targets: targets = sorted(glob.glob("*.md")) + sorted(glob.glob("journal/*.md")) if not targets: print("対象ファイルなし"); return 0 for t in targets: if os.path.isfile(t): check(t) else: print("見つからない: %s" % t) errs = [i for i in issues if i[0] == "ERROR"] warns = [i for i in issues if i[0] == "WARN"] for sev, path, line, msg in errs + warns: print("%-5s %s:%d %s" % (sev, path, line, msg)) print("\n%d files / ERROR %d / WARN %d" % (len(targets), len(errs), len(warns))) return 1 if errs else 0 if __name__ == "__main__": sys.exit(main(sys.argv))